diff --git a/lib/paper/engine.js b/lib/paper/engine.js new file mode 100644 index 0000000..97a2310 --- /dev/null +++ b/lib/paper/engine.js @@ -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); + } + } +}