mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-16 21:41:02 +00:00
435 lines
14 KiB
JavaScript
435 lines
14 KiB
JavaScript
import { kalshiFetch } 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 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();
|
|
this.recentFills = [];
|
|
this.totalPnL = 0;
|
|
this.wins = 0;
|
|
this.losses = 0;
|
|
this.totalTrades = 0;
|
|
this._paused = false;
|
|
this._maxLossPerTradeCents = 500;
|
|
this._maxDailyLossCents = 2000;
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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') {
|
|
if (orderbook.no?.length) {
|
|
const bestNoBid = orderbook.no[0]?.[0];
|
|
if (bestNoBid != null) return 100 - bestNoBid;
|
|
}
|
|
} else {
|
|
if (orderbook.yes?.length) {
|
|
const bestYesBid = orderbook.yes[0]?.[0];
|
|
if (bestYesBid != null) return 100 - bestYesBid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Verify an order's actual fill status by polling Kalshi.
|
|
* IOC responses can report 0 fills even when fills happened async.
|
|
*/
|
|
async _verifyOrderFills(orderId, maxAttempts = 3) {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
if (i > 0) await new Promise(r => setTimeout(r, 500 * i));
|
|
try {
|
|
const data = await kalshiFetch('GET', `/trade-api/v2/portfolio/orders/${orderId}`);
|
|
const order = data?.order;
|
|
if (!order) continue;
|
|
|
|
const fillCount = order.taker_fill_count || order.fill_count || 0;
|
|
const fillCost = order.taker_fill_cost || order.fill_cost || 0;
|
|
const status = (order.status || '').toLowerCase();
|
|
const isFinal = ['canceled', 'cancelled', 'executed', 'filled', 'closed'].includes(status);
|
|
|
|
if (isFinal || fillCount > 0) {
|
|
return { fillCount, fillCost, status, order };
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Live] Verify attempt ${i + 1} failed:`, e.message);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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();
|
|
|
|
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 sizeDollars = signal.size;
|
|
const costCents = sizeDollars * 100;
|
|
|
|
if (costCents > this._maxLossPerTradeCents) {
|
|
console.log(`[Live] Trade cost $${sizeDollars} exceeds per-trade cap — skipping`);
|
|
return null;
|
|
}
|
|
|
|
// SAFETY: Require orderbook data before placing real money orders
|
|
const bestAsk = this._getBestAskFromOrderbook(signal.side, marketState.orderbook);
|
|
if (bestAsk == null) {
|
|
console.log(`[Live] No orderbook data for ${signal.side} side — refusing to trade blind (${signal.strategy})`);
|
|
return null;
|
|
}
|
|
|
|
const maxAcceptable = signal.maxPrice || (signal.price + 3);
|
|
|
|
if (bestAsk > maxAcceptable) {
|
|
console.log(`[Live] Best ask ${bestAsk}¢ > max ${maxAcceptable}¢ for ${signal.strategy} — skipping`);
|
|
return null;
|
|
}
|
|
|
|
const priceCents = Math.round(bestAsk);
|
|
|
|
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();
|
|
|
|
const orderBody = {
|
|
ticker: signal.ticker,
|
|
action: 'buy',
|
|
side,
|
|
count: contracts,
|
|
type: 'limit',
|
|
client_order_id: clientOrderId,
|
|
time_in_force: 'immediate_or_cancel',
|
|
};
|
|
|
|
if (side === 'yes') {
|
|
orderBody.yes_price = priceCents;
|
|
} else {
|
|
orderBody.no_price = priceCents;
|
|
}
|
|
|
|
try {
|
|
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;
|
|
|
|
if (!order?.order_id) {
|
|
console.error('[Live] Order response missing order_id:', JSON.stringify(result).slice(0, 300));
|
|
return null;
|
|
}
|
|
|
|
let fillCount = order.taker_fill_count || 0;
|
|
let fillCost = order.taker_fill_cost || 0;
|
|
let status = (order.status || '').toLowerCase();
|
|
|
|
// If immediate response says 0 fills, verify with Kalshi to catch async fills
|
|
if (fillCount === 0) {
|
|
console.log(`[Live] Immediate response: 0 fills (status: ${status}). Verifying with Kalshi...`);
|
|
const verified = await this._verifyOrderFills(order.order_id);
|
|
|
|
if (verified) {
|
|
fillCount = verified.fillCount;
|
|
fillCost = verified.fillCost;
|
|
status = verified.status;
|
|
console.log(`[Live] Verified: ${fillCount} fills, $${(fillCost/100).toFixed(2)} cost, status: ${status}`);
|
|
}
|
|
|
|
if (fillCount === 0) {
|
|
console.log(`[Live] Confirmed 0 fills for ${signal.ticker} @ ${priceCents}¢ — no liquidity`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const orderRecord = {
|
|
orderId: order.order_id,
|
|
clientOrderId,
|
|
strategy: signal.strategy,
|
|
ticker: signal.ticker,
|
|
side,
|
|
priceCents,
|
|
contracts: fillCount,
|
|
costCents: fillCost || costCents,
|
|
reason: signal.reason,
|
|
status: 'filled',
|
|
createdAt: Date.now(),
|
|
settled: false,
|
|
result: null,
|
|
pnl: null,
|
|
fillCount,
|
|
fillCost,
|
|
bestAsk,
|
|
maxPrice: maxAcceptable,
|
|
marketState: {
|
|
yesPct: marketState.yesPct,
|
|
noPct: marketState.noPct
|
|
}
|
|
};
|
|
|
|
this.totalTrades++;
|
|
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()} ${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');
|
|
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
}
|