diff --git a/lib/live/engine.js b/lib/live/engine.js new file mode 100644 index 0000000..29d9954 --- /dev/null +++ b/lib/live/engine.js @@ -0,0 +1,355 @@ +import { kalshiFetch, KALSHI_API_BASE } from '../kalshi/rest.js'; +import { db } from '../db.js'; +import { notify } from '../notify.js'; +import crypto from 'crypto'; + +/** + * Live Trading Engine — real money on Kalshi. + * Uses fill_or_kill orders with buy_max_cost for safety. + * All amounts in CENTS internally. + */ +export class LiveEngine { + constructor() { + this.enabledStrategies = new Set(); + this.openOrders = new Map(); // orderId -> orderInfo + this.recentFills = []; + this.totalPnL = 0; + this.wins = 0; + this.losses = 0; + this.totalTrades = 0; + this._paused = false; + this._maxLossPerTradeCents = 500; // $5 safety cap per trade + this._maxDailyLossCents = 2000; // $20 daily loss limit + this._dailyLoss = 0; + this._dailyLossResetTime = Date.now(); + this._lastBalance = null; + this._lastPortfolioValue = null; + this._positions = []; + } + + async init() { + try { + const states = await db.query( + 'SELECT * FROM live_engine_state ORDER BY timestamp DESC LIMIT 1' + ); + const row = (states[0] || [])[0]; + if (row) { + this.totalPnL = row.totalPnL || 0; + this.wins = row.wins || 0; + this.losses = row.losses || 0; + this.totalTrades = row.totalTrades || 0; + if (row.enabledStrategies) { + for (const s of row.enabledStrategies) this.enabledStrategies.add(s); + } + console.log(`[Live] Restored: PnL=$${(this.totalPnL/100).toFixed(2)}, ${this.wins}W/${this.losses}L`); + } + + const orders = await db.query( + 'SELECT * FROM live_orders WHERE status = "pending" OR status = "resting"' + ); + for (const o of (orders[0] || [])) { + this.openOrders.set(o.orderId, o); + } + if (this.openOrders.size) { + console.log(`[Live] Loaded ${this.openOrders.size} open order(s) from DB`); + } + } catch (e) { + console.error('[Live] Init error:', e.message); + } + } + + isStrategyEnabled(name) { + return this.enabledStrategies.has(name); + } + + enableStrategy(name) { + this.enabledStrategies.add(name); + this._saveState(); + console.log(`[Live] Strategy "${name}" ENABLED`); + } + + disableStrategy(name) { + this.enabledStrategies.delete(name); + this._saveState(); + console.log(`[Live] Strategy "${name}" DISABLED`); + } + + pause() { + this._paused = true; + console.log('[Live] ⚠️ PAUSED — no new orders will be placed'); + } + + resume() { + this._paused = false; + console.log('[Live] ▶️ RESUMED'); + } + + _resetDailyLossIfNeeded() { + const now = Date.now(); + const elapsed = now - this._dailyLossResetTime; + if (elapsed > 24 * 60 * 60 * 1000) { + this._dailyLoss = 0; + this._dailyLossResetTime = now; + } + } + + async fetchBalance() { + try { + const data = await kalshiFetch('GET', '/trade-api/v2/portfolio/balance'); + this._lastBalance = data.balance || 0; // cents + this._lastPortfolioValue = data.portfolio_value || 0; + return { + balance: this._lastBalance, + portfolioValue: this._lastPortfolioValue + }; + } catch (e) { + console.error('[Live] Balance fetch error:', e.message); + return { balance: this._lastBalance, portfolioValue: this._lastPortfolioValue }; + } + } + + async fetchPositions() { + try { + const data = await kalshiFetch( + 'GET', + '/trade-api/v2/portfolio/positions?settlement_status=unsettled&limit=200' + ); + const positions = data?.market_positions || data?.positions || []; + this._positions = Array.isArray(positions) ? positions : []; + return this._positions; + } catch (e) { + console.error('[Live] Positions fetch error:', e.message); + return this._positions; + } + } + + async executeTrade(signal, marketState) { + if (this._paused) { + console.log(`[Live] PAUSED — ignoring signal from ${signal.strategy}`); + return null; + } + + if (!this.enabledStrategies.has(signal.strategy)) return null; + + this._resetDailyLossIfNeeded(); + + // Safety: daily loss limit + if (this._dailyLoss >= this._maxDailyLossCents) { + console.log(`[Live] Daily loss limit ($${(this._maxDailyLossCents/100).toFixed(2)}) reached — pausing`); + this.pause(); + await notify( + `⚠️ Daily loss limit reached ($${(this._dailyLoss/100).toFixed(2)}). Auto-paused.`, + 'Kalbot Safety', 'urgent', 'warning,octagonal_sign' + ); + return null; + } + + const priceCents = Math.round(signal.price); + const sizeDollars = signal.size; + const costCents = sizeDollars * 100; + + // Safety: per-trade cost cap + if (costCents > this._maxLossPerTradeCents) { + console.log(`[Live] Trade cost $${sizeDollars} exceeds per-trade cap — skipping`); + return null; + } + + // Calculate contract count: cost / price per contract + // On Kalshi, buying 1 YES contract at 40¢ costs $0.40 + // To spend $1, buy floor($1 / $0.40) = 2 contracts + const priceInDollars = priceCents / 100; + const contracts = Math.max(1, Math.floor(sizeDollars / priceInDollars)); + + const clientOrderId = crypto.randomUUID(); + + const side = signal.side.toLowerCase(); + + const orderBody = { + ticker: signal.ticker, + action: 'buy', + side, + count: contracts, + type: 'limit', + client_order_id: clientOrderId, + buy_max_cost: costCents, // FoK behavior, max cost safety + }; + + // Set price based on side + if (side === 'yes') { + orderBody.yes_price = priceCents; + } else { + orderBody.no_price = priceCents; + } + + try { + console.log(`[Live] Placing order: ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) | ${signal.reason}`); + + const result = await kalshiFetch('POST', '/trade-api/v2/portfolio/orders', orderBody); + const order = result?.order; + + if (!order?.order_id) { + console.error('[Live] Order response missing order_id:', JSON.stringify(result).slice(0, 300)); + return null; + } + + const orderRecord = { + orderId: order.order_id, + clientOrderId, + strategy: signal.strategy, + ticker: signal.ticker, + side, + priceCents, + contracts, + costCents, + reason: signal.reason, + status: order.status || 'pending', + createdAt: Date.now(), + settled: false, + result: null, + pnl: null, + fillCount: order.taker_fill_count || 0, + fillCost: order.taker_fill_cost || 0, + marketState: { + yesPct: marketState.yesPct, + noPct: marketState.noPct + } + }; + + if (order.status === 'executed' || order.status === 'filled') { + orderRecord.status = 'filled'; + this.totalTrades++; + } else if (order.status === 'canceled' || order.status === 'cancelled') { + orderRecord.status = 'cancelled'; + console.log(`[Live] Order cancelled (likely FoK not filled): ${order.order_id}`); + return null; + } else { + this.openOrders.set(order.order_id, orderRecord); + } + + try { + await db.create('live_orders', orderRecord); + } catch (e) { + console.error('[Live] DB write error:', e.message); + } + + const msg = `💰 LIVE [${signal.strategy}] ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) | ${signal.reason}`; + console.log(`[Live] ${msg}`); + await notify(msg, `Live: ${signal.strategy}`, 'high', 'money_with_wings'); + + await this._saveState(); + return orderRecord; + } catch (e) { + console.error(`[Live] Order failed: ${e.message}`); + await notify( + `❌ LIVE ORDER FAILED [${signal.strategy}]: ${e.message}`, + 'Kalbot Error', 'urgent', 'x,warning' + ); + return null; + } + } + + async settle(ticker, rawResult) { + const result = String(rawResult || '').toLowerCase(); + if (result !== 'yes' && result !== 'no') return null; + + const settled = []; + + for (const [orderId, order] of this.openOrders) { + if (order.ticker !== ticker) continue; + + const won = order.side === result; + const fillCostCents = order.fillCost || order.costCents; + const payout = won ? order.contracts * 100 : 0; // $1 per winning contract + const pnl = payout - fillCostCents; + + order.settled = true; + order.result = result; + order.pnl = pnl; + order.settleTime = Date.now(); + order.status = 'settled'; + + this.totalPnL += pnl; + if (won) this.wins++; + else { + this.losses++; + this._dailyLoss += Math.abs(pnl); + } + + try { + await db.query( + 'UPDATE live_orders SET settled = true, result = $result, pnl = $pnl, settleTime = $st, status = "settled" WHERE orderId = $oid', + { result, pnl, st: order.settleTime, oid: orderId } + ); + } catch (e) { + console.error('[Live] Settle DB error:', e.message); + } + + const emoji = won ? '✅' : '❌'; + const msg = `${emoji} LIVE [${order.strategy}] ${order.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${(pnl/100).toFixed(2)}`; + console.log(`[Live] ${msg}`); + await notify(msg, won ? 'Live Win!' : 'Live Loss', 'high', won ? 'chart_with_upwards_trend' : 'chart_with_downwards_trend'); + + settled.push(order); + this.openOrders.delete(orderId); + } + + if (settled.length) await this._saveState(); + return settled.length ? settled : null; + } + + getOpenTickers() { + const tickers = new Set(); + for (const [, order] of this.openOrders) { + if (!order.settled) tickers.add(order.ticker); + } + return Array.from(tickers); + } + + hasOpenPositionForStrategy(strategyName) { + for (const [, order] of this.openOrders) { + if (order.strategy === strategyName && !order.settled) return true; + } + return false; + } + + getStats() { + const openList = []; + for (const [, o] of this.openOrders) { + if (!o.settled) openList.push(o); + } + return { + balance: this._lastBalance != null ? (this._lastBalance / 100) : null, + portfolioValue: this._lastPortfolioValue != null ? (this._lastPortfolioValue / 100) : null, + totalPnL: parseFloat((this.totalPnL / 100).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, + totalTrades: this.totalTrades, + openOrders: openList, + paused: this._paused, + dailyLoss: parseFloat((this._dailyLoss / 100).toFixed(2)), + maxDailyLoss: parseFloat((this._maxDailyLossCents / 100).toFixed(2)), + maxPerTrade: parseFloat((this._maxLossPerTradeCents / 100).toFixed(2)), + enabledStrategies: Array.from(this.enabledStrategies), + positions: this._positions + }; + } + + async _saveState() { + try { + await db.create('live_engine_state', { + totalPnL: this.totalPnL, + wins: this.wins, + losses: this.losses, + totalTrades: this.totalTrades, + enabledStrategies: Array.from(this.enabledStrategies), + paused: this._paused, + timestamp: Date.now() + }); + } catch (e) { + console.error('[Live] State save error:', e.message); + } + } +}