From b9c55fb650cacb98c7d05bc9e4a74d8fce97fc8a Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Mon, 16 Mar 2026 12:30:20 -0700 Subject: [PATCH] Feat: Orderbook-aware pricing for live orders --- lib/live/engine.js | 61 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/lib/live/engine.js b/lib/live/engine.js index a2ad216..ad62e1a 100644 --- a/lib/live/engine.js +++ b/lib/live/engine.js @@ -6,20 +6,21 @@ import crypto from 'crypto'; /** * Live Trading Engine — real money on Kalshi. * Uses IOC (Immediate-or-Cancel) orders for thin-book safety. + * Now orderbook-aware: uses best available ask instead of display price. * All amounts in CENTS internally. */ export class LiveEngine { constructor() { this.enabledStrategies = new Set(); - this.openOrders = new Map(); // orderId -> orderInfo + this.openOrders = new Map(); 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._maxLossPerTradeCents = 500; + this._maxDailyLossCents = 2000; this._dailyLoss = 0; this._dailyLossResetTime = Date.now(); this._lastBalance = null; @@ -123,6 +124,30 @@ export class LiveEngine { } } + /** + * Determine the best executable price from the orderbook. + * For buying yes: best ask = 100 - best_no_bid (or lowest yes offer) + * For buying no: best ask = 100 - best_yes_bid (or lowest no offer) + */ + _getBestAskFromOrderbook(side, orderbook) { + if (!orderbook) return null; + + if (side === 'yes') { + // Best yes ask = 100 - highest no bid + if (orderbook.no?.length) { + const bestNoBid = orderbook.no[0]?.[0]; + if (bestNoBid != null) return 100 - bestNoBid; + } + } else { + // Best no ask = 100 - highest yes bid + if (orderbook.yes?.length) { + const bestYesBid = orderbook.yes[0]?.[0]; + if (bestYesBid != null) return 100 - bestYesBid; + } + } + return null; + } + async executeTrade(signal, marketState) { if (this._paused) { console.log(`[Live] PAUSED — ignoring signal from ${signal.strategy}`); @@ -143,7 +168,6 @@ export class LiveEngine { return null; } - const priceCents = Math.round(signal.price); const sizeDollars = signal.size; const costCents = sizeDollars * 100; @@ -152,6 +176,24 @@ export class LiveEngine { return null; } + // Determine actual execution price from orderbook + const bestAsk = this._getBestAskFromOrderbook(signal.side, marketState.orderbook); + const maxAcceptable = signal.maxPrice || (signal.price + 3); + + let execPrice; + if (bestAsk != null) { + if (bestAsk > maxAcceptable) { + console.log(`[Live] Best ask ${bestAsk}¢ > max ${maxAcceptable}¢ for ${signal.strategy} — skipping`); + return null; + } + execPrice = bestAsk; + } else { + // No orderbook data, use signal price + small buffer + execPrice = Math.min(signal.price + 1, maxAcceptable); + } + + const priceCents = Math.round(execPrice); + if (priceCents <= 0 || priceCents >= 100) { console.log(`[Live] Invalid price ${priceCents}¢ — skipping`); return null; @@ -163,9 +205,6 @@ export class LiveEngine { const clientOrderId = crypto.randomUUID(); const side = signal.side.toLowerCase(); - // Build order with IOC (Immediate-or-Cancel) instead of FoK via buy_max_cost. - // IOC partially fills whatever is available and cancels the rest, - // so thin order books won't cause a hard 409 rejection. const orderBody = { ticker: signal.ticker, action: 'buy', @@ -183,7 +222,7 @@ export class LiveEngine { } try { - console.log(`[Live] Placing IOC order: ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) | ${signal.reason}`); + console.log(`[Live] Placing IOC order: ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) [ask: ${bestAsk ?? '?'}¢, max: ${maxAcceptable}¢] | ${signal.reason}`); const result = await kalshiFetch('POST', '/trade-api/v2/portfolio/orders', orderBody); const order = result?.order; @@ -197,7 +236,6 @@ export class LiveEngine { const fillCost = order.taker_fill_cost || 0; const status = (order.status || '').toLowerCase(); - // IOC with zero fills means nothing was available at our price if (fillCount === 0 && (status === 'canceled' || status === 'cancelled')) { console.log(`[Live] IOC got 0 fills — no liquidity at ${priceCents}¢ for ${signal.ticker}`); return null; @@ -220,6 +258,8 @@ export class LiveEngine { pnl: null, fillCount, fillCost, + bestAsk, + maxPrice: maxAcceptable, marketState: { yesPct: marketState.yesPct, noPct: marketState.noPct @@ -230,7 +270,6 @@ export class LiveEngine { this.totalTrades++; } - // Only track as open if we actually got fills that need settling if (fillCount > 0) { this.openOrders.set(order.order_id, orderRecord); } @@ -241,7 +280,7 @@ export class LiveEngine { console.error('[Live] DB write error:', e.message); } - const msg = `💰 LIVE [${signal.strategy}] ${side.toUpperCase()} ${fillCount}x @ ${priceCents}¢ ($${(fillCost/100).toFixed(2)}) | ${signal.reason}`; + const msg = `💰 LIVE [${signal.strategy}] ${side.toUpperCase()} ${fillCount}x @ ${priceCents}¢ ($${(fillCost/100).toFixed(2)}) [ask:${bestAsk ?? '?'}¢] | ${signal.reason}`; console.log(`[Live] ${msg}`); await notify(msg, `Live: ${signal.strategy}`, 'high', 'money_with_wings');