Feat: Paper trading engine with virtual balance

This commit is contained in:
2026-03-15 13:08:56 -07:00
parent 0019f088c4
commit 8c0b750085

181
lib/paper/engine.js Normal file
View File

@@ -0,0 +1,181 @@
import { db } from '../db.js';
import { notify } from '../notify.js';
/**
* Paper Trading Engine.
* Executes virtual trades, tracks PnL, stores in SurrealDB.
*/
export class PaperEngine {
constructor(initialBalance = 1000) {
this.balance = initialBalance;
this.openPositions = new Map(); // ticker -> [positions]
this.tradeHistory = [];
this.totalPnL = 0;
this.wins = 0;
this.losses = 0;
}
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 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) || [];
list.push(pos);
this.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`);
return null;
}
const trade = {
id: `pt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
strategy: signal.strategy,
ticker: signal.ticker,
side: signal.side,
price: signal.price, // Entry price in cents
size: signal.size,
cost,
reason: signal.reason,
entryTime: Date.now(),
settled: false,
result: null,
pnl: null,
marketState: {
yesPct: marketState.yesPct,
noPct: marketState.noPct,
yesOdds: marketState.yesOdds,
noOdds: marketState.noOdds
}
};
this.balance -= cost;
const list = this.openPositions.get(trade.ticker) || [];
list.push(trade);
this.openPositions.set(trade.ticker, list);
// Store in SurrealDB
try {
await db.create('paper_positions', trade);
await this._saveState();
} catch (e) {
console.error('[Paper] DB write error:', e.message);
}
const msg = `📝 PAPER ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.strategy} | ${trade.reason}`;
console.log(`[Paper] ${msg}`);
await notify(msg, 'Paper Trade');
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;
console.log(`[Paper] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
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;
pos.settled = true;
pos.result = result;
pos.pnl = parseFloat(pnl.toFixed(2));
pos.settleTime = Date.now();
this.balance += payout;
this.totalPnL += pnl;
if (won) this.wins++;
else this.losses++;
// 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);
}
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');
}
this.openPositions.delete(ticker);
await this._saveState();
return positions;
}
getStats() {
const openPositionsList = [];
for (const [ticker, positions] of this.openPositions) {
openPositionsList.push(...positions);
}
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))
: 0,
openPositions: openPositionsList,
totalTrades: this.wins + this.losses
};
}
async _saveState() {
try {
await db.create('paper_state', {
balance: this.balance,
totalPnL: this.totalPnL,
wins: this.wins,
losses: this.losses,
timestamp: Date.now()
});
} catch (e) {
console.error('[Paper] State save error:', e.message);
}
}
}