From 0acc63c51294e3137171ca219fca1afab8ce51a4 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Sun, 15 Mar 2026 17:35:43 -0700 Subject: [PATCH] Fix: Normalize result case, settle orphans, add reset --- lib/paper/engine.js | 124 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 5 deletions(-) diff --git a/lib/paper/engine.js b/lib/paper/engine.js index 10c68d0..1626ebe 100644 --- a/lib/paper/engine.js +++ b/lib/paper/engine.js @@ -76,6 +76,10 @@ export class PaperEngine { list.push(pos); acct.openPositions.set(pos.ticker, list); } + const totalOpen = positions[0].length; + if (totalOpen > 0) { + console.log(`[Paper] Loaded ${totalOpen} open position(s) from DB`); + } } } catch (e) { console.error('[Paper] Init error (fresh start):', e.message); @@ -95,7 +99,7 @@ export class PaperEngine { id: `pt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, strategy: signal.strategy, ticker: signal.ticker, - side: signal.side, + side: signal.side.toLowerCase(), price: signal.price, size: signal.size, cost, @@ -132,7 +136,15 @@ export class PaperEngine { return trade; } - async settle(ticker, result) { + async settle(ticker, rawResult) { + // Normalize result to lowercase — Kalshi may return "Yes"/"No"/"yes"/"no" + const result = String(rawResult || '').toLowerCase(); + + if (result !== 'yes' && result !== 'no') { + console.warn(`[Paper] Unknown settlement result "${rawResult}" for ${ticker}, skipping.`); + return null; + } + const allSettled = []; for (const [strategyName, acct] of this.accounts) { @@ -142,8 +154,15 @@ export class PaperEngine { console.log(`[Paper:${strategyName}] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`); for (const pos of positions) { - const won = pos.side === result; - const payout = won ? (100 / pos.price) * pos.cost : 0; + // Normalize stored side too + const side = String(pos.side || '').toLowerCase(); + const won = side === result; + + // Payout: if won, each contract pays 100¢. Cost is in dollars, price in cents. + // Contracts bought = cost / (price / 100). Payout = contracts * $1. + // Simplified: payout = cost * (100 / price) + const price = pos.price > 0 ? pos.price : 50; + const payout = won ? (100 / price) * pos.cost : 0; const pnl = payout - pos.cost; pos.settled = true; @@ -167,7 +186,7 @@ export class PaperEngine { } const emoji = won ? '✅' : '❌'; - const msg = `${emoji} [${strategyName}] ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Bal: $${acct.balance.toFixed(2)}`; + const msg = `${emoji} [${strategyName}] ${side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Bal: $${acct.balance.toFixed(2)}`; console.log(`[Paper] ${msg}`); await notify(msg, won ? `${strategyName} Win!` : `${strategyName} Loss`); @@ -181,6 +200,101 @@ export class PaperEngine { return allSettled.length > 0 ? allSettled : null; } + /** + * Attempt to settle any orphaned open positions by checking market status. + * Called on startup to clean up positions from previous runs. + */ + async settleOrphans(getMarketFn) { + const orphanTickers = new Set(); + for (const [, acct] of this.accounts) { + for (const ticker of acct.openPositions.keys()) { + orphanTickers.add(ticker); + } + } + + if (!orphanTickers.size) return; + console.log(`[Paper] Checking ${orphanTickers.size} ticker(s) for orphaned positions...`); + + for (const ticker of orphanTickers) { + try { + const market = await getMarketFn(ticker); + const status = String(market?.status || '').toLowerCase(); + const result = market?.result; + + if (result) { + console.log(`[Paper] Orphan ticker ${ticker} has result "${result}" — settling now`); + await this.settle(ticker, result); + } else if (['closed', 'settled', 'expired', 'finalized'].includes(status)) { + // Market closed but no result yet — mark as expired loss + console.log(`[Paper] Orphan ticker ${ticker} status="${status}" with no result — force-settling as expired`); + await this._forceExpirePositions(ticker); + } else { + console.log(`[Paper] Orphan ticker ${ticker} still active (status="${status}") — keeping open`); + } + } catch (e) { + console.error(`[Paper] Orphan check failed for ${ticker}:`, e.message); + } + } + } + + async _forceExpirePositions(ticker) { + for (const [strategyName, acct] of this.accounts) { + const positions = acct.openPositions.get(ticker); + if (!positions || !positions.length) continue; + + for (const pos of positions) { + pos.settled = true; + pos.result = 'expired'; + pos.pnl = parseFloat((-pos.cost).toFixed(2)); + pos.settleTime = Date.now(); + + acct.totalPnL -= pos.cost; + acct.losses++; + + try { + await db.query( + `UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`, + { id: pos.id, result: 'expired', pnl: pos.pnl, settleTime: pos.settleTime } + ); + } catch (e) { + console.error('[Paper] Force-expire DB error:', e.message); + } + + console.log(`[Paper:${strategyName}] Force-expired position ${pos.id} for ${ticker} (lost $${pos.cost})`); + } + + acct.openPositions.delete(ticker); + await this._saveState(acct); + } + } + + /** + * Reset all strategy stats and clear trade history. + */ + async resetAll() { + for (const [name, acct] of this.accounts) { + acct.balance = acct.initialBalance; + acct.totalPnL = 0; + acct.wins = 0; + acct.losses = 0; + acct.openPositions.clear(); + console.log(`[Paper:${name}] Reset to $${acct.initialBalance}`); + } + + try { + await db.query('DELETE paper_positions'); + await db.query('DELETE paper_strategy_state'); + console.log('[Paper] Cleared all DB records'); + } catch (e) { + console.error('[Paper] Reset DB error:', e.message); + } + + // Save fresh state for each account + for (const [, acct] of this.accounts) { + await this._saveState(acct); + } + } + /** * Get combined stats (backward compat) — aggregates all strategies. */