diff --git a/lib/live/engine.js b/lib/live/engine.js index a7e817d..f46e2d4 100644 --- a/lib/live/engine.js +++ b/lib/live/engine.js @@ -5,7 +5,7 @@ import crypto from 'crypto'; /** * Live Trading Engine — real money on Kalshi. - * Uses fill_or_kill orders with buy_max_cost for safety. + * Uses IOC (Immediate-or-Cancel) orders for thin-book safety. * All amounts in CENTS internally. */ export class LiveEngine { @@ -152,12 +152,20 @@ export class LiveEngine { return null; } + if (priceCents <= 0 || priceCents >= 100) { + console.log(`[Live] Invalid price ${priceCents}¢ — skipping`); + return null; + } + const priceInDollars = priceCents / 100; const contracts = Math.max(1, Math.floor(sizeDollars / priceInDollars)); 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', @@ -165,7 +173,7 @@ export class LiveEngine { count: contracts, type: 'limit', client_order_id: clientOrderId, - buy_max_cost: costCents, + time_in_force: 'ioc', }; if (side === 'yes') { @@ -175,7 +183,7 @@ export class LiveEngine { } try { - console.log(`[Live] Placing order: ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) | ${signal.reason}`); + console.log(`[Live] Placing IOC order: ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) | ${signal.reason}`); const result = await kalshiFetch('POST', '/trade-api/v2/portfolio/orders', orderBody); const order = result?.order; @@ -185,6 +193,16 @@ export class LiveEngine { return null; } + const fillCount = order.taker_fill_count || 0; + 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; + } + const orderRecord = { orderId: order.order_id, clientOrderId, @@ -192,30 +210,28 @@ export class LiveEngine { ticker: signal.ticker, side, priceCents, - contracts, - costCents, + contracts: fillCount || contracts, + costCents: fillCost || costCents, reason: signal.reason, - status: order.status || 'pending', + status: fillCount > 0 ? 'filled' : (order.status || 'pending'), createdAt: Date.now(), settled: false, result: null, pnl: null, - fillCount: order.taker_fill_count || 0, - fillCost: order.taker_fill_cost || 0, + fillCount, + fillCost, marketState: { yesPct: marketState.yesPct, noPct: marketState.noPct } }; - if (order.status === 'executed' || order.status === 'filled') { - orderRecord.status = 'filled'; + if (fillCount > 0) { 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 { + } + + // Only track as open if we actually got fills that need settling + if (fillCount > 0) { this.openOrders.set(order.order_id, orderRecord); } @@ -225,7 +241,7 @@ export class LiveEngine { console.error('[Live] DB write error:', e.message); } - const msg = `💰 LIVE [${signal.strategy}] ${side.toUpperCase()} ${contracts}x @ ${priceCents}¢ ($${sizeDollars}) | ${signal.reason}`; + const msg = `💰 LIVE [${signal.strategy}] ${side.toUpperCase()} ${fillCount}x @ ${priceCents}¢ ($${(fillCost/100).toFixed(2)}) | ${signal.reason}`; console.log(`[Live] ${msg}`); await notify(msg, `Live: ${signal.strategy}`, 'high', 'money_with_wings');