mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-17 05:51:02 +00:00
Feat: Orderbook-aware pricing for live orders
This commit is contained in:
@@ -6,20 +6,21 @@ import crypto from 'crypto';
|
|||||||
/**
|
/**
|
||||||
* Live Trading Engine — real money on Kalshi.
|
* Live Trading Engine — real money on Kalshi.
|
||||||
* Uses IOC (Immediate-or-Cancel) orders for thin-book safety.
|
* 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.
|
* All amounts in CENTS internally.
|
||||||
*/
|
*/
|
||||||
export class LiveEngine {
|
export class LiveEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.enabledStrategies = new Set();
|
this.enabledStrategies = new Set();
|
||||||
this.openOrders = new Map(); // orderId -> orderInfo
|
this.openOrders = new Map();
|
||||||
this.recentFills = [];
|
this.recentFills = [];
|
||||||
this.totalPnL = 0;
|
this.totalPnL = 0;
|
||||||
this.wins = 0;
|
this.wins = 0;
|
||||||
this.losses = 0;
|
this.losses = 0;
|
||||||
this.totalTrades = 0;
|
this.totalTrades = 0;
|
||||||
this._paused = false;
|
this._paused = false;
|
||||||
this._maxLossPerTradeCents = 500; // $5 safety cap per trade
|
this._maxLossPerTradeCents = 500;
|
||||||
this._maxDailyLossCents = 2000; // $20 daily loss limit
|
this._maxDailyLossCents = 2000;
|
||||||
this._dailyLoss = 0;
|
this._dailyLoss = 0;
|
||||||
this._dailyLossResetTime = Date.now();
|
this._dailyLossResetTime = Date.now();
|
||||||
this._lastBalance = null;
|
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) {
|
async executeTrade(signal, marketState) {
|
||||||
if (this._paused) {
|
if (this._paused) {
|
||||||
console.log(`[Live] PAUSED — ignoring signal from ${signal.strategy}`);
|
console.log(`[Live] PAUSED — ignoring signal from ${signal.strategy}`);
|
||||||
@@ -143,7 +168,6 @@ export class LiveEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceCents = Math.round(signal.price);
|
|
||||||
const sizeDollars = signal.size;
|
const sizeDollars = signal.size;
|
||||||
const costCents = sizeDollars * 100;
|
const costCents = sizeDollars * 100;
|
||||||
|
|
||||||
@@ -152,6 +176,24 @@ export class LiveEngine {
|
|||||||
return null;
|
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) {
|
if (priceCents <= 0 || priceCents >= 100) {
|
||||||
console.log(`[Live] Invalid price ${priceCents}¢ — skipping`);
|
console.log(`[Live] Invalid price ${priceCents}¢ — skipping`);
|
||||||
return null;
|
return null;
|
||||||
@@ -163,9 +205,6 @@ export class LiveEngine {
|
|||||||
const clientOrderId = crypto.randomUUID();
|
const clientOrderId = crypto.randomUUID();
|
||||||
const side = signal.side.toLowerCase();
|
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 = {
|
const orderBody = {
|
||||||
ticker: signal.ticker,
|
ticker: signal.ticker,
|
||||||
action: 'buy',
|
action: 'buy',
|
||||||
@@ -183,7 +222,7 @@ export class LiveEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 result = await kalshiFetch('POST', '/trade-api/v2/portfolio/orders', orderBody);
|
||||||
const order = result?.order;
|
const order = result?.order;
|
||||||
@@ -197,7 +236,6 @@ export class LiveEngine {
|
|||||||
const fillCost = order.taker_fill_cost || 0;
|
const fillCost = order.taker_fill_cost || 0;
|
||||||
const status = (order.status || '').toLowerCase();
|
const status = (order.status || '').toLowerCase();
|
||||||
|
|
||||||
// IOC with zero fills means nothing was available at our price
|
|
||||||
if (fillCount === 0 && (status === 'canceled' || status === 'cancelled')) {
|
if (fillCount === 0 && (status === 'canceled' || status === 'cancelled')) {
|
||||||
console.log(`[Live] IOC got 0 fills — no liquidity at ${priceCents}¢ for ${signal.ticker}`);
|
console.log(`[Live] IOC got 0 fills — no liquidity at ${priceCents}¢ for ${signal.ticker}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -220,6 +258,8 @@ export class LiveEngine {
|
|||||||
pnl: null,
|
pnl: null,
|
||||||
fillCount,
|
fillCount,
|
||||||
fillCost,
|
fillCost,
|
||||||
|
bestAsk,
|
||||||
|
maxPrice: maxAcceptable,
|
||||||
marketState: {
|
marketState: {
|
||||||
yesPct: marketState.yesPct,
|
yesPct: marketState.yesPct,
|
||||||
noPct: marketState.noPct
|
noPct: marketState.noPct
|
||||||
@@ -230,7 +270,6 @@ export class LiveEngine {
|
|||||||
this.totalTrades++;
|
this.totalTrades++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only track as open if we actually got fills that need settling
|
|
||||||
if (fillCount > 0) {
|
if (fillCount > 0) {
|
||||||
this.openOrders.set(order.order_id, orderRecord);
|
this.openOrders.set(order.order_id, orderRecord);
|
||||||
}
|
}
|
||||||
@@ -241,7 +280,7 @@ export class LiveEngine {
|
|||||||
console.error('[Live] DB write error:', e.message);
|
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}`);
|
console.log(`[Live] ${msg}`);
|
||||||
await notify(msg, `Live: ${signal.strategy}`, 'high', 'money_with_wings');
|
await notify(msg, `Live: ${signal.strategy}`, 'high', 'money_with_wings');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user