Refactor: Per-strategy isolated balance and PnL

This commit is contained in:
2026-03-15 16:50:34 -07:00
parent 0adcc947ce
commit c377c56975

View File

@@ -2,54 +2,92 @@ import { db } from '../db.js';
import { notify } from '../notify.js'; import { notify } from '../notify.js';
/** /**
* Paper Trading Engine. * Per-Strategy Paper Trading Engine.
* Executes virtual trades, tracks PnL, stores in SurrealDB. * Each strategy gets its own isolated balance, PnL, and trade history.
*/ */
export class PaperEngine { class StrategyPaperAccount {
constructor(initialBalance = 1000) { constructor(strategyName, initialBalance = 1000) {
this.strategyName = strategyName;
this.balance = initialBalance; this.balance = initialBalance;
this.initialBalance = initialBalance;
this.openPositions = new Map(); // ticker -> [positions] this.openPositions = new Map(); // ticker -> [positions]
this.tradeHistory = [];
this.totalPnL = 0; this.totalPnL = 0;
this.wins = 0; this.wins = 0;
this.losses = 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() { async init() {
// Load state from SurrealDB
try { try {
const state = await db.query('SELECT * FROM paper_state ORDER BY timestamp DESC LIMIT 1'); // Load saved per-strategy states
const saved = state[0]?.[0]; const states = await db.query('SELECT * FROM paper_strategy_state ORDER BY timestamp DESC');
if (saved) { const rows = states[0] || [];
this.balance = saved.balance; const seen = new Set();
this.totalPnL = saved.totalPnL; for (const saved of rows) {
this.wins = saved.wins; if (!saved.strategyName || seen.has(saved.strategyName)) continue;
this.losses = saved.losses; seen.add(saved.strategyName);
console.log(`[Paper] Restored state: $${this.balance.toFixed(2)} balance, ${this.wins}W/${this.losses}L`); 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 // Load open positions
const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false'); const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false');
if (positions[0]) { if (positions[0]) {
for (const pos of 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); 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) { } catch (e) {
console.error('[Paper] Init error (fresh start):', e.message); console.error('[Paper] Init error (fresh start):', e.message);
} }
} }
/**
* Execute a paper trade from a strategy signal.
*/
async executeTrade(signal, marketState) { async executeTrade(signal, marketState) {
const cost = signal.size; // Each contract costs signal.price cents, but we simplify: $1 per contract unit const acct = this._getAccount(signal.strategy);
if (this.balance < cost) { const cost = signal.size;
console.log(`[Paper] Insufficient balance ($${this.balance.toFixed(2)}) for $${cost} trade`);
if (acct.balance < cost) {
console.log(`[Paper:${signal.strategy}] Insufficient balance ($${acct.balance.toFixed(2)}) for $${cost} trade`);
return null; return null;
} }
@@ -58,7 +96,7 @@ export class PaperEngine {
strategy: signal.strategy, strategy: signal.strategy,
ticker: signal.ticker, ticker: signal.ticker,
side: signal.side, side: signal.side,
price: signal.price, // Entry price in cents price: signal.price,
size: signal.size, size: signal.size,
cost, cost,
reason: signal.reason, 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); list.push(trade);
this.openPositions.set(trade.ticker, list); acct.openPositions.set(trade.ticker, list);
// Store in SurrealDB
try { try {
await db.create('paper_positions', trade); await db.create('paper_positions', trade);
await this._saveState(); await this._saveState(acct);
} catch (e) { } catch (e) {
console.error('[Paper] DB write error:', e.message); 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}`); console.log(`[Paper] ${msg}`);
await notify(msg, 'Paper Trade'); await notify(msg, `Paper: ${trade.strategy}`);
return trade; return trade;
} }
/**
* Settle all positions for a ticker when the market resolves.
*/
async settle(ticker, result) { async settle(ticker, result) {
const positions = this.openPositions.get(ticker); const allSettled = [];
if (!positions || positions.length === 0) return;
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) { console.log(`[Paper:${strategyName}] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
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;
pos.settled = true; for (const pos of positions) {
pos.result = result; const won = pos.side === result;
pos.pnl = parseFloat(pnl.toFixed(2)); const payout = won ? (100 / pos.price) * pos.cost : 0;
pos.settleTime = Date.now(); const pnl = payout - pos.cost;
this.balance += payout; pos.settled = true;
this.totalPnL += pnl; pos.result = result;
pos.pnl = parseFloat(pnl.toFixed(2));
pos.settleTime = Date.now();
if (won) this.wins++; acct.balance += payout;
else this.losses++; acct.totalPnL += pnl;
// Update in SurrealDB if (won) acct.wins++;
try { else acct.losses++;
await db.query(`UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`, {
id: pos.id, try {
result, await db.query(
pnl: pos.pnl, `UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`,
settleTime: pos.settleTime { id: pos.id, result, pnl: pos.pnl, settleTime: pos.settleTime }
}); );
} catch (e) { } catch (e) {
console.error('[Paper] Settle DB error:', e.message); 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 ? '✅' : '❌'; acct.openPositions.delete(ticker);
const msg = `${emoji} ${pos.strategy} ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Balance: $${this.balance.toFixed(2)}`; await this._saveState(acct);
console.log(`[Paper] ${msg}`);
await notify(msg, won ? 'Paper Win!' : 'Paper Loss');
} }
this.openPositions.delete(ticker); return allSettled.length > 0 ? allSettled : null;
await this._saveState();
return positions;
} }
/**
* Get combined stats (backward compat) — aggregates all strategies.
*/
getStats() { getStats() {
const openPositionsList = []; const allOpen = [];
for (const [ticker, positions] of this.openPositions) { let totalBalance = 0;
openPositionsList.push(...positions); 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 { return {
balance: parseFloat(this.balance.toFixed(2)), balance: parseFloat(totalBalance.toFixed(2)),
totalPnL: parseFloat(this.totalPnL.toFixed(2)), totalPnL: parseFloat(totalPnL.toFixed(2)),
wins: this.wins, wins: totalWins,
losses: this.losses, losses: totalLosses,
winRate: this.wins + this.losses > 0 winRate: totalWins + totalLosses > 0
? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1)) ? parseFloat(((totalWins / (totalWins + totalLosses)) * 100).toFixed(1))
: 0, : 0,
openPositions: openPositionsList, openPositions: allOpen,
totalTrades: this.wins + this.losses 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 { try {
await db.create('paper_state', { await db.create('paper_strategy_state', {
balance: this.balance, strategyName: acct.strategyName,
totalPnL: this.totalPnL, balance: acct.balance,
wins: this.wins, totalPnL: acct.totalPnL,
losses: this.losses, wins: acct.wins,
losses: acct.losses,
timestamp: Date.now() timestamp: Date.now()
}); });
} catch (e) { } catch (e) {