Files
KalBot/lib/live/engine.js

356 lines
11 KiB
JavaScript

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);
}
}
}