From c377c5697540ca448d031a29f63e3f4cef30cae4 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Sun, 15 Mar 2026 16:50:34 -0700 Subject: [PATCH] Refactor: Per-strategy isolated balance and PnL --- lib/paper/engine.js | 232 ++++++++++++++++++++++++++++---------------- 1 file changed, 146 insertions(+), 86 deletions(-) diff --git a/lib/paper/engine.js b/lib/paper/engine.js index 97a2310..10c68d0 100644 --- a/lib/paper/engine.js +++ b/lib/paper/engine.js @@ -2,54 +2,92 @@ import { db } from '../db.js'; import { notify } from '../notify.js'; /** - * Paper Trading Engine. - * Executes virtual trades, tracks PnL, stores in SurrealDB. + * Per-Strategy Paper Trading Engine. + * Each strategy gets its own isolated balance, PnL, and trade history. */ -export class PaperEngine { - constructor(initialBalance = 1000) { +class StrategyPaperAccount { + constructor(strategyName, initialBalance = 1000) { + this.strategyName = strategyName; this.balance = initialBalance; + this.initialBalance = initialBalance; this.openPositions = new Map(); // ticker -> [positions] - this.tradeHistory = []; this.totalPnL = 0; this.wins = 0; this.losses = 0; } + getStats() { + const openPositionsList = []; + for (const [, positions] of this.openPositions) { + openPositionsList.push(...positions); + } + return { + strategy: this.strategyName, + balance: parseFloat(this.balance.toFixed(2)), + initialBalance: this.initialBalance, + totalPnL: parseFloat(this.totalPnL.toFixed(2)), + wins: this.wins, + losses: this.losses, + winRate: this.wins + this.losses > 0 + ? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1)) + : 0, + openPositions: openPositionsList, + totalTrades: this.wins + this.losses + }; + } +} + +export class PaperEngine { + constructor(initialBalancePerStrategy = 1000) { + this.initialBalancePerStrategy = initialBalancePerStrategy; + this.accounts = new Map(); // strategyName -> StrategyPaperAccount + } + + _getAccount(strategyName) { + if (!this.accounts.has(strategyName)) { + this.accounts.set(strategyName, new StrategyPaperAccount(strategyName, this.initialBalancePerStrategy)); + } + return this.accounts.get(strategyName); + } + async init() { - // Load state from SurrealDB try { - const state = await db.query('SELECT * FROM paper_state ORDER BY timestamp DESC LIMIT 1'); - const saved = state[0]?.[0]; - if (saved) { - this.balance = saved.balance; - this.totalPnL = saved.totalPnL; - this.wins = saved.wins; - this.losses = saved.losses; - console.log(`[Paper] Restored state: $${this.balance.toFixed(2)} balance, ${this.wins}W/${this.losses}L`); + // Load saved per-strategy states + const states = await db.query('SELECT * FROM paper_strategy_state ORDER BY timestamp DESC'); + const rows = states[0] || []; + const seen = new Set(); + for (const saved of rows) { + if (!saved.strategyName || seen.has(saved.strategyName)) continue; + seen.add(saved.strategyName); + const acct = this._getAccount(saved.strategyName); + acct.balance = saved.balance; + acct.totalPnL = saved.totalPnL; + acct.wins = saved.wins; + acct.losses = saved.losses; + console.log(`[Paper:${saved.strategyName}] Restored: $${acct.balance.toFixed(2)}, ${acct.wins}W/${acct.losses}L`); } // Load open positions const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false'); if (positions[0]) { for (const pos of positions[0]) { - const list = this.openPositions.get(pos.ticker) || []; + const acct = this._getAccount(pos.strategy); + const list = acct.openPositions.get(pos.ticker) || []; list.push(pos); - this.openPositions.set(pos.ticker, list); + acct.openPositions.set(pos.ticker, list); } - console.log(`[Paper] Restored ${this.openPositions.size} open position(s)`); } } catch (e) { console.error('[Paper] Init error (fresh start):', e.message); } } - /** - * Execute a paper trade from a strategy signal. - */ async executeTrade(signal, marketState) { - const cost = signal.size; // Each contract costs signal.price cents, but we simplify: $1 per contract unit - if (this.balance < cost) { - console.log(`[Paper] Insufficient balance ($${this.balance.toFixed(2)}) for $${cost} trade`); + const acct = this._getAccount(signal.strategy); + const cost = signal.size; + + if (acct.balance < cost) { + console.log(`[Paper:${signal.strategy}] Insufficient balance ($${acct.balance.toFixed(2)}) for $${cost} trade`); return null; } @@ -58,7 +96,7 @@ export class PaperEngine { strategy: signal.strategy, ticker: signal.ticker, side: signal.side, - price: signal.price, // Entry price in cents + price: signal.price, size: signal.size, cost, reason: signal.reason, @@ -74,104 +112,126 @@ export class PaperEngine { } }; - this.balance -= cost; + acct.balance -= cost; - const list = this.openPositions.get(trade.ticker) || []; + const list = acct.openPositions.get(trade.ticker) || []; list.push(trade); - this.openPositions.set(trade.ticker, list); + acct.openPositions.set(trade.ticker, list); - // Store in SurrealDB try { await db.create('paper_positions', trade); - await this._saveState(); + await this._saveState(acct); } catch (e) { console.error('[Paper] DB write error:', e.message); } - const msg = `๐Ÿ“ PAPER ${trade.side.toUpperCase()} @ ${trade.price}ยข ($${cost}) | ${trade.strategy} | ${trade.reason}`; + const msg = `๐Ÿ“ PAPER [${trade.strategy}] ${trade.side.toUpperCase()} @ ${trade.price}ยข ($${cost}) | ${trade.reason}`; console.log(`[Paper] ${msg}`); - await notify(msg, 'Paper Trade'); + await notify(msg, `Paper: ${trade.strategy}`); return trade; } - /** - * Settle all positions for a ticker when the market resolves. - */ async settle(ticker, result) { - const positions = this.openPositions.get(ticker); - if (!positions || positions.length === 0) return; + const allSettled = []; - console.log(`[Paper] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`); + for (const [strategyName, acct] of this.accounts) { + const positions = acct.openPositions.get(ticker); + if (!positions || positions.length === 0) continue; - for (const pos of positions) { - const won = pos.side === result; - // Payout: if won, pay out at $1 per contract (100ยข), minus cost - // If lost, lose the cost - const payout = won ? (100 / pos.price) * pos.cost : 0; - const pnl = payout - pos.cost; + console.log(`[Paper:${strategyName}] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`); - pos.settled = true; - pos.result = result; - pos.pnl = parseFloat(pnl.toFixed(2)); - pos.settleTime = Date.now(); + for (const pos of positions) { + const won = pos.side === result; + const payout = won ? (100 / pos.price) * pos.cost : 0; + const pnl = payout - pos.cost; - this.balance += payout; - this.totalPnL += pnl; + pos.settled = true; + pos.result = result; + pos.pnl = parseFloat(pnl.toFixed(2)); + pos.settleTime = Date.now(); - if (won) this.wins++; - else this.losses++; + acct.balance += payout; + acct.totalPnL += pnl; - // Update in SurrealDB - try { - await db.query(`UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`, { - id: pos.id, - result, - pnl: pos.pnl, - settleTime: pos.settleTime - }); - } catch (e) { - console.error('[Paper] Settle DB error:', e.message); + if (won) acct.wins++; + else 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, pnl: pos.pnl, settleTime: pos.settleTime } + ); + } catch (e) { + console.error('[Paper] Settle DB error:', e.message); + } + + const emoji = won ? 'โœ…' : 'โŒ'; + const msg = `${emoji} [${strategyName}] ${pos.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`); + + allSettled.push(pos); } - const emoji = won ? 'โœ…' : 'โŒ'; - const msg = `${emoji} ${pos.strategy} ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Balance: $${this.balance.toFixed(2)}`; - console.log(`[Paper] ${msg}`); - await notify(msg, won ? 'Paper Win!' : 'Paper Loss'); + acct.openPositions.delete(ticker); + await this._saveState(acct); } - this.openPositions.delete(ticker); - await this._saveState(); - - return positions; + return allSettled.length > 0 ? allSettled : null; } + /** + * Get combined stats (backward compat) โ€” aggregates all strategies. + */ getStats() { - const openPositionsList = []; - for (const [ticker, positions] of this.openPositions) { - openPositionsList.push(...positions); + const allOpen = []; + let totalBalance = 0; + let totalPnL = 0; + let totalWins = 0; + let totalLosses = 0; + + for (const [, acct] of this.accounts) { + const s = acct.getStats(); + totalBalance += s.balance; + totalPnL += s.totalPnL; + totalWins += s.wins; + totalLosses += s.losses; + allOpen.push(...s.openPositions); } return { - balance: parseFloat(this.balance.toFixed(2)), - totalPnL: parseFloat(this.totalPnL.toFixed(2)), - wins: this.wins, - losses: this.losses, - winRate: this.wins + this.losses > 0 - ? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1)) + balance: parseFloat(totalBalance.toFixed(2)), + totalPnL: parseFloat(totalPnL.toFixed(2)), + wins: totalWins, + losses: totalLosses, + winRate: totalWins + totalLosses > 0 + ? parseFloat(((totalWins / (totalWins + totalLosses)) * 100).toFixed(1)) : 0, - openPositions: openPositionsList, - totalTrades: this.wins + this.losses + openPositions: allOpen, + totalTrades: totalWins + totalLosses }; } - async _saveState() { + /** + * Get per-strategy stats for the paper dashboard. + */ + getPerStrategyStats() { + const result = {}; + for (const [name, acct] of this.accounts) { + result[name] = acct.getStats(); + } + return result; + } + + async _saveState(acct) { try { - await db.create('paper_state', { - balance: this.balance, - totalPnL: this.totalPnL, - wins: this.wins, - losses: this.losses, + await db.create('paper_strategy_state', { + strategyName: acct.strategyName, + balance: acct.balance, + totalPnL: acct.totalPnL, + wins: acct.wins, + losses: acct.losses, timestamp: Date.now() }); } catch (e) {