Compare commits

..

111 Commits

Author SHA1 Message Date
babeb7605a Feat: Add Early Fader mean reversion strategy 2026-03-16 21:51:52 -07:00
0bf1150d62 Update bull-dip-buyer.js 2026-03-16 20:38:21 -07:00
fd53f12f63 Fix: shift late momentum start from 5m to 6m 2026-03-16 20:12:18 -07:00
149f5091fe Increase default bet size from 2 to 4 2026-03-16 20:05:19 -07:00
ab06fa683d Update momentum-rider.js 2026-03-16 20:04:26 -07:00
6314ed7d5e Refactor: Increase bet size for bull-dip-buyer 2026-03-16 19:45:52 -07:00
fa5303d9dc Feat: support YES+NO late momentum 2026-03-16 18:43:02 -07:00
0301b8a0ae Delete lib/strategies/dont-doubt-bull.js 2026-03-16 18:18:36 -07:00
135f623789 Delete lib/strategies/martingale-alpha.js 2026-03-16 18:18:29 -07:00
6f208da27a Feat: show expected payout on open live orders 2026-03-16 17:09:49 -07:00
f9cb0e1d7a Feat: show expected payout on open paper positions 2026-03-16 17:09:38 -07:00
05fd36ca1e Feat: Slightly raise momentum-rider bet size 2026-03-16 16:26:34 -07:00
55573ed7aa Feat: Slightly raise dip-buyer bet size 2026-03-16 16:26:30 -07:00
a034b26069 Feat: add late-momentum timing-gated strategy 2026-03-16 16:02:03 -07:00
076480d05d Fix: normalize live trade PnL cents/dollars 2026-03-16 14:51:58 -07:00
0cb4a082b1 Feat: gate dip buys to first 6 minutes 2026-03-16 14:27:25 -07:00
10827a817c Delete lib/strategies/threshold.js 2026-03-16 13:57:50 -07:00
0437bdb1db Fix: reconcile delayed live settlements after rotation 2026-03-16 13:51:55 -07:00
1c8dec1f17 Fix: settle delayed live results via orphan scan 2026-03-16 13:51:41 -07:00
2b9f2c5c2b Update readme 2026-03-16 13:30:42 -07:00
ca26f499f7 Docs: note Kalshi fixed-point migration 2026-03-16 13:30:05 -07:00
24f1405a93 Fix: Support Kalshi v2 fixed-point fill fields 2026-03-16 13:14:27 -07:00
6faad2b28e Fix: Enforce minYesPrice floor, require orderbook for live 2026-03-16 12:58:15 -07:00
4553a82b0d Fix: Verify fills async, reject blind orders, track ghost fills 2026-03-16 12:58:07 -07:00
8a06b9b668 Feat: Separate paper/live eval, pass orderbook ctx 2026-03-16 12:30:41 -07:00
9e138b22c6 Feat: Expose orderbook in getState for strategies 2026-03-16 12:30:31 -07:00
b9c55fb650 Feat: Orderbook-aware pricing for live orders 2026-03-16 12:30:20 -07:00
c050829b3a Feat: Split paper/live tracking, orderbook pricing 2026-03-16 12:30:14 -07:00
7ba25a1eaa Feat: Split paper/live tracking, orderbook pricing 2026-03-16 12:30:09 -07:00
0fc244fcf1 Feat: Split paper/live tracking, orderbook pricing 2026-03-16 12:30:02 -07:00
2beb261bad Feat: Split paper/live tracking, orderbook pricing 2026-03-16 12:29:57 -07:00
3b302a671c Feat: Split paper/live tracking, orderbook pricing 2026-03-16 12:29:52 -07:00
83df308c37 Feat: Add orderbook context to strategy base 2026-03-16 12:29:47 -07:00
00e613e27a Fix: Strip query params from path before signing 2026-03-16 12:12:44 -07:00
4b403e4aba Fix: Use correct Kalshi time_in_force value 2026-03-16 12:02:55 -07:00
b1a442e129 Fix: Use IOC instead of FoK, fix count calc 2026-03-16 11:47:21 -07:00
9577e55c95 Fix: Import kalshiFetch from correct module path 2026-03-16 11:38:38 -07:00
556cdc0ff1 Feat: Export getPositions + getBalance for live 2026-03-16 11:31:45 -07:00
e7198fede5 Feat: Worker runs both paper + live engines 2026-03-16 11:31:35 -07:00
c1bc4750bc Feat: Add nav link to live dashboard 2026-03-16 11:31:26 -07:00
32e1fa31f7 Feat: Full live trading dashboard with toggles 2026-03-16 11:31:15 -07:00
da882a7d46 Refactor: Redirect /dashboard to /dash 2026-03-16 11:31:12 -07:00
72dc03dffd Feat: Login redirects to /dash 2026-03-16 11:31:07 -07:00
3da40a1bf9 Feat: Redirect to /dash after login 2026-03-16 11:31:02 -07:00
7115c0ae08 Feat: Protect live API routes in middleware 2026-03-16 11:30:59 -07:00
1d99902ca3 Feat: Add strategy enable/disable + pause API 2026-03-16 11:30:54 -07:00
9015d0f524 Feat: Add live trades history API endpoint 2026-03-16 11:30:49 -07:00
b92e03370a Feat: Add live state API endpoint 2026-03-16 11:30:44 -07:00
551db9d5bc Feat: Add live trading engine with Kalshi API 2026-03-16 11:30:34 -07:00
3eef181780 Refactor: Reduce default betSize from 2 to 1 2026-03-16 10:45:13 -07:00
d4b2830943 Delete lib/strategies/martingale.js 2026-03-16 10:23:55 -07:00
c649e6fb8f Delete lib/strategies/sniper-reversal.js 2026-03-16 10:23:41 -07:00
fc8565e562 Feat: Auto-load strategies dynamically from folder 2026-03-16 00:33:56 -07:00
29fd889acb Fix: Add mutex lock to prevent trade evaluation race condition during settlement 2026-03-16 00:29:06 -07:00
7ba11ecdcb Refactor: Set paper trade notifications to priority 1 2026-03-15 21:19:56 -07:00
2948312619 Refactor: Register new strategies in worker 2026-03-15 20:44:34 -07:00
caca6d29b6 Feat: Add Don't Doubt Bull strategy 2026-03-15 20:44:13 -07:00
cffb156231 Feat: Add Momentum Rider strategy 2026-03-15 20:44:07 -07:00
f3910603fb Feat: Add Sniper Reversal strategy 2026-03-15 20:44:02 -07:00
4feed18ce0 Feat: Add Bull Dip Buyer strategy 2026-03-15 20:43:40 -07:00
1999377682 Fix: SurrealDB v2 compatible record IDs for trade history 2026-03-15 20:14:08 -07:00
04bd2fada6 Fix: Auto-reconnect to SurrealDB on token expiration 2026-03-15 20:11:56 -07:00
11339a0900 Fix: Explicitly set Surreal record IDs to save history 2026-03-15 20:11:28 -07:00
9f0ff58118 Fix: Prevent bot from placing 100¢ trades with zero profit potential 2026-03-15 18:49:43 -07:00
cf35715302 Fix: Prevent bot from placing 100¢ trades with zero profit potential 2026-03-15 18:49:39 -07:00
bd0811e113 Fix: UI incorrectly marking break-even wins as losses 2026-03-15 18:49:31 -07:00
02651535e6 Fix: Block new trades if strategy has unresolved positions 2026-03-15 18:42:09 -07:00
83ab2830b6 Feat: Add orphan polling task for delayed Kalshi results 2026-03-15 18:39:58 -07:00
b8f2406622 Fix: SurrealDB update syntax and add orphan checking 2026-03-15 18:39:51 -07:00
aa96eac863 Fix: Setup background task to poll delayed results 2026-03-15 18:35:54 -07:00
684ba9173c Fix: Add open ticker polling for delayed Kalshi results 2026-03-15 18:35:46 -07:00
a1c81c8c46 Fix: Block trades/settlements during reset, sync state 2026-03-15 17:58:24 -07:00
c4fc90094e Fix: Reset preserves open positions, fixes balance race 2026-03-15 17:58:14 -07:00
8363c85f38 Refactor: Convert to light mode 2026-03-15 17:36:17 -07:00
23d8df2116 Fix: Light mode, reset btn, history=settled only 2026-03-15 17:36:09 -07:00
3b1c594636 Feat: Protect /api/reset behind auth 2026-03-15 17:36:04 -07:00
eb36190254 Feat: Add paper trading reset endpoint 2026-03-15 17:36:01 -07:00
0bcb9666b0 Fix: Only return settled trades in history 2026-03-15 17:35:57 -07:00
1e04e0c558 Fix: Settle orphans on startup, reset flag, norm result 2026-03-15 17:35:51 -07:00
0acc63c512 Fix: Normalize result case, settle orphans, add reset 2026-03-15 17:35:43 -07:00
9c82b49ed9 Feat: Redirect login to /paper instead of /dashboard 2026-03-15 16:51:47 -07:00
b6b0d990d4 Feat: Placeholder live trading dashboard 2026-03-15 16:51:40 -07:00
32341e76b0 Refactor: Redirect dashboard to /paper for now 2026-03-15 16:51:30 -07:00
5ce2fa6924 Feat: Per-strategy paper dashboard for strat hunting 2026-03-15 16:51:19 -07:00
8d90c92d3f Feat: Protect /paper and /dash routes 2026-03-15 16:51:03 -07:00
c16ef77beb Feat: Filter trades by strategy query param 2026-03-15 16:50:58 -07:00
b35fcfe13f Feat: Include paperByStrategy in state response 2026-03-15 16:50:52 -07:00
2cd79d45d1 Feat: Add martingale-alpha, per-strategy state output 2026-03-15 16:50:45 -07:00
c377c56975 Refactor: Per-strategy isolated balance and PnL 2026-03-15 16:50:34 -07:00
0adcc947ce Feat: Add martingale-alpha coin-flip 3-round strat 2026-03-15 16:50:25 -07:00
647b46d1b8 Fix: Handle dollar-string WS fields, fix orderbook 2026-03-15 15:47:51 -07:00
c2f878b23d Fix: Parse new Kalshi WS dollar-string format 2026-03-15 15:47:45 -07:00
aedb6aeda5 Fix: reduce Kalshi 400/429 spam 2026-03-15 15:34:14 -07:00
e93381c9f1 Fix: parse Surreal query results safely 2026-03-15 15:06:42 -07:00
eda38cb58e Fix: normalize Surreal query response 2026-03-15 15:06:35 -07:00
d7dabea20f Fix: robust quote/orderbook parsing 2026-03-15 15:06:23 -07:00
3c48e2bd50 Fix: unwrap WS payloads + safer reconnect 2026-03-15 15:06:16 -07:00
1c57c60770 Fix: Harden RSA key env parsing 2026-03-15 14:55:02 -07:00
96f1f9359e Fix: Rank BTC events and expose candidates 2026-03-15 14:54:58 -07:00
d57c0402d1 Fix: Skip empty events and pick tradable market 2026-03-15 14:54:51 -07:00
51177b5b8a Refactor: hardcode series ticker, keep dynamic 2026-03-15 14:42:51 -07:00
b95430e863 Refactor: remove series ticker env var 2026-03-15 14:42:48 -07:00
0a5f2af3ae Fix: heartbeat state + startup write 2026-03-15 14:40:32 -07:00
6921fb1cdd Fix: empty markets fallback + rotation fix 2026-03-15 14:40:21 -07:00
677050a224 Fix: robust active event discovery 2026-03-15 14:40:15 -07:00
8c76087b6a Fix: make Kalshi API base configurable 2026-03-15 14:40:09 -07:00
c3b9bf1475 Fix: add configurable Kalshi series/base 2026-03-15 14:40:06 -07:00
b92c8fab4b Feat: Add middleware to protect dashboard and API routes 2026-03-15 14:22:24 -07:00
e5565327ec Feat: Implement signed HttpOnly session cookie 2026-03-15 14:22:20 -07:00
d2d742df3b Feat: Add Web Crypto session signing and verification 2026-03-15 14:22:16 -07:00
688e40edd3 Fix: Add dashboard redirect on successful login 2026-03-15 14:21:18 -07:00
30 changed files with 3259 additions and 955 deletions

View File

@@ -6,5 +6,6 @@ PORT=3004
SURREAL_URL= SURREAL_URL=
SURREAL_USER= SURREAL_USER=
SURREAL_PASS= SURREAL_PASS=
KALSHI_API_BASE=https://api.elections.kalshi.com
KALSHI_API_KEY_ID=your-key-id-here KALSHI_API_KEY_ID=your-key-id-here
KALSHI_RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nPASTE_YOUR_FULL_KEY_HERE\n-----END RSA PRIVATE KEY-----" KALSHI_RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nPASTE_YOUR_FULL_KEY_HERE\n-----END RSA PRIVATE KEY-----"

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
export const dynamic = 'force-dynamic';
const STATE_FILE = '/tmp/kalbot-live-state.json';
export async function GET() {
try {
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
const data = JSON.parse(raw);
return NextResponse.json(data);
} catch (e) {
return NextResponse.json({
market: null,
live: {
balance: null, portfolioValue: null, totalPnL: 0,
wins: 0, losses: 0, winRate: 0, totalTrades: 0,
openOrders: [], paused: true, dailyLoss: 0,
maxDailyLoss: 20, maxPerTrade: 5,
enabledStrategies: [], positions: []
},
strategies: [],
workerUptime: 0,
lastUpdate: null,
error: 'Worker not running or no data yet'
});
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
export async function POST(req) {
try {
const body = await req.json();
const { action, strategy } = body;
// Write command file that worker polls
const cmd = JSON.stringify({ action, strategy, ts: Date.now() });
fs.writeFileSync('/tmp/kalbot-live-cmd', cmd);
return NextResponse.json({ success: true, message: `Command "${action}" sent.` });
} catch (e) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import Surreal from 'surrealdb';
export const dynamic = 'force-dynamic';
function normalizeRows(result) {
if (!Array.isArray(result) || !result.length) return [];
const first = result[0];
if (Array.isArray(first)) return first;
if (first && typeof first === 'object' && Array.isArray(first.result)) return first.result;
return [];
}
export async function GET(req) {
const url = process.env.SURREAL_URL;
if (!url) return NextResponse.json({ trades: [], error: 'No DB configured' });
const { searchParams } = new URL(req.url);
const strategyFilter = searchParams.get('strategy');
let client = null;
try {
client = new Surreal();
await client.connect(url);
await client.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
await client.use({ namespace: 'kalbot', database: 'kalbot' });
let query = 'SELECT * FROM live_orders WHERE settled = true';
const vars = {};
if (strategyFilter) {
query += ' AND strategy = $strategy';
vars.strategy = strategyFilter;
}
query += ' ORDER BY settleTime DESC LIMIT 50';
const result = await client.query(query, vars);
return NextResponse.json({ trades: normalizeRows(result) });
} catch (e) {
return NextResponse.json({ trades: [], error: e.message });
} finally {
try { await client?.close?.(); } catch {}
}
}

View File

@@ -1,38 +1,44 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import crypto from 'crypto'; import crypto from 'crypto';
import { signSession } from '../../../lib/auth';
export async function POST(req) { export async function POST(req) {
try { try {
const body = await req.json(); const body = await req.json();
const { email, password, captcha } = body; const { email, password, captcha } = body;
const cookieHash = req.cookies.get('captcha_hash')?.value;
const secret = process.env.CAPTCHA_SECRET || 'dev_secret_meow';
const expectedHash = crypto.createHmac('sha256', secret).update((captcha || '').toLowerCase()).digest('hex');
if (!cookieHash || cookieHash !== expectedHash) { const cookieHash = req.cookies.get('captcha_hash')?.value;
return NextResponse.json({ error: 'Invalid or expired captcha' }, { status: 400 }); const secret = process.env.CAPTCHA_SECRET || 'dev_secret_meow';
} const expectedHash = crypto.createHmac('sha256', secret).update((captcha || '').toLowerCase()).digest('hex');
if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASS) { if (!cookieHash || cookieHash !== expectedHash) {
// Real implementation would set a JWT or session cookie here return NextResponse.json({ error: 'Invalid or expired captcha' }, { status: 400 });
return NextResponse.json({ success: true, message: 'Welcome back, Master!' });
} else {
// Trigger NTFY alert for failed login
if (process.env.NTFY_URL) {
await fetch(process.env.NTFY_URL, {
method: 'POST',
body: `Failed login attempt for email: ${email}`,
headers: {
'Title': 'Kalbot Login Alert',
'Priority': 'urgent',
'Tags': 'warning,skull'
}
}).catch(e => console.error("Ntfy error:", e));
}
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
} catch (err) {
return NextResponse.json({ error: 'Server error' }, { status: 500 });
} }
if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASS) {
const token = await signSession();
const response = NextResponse.json({ success: true, message: 'Welcome back, Master!', redirect: '/dash' });
response.cookies.set('kalbot_session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24
});
return response;
} else {
if (process.env.NTFY_URL) {
await fetch(process.env.NTFY_URL, {
method: 'POST',
body: `Failed login attempt for email: ${email}`,
headers: { 'Title': 'Kalbot Login Alert', 'Priority': 'urgent', 'Tags': 'warning,skull' }
}).catch(e => console.error("Ntfy error:", e));
}
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
} catch (err) {
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
} }

12
app/api/reset/route.js Normal file
View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
export async function POST() {
try {
// Write a flag file that the worker polls for
fs.writeFileSync('/tmp/kalbot-reset-flag', 'reset');
return NextResponse.json({ success: true, message: 'Reset signal sent. Data will clear momentarily.' });
} catch (e) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@@ -14,6 +14,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
market: null, market: null,
paper: { balance: 1000, totalPnL: 0, wins: 0, losses: 0, winRate: 0, openPositions: [], totalTrades: 0 }, paper: { balance: 1000, totalPnL: 0, wins: 0, losses: 0, winRate: 0, openPositions: [], totalTrades: 0 },
paperByStrategy: {},
strategies: [], strategies: [],
workerUptime: 0, workerUptime: 0,
lastUpdate: null, lastUpdate: null,

View File

@@ -3,23 +3,52 @@ import Surreal from 'surrealdb';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET() { function normalizeRows(result) {
if (!Array.isArray(result) || !result.length) return [];
const first = result[0];
if (Array.isArray(first)) return first;
if (first && typeof first === 'object' && Array.isArray(first.result)) return first.result;
return [];
}
export async function GET(req) {
const url = process.env.SURREAL_URL; const url = process.env.SURREAL_URL;
if (!url) { if (!url) {
return NextResponse.json({ trades: [], error: 'No DB configured' }); return NextResponse.json({ trades: [], error: 'No DB configured' });
} }
const { searchParams } = new URL(req.url);
const strategyFilter = searchParams.get('strategy');
let client = null;
try { try {
const client = new Surreal(); client = new Surreal();
await client.connect(url); await client.connect(url);
await client.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS }); await client.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
await client.use({ namespace: 'kalbot', database: 'kalbot' }); await client.use({ namespace: 'kalbot', database: 'kalbot' });
const result = await client.query('SELECT * FROM paper_positions ORDER BY entryTime DESC LIMIT 50'); let query = 'SELECT * FROM paper_positions WHERE settled = true';
const trades = result[0] || []; const vars = {};
if (strategyFilter) {
query += ' AND strategy = $strategy';
vars.strategy = strategyFilter;
}
query += ' ORDER BY settleTime DESC LIMIT 50';
const result = await client.query(query, vars);
const trades = normalizeRows(result);
return NextResponse.json({ trades }); return NextResponse.json({ trades });
} catch (e) { } catch (e) {
return NextResponse.json({ trades: [], error: e.message }); return NextResponse.json({ trades: [], error: e.message });
} finally {
try {
await client?.close?.();
} catch {
// ignore
}
} }
} }

394
app/dash/page.js Normal file
View File

@@ -0,0 +1,394 @@
'use client';
import { useState, useEffect } from 'react';
const GREEN = '#16A34A';
const RED = '#DC2626';
const BLUE = '#2563EB';
const AMBER = '#D97706';
export default function LiveDashboard() {
const [data, setData] = useState(null);
const [trades, setTrades] = useState([]);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState(null);
useEffect(() => {
const fetchState = async () => {
try {
const res = await fetch('/api/live-state');
const json = await res.json();
setData(json);
setLoading(false);
} catch (e) {
console.error('State fetch error:', e);
}
};
fetchState();
const interval = setInterval(fetchState, 2000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const fetchTrades = async () => {
try {
const res = await fetch('/api/live-trades');
const json = await res.json();
setTrades(json.trades || []);
} catch (e) {
console.error('Trades fetch error:', e);
}
};
fetchTrades();
const interval = setInterval(fetchTrades, 10000);
return () => clearInterval(interval);
}, []);
const sendCommand = async (action, strategy = null) => {
setToggling(strategy || action);
try {
await fetch('/api/live-toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, strategy })
});
} catch (e) {
console.error('Command error:', e);
}
setTimeout(() => setToggling(null), 1500);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-green-400 text-lg animate-pulse">Loading Live Trading...</div>
</div>
);
}
const market = data?.market;
const live = data?.live || {};
const strategies = data?.strategies || [];
const enabledSet = new Set(live.enabledStrategies || []);
return (
<div className="min-h-screen bg-gray-950 text-gray-100 font-sans pb-20">
{/* Header */}
<header className="sticky top-0 z-50 bg-gray-900/95 backdrop-blur border-b border-gray-800 px-4 py-3">
<div className="flex items-center justify-between max-w-lg mx-auto">
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-green-400">Kalbot</h1>
<span className="text-xs bg-red-900/50 text-red-400 px-2 py-0.5 rounded-full font-bold border border-red-800">LIVE</span>
</div>
<div className="flex items-center gap-3">
<a href="/paper" className="text-[10px] px-2 py-1 rounded bg-gray-800 text-gray-400 border border-gray-700 hover:bg-gray-700 transition-colors">
📝 Paper
</a>
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${data?.lastUpdate ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
<span className="text-xs text-gray-500">{data?.lastUpdate ? 'Live' : 'Offline'}</span>
</div>
</div>
</div>
</header>
<main className="max-w-lg mx-auto px-4 pt-4 space-y-4">
{/* Kill Switch + Balance */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Kalshi Balance</p>
<p className="text-2xl font-bold text-white">
{live.balance != null ? `$${live.balance.toFixed(2)}` : '—'}
</p>
{live.portfolioValue != null && (
<p className="text-[10px] text-gray-500">Portfolio: ${live.portfolioValue.toFixed(2)}</p>
)}
</div>
<button
onClick={() => sendCommand(live.paused ? 'resume' : 'pause')}
disabled={toggling === 'pause' || toggling === 'resume'}
className={`px-4 py-2 rounded-lg font-bold text-sm transition-all ${
live.paused
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-red-600 hover:bg-red-500 text-white animate-pulse'
} disabled:opacity-50`}
>
{live.paused ? '▶ Resume' : '⏸ Kill Switch'}
</button>
</div>
<div className="grid grid-cols-4 gap-2">
<StatBox label="PnL" value={`${live.totalPnL >= 0 ? '+' : ''}$${live.totalPnL?.toFixed(2) || '0.00'}`} color={live.totalPnL >= 0 ? GREEN : RED} dark />
<StatBox label="Win Rate" value={`${live.winRate || 0}%`} color={(live.winRate || 0) >= 50 ? GREEN : RED} dark />
<StatBox label="Trades" value={live.totalTrades || 0} dark />
<StatBox label="Daily Loss" value={`$${live.dailyLoss?.toFixed(2) || '0.00'}`} color={live.dailyLoss > 0 ? AMBER : null} dark />
</div>
{live.paused && (
<div className="mt-3 text-xs text-red-400 bg-red-900/30 border border-red-800 rounded-lg p-2 text-center font-medium">
TRADING PAUSED No new orders will be placed
</div>
)}
<div className="flex justify-between text-[10px] text-gray-600 mt-3">
<span>Per-trade cap: ${live.maxPerTrade?.toFixed(2) || '5.00'}</span>
<span>Daily limit: ${live.maxDailyLoss?.toFixed(2) || '20.00'}</span>
</div>
</div>
{/* Market Card */}
<MarketCard market={market} />
{/* Strategy Toggles */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800">
<h3 className="text-xs text-gray-400 uppercase tracking-wider font-bold"> Strategy Controls</h3>
</div>
{strategies.map((s, i) => {
const isEnabled = enabledSet.has(s.name);
const isToggling = toggling === s.name;
return (
<div key={s.name} className={`flex items-center justify-between px-4 py-3 ${i < strategies.length - 1 ? 'border-b border-gray-800' : ''}`}>
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${isEnabled ? 'bg-green-500' : 'bg-gray-600'}`} />
<div>
<p className="text-sm font-medium capitalize text-gray-200">{s.name}</p>
{s.config && (
<p className="text-[10px] text-gray-600">
${s.config.betSize || 1}/trade {s.config.cooldownMs ? `${(s.config.cooldownMs / 1000).toFixed(0)}s cd` : ''}
</p>
)}
</div>
</div>
<button
onClick={() => sendCommand(isEnabled ? 'disable' : 'enable', s.name)}
disabled={isToggling}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
isEnabled ? 'bg-green-600' : 'bg-gray-700'
} disabled:opacity-50`}
>
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all duration-300 ${
isEnabled ? 'left-6' : 'left-0.5'
}`} />
</button>
</div>
);
})}
{strategies.length === 0 && (
<p className="text-gray-600 text-xs text-center py-4">No strategies loaded yet.</p>
)}
</div>
{/* Open Orders */}
{live.openOrders?.length > 0 && (
<div>
<h4 className="text-[10px] text-gray-500 uppercase tracking-wider mb-2 font-bold">
Open Orders ({live.openOrders.length})
</h4>
{live.openOrders.map((o, i) => (
<LiveOrderRow key={o.orderId || i} order={o} isOpen />
))}
</div>
)}
{/* Kalshi Positions */}
{live.positions?.length > 0 && (
<div>
<h4 className="text-[10px] text-gray-500 uppercase tracking-wider mb-2 font-bold">
Kalshi Positions ({live.positions.length})
</h4>
{live.positions.map((p, i) => (
<div key={i} className="bg-gray-900 rounded-lg p-3 border border-gray-800 mb-2">
<div className="flex items-center justify-between">
<span className="text-xs font-mono text-gray-300">{p.market_ticker || p.ticker}</span>
<span className="text-xs text-gray-400">{p.position_fp || p.position || 0} contracts</span>
</div>
{p.realized_pnl_dollars && (
<p className="text-[10px] text-gray-500 mt-1">
Realized PnL: <span className={Number(p.realized_pnl_dollars) >= 0 ? 'text-green-400' : 'text-red-400'}>
${Number(p.realized_pnl_dollars).toFixed(2)}
</span>
</p>
)}
</div>
))}
</div>
)}
{/* Trade History */}
<div>
<h4 className="text-[10px] text-gray-500 uppercase tracking-wider mb-2 font-bold">
Trade History ({trades.length})
</h4>
{trades.length === 0 ? (
<p className="text-gray-600 text-xs text-center py-4">No settled trades yet. Enable a strategy to start.</p>
) : (
trades.map((t, i) => <LiveOrderRow key={i} order={t} />)
)}
</div>
</main>
{/* Footer */}
<div className="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-gray-800 py-2 px-4">
<div className="max-w-lg mx-auto flex justify-between text-xs text-gray-600">
<span>Worker: {formatUptime(data?.workerUptime)}</span>
<span>{data?.lastUpdate ? new Date(data.lastUpdate).toLocaleTimeString() : 'never'}</span>
</div>
</div>
</div>
);
}
function MarketCard({ market }) {
if (!market) {
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-gray-600 text-center text-sm">No active market waiting...</p>
</div>
);
}
const timeLeft = market.closeTime ? getTimeLeft(market.closeTime) : null;
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="font-bold text-sm text-gray-100">BTC Up or Down</h2>
<p className="text-[10px] text-gray-600">15 min</p>
</div>
<div className="flex items-center gap-2">
{timeLeft && (
<span className="text-[10px] bg-gray-800 px-2 py-0.5 rounded-full text-gray-400"> {timeLeft}</span>
)}
<span className="text-lg"></span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Up</span>
<span style={{ color: GREEN }} className="font-medium">{market.yesPct}%</span>
</div>
<div className="w-full bg-gray-800 rounded-full h-1.5">
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.yesPct}%`, backgroundColor: GREEN }} />
</div>
</div>
<div className="flex-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Down</span>
<span style={{ color: BLUE }} className="font-medium">{market.noPct}%</span>
</div>
<div className="w-full bg-gray-800 rounded-full h-1.5">
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.noPct}%`, backgroundColor: BLUE }} />
</div>
</div>
</div>
<div className="flex justify-between text-[10px] text-gray-600 mt-2">
<span>${(market.volume || 0).toLocaleString()} vol</span>
<span className="font-mono">{market.ticker}</span>
</div>
</div>
);
}
function LiveOrderRow({ order, isOpen }) {
const won = order.result && order.side?.toLowerCase() === order.result?.toLowerCase();
const isNeutral = order.result === 'cancelled' || order.result === 'expired';
const rawPnl = order?.pnl != null ? Number(order.pnl) : null;
const pnlVal = Number.isFinite(rawPnl)
? (Number.isInteger(rawPnl) ? rawPnl / 100 : rawPnl)
: null;
const pnlColor = pnlVal == null ? 'text-gray-600' : pnlVal > 0 ? 'text-green-400' : pnlVal < 0 ? 'text-red-400' : 'text-gray-400';
const priceRaw = Number(order.priceCents ?? order.price);
const priceCents = Number.isFinite(priceRaw)
? (priceRaw > 0 && priceRaw <= 1 ? Math.round(priceRaw * 100) : Math.round(priceRaw))
: null;
const contractsRaw = Number(order.contracts ?? 1);
const contracts = Number.isFinite(contractsRaw) && contractsRaw > 0 ? contractsRaw : 1;
const hasExpected = isOpen && priceCents != null && priceCents > 0 && priceCents < 100;
const grossPayout = hasExpected ? contracts : null; // $1 per winning contract
const cost = hasExpected ? (priceCents / 100) * contracts : null;
const winPnL = hasExpected ? grossPayout - cost : null;
const losePnL = hasExpected ? -cost : null;
return (
<div className="bg-gray-900 rounded-lg p-3 border border-gray-800 mb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isOpen ? (
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
) : (
<span>{isNeutral ? '' : won ? '✅' : '❌'}</span>
)}
<span className="text-sm font-medium capitalize text-gray-200">{order.side}</span>
<span className="text-xs text-gray-500">@ {order.priceCents || order.price}¢</span>
<span className="text-[10px] text-gray-600">{order.contracts || 1}x</span>
</div>
<span className={`text-sm font-bold ${pnlColor}`}>
{pnlVal != null ? `${pnlVal >= 0 ? '+' : ''}$${pnlVal.toFixed(2)}` : 'open'}
</span>
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-gray-600">{order.reason}</span>
<span className="text-[10px] text-gray-600 capitalize">{order.strategy}</span>
</div>
{hasExpected && (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-gray-500">
<span>
If win: <span className="text-green-400 font-medium">+${winPnL.toFixed(2)}</span>
</span>
<span>
If lose: <span className="text-red-400 font-medium">${losePnL.toFixed(2)}</span>
</span>
<span>
Gross payout: <span className="text-gray-300 font-medium">${grossPayout.toFixed(2)}</span>
</span>
</div>
)}
{order.result && !isOpen && (
<div className="flex justify-between items-center mt-0.5">
<span className="text-[10px] text-gray-600">Result: {order.result}</span>
<span className="text-[10px] text-gray-600">
{order.settleTime ? new Date(order.settleTime).toLocaleTimeString() : order.createdAt ? new Date(order.createdAt).toLocaleTimeString() : ''}
</span>
</div>
)}
</div>
);
}
function StatBox({ label, value, color, dark }) {
return (
<div className={`rounded-lg p-2 border text-center ${dark ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-100'}`}>
<p className="text-[9px] text-gray-500 uppercase tracking-wider">{label}</p>
<p className="text-xs font-bold mt-0.5" style={color ? { color } : { color: dark ? '#F3F4F6' : '#111827' }}>{value}</p>
</div>
);
}
function getTimeLeft(closeTime) {
const diff = new Date(closeTime).getTime() - Date.now();
if (diff <= 0) return 'Closing...';
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatUptime(seconds) {
if (!seconds) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}

View File

@@ -1,360 +1,5 @@
'use client'; import { redirect } from 'next/navigation';
import { useState, useEffect } from 'react';
const GREEN = '#28CC95';
const RED = '#FF6B6B';
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState(null); redirect('/dash');
const [trades, setTrades] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('market');
useEffect(() => {
const fetchState = async () => {
try {
const res = await fetch('/api/state');
const json = await res.json();
setData(json);
setLoading(false);
} catch (e) {
console.error('State fetch error:', e);
}
};
const fetchTrades = async () => {
try {
const res = await fetch('/api/trades');
const json = await res.json();
setTrades(json.trades || []);
} catch (e) {
console.error('Trades fetch error:', e);
}
};
fetchState();
fetchTrades();
const interval = setInterval(fetchState, 2000);
const tradesInterval = setInterval(fetchTrades, 10000);
return () => {
clearInterval(interval);
clearInterval(tradesInterval);
};
}, []);
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="text-[#28CC95] text-lg animate-pulse">Loading Kalbot...</div>
</div>
);
}
const market = data?.market;
const paper = data?.paper;
const strategies = data?.strategies || [];
return (
<div className="min-h-screen bg-[#0a0a0a] text-white font-sans pb-20">
{/* Header */}
<header className="sticky top-0 z-50 bg-[#0a0a0a]/95 backdrop-blur border-b border-white/10 px-4 py-3">
<div className="flex items-center justify-between max-w-lg mx-auto">
<h1 className="text-lg font-bold" style={{ color: GREEN }}>Kalbot</h1>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${data?.lastUpdate ? 'bg-green-400 animate-pulse' : 'bg-red-500'}`} />
<span className="text-xs text-gray-400">
{data?.lastUpdate ? 'Live' : 'Offline'}
</span>
</div>
</div>
</header>
<main className="max-w-lg mx-auto px-4 pt-4 space-y-4">
{/* Market Card */}
<MarketCard market={market} />
{/* Paper Stats */}
<PaperStats paper={paper} />
{/* Tab Bar */}
<div className="flex gap-1 bg-white/5 rounded-lg p-1">
{['market', 'strategies', 'trades'].map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-all ${
tab === t ? 'bg-white/10 text-white' : 'text-gray-500 hover:text-gray-300'
}`}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{/* Tab Content */}
{tab === 'market' && <MarketDetails market={market} />}
{tab === 'strategies' && <StrategiesView strategies={strategies} />}
{tab === 'trades' && <TradesView trades={trades} openPositions={paper?.openPositions || []} />}
</main>
{/* Worker Uptime */}
<div className="fixed bottom-0 left-0 right-0 bg-[#0a0a0a]/95 backdrop-blur border-t border-white/5 py-2 px-4">
<div className="max-w-lg mx-auto flex justify-between text-xs text-gray-600">
<span>Worker uptime: {formatUptime(data?.workerUptime)}</span>
<span>Updated: {data?.lastUpdate ? new Date(data.lastUpdate).toLocaleTimeString() : 'never'}</span>
</div>
</div>
</div>
);
}
function MarketCard({ market }) {
if (!market) {
return (
<div className="bg-white/5 rounded-2xl p-5 border border-white/10">
<p className="text-gray-500 text-center">No active market waiting for next 15-min window...</p>
</div>
);
}
const timeLeft = market.closeTime ? getTimeLeft(market.closeTime) : null;
return (
<div className="bg-white/5 rounded-2xl p-5 border border-white/10 space-y-4">
{/* Title */}
<div className="flex items-center justify-between">
<div>
<h2 className="font-bold text-base">BTC Up or Down</h2>
<p className="text-xs text-gray-400">15 minutes</p>
</div>
<div className="flex items-center gap-2">
{timeLeft && (
<span className="text-xs bg-white/10 px-2 py-1 rounded-full text-gray-300">
{timeLeft}
</span>
)}
<span className="text-2xl"></span>
</div>
</div>
{/* Up */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Up</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">{market.yesOdds}x</span>
<span className="text-sm font-bold px-3 py-1 rounded-full border"
style={{ borderColor: GREEN, color: GREEN }}>
{market.yesPct}%
</span>
</div>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div className="h-2 rounded-full transition-all duration-500"
style={{ width: `${market.yesPct}%`, backgroundColor: GREEN }} />
</div>
</div>
{/* Down */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Down</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">{market.noOdds}x</span>
<span className="text-sm font-bold px-3 py-1 rounded-full border"
style={{ borderColor: '#4A90D9', color: '#4A90D9' }}>
{market.noPct}%
</span>
</div>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div className="h-2 rounded-full transition-all duration-500"
style={{ width: `${market.noPct}%`, backgroundColor: '#4A90D9' }} />
</div>
</div>
{/* Volume */}
<div className="flex justify-between text-xs text-gray-500 pt-1">
<span>${(market.volume || 0).toLocaleString()} vol</span>
<span className="font-mono text-gray-600">{market.ticker}</span>
</div>
</div>
);
}
function PaperStats({ paper }) {
if (!paper) return null;
const pnlColor = paper.totalPnL >= 0 ? GREEN : RED;
return (
<div className="grid grid-cols-4 gap-2">
<StatBox label="Balance" value={`$${paper.balance}`} />
<StatBox label="PnL" value={`${paper.totalPnL >= 0 ? '+' : ''}$${paper.totalPnL}`} color={pnlColor} />
<StatBox label="Win Rate" value={`${paper.winRate}%`} color={paper.winRate >= 50 ? GREEN : RED} />
<StatBox label="Trades" value={paper.totalTrades} />
</div>
);
}
function StatBox({ label, value, color }) {
return (
<div className="bg-white/5 rounded-xl p-3 border border-white/5 text-center">
<p className="text-[10px] text-gray-500 uppercase tracking-wider">{label}</p>
<p className="text-sm font-bold mt-0.5" style={color ? { color } : {}}>{value}</p>
</div>
);
}
function MarketDetails({ market }) {
if (!market) return <p className="text-gray-500 text-sm text-center py-8">No market data</p>;
const rows = [
['Yes Bid / Ask', `${market.yesBid || '-'}¢ / ${market.yesAsk || '-'}¢`],
['No Bid / Ask', `${market.noBid || '-'}¢ / ${market.noAsk || '-'}¢`],
['Last Price', `${market.lastPrice || '-'}¢`],
['Volume 24h', `$${(market.volume24h || 0).toLocaleString()}`],
['Open Interest', (market.openInterest || 0).toLocaleString()],
['Status', market.status || 'unknown'],
['Closes', market.closeTime ? new Date(market.closeTime).toLocaleTimeString() : '-'],
];
return (
<div className="bg-white/5 rounded-xl border border-white/5 overflow-hidden">
{rows.map(([k, v], i) => (
<div key={k} className={`flex justify-between px-4 py-3 ${i < rows.length - 1 ? 'border-b border-white/5' : ''}`}>
<span className="text-sm text-gray-400">{k}</span>
<span className="text-sm font-medium">{v}</span>
</div>
))}
</div>
);
}
function StrategiesView({ strategies }) {
if (!strategies.length) {
return <p className="text-gray-500 text-sm text-center py-8">No strategies loaded</p>;
}
return (
<div className="space-y-3">
{strategies.map((s, i) => (
<div key={i} className="bg-white/5 rounded-xl p-4 border border-white/5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${s.enabled && !s.paused ? 'bg-green-400' : 'bg-red-500'}`} />
<span className="font-bold text-sm capitalize">{s.name}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-white/10 text-gray-400">{s.mode}</span>
</div>
<div className="space-y-1 text-xs text-gray-400">
{s.config && Object.entries(s.config).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span>{k}</span>
<span className="text-gray-300">{typeof v === 'number' ? v : String(v)}</span>
</div>
))}
{s.consecutiveLosses !== undefined && (
<div className="flex justify-between mt-1 pt-1 border-t border-white/5">
<span>Consecutive Losses</span>
<span className={s.consecutiveLosses > 0 ? 'text-red-400' : 'text-gray-300'}>
{s.consecutiveLosses}
</span>
</div>
)}
{s.currentBetSize !== undefined && (
<div className="flex justify-between">
<span>Next Bet</span>
<span className="text-gray-300">${s.currentBetSize}</span>
</div>
)}
{s.paused && (
<div className="text-red-400 font-medium mt-1"> PAUSED max losses reached</div>
)}
</div>
</div>
))}
</div>
);
}
function TradesView({ trades, openPositions }) {
return (
<div className="space-y-3">
{/* Open Positions */}
{openPositions.length > 0 && (
<div>
<h3 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Open Positions</h3>
{openPositions.map((t, i) => (
<TradeRow key={i} trade={t} isOpen />
))}
</div>
)}
{/* Trade History */}
<div>
<h3 className="text-xs text-gray-500 uppercase tracking-wider mb-2">
History ({trades.length})
</h3>
{trades.length === 0 ? (
<p className="text-gray-600 text-sm text-center py-6">No trades yet. Strategies are watching...</p>
) : (
trades.map((t, i) => <TradeRow key={i} trade={t} />)
)}
</div>
</div>
);
}
function TradeRow({ trade, isOpen }) {
const won = trade.pnl > 0;
const pnlColor = trade.pnl == null ? 'text-gray-400' : won ? 'text-green-400' : 'text-red-400';
return (
<div className="bg-white/5 rounded-lg p-3 border border-white/5 mb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isOpen ? (
<span className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
) : (
<span>{won ? '✅' : '❌'}</span>
)}
<span className="text-sm font-medium capitalize">{trade.side}</span>
<span className="text-xs text-gray-500">@ {trade.price}¢</span>
</div>
<span className={`text-sm font-bold ${pnlColor}`}>
{trade.pnl != null ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl}` : 'open'}
</span>
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-gray-600 capitalize">{trade.strategy}</span>
<span className="text-[10px] text-gray-600">
{trade.entryTime ? new Date(trade.entryTime).toLocaleTimeString() : ''}
</span>
</div>
{trade.reason && (
<p className="text-[10px] text-gray-600 mt-1 truncate">{trade.reason}</p>
)}
</div>
);
}
function getTimeLeft(closeTime) {
const diff = new Date(closeTime).getTime() - Date.now();
if (diff <= 0) return 'Closing...';
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatUptime(seconds) {
if (!seconds) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
} }

View File

@@ -1,106 +1,72 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
// TODO: We should use this Kalshi green accent (#28CC95) all over the code.
export default function LoginPage() { export default function LoginPage() {
const[email, setEmail] = useState(''); const router = useRouter();
const [password, setPassword] = useState(''); const [email, setEmail] = useState('');
const[captcha, setCaptcha] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [captcha, setCaptcha] = useState('');
const[success, setSuccess] = useState(''); const [error, setError] = useState('');
const captchaImgRef = useRef(null); const [success, setSuccess] = useState('');
const captchaImgRef = useRef(null);
const refreshCaptcha = () => { const refreshCaptcha = () => {
if (captchaImgRef.current) { if (captchaImgRef.current) {
captchaImgRef.current.src = `/api/captcha?${new Date().getTime()}`; captchaImgRef.current.src = `/api/captcha?${new Date().getTime()}`;
} }
}; };
const handleLogin = async (e) => { const handleLogin = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setSuccess(''); setSuccess('');
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, captcha }) body: JSON.stringify({ email, password, captcha })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error); setError(data.error);
refreshCaptcha(); refreshCaptcha();
setCaptcha(''); setCaptcha('');
} else { } else {
setSuccess(data.message); setSuccess(data.message);
} router.push(data.redirect || '/dash');
}; }
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-gray-900 font-sans"> <div className="min-h-screen flex items-center justify-center bg-gray-50 text-gray-900 font-sans">
<div className="bg-white p-8 rounded-lg shadow-xl w-full max-w-md border border-gray-100"> <div className="bg-white p-8 rounded-lg shadow-xl w-full max-w-md border border-gray-100">
<h1 className="text-2xl font-bold mb-6 text-center text-[#28CC95]">Kalbot Access</h1> <h1 className="text-2xl font-bold mb-6 text-center text-[#28CC95]">Kalbot Access</h1>
{error && <div className="bg-red-50 border border-red-200 text-red-600 p-3 rounded mb-4 text-sm">{error}</div>}
{success && <div className="bg-green-50 border border-green-200 text-green-600 p-3 rounded mb-4 text-sm">{success}</div>}
<form onSubmit={handleLogin} className="space-y-4"> {error && <div className="bg-red-50 border border-red-200 text-red-600 p-3 rounded mb-4 text-sm">{error}</div>}
<div> {success && <div className="bg-green-50 border border-green-200 text-green-600 p-3 rounded mb-4 text-sm">{success}</div>}
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
required
className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
required
className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Captcha Verification</label>
<div className="flex items-center space-x-3 mb-2">
<img
ref={captchaImgRef}
src="/api/captcha"
alt="captcha"
className="h-12 rounded cursor-pointer border border-gray-300"
onClick={refreshCaptcha}
title="Click to refresh"
/>
<button type="button" onClick={refreshCaptcha} className="text-sm font-medium text-[#28CC95] hover:opacity-80 transition-opacity">
Refresh
</button>
</div>
<input
type="text"
required
placeholder="Enter the text above"
className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow"
value={captcha}
onChange={(e) => setCaptcha(e.target.value)}
/>
</div>
<button <form onSubmit={handleLogin} className="space-y-4">
type="submit" <div>
className="w-full bg-[#28CC95] hover:brightness-95 text-white font-bold py-2 px-4 rounded transition-all mt-6 shadow-sm" <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
> <input type="email" required className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow" value={email} onChange={(e) => setEmail(e.target.value)} />
Login </div>
</button> <div>
</form> <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" required className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Captcha Verification</label>
<div className="flex items-center space-x-3 mb-2">
<img ref={captchaImgRef} src="/api/captcha" alt="captcha" className="h-12 rounded cursor-pointer border border-gray-300" onClick={refreshCaptcha} title="Click to refresh" />
<button type="button" onClick={refreshCaptcha} className="text-sm font-medium text-[#28CC95] hover:opacity-80 transition-opacity">Refresh</button>
</div> </div>
</div> <input type="text" required placeholder="Enter the text above" className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow" value={captcha} onChange={(e) => setCaptcha(e.target.value)} />
); </div>
<button type="submit" className="w-full bg-[#28CC95] hover:brightness-95 text-white font-bold py-2 px-4 rounded transition-all mt-6 shadow-sm">Login</button>
</form>
</div>
</div>
);
} }

421
app/paper/page.js Normal file
View File

@@ -0,0 +1,421 @@
'use client';
import { useState, useEffect } from 'react';
const GREEN = '#16A34A';
const RED = '#DC2626';
const BLUE = '#2563EB';
export default function PaperDashboard() {
const [data, setData] = useState(null);
const [trades, setTrades] = useState({});
const [loading, setLoading] = useState(true);
const [activeStrat, setActiveStrat] = useState(null);
const [resetting, setResetting] = useState(false);
useEffect(() => {
const fetchState = async () => {
try {
const res = await fetch('/api/state');
const json = await res.json();
setData(json);
if (!activeStrat && json.strategies?.length) {
setActiveStrat(json.strategies[0].name);
}
setLoading(false);
} catch (e) {
console.error('State fetch error:', e);
}
};
fetchState();
const interval = setInterval(fetchState, 2000);
return () => clearInterval(interval);
}, [activeStrat]);
useEffect(() => {
if (!activeStrat) return;
const fetchTrades = async () => {
try {
const res = await fetch(`/api/trades?strategy=${encodeURIComponent(activeStrat)}`);
const json = await res.json();
setTrades(prev => ({ ...prev, [activeStrat]: json.trades || [] }));
} catch (e) {
console.error('Trades fetch error:', e);
}
};
fetchTrades();
const interval = setInterval(fetchTrades, 10000);
return () => clearInterval(interval);
}, [activeStrat]);
const handleReset = async () => {
if (!confirm('Reset ALL paper trading data? This clears all history, stats, and open positions for every strategy.')) return;
setResetting(true);
try {
await fetch('/api/reset', { method: 'POST' });
setTrades({});
} catch (e) {
console.error('Reset error:', e);
}
setTimeout(() => setResetting(false), 2000);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-green-600 text-lg animate-pulse">Loading Paper Trading...</div>
</div>
);
}
const market = data?.market;
const strategies = data?.strategies || [];
const paperByStrategy = data?.paperByStrategy || {};
const activeStratData = strategies.find(s => s.name === activeStrat);
const activeStats = paperByStrategy[activeStrat];
const activeTrades = trades[activeStrat] || [];
return (
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans pb-20">
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur border-b border-gray-200 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between max-w-lg mx-auto">
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold" style={{ color: GREEN }}>Kalbot</h1>
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">PAPER</span>
</div>
<div className="flex items-center gap-3">
<a href="/dash" className="text-[10px] px-2 py-1 rounded bg-gray-900 text-green-400 border border-gray-700 hover:bg-gray-800 transition-colors font-bold">
💰 Live
</a>
<button
onClick={handleReset}
disabled={resetting}
className="text-[10px] px-2 py-1 rounded bg-red-50 text-red-600 border border-red-200 hover:bg-red-100 transition-colors disabled:opacity-50 font-medium"
>
{resetting ? 'Resetting...' : '🗑 Reset All'}
</button>
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${data?.lastUpdate ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
<span className="text-xs text-gray-500">
{data?.lastUpdate ? 'Live' : 'Offline'}
</span>
</div>
</div>
</div>
</header>
<main className="max-w-lg mx-auto px-4 pt-4 space-y-4">
<MarketCardCompact market={market} />
<div className="flex gap-1 bg-gray-100 rounded-lg p-1 overflow-x-auto">
{strategies.map(s => (
<button
key={s.name}
onClick={() => setActiveStrat(s.name)}
className={`flex-shrink-0 py-2 px-3 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
activeStrat === s.name
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${
s.enabled && !s.paused ? 'bg-green-500' : 'bg-red-500'
}`} />
{s.name}
</button>
))}
</div>
{activeStrat && activeStratData && (
<StrategyDetailView
strategy={activeStratData}
stats={activeStats}
trades={activeTrades}
/>
)}
<AllStrategiesOverview paperByStrategy={paperByStrategy} strategies={strategies} />
</main>
<div className="fixed bottom-0 left-0 right-0 bg-white/95 backdrop-blur border-t border-gray-200 py-2 px-4">
<div className="max-w-lg mx-auto flex justify-between text-xs text-gray-400">
<span>Worker: {formatUptime(data?.workerUptime)}</span>
<span>{data?.lastUpdate ? new Date(data.lastUpdate).toLocaleTimeString() : 'never'}</span>
</div>
</div>
</div>
);
}
function MarketCardCompact({ market }) {
if (!market) {
return (
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
<p className="text-gray-400 text-center text-sm">No active market waiting...</p>
</div>
);
}
const timeLeft = market.closeTime ? getTimeLeft(market.closeTime) : null;
return (
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="font-bold text-sm text-gray-900">BTC Up or Down</h2>
<p className="text-[10px] text-gray-400">15 min</p>
</div>
<div className="flex items-center gap-2">
{timeLeft && (
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded-full text-gray-600"> {timeLeft}</span>
)}
<span className="text-lg"></span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-600">Up</span>
<span style={{ color: GREEN }} className="font-medium">{market.yesPct}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.yesPct}%`, backgroundColor: GREEN }} />
</div>
</div>
<div className="flex-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-600">Down</span>
<span style={{ color: BLUE }} className="font-medium">{market.noPct}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.noPct}%`, backgroundColor: BLUE }} />
</div>
</div>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mt-2">
<span>${(market.volume || 0).toLocaleString()} vol</span>
<span className="font-mono">{market.ticker}</span>
</div>
</div>
);
}
function StrategyDetailView({ strategy, stats, trades }) {
const s = stats || { balance: 1000, totalPnL: 0, wins: 0, losses: 0, winRate: 0, totalTrades: 0, openPositions: [] };
const pnlColor = s.totalPnL >= 0 ? GREEN : RED;
return (
<div className="space-y-3">
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${strategy.enabled && !strategy.paused ? 'bg-green-500' : 'bg-red-500'}`} />
<h3 className="font-bold text-sm capitalize text-gray-900">{strategy.name}</h3>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">{strategy.mode}</span>
</div>
<div className="grid grid-cols-4 gap-2 mb-3">
<StatBox label="Balance" value={`$${s.balance}`} />
<StatBox label="PnL" value={`${s.totalPnL >= 0 ? '+' : ''}$${s.totalPnL}`} color={pnlColor} />
<StatBox label="Win Rate" value={`${s.winRate}%`} color={s.winRate >= 50 ? GREEN : RED} />
<StatBox label="Trades" value={s.totalTrades} />
</div>
<div className="space-y-1 text-xs text-gray-500 border-t border-gray-100 pt-3">
{strategy.config && Object.entries(strategy.config).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span>{k}</span>
<span className="text-gray-700">{typeof v === 'number' ? v : String(v)}</span>
</div>
))}
{strategy.consecutiveLosses !== undefined && (
<div className="flex justify-between mt-1 pt-1 border-t border-gray-100">
<span>Consecutive Losses</span>
<span className={strategy.consecutiveLosses > 0 ? 'text-red-600' : 'text-gray-700'}>
{strategy.consecutiveLosses}
</span>
</div>
)}
{strategy.currentBetSize !== undefined && (
<div className="flex justify-between">
<span>Next Bet</span>
<span className="text-gray-700">${strategy.currentBetSize}</span>
</div>
)}
{strategy.round !== undefined && (
<div className="flex justify-between">
<span>Cycle Round</span>
<span className="text-gray-700">{strategy.round}/{strategy.maxRounds}</span>
</div>
)}
{strategy.cycleWins !== undefined && (
<div className="flex justify-between">
<span>Cycles Won/Lost</span>
<span className="text-gray-700">{strategy.cycleWins}W / {strategy.cycleLosses}L</span>
</div>
)}
{strategy.cycleWinRate !== undefined && (
<div className="flex justify-between">
<span>Cycle Win Rate</span>
<span className={strategy.cycleWinRate >= 50 ? 'text-green-600' : 'text-red-600'}>
{strategy.cycleWinRate}%
</span>
</div>
)}
{strategy.paused && (
<div className="text-red-600 font-medium mt-1"> PAUSED max losses reached</div>
)}
</div>
</div>
{s.openPositions.length > 0 && (
<div>
<h4 className="text-[10px] text-gray-400 uppercase tracking-wider mb-2 font-bold">Open Positions ({s.openPositions.length})</h4>
{s.openPositions.map((t, i) => <TradeRow key={i} trade={t} isOpen />)}
</div>
)}
<div>
<h4 className="text-[10px] text-gray-400 uppercase tracking-wider mb-2 font-bold">
Trade History ({trades.length})
</h4>
{trades.length === 0 ? (
<p className="text-gray-400 text-xs text-center py-4">No settled trades yet.</p>
) : (
trades.map((t, i) => <TradeRow key={i} trade={t} />)
)}
</div>
</div>
);
}
function AllStrategiesOverview({ paperByStrategy, strategies }) {
const entries = strategies.map(s => ({
name: s.name,
stats: paperByStrategy[s.name] || { balance: 1000, totalPnL: 0, winRate: 0, totalTrades: 0 },
enabled: s.enabled,
paused: s.paused
}));
if (!entries.length) return null;
entries.sort((a, b) => b.stats.totalPnL - a.stats.totalPnL);
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="px-4 py-3 border-b border-gray-100">
<h3 className="text-xs text-gray-500 uppercase tracking-wider font-bold">📊 Strategy Leaderboard</h3>
</div>
{entries.map((e, i) => {
const pnlColor = e.stats.totalPnL >= 0 ? GREEN : RED;
return (
<div key={e.name} className={`flex items-center justify-between px-4 py-3 ${i < entries.length - 1 ? 'border-b border-gray-100' : ''}`}>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
<span className={`w-1.5 h-1.5 rounded-full ${e.enabled && !e.paused ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-medium capitalize text-gray-800">{e.name}</span>
</div>
<div className="flex items-center gap-4 text-xs">
<span className="text-gray-400">{e.stats.totalTrades} trades</span>
<span className="text-gray-400">{e.stats.winRate}% wr</span>
<span className="font-bold" style={{ color: pnlColor }}>
{e.stats.totalPnL >= 0 ? '+' : ''}${e.stats.totalPnL}
</span>
</div>
</div>
);
})}
</div>
);
}
function StatBox({ label, value, color }) {
return (
<div className="bg-gray-50 rounded-lg p-2 border border-gray-100 text-center">
<p className="text-[9px] text-gray-400 uppercase tracking-wider">{label}</p>
<p className="text-xs font-bold mt-0.5" style={color ? { color } : { color: '#111827' }}>{value}</p>
</div>
);
}
function TradeRow({ trade, isOpen }) {
const won = trade.result && trade.side.toLowerCase() === trade.result.toLowerCase();
const isNeutral = trade.result === 'cancelled' || trade.result === 'expired';
const pnlColor = trade.pnl == null ? 'text-gray-400' : trade.pnl > 0 ? 'text-green-600' : trade.pnl < 0 ? 'text-red-600' : 'text-gray-600';
const price = Number(trade.price);
const cost = Number(trade.cost ?? trade.size);
const hasExpected = isOpen && Number.isFinite(price) && price > 0 && price < 100 && Number.isFinite(cost) && cost > 0;
const grossPayout = hasExpected ? (100 / price) * cost : null;
const winPnL = hasExpected ? grossPayout - cost : null;
const losePnL = hasExpected ? -cost : null;
return (
<div className="bg-white rounded-lg p-3 border border-gray-200 mb-2 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isOpen ? (
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
) : (
<span>{isNeutral ? '' : won ? '✅' : '❌'}</span>
)}
<span className="text-sm font-medium capitalize text-gray-900">{trade.side}</span>
<span className="text-xs text-gray-400">@ {trade.price}¢</span>
<span className="text-[10px] text-gray-400">${trade.size}</span>
</div>
<span className={`text-sm font-bold ${pnlColor}`}>
{trade.pnl != null ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl}` : 'open'}
</span>
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-gray-400">{trade.reason}</span>
</div>
{hasExpected && (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-gray-500">
<span>
If win: <span className="text-green-600 font-medium">+${winPnL.toFixed(2)}</span>
</span>
<span>
If lose: <span className="text-red-600 font-medium">${losePnL.toFixed(2)}</span>
</span>
<span>
Gross payout: <span className="text-gray-700 font-medium">${grossPayout.toFixed(2)}</span>
</span>
</div>
)}
<div className="flex justify-between items-center mt-0.5">
{trade.result && !isOpen && (
<span className="text-[10px] text-gray-400">Result: {trade.result}</span>
)}
{!trade.result && <span />}
<span className="text-[10px] text-gray-400">
{trade.entryTime ? new Date(trade.entryTime).toLocaleTimeString() : ''}
</span>
</div>
</div>
);
}
function getTimeLeft(closeTime) {
const diff = new Date(closeTime).getTime() - Date.now();
if (diff <= 0) return 'Closing...';
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatUptime(seconds) {
if (!seconds) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}

57
lib/auth.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* Edge-compatible session signer/verifier using Web Crypto API.
*/
async function getSessionKey() {
const secret = process.env.CAPTCHA_SECRET || 'dev_secret_meow';
const encoder = new TextEncoder();
return await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
}
export async function signSession() {
const expires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours validity
const data = `admin.${expires}`;
const encoder = new TextEncoder();
const key = await getSessionKey();
const signatureBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureArray = Array.from(new Uint8Array(signatureBuffer));
const signatureHex = signatureArray.map(b => b.toString(16).padStart(2, '0')).join('');
return `${data}.${signatureHex}`;
}
export async function verifySession(token) {
if (!token) return false;
const parts = token.split('.');
if (parts.length !== 3) return false;
const [user, expires, signatureHex] = parts;
if (user !== 'admin') return false;
// Check if token expired
if (Date.now() > parseInt(expires, 10)) return false;
const data = `${user}.${expires}`;
const encoder = new TextEncoder();
const key = await getSessionKey();
// Convert hex string back to Uint8Array
const signatureBytes = new Uint8Array(
signatureHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
);
try {
// Verify the HMAC signature ensures the token hasn't been tampered with
return await crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(data));
} catch {
return false;
}
}

View File

@@ -7,8 +7,6 @@ class Database {
} }
async connect() { async connect() {
if (this.connected) return;
const url = process.env.SURREAL_URL; const url = process.env.SURREAL_URL;
const user = process.env.SURREAL_USER; const user = process.env.SURREAL_USER;
const pass = process.env.SURREAL_PASS; const pass = process.env.SURREAL_PASS;
@@ -20,7 +18,9 @@ class Database {
} }
try { try {
this.client = new Surreal(); if (!this.client) {
this.client = new Surreal();
}
await this.client.connect(url); await this.client.connect(url);
await this.client.signin({ username: user, password: pass }); await this.client.signin({ username: user, password: pass });
await this.client.use({ namespace: 'kalbot', database: 'kalbot' }); await this.client.use({ namespace: 'kalbot', database: 'kalbot' });
@@ -32,11 +32,45 @@ class Database {
} }
} }
_normalizeQueryResult(raw) {
if (!Array.isArray(raw)) return [[]];
return raw.map((entry) => {
if (Array.isArray(entry)) return entry;
if (entry && typeof entry === 'object' && 'result' in entry) {
return Array.isArray(entry.result) ? entry.result : [entry.result];
}
return [];
});
}
async _handleTokenExpiration(e) {
if (e.message && e.message.toLowerCase().includes('token has expired')) {
console.log('[DB] Session token expired! Attempting to re-authenticate...');
this.connected = false;
await this.connect();
return this.connected; // Returns true if reconnection was successful
}
return false;
}
async query(sql, vars = {}) { async query(sql, vars = {}) {
if (!this.connected) return [[]]; if (!this.connected) return [[]];
try { try {
return await this.client.query(sql, vars); const raw = await this.client.query(sql, vars);
return this._normalizeQueryResult(raw);
} catch (e) { } catch (e) {
// Check if it's an expiration issue, if so, reconnect and retry once
if (await this._handleTokenExpiration(e)) {
try {
const retryRaw = await this.client.query(sql, vars);
return this._normalizeQueryResult(retryRaw);
} catch (retryErr) {
console.error('[DB] Query retry error:', retryErr.message);
return [[]];
}
}
console.error('[DB] Query error:', e.message); console.error('[DB] Query error:', e.message);
return [[]]; return [[]];
} }
@@ -47,6 +81,14 @@ class Database {
try { try {
return await this.client.create(table, data); return await this.client.create(table, data);
} catch (e) { } catch (e) {
if (await this._handleTokenExpiration(e)) {
try {
return await this.client.create(table, data);
} catch (retryErr) {
console.error('[DB] Create retry error:', retryErr.message);
return null;
}
}
console.error('[DB] Create error:', e.message); console.error('[DB] Create error:', e.message);
return null; return null;
} }
@@ -57,6 +99,14 @@ class Database {
try { try {
return await this.client.select(table); return await this.client.select(table);
} catch (e) { } catch (e) {
if (await this._handleTokenExpiration(e)) {
try {
return await this.client.select(table);
} catch (retryErr) {
console.error('[DB] Select retry error:', retryErr.message);
return [];
}
}
console.error('[DB] Select error:', e.message); console.error('[DB] Select error:', e.message);
return []; return [];
} }

View File

@@ -1,23 +1,47 @@
import crypto from 'crypto'; import crypto from 'crypto';
const KALSHI_API_BASE = 'https://api.elections.kalshi.com'; const DEFAULT_KALSHI_API_BASE = 'https://api.elections.kalshi.com';
const KALSHI_API_BASE = (process.env.KALSHI_API_BASE || DEFAULT_KALSHI_API_BASE).trim().replace(/\/+$/, '');
function normalizePrivateKey(value) {
if (!value) return '';
let key = String(value).trim();
if (
(key.startsWith('"') && key.endsWith('"')) ||
(key.startsWith("'") && key.endsWith("'"))
) {
key = key.slice(1, -1);
}
return key
.replace(/\\r\\n/g, '\n')
.replace(/\r\n/g, '\n')
.replace(/\\n/g, '\n')
.trim();
}
/** /**
* Signs a Kalshi API request using RSA-PSS with SHA-256. * Signs a Kalshi API request using RSA-PSS with SHA-256.
* Returns headers needed for authenticated requests. * Returns headers needed for authenticated requests.
*/ */
export function signRequest(method, path, timestampMs = Date.now()) { export function signRequest(method, path, timestampMs = Date.now()) {
const keyId = process.env.KALSHI_API_KEY_ID; const keyId = process.env.KALSHI_API_KEY_ID?.trim();
const privateKeyPem = process.env.KALSHI_RSA_PRIVATE_KEY?.replace(/\\n/g, '\n'); const privateKeyPem = normalizePrivateKey(process.env.KALSHI_RSA_PRIVATE_KEY);
if (!keyId || !privateKeyPem) { if (!keyId || !privateKeyPem) {
throw new Error('Missing KALSHI_API_KEY_ID or KALSHI_RSA_PRIVATE_KEY'); throw new Error('Missing KALSHI_API_KEY_ID or KALSHI_RSA_PRIVATE_KEY');
} }
const ts = String(timestampMs); // Strip query parameters before signing per Kalshi docs
const message = `${ts}${method.toUpperCase()}${path}`; const pathWithoutQuery = path.split('?')[0];
const signature = crypto.sign('sha256', Buffer.from(message), { const ts = String(timestampMs);
const message = `${ts}${method.toUpperCase()}${pathWithoutQuery}`;
const sign = crypto.createSign('RSA-SHA256');
sign.update(message);
sign.end();
const signature = sign.sign({
key: privateKeyPem, key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING, padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST

View File

@@ -1,65 +1,231 @@
import { signRequest, KALSHI_API_BASE } from './auth.js'; import { signRequest, KALSHI_API_BASE } from './auth.js';
async function kalshiFetch(method, path, body = null) { const SERIES_TICKER = (process.env.KALSHI_SERIES_TICKER || 'KXBTC15M').trim().toUpperCase();
const headers = signRequest(method, path); const OPEN_EVENT_STATUSES = new Set(['open', 'active', 'initialized', 'trading']);
const opts = { method, headers }; const TRADABLE_EVENT_STATUSES = new Set(['open', 'active', 'trading']);
if (body) opts.body = JSON.stringify(body);
const DEFAULT_HTTP_RETRIES = Math.max(0, Number(process.env.KALSHI_HTTP_RETRIES || 3));
const BASE_BACKOFF_MS = Math.max(100, Number(process.env.KALSHI_HTTP_BACKOFF_MS || 350));
const EVENTS_CACHE_TTL_MS = Math.max(1000, Number(process.env.KALSHI_EVENTS_CACHE_TTL_MS || 5000));
const eventsCache = new Map();
const inflightEvents = new Map();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function parseRetryAfterMs(value) {
if (!value) return null;
const asSeconds = Number(value);
if (Number.isFinite(asSeconds) && asSeconds >= 0) return asSeconds * 1000;
const asDate = new Date(value).getTime();
if (Number.isFinite(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function backoffMs(attempt) {
const exp = BASE_BACKOFF_MS * Math.pow(2, attempt);
const jitter = Math.floor(Math.random() * 200);
return exp + jitter;
}
async function kalshiFetch(method, path, body = null, opts = {}) {
const retries = Number.isFinite(opts.retries) ? opts.retries : DEFAULT_HTTP_RETRIES;
const payload = body == null ? null : JSON.stringify(body);
for (let attempt = 0; attempt <= retries; attempt++) {
const headers = signRequest(method, path);
const req = { method, headers };
if (payload) req.body = payload;
let res;
try {
res = await fetch(`${KALSHI_API_BASE}${path}`, req);
} catch (e) {
if (attempt < retries) {
await sleep(backoffMs(attempt));
continue;
}
throw new Error(`Kalshi API ${method} ${path} network error: ${e.message}`);
}
if (res.ok) {
if (res.status === 204) return {};
const text = await res.text();
if (!text) return {};
try { return JSON.parse(text); } catch { return {}; }
}
const res = await fetch(`${KALSHI_API_BASE}${path}`, opts);
if (!res.ok) {
const text = await res.text(); const text = await res.text();
const retryable = res.status === 429 || (res.status >= 500 && res.status < 600);
if (retryable && attempt < retries) {
const retryAfter = parseRetryAfterMs(res.headers.get('retry-after'));
await sleep(retryAfter ?? backoffMs(attempt));
continue;
}
throw new Error(`Kalshi API ${method} ${path}${res.status}: ${text}`); throw new Error(`Kalshi API ${method} ${path}${res.status}: ${text}`);
} }
return res.json();
throw new Error(`Kalshi API ${method} ${path} failed after retries`);
}
function getTimeMs(value) {
if (!value) return null;
const ts = new Date(value).getTime();
return Number.isFinite(ts) ? ts : null;
}
function getEventCloseTimeMs(event) {
return (
getTimeMs(event?.close_time) ||
getTimeMs(event?.expiration_time) ||
getTimeMs(event?.settlement_time) ||
getTimeMs(event?.end_date) ||
null
);
}
function rankEvents(events = []) {
const now = Date.now();
return events
.filter(Boolean)
.map((event) => {
const status = String(event.status || '').toLowerCase();
const closeTs = getEventCloseTimeMs(event);
const openLike = OPEN_EVENT_STATUSES.has(status);
const tradableLike = TRADABLE_EVENT_STATUSES.has(status);
const notClearlyExpired = closeTs == null || closeTs > now - 60_000;
const delta = closeTs == null ? Number.MAX_SAFE_INTEGER : closeTs - now;
const closenessScore = delta < 0 ? Math.abs(delta) + 3_600_000 : delta;
return { event, openLike, tradableLike, notClearlyExpired, closenessScore };
})
.filter((x) => x.openLike || x.notClearlyExpired)
.sort((a, b) => {
if (a.tradableLike !== b.tradableLike) return a.tradableLike ? -1 : 1;
if (a.openLike !== b.openLike) return a.openLike ? -1 : 1;
if (a.notClearlyExpired !== b.notClearlyExpired) return a.notClearlyExpired ? -1 : 1;
return a.closenessScore - b.closenessScore;
})
.map((x) => x.event);
}
function getCachedEvents(key) {
const hit = eventsCache.get(key);
if (!hit) return null;
if (Date.now() > hit.expiresAt) { eventsCache.delete(key); return null; }
return hit.data;
}
function setCachedEvents(key, events) {
eventsCache.set(key, { expiresAt: Date.now() + EVENTS_CACHE_TTL_MS, data: events });
}
async function fetchEvents(series, query) {
const normalizedSeries = String(series || '').trim().toUpperCase();
const normalizedQuery = String(query || '').trim();
const key = `${normalizedSeries}|${normalizedQuery}`;
const cached = getCachedEvents(key);
if (cached) return cached;
const pending = inflightEvents.get(key);
if (pending) return pending;
const task = (async () => {
try {
const path = `/trade-api/v2/events?series_ticker=${encodeURIComponent(normalizedSeries)}&${normalizedQuery}`;
const data = await kalshiFetch('GET', path);
const events = Array.isArray(data.events) ? data.events : [];
setCachedEvents(key, events);
return events;
} finally {
inflightEvents.delete(key);
}
})();
inflightEvents.set(key, task);
return task;
}
export async function getActiveBTCEvents(limit = 12) {
const seriesCandidates = [SERIES_TICKER];
const eventMap = new Map();
for (const series of seriesCandidates) {
try {
const openEvents = await fetchEvents(series, 'status=open&limit=25');
for (const event of openEvents) {
if (event?.event_ticker) eventMap.set(event.event_ticker, event);
}
if (!openEvents.length) {
const fallbackEvents = await fetchEvents(series, 'limit=25');
for (const event of fallbackEvents) {
if (event?.event_ticker) eventMap.set(event.event_ticker, event);
}
}
} catch (e) {
console.error(`[Kalshi] Event fetch failed (${series}):`, e.message);
}
}
return rankEvents([...eventMap.values()]).slice(0, limit);
} }
/**
* Get events for the BTC 15-min series.
* Returns the currently active event + its markets.
*/
export async function getActiveBTCEvent() { export async function getActiveBTCEvent() {
const data = await kalshiFetch('GET', '/trade-api/v2/events?series_ticker=KXBTC15M&status=open&limit=1'); const events = await getActiveBTCEvents(1);
const event = data.events?.[0]; return events[0] || null;
if (!event) return null;
return event;
} }
/**
* Get markets for a specific event ticker.
*/
export async function getEventMarkets(eventTicker) { export async function getEventMarkets(eventTicker) {
const data = await kalshiFetch('GET', `/trade-api/v2/events/${eventTicker}`); const data = await kalshiFetch('GET', `/trade-api/v2/events/${eventTicker}`);
return data.event?.markets || []; const markets = data?.event?.markets ?? data?.markets ?? data?.event_markets ?? [];
return Array.isArray(markets) ? markets : [];
} }
/**
* Get orderbook for a specific market ticker.
*/
export async function getOrderbook(ticker) { export async function getOrderbook(ticker) {
const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}/orderbook`); const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}/orderbook`);
return data.orderbook || data; return data.orderbook || data;
} }
/**
* Get single market details.
*/
export async function getMarket(ticker) { export async function getMarket(ticker) {
const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}`); const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}`);
return data.market || data; return data.market || data;
} }
/**
* Place a real order on Kalshi. NOT used in paper mode.
*/
export async function placeOrder(params) { export async function placeOrder(params) {
return kalshiFetch('POST', '/trade-api/v2/portfolio/orders', params); return kalshiFetch('POST', '/trade-api/v2/portfolio/orders', params);
} }
/**
* Get wallet balance.
*/
export async function getBalance() { export async function getBalance() {
return kalshiFetch('GET', '/trade-api/v2/portfolio/balance'); return kalshiFetch('GET', '/trade-api/v2/portfolio/balance');
} }
export async function getPositions(params = {}) {
let path = '/trade-api/v2/portfolio/positions';
const qs = [];
if (params.settlement_status) qs.push(`settlement_status=${params.settlement_status}`);
if (params.limit) qs.push(`limit=${params.limit}`);
if (params.event_ticker) qs.push(`event_ticker=${params.event_ticker}`);
if (qs.length) path += `?${qs.join('&')}`;
return kalshiFetch('GET', path);
}
export async function getOrder(orderId) {
return kalshiFetch('GET', `/trade-api/v2/portfolio/orders/${orderId}`);
}
export async function cancelOrder(orderId) {
return kalshiFetch('DELETE', `/trade-api/v2/portfolio/orders/${orderId}`);
}
export async function getFills(params = {}) {
let path = '/trade-api/v2/portfolio/fills';
const qs = [];
if (params.ticker) qs.push(`ticker=${params.ticker}`);
if (params.limit) qs.push(`limit=${params.limit}`);
if (qs.length) path += `?${qs.join('&')}`;
return kalshiFetch('GET', path);
}
export { kalshiFetch }; export { kalshiFetch };

View File

@@ -4,6 +4,16 @@ import { EventEmitter } from 'events';
const WS_URL = 'wss://api.elections.kalshi.com/trade-api/ws/v2'; const WS_URL = 'wss://api.elections.kalshi.com/trade-api/ws/v2';
function unwrapPacket(packet) {
if (!packet || typeof packet !== 'object') return { type: null, payload: null };
const type = packet.type || null;
const meta = { id: packet.id, sid: packet.sid, seq: packet.seq, type };
if (packet.msg && typeof packet.msg === 'object') {
return { type, payload: { ...meta, ...packet.msg } };
}
return { type, payload: { ...meta, ...packet } };
}
export class KalshiWS extends EventEmitter { export class KalshiWS extends EventEmitter {
constructor() { constructor() {
super(); super();
@@ -12,10 +22,13 @@ export class KalshiWS extends EventEmitter {
this.alive = false; this.alive = false;
this.reconnectTimer = null; this.reconnectTimer = null;
this.pingInterval = null; this.pingInterval = null;
this.shouldReconnect = true;
this._cmdId = 1;
} }
connect() { connect() {
if (this.ws) this.disconnect(); if (this.ws) this.disconnect();
this.shouldReconnect = true;
const path = '/trade-api/ws/v2'; const path = '/trade-api/ws/v2';
const headers = signRequest('GET', path); const headers = signRequest('GET', path);
@@ -26,27 +39,28 @@ export class KalshiWS extends EventEmitter {
console.log('[WS] Connected to Kalshi'); console.log('[WS] Connected to Kalshi');
this.alive = true; this.alive = true;
this._startPing(); this._startPing();
// Resubscribe to any tickers we were watching
for (const ticker of this.subscribedTickers) { for (const ticker of this.subscribedTickers) this._sendSubscribe(ticker);
this._sendSubscribe(ticker);
}
this.emit('connected'); this.emit('connected');
}); });
this.ws.on('message', (raw) => { this.ws.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const packet = JSON.parse(raw.toString());
this._handleMessage(msg); this._handleMessage(packet);
} catch (e) { } catch (e) {
console.error('[WS] Parse error:', e.message); console.error('[WS] Parse error:', e.message);
} }
}); });
this.ws.on('close', (code) => { this.ws.on('close', (code) => {
console.log(`[WS] Disconnected (code: ${code}). Reconnecting in 3s...`); console.log(`[WS] Disconnected (code: ${code}).`);
this.alive = false; this.alive = false;
this._stopPing(); this._stopPing();
this._scheduleReconnect(); if (this.shouldReconnect) {
console.log('[WS] Reconnecting in 3s...');
this._scheduleReconnect();
}
}); });
this.ws.on('error', (err) => { this.ws.on('error', (err) => {
@@ -55,22 +69,30 @@ export class KalshiWS extends EventEmitter {
} }
subscribeTicker(ticker) { subscribeTicker(ticker) {
if (!ticker) return;
this.subscribedTickers.add(ticker); this.subscribedTickers.add(ticker);
if (this.alive) this._sendSubscribe(ticker); if (this.alive) this._sendSubscribe(ticker);
} }
unsubscribeTicker(ticker) { unsubscribeTicker(ticker) {
if (!ticker) return;
this.subscribedTickers.delete(ticker); this.subscribedTickers.delete(ticker);
if (this.alive) { if (this.alive && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ this.ws.send(JSON.stringify({
id: Date.now(), id: this._cmdId++,
cmd: 'unsubscribe', cmd: 'unsubscribe',
params: { channels: ['orderbook_delta', 'ticker'], market_tickers: [ticker] } params: { channels: ['orderbook_delta'], market_ticker: ticker }
}));
this.ws.send(JSON.stringify({
id: this._cmdId++,
cmd: 'unsubscribe',
params: { channels: ['ticker'], market_ticker: ticker }
})); }));
} }
} }
disconnect() { disconnect() {
this.shouldReconnect = false;
this._stopPing(); this._stopPing();
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
if (this.ws) { if (this.ws) {
@@ -82,34 +104,56 @@ export class KalshiWS extends EventEmitter {
} }
_sendSubscribe(ticker) { _sendSubscribe(ticker) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
// Subscribe orderbook_delta (private channel) with market_ticker
this.ws.send(JSON.stringify({ this.ws.send(JSON.stringify({
id: Date.now(), id: this._cmdId++,
cmd: 'subscribe', cmd: 'subscribe',
params: { channels: ['orderbook_delta', 'ticker'], market_tickers: [ticker] } params: { channels: ['orderbook_delta'], market_ticker: ticker }
}));
// Subscribe ticker (public channel) with market_ticker
this.ws.send(JSON.stringify({
id: this._cmdId++,
cmd: 'subscribe',
params: { channels: ['ticker'], market_ticker: ticker }
})); }));
} }
_handleMessage(msg) { _handleMessage(packet) {
const { type } = msg; const { type, payload } = unwrapPacket(packet);
if (!type || !payload) return;
if (type === 'orderbook_snapshot' || type === 'orderbook_delta') { if (type === 'orderbook_snapshot' || type === 'orderbook_delta') {
this.emit('orderbook', msg); this.emit('orderbook', payload);
} else if (type === 'ticker') { return;
this.emit('ticker', msg); }
} else if (type === 'subscribed') {
console.log(`[WS] Subscribed to: ${msg.msg?.channels || 'unknown'}`); if (type === 'ticker') {
this.emit('ticker', payload);
return;
}
if (type === 'subscribed' || type === 'ok') {
console.log(`[WS] ${type}:`, JSON.stringify(payload).slice(0, 200));
}
if (type === 'error') {
console.error('[WS] Server error:', JSON.stringify(payload).slice(0, 300));
} }
} }
_startPing() { _startPing() {
this._stopPing();
this.pingInterval = setInterval(() => { this.pingInterval = setInterval(() => {
if (this.alive && this.ws?.readyState === WebSocket.OPEN) { if (this.alive && this.ws?.readyState === WebSocket.OPEN) this.ws.ping();
this.ws.ping();
}
}, 15000); }, 15000);
} }
_stopPing() { _stopPing() {
clearInterval(this.pingInterval); clearInterval(this.pingInterval);
this.pingInterval = null;
} }
_scheduleReconnect() { _scheduleReconnect() {

497
lib/live/engine.js Normal file
View File

@@ -0,0 +1,497 @@
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" OR status = "filled"'
);
for (const o of orders[0] || []) {
if (!o.settled) 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;
}
}
_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;
}
_getFillCount(order) {
if (!order) return 0;
const fp = order.taker_fill_count_fp ?? order.fill_count_fp;
if (fp != null) return Math.round(parseFloat(fp));
return order.taker_fill_count ?? order.fill_count ?? 0;
}
_getFillCostCents(order) {
if (!order) return 0;
const dollars = order.taker_fill_cost_dollars ?? order.fill_cost_dollars;
if (dollars != null) return Math.round(parseFloat(dollars) * 100);
return order.taker_fill_cost ?? order.fill_cost ?? 0;
}
/**
* 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 = this._getFillCount(order);
const fillCost = this._getFillCostCents(order);
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 = this._getFillCount(order);
let fillCost = this._getFillCostCents(order);
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;
}
/**
* Recover live orders when market rotates before result is available.
* Polls market endpoints for delayed settlement result and settles locally when it appears.
*/
async checkOrphans(getMarketFn) {
const tickers = this.getOpenTickers();
if (!tickers.length) return [];
const settled = [];
for (const ticker of tickers) {
try {
const market = await getMarketFn(ticker);
const result = String(market?.result || '').toLowerCase();
if (result === 'yes' || result === 'no') {
console.log(`[Live] Delayed settlement found for ${ticker}: ${result}`);
const done = await this.settle(ticker, result);
if (done?.length) settled.push(...done);
}
} catch (e) {
console.error(`[Live] Orphan check failed for ${ticker}:`, e.message);
}
}
return settled;
}
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);
}
}
}

View File

@@ -1,7 +1,21 @@
import { getActiveBTCEvent, getEventMarkets, getOrderbook, getMarket } from '../kalshi/rest.js'; import { getActiveBTCEvents, getEventMarkets, getOrderbook, getMarket } from '../kalshi/rest.js';
import { KalshiWS } from '../kalshi/websocket.js'; import { KalshiWS } from '../kalshi/websocket.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
const OPEN_MARKET_STATUSES = new Set(['open', 'active', 'initialized', 'trading']);
const TRADABLE_MARKET_STATUSES = new Set(['open', 'active', 'trading']);
/**
* Converts a dollar string like "0.4200" to cents integer (42).
* Returns null if not parseable.
*/
function dollarsToCents(val) {
if (val == null) return null;
const n = Number(val);
if (!Number.isFinite(n)) return null;
return Math.round(n * 100);
}
/** /**
* Tracks the currently active BTC 15-min market. * Tracks the currently active BTC 15-min market.
* Auto-rotates when the current market expires. * Auto-rotates when the current market expires.
@@ -21,16 +35,11 @@ export class MarketTracker extends EventEmitter {
async start() { async start() {
console.log('[Tracker] Starting market tracker...'); console.log('[Tracker] Starting market tracker...');
// Connect WebSocket
this.ws.connect(); this.ws.connect();
this.ws.on('orderbook', (msg) => this._onOrderbook(msg)); this.ws.on('orderbook', (msg) => this._onOrderbook(msg));
this.ws.on('ticker', (msg) => this._onTicker(msg)); this.ws.on('ticker', (msg) => this._onTicker(msg));
// Initial market discovery
await this._findAndSubscribe(); await this._findAndSubscribe();
// Check for market rotation every 30 seconds
this.rotateInterval = setInterval(() => this._checkRotation(), 30000); this.rotateInterval = setInterval(() => this._checkRotation(), 30000);
} }
@@ -42,14 +51,34 @@ export class MarketTracker extends EventEmitter {
getState() { getState() {
if (!this.marketData) return null; if (!this.marketData) return null;
const yesAsk = this.orderbook.yes?.[0]?.[0] || this.marketData.yes_ask; const quotes = this._extractMarketQuotes(this.marketData);
const noAsk = this.orderbook.no?.[0]?.[0] || this.marketData.no_ask; const bestYesBook = this._bestBookPrice(this.orderbook.yes);
const bestNoBook = this._bestBookPrice(this.orderbook.no);
// Prices on Kalshi are in cents (1-99) const yesBid = quotes.yesBid ?? bestYesBook;
const yesPct = yesAsk || 50; const noBid = quotes.noBid ?? bestNoBook;
const noPct = noAsk || 50; const yesAsk = quotes.yesAsk ?? (noBid != null ? 100 - noBid : null);
const noAsk = quotes.noAsk ?? (yesBid != null ? 100 - yesBid : null);
let yesPct = yesAsk ?? yesBid ?? bestYesBook;
let noPct = noAsk ?? noBid ?? bestNoBook;
if (yesPct == null && noPct != null) yesPct = 100 - noPct;
if (noPct == null && yesPct != null) noPct = 100 - yesPct;
if (yesPct == null && noPct == null && quotes.lastPrice != null) {
yesPct = quotes.lastPrice;
noPct = 100 - quotes.lastPrice;
}
if (yesPct == null || noPct == null) {
yesPct = 50;
noPct = 50;
}
yesPct = this._clampPct(yesPct) ?? 50;
noPct = this._clampPct(noPct) ?? 50;
// Odds = 100 / price
const yesOdds = yesPct > 0 ? (100 / yesPct).toFixed(2) : '0.00'; const yesOdds = yesPct > 0 ? (100 / yesPct).toFixed(2) : '0.00';
const noOdds = noPct > 0 ? (100 / noPct).toFixed(2) : '0.00'; const noOdds = noPct > 0 ? (100 / noPct).toFixed(2) : '0.00';
@@ -62,85 +91,256 @@ export class MarketTracker extends EventEmitter {
noPct, noPct,
yesOdds: parseFloat(yesOdds), yesOdds: parseFloat(yesOdds),
noOdds: parseFloat(noOdds), noOdds: parseFloat(noOdds),
yesBid: this.marketData.yes_bid, yesBid: this._clampPct(yesBid),
yesAsk: this.marketData.yes_ask, yesAsk: this._clampPct(yesAsk),
noBid: this.marketData.no_bid, noBid: this._clampPct(noBid),
noAsk: this.marketData.no_ask, noAsk: this._clampPct(noAsk),
volume: this.marketData.volume || 0, volume: this._num(this.marketData.volume) ?? 0,
volume24h: this.marketData.volume_24h || 0, volume24h: this._num(this.marketData.volume_24h) ?? 0,
openInterest: this.marketData.open_interest || 0, openInterest: this._num(this.marketData.open_interest) ?? 0,
lastPrice: this.marketData.last_price, lastPrice: this._clampPct(quotes.lastPrice),
closeTime: this.marketData.close_time || this.marketData.expiration_time, closeTime: this.marketData.close_time || this.marketData.expiration_time,
status: this.marketData.status, status: this.marketData.status,
result: this.marketData.result, result: this.marketData.result,
timestamp: Date.now() timestamp: Date.now(),
// Expose raw orderbook for strategies and live engine
orderbook: {
yes: this.orderbook.yes,
no: this.orderbook.no
}
}; };
} }
_num(value) {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
_clampPct(value) {
const n = this._num(value);
if (n == null) return null;
return Math.max(0, Math.min(100, n));
}
_toTs(value) {
if (!value) return null;
const ts = new Date(value).getTime();
return Number.isFinite(ts) ? ts : null;
}
_extractMarketQuotes(market) {
const pick = (...keys) => {
for (const key of keys) {
const v = this._num(market?.[key]);
if (v != null) return v;
}
return null;
};
let yesBid = pick('yes_bid', 'yesBid');
let yesAsk = pick('yes_ask', 'yesAsk');
let noBid = pick('no_bid', 'noBid');
let noAsk = pick('no_ask', 'noAsk');
let lastPrice = pick('last_price', 'lastPrice', 'yes_price', 'yesPrice');
if (yesBid == null) yesBid = dollarsToCents(market?.yes_bid_dollars);
if (yesAsk == null) yesAsk = dollarsToCents(market?.yes_ask_dollars);
if (noBid == null) noBid = dollarsToCents(market?.no_bid_dollars);
if (noAsk == null) noAsk = dollarsToCents(market?.no_ask_dollars);
if (lastPrice == null) lastPrice = dollarsToCents(market?.price_dollars);
return { yesBid, yesAsk, noBid, noAsk, lastPrice };
}
_normalizeBookSide(levels) {
if (!Array.isArray(levels)) return [];
const out = [];
for (const level of levels) {
let price = null;
let qty = null;
if (Array.isArray(level)) {
const rawPrice = level[0];
const rawQty = level[1];
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
price = dollarsToCents(rawPrice);
qty = this._num(rawQty);
} else {
price = this._num(rawPrice);
qty = this._num(rawQty);
}
} else if (level && typeof level === 'object') {
const rawPrice = level.price ?? level.price_dollars ?? level[0];
const rawQty = level.qty ?? level.quantity ?? level.size ?? level.count ?? level[1];
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
price = dollarsToCents(rawPrice);
} else {
price = this._num(rawPrice);
}
qty = this._num(rawQty);
}
if (price == null || qty == null || qty <= 0) continue;
out.push([price, qty]);
}
return out.sort((a, b) => b[0] - a[0]);
}
_normalizeOrderbook(book) {
const root = book?.orderbook && typeof book.orderbook === 'object' ? book.orderbook : book;
return {
yes: this._normalizeBookSide(root?.yes ?? root?.yes_dollars_fp ?? root?.yes_dollars),
no: this._normalizeBookSide(root?.no ?? root?.no_dollars_fp ?? root?.no_dollars)
};
}
_bestBookPrice(sideBook) {
if (!Array.isArray(sideBook) || !sideBook.length) return null;
return this._num(sideBook[0][0]);
}
_pickBestMarket(markets = []) {
const now = Date.now();
const ranked = markets
.filter(Boolean)
.map((market) => {
const status = String(market?.status || '').toLowerCase();
const closeTs =
this._toTs(market?.close_time) ||
this._toTs(market?.expiration_time) ||
this._toTs(market?.settlement_time) ||
null;
const tradable = TRADABLE_MARKET_STATUSES.has(status);
const openLike = OPEN_MARKET_STATUSES.has(status);
const notClearlyExpired = closeTs == null || closeTs > now - 60_000;
return { market, tradable, openLike, notClearlyExpired, closeTs };
})
.filter((x) => x.openLike || x.notClearlyExpired);
if (!ranked.length) return markets[0] || null;
ranked.sort((a, b) => {
if (a.tradable !== b.tradable) return a.tradable ? -1 : 1;
if (a.openLike !== b.openLike) return a.openLike ? -1 : 1;
if (a.notClearlyExpired !== b.notClearlyExpired) return a.notClearlyExpired ? -1 : 1;
const aTs = a.closeTs ?? Number.MAX_SAFE_INTEGER;
const bTs = b.closeTs ?? Number.MAX_SAFE_INTEGER;
return aTs - bTs;
});
return ranked[0].market;
}
async _findAndSubscribe() { async _findAndSubscribe() {
try { try {
const event = await getActiveBTCEvent(); const candidates = await getActiveBTCEvents(12);
if (!event) {
if (!candidates.length) {
if (!this.currentTicker) this.emit('update', null);
console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...'); console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...');
return; return;
} }
const markets = event.markets || await getEventMarkets(event.event_ticker); let selectedEvent = null;
// Find the up/down market (usually only one market per event) let selectedMarket = null;
const market = markets.find(m => m.status === 'active' || m.status === 'open') || markets[0];
if (!market) { for (const event of candidates) {
console.log('[Tracker] No active market in event. Retrying...'); const eventTicker = event?.event_ticker;
if (!eventTicker) continue;
let markets = Array.isArray(event.markets) ? event.markets : [];
if (!markets.length) {
try {
markets = await getEventMarkets(eventTicker);
} catch (e) {
console.error(`[Tracker] Failed loading markets for ${eventTicker}:`, e.message);
continue;
}
}
if (!markets.length) continue;
const market = this._pickBestMarket(markets);
if (!market?.ticker) continue;
selectedEvent = event;
selectedMarket = market;
break;
}
if (!selectedEvent || !selectedMarket) {
if (!this.currentTicker) this.emit('update', null);
console.log('[Tracker] No tradable BTC 15m market found yet. Retrying...');
return; return;
} }
const newTicker = market.ticker; const newTicker = selectedMarket.ticker;
if (newTicker === this.currentTicker) return; if (newTicker === this.currentTicker) {
this.currentEvent = selectedEvent.event_ticker || this.currentEvent;
this.marketData = { ...(this.marketData || {}), ...selectedMarket };
this.emit('update', this.getState());
return;
}
// Unsubscribe from old const oldTicker = this.currentTicker;
if (this.currentTicker) {
console.log(`[Tracker] Rotating from ${this.currentTicker}${newTicker}`); if (oldTicker) {
this.ws.unsubscribeTicker(this.currentTicker); console.log(`[Tracker] Rotating from ${oldTicker}${newTicker}`);
this.ws.unsubscribeTicker(oldTicker);
} }
this.currentTicker = newTicker; this.currentTicker = newTicker;
this.currentEvent = event.event_ticker; this.currentEvent = selectedEvent.event_ticker;
this.marketData = market; this.marketData = selectedMarket;
this.orderbook = { yes: [], no: [] }; this.orderbook = { yes: [], no: [] };
// Fetch fresh orderbook via REST
try { try {
const ob = await getOrderbook(newTicker); const [freshMarket, ob] = await Promise.all([
this.orderbook = ob; getMarket(newTicker).catch(() => null),
getOrderbook(newTicker).catch(() => null)
]);
if (freshMarket) this.marketData = { ...selectedMarket, ...freshMarket };
if (ob) this.orderbook = this._normalizeOrderbook(ob);
} catch (e) { } catch (e) {
console.error('[Tracker] Orderbook fetch error:', e.message); console.error('[Tracker] Initial market bootstrap error:', e.message);
} }
// Subscribe via WS
this.ws.subscribeTicker(newTicker); this.ws.subscribeTicker(newTicker);
console.log(`[Tracker] Now tracking: ${newTicker} (${market.title || market.subtitle})`);
console.log(
`[Tracker] Now tracking: ${newTicker} (${this.marketData?.title || this.marketData?.subtitle || selectedEvent.event_ticker})`
);
this.emit('update', this.getState()); this.emit('update', this.getState());
this.emit('market-rotated', { from: this.currentTicker, to: newTicker }); this.emit('market-rotated', { from: oldTicker, to: newTicker });
} catch (err) { } catch (err) {
console.error('[Tracker] Discovery error:', err.message); console.error('[Tracker] Discovery error:', err.message);
} }
} }
async _checkRotation() { async _checkRotation() {
// Refresh market data via REST
if (this.currentTicker) { if (this.currentTicker) {
try { try {
const fresh = await getMarket(this.currentTicker); const fresh = await getMarket(this.currentTicker);
this.marketData = fresh; this.marketData = { ...(this.marketData || {}), ...(fresh || {}) };
const state = this.getState(); const state = this.getState();
this.emit('update', state); this.emit('update', state);
// If market closed/settled, find the next one const status = String(fresh?.status || '').toLowerCase();
if (fresh.status === 'closed' || fresh.status === 'settled' || fresh.result) { const settledLike = status === 'closed' || status === 'settled' || status === 'expired' || status === 'finalized';
if (settledLike || fresh?.result) {
console.log(`[Tracker] Market ${this.currentTicker} settled (result: ${fresh.result}). Rotating...`); console.log(`[Tracker] Market ${this.currentTicker} settled (result: ${fresh.result}). Rotating...`);
this.emit('settled', { ticker: this.currentTicker, result: fresh.result }); this.emit('settled', { ticker: this.currentTicker, result: fresh.result });
this.currentTicker = null; this.currentTicker = null;
@@ -158,11 +358,36 @@ export class MarketTracker extends EventEmitter {
if (msg.market_ticker !== this.currentTicker) return; if (msg.market_ticker !== this.currentTicker) return;
if (msg.type === 'orderbook_snapshot') { if (msg.type === 'orderbook_snapshot') {
this.orderbook = { yes: msg.yes || [], no: msg.no || [] }; this.orderbook = this._normalizeOrderbook(msg);
console.log(`[Tracker] Orderbook snapshot: ${this.orderbook.yes.length} yes levels, ${this.orderbook.no.length} no levels`);
} else if (msg.type === 'orderbook_delta') { } else if (msg.type === 'orderbook_delta') {
// Apply delta updates const side = String(msg.side || '').toLowerCase();
if (msg.yes) this.orderbook.yes = this._applyDelta(this.orderbook.yes, msg.yes);
if (msg.no) this.orderbook.no = this._applyDelta(this.orderbook.no, msg.no); let price = this._num(msg.price);
if (price == null) price = dollarsToCents(msg.price_dollars);
let delta = this._num(msg.delta);
if (delta == null) delta = this._num(msg.delta_fp);
const absoluteQty = this._num(msg.qty ?? msg.quantity ?? msg.size);
if ((side === 'yes' || side === 'no') && price != null) {
const book = this.orderbook[side] || [];
const map = new Map(book);
const current = this._num(map.get(price)) ?? 0;
const next = delta != null ? current + delta : (absoluteQty ?? current);
if (next <= 0) map.delete(price);
else map.set(price, next);
this.orderbook[side] = [...map.entries()].sort((a, b) => b[0] - a[0]);
} else {
const yesArr = msg.yes ?? msg.yes_dollars_fp;
const noArr = msg.no ?? msg.no_dollars_fp;
if (Array.isArray(yesArr)) this.orderbook.yes = this._applyDelta(this.orderbook.yes, yesArr);
if (Array.isArray(noArr)) this.orderbook.no = this._applyDelta(this.orderbook.no, noArr);
}
} }
this.emit('update', this.getState()); this.emit('update', this.getState());
@@ -171,27 +396,58 @@ export class MarketTracker extends EventEmitter {
_onTicker(msg) { _onTicker(msg) {
if (msg.market_ticker !== this.currentTicker) return; if (msg.market_ticker !== this.currentTicker) return;
// Merge ticker data into marketData
if (this.marketData) { if (this.marketData) {
Object.assign(this.marketData, { const fields = [
yes_bid: msg.yes_bid ?? this.marketData.yes_bid, 'yes_bid', 'yes_ask', 'no_bid', 'no_ask', 'last_price', 'volume',
yes_ask: msg.yes_ask ?? this.marketData.yes_ask, 'yes_bid_dollars', 'yes_ask_dollars', 'no_bid_dollars', 'no_ask_dollars',
no_bid: msg.no_bid ?? this.marketData.no_bid, 'price_dollars', 'volume_fp', 'open_interest_fp',
no_ask: msg.no_ask ?? this.marketData.no_ask, 'dollar_volume', 'dollar_open_interest'
last_price: msg.last_price ?? this.marketData.last_price, ];
volume: msg.volume ?? this.marketData.volume
}); for (const key of fields) {
if (msg[key] != null) this.marketData[key] = msg[key];
}
if (msg.dollar_volume != null) this.marketData.volume = this._num(msg.dollar_volume) ?? this.marketData.volume;
if (msg.dollar_open_interest != null) this.marketData.open_interest = this._num(msg.dollar_open_interest) ?? this.marketData.open_interest;
if (msg.volume_fp != null && this.marketData.volume == null) this.marketData.volume = this._num(msg.volume_fp);
} }
this.emit('update', this.getState()); this.emit('update', this.getState());
} }
_applyDelta(book, deltas) { _applyDelta(book, deltas) {
const map = new Map(book); const map = new Map(book || []);
for (const [price, qty] of deltas) {
if (qty === 0) map.delete(price); for (const delta of Array.isArray(deltas) ? deltas : []) {
let price = null;
let qty = null;
if (Array.isArray(delta)) {
const rawPrice = delta[0];
const rawQty = delta[1];
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
price = dollarsToCents(rawPrice);
} else {
price = this._num(rawPrice);
}
qty = this._num(rawQty);
} else if (delta && typeof delta === 'object') {
const rawPrice = delta.price ?? delta.price_dollars ?? delta[0];
const rawQty = delta.qty ?? delta.quantity ?? delta.size ?? delta[1];
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
price = dollarsToCents(rawPrice);
} else {
price = this._num(rawPrice);
}
qty = this._num(rawQty);
}
if (price == null || qty == null) continue;
if (qty <= 0) map.delete(price);
else map.set(price, qty); else map.set(price, qty);
} }
return [...map.entries()].sort((a, b) => a[0] - b[0]);
return [...map.entries()].sort((a, b) => b[0] - a[0]);
} }
} }

View File

@@ -2,63 +2,134 @@ import { db } from '../db.js';
import { notify } from '../notify.js'; import { notify } from '../notify.js';
/** /**
* Paper Trading Engine. * Per-Strategy Paper Trading Engine.
* Executes virtual trades, tracks PnL, stores in SurrealDB. * Each strategy gets its own isolated balance, PnL, and trade history.
*/ */
export class PaperEngine { class StrategyPaperAccount {
constructor(initialBalance = 1000) { constructor(strategyName, initialBalance = 1000) {
this.strategyName = strategyName;
this.balance = initialBalance; this.balance = initialBalance;
this.initialBalance = initialBalance;
this.openPositions = new Map(); // ticker -> [positions] this.openPositions = new Map(); // ticker -> [positions]
this.tradeHistory = [];
this.totalPnL = 0; this.totalPnL = 0;
this.wins = 0; this.wins = 0;
this.losses = 0; this.losses = 0;
} }
getStats() {
const openPositionsList = [];
for (const [, positions] of this.openPositions) {
openPositionsList.push(...positions);
}
return {
strategy: this.strategyName,
balance: parseFloat(this.balance.toFixed(2)),
initialBalance: this.initialBalance,
totalPnL: parseFloat(this.totalPnL.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,
openPositions: openPositionsList,
totalTrades: this.wins + this.losses
};
}
}
export class PaperEngine {
constructor(initialBalancePerStrategy = 1000) {
this.initialBalancePerStrategy = initialBalancePerStrategy;
this.accounts = new Map(); // strategyName -> StrategyPaperAccount
this._resetting = false;
}
_getAccount(strategyName) {
if (!this.accounts.has(strategyName)) {
this.accounts.set(strategyName, new StrategyPaperAccount(strategyName, this.initialBalancePerStrategy));
}
return this.accounts.get(strategyName);
}
/**
* Generate a short unique ID safe for SurrealDB v2 record keys.
* Avoids colons so we control the full `table:id` format ourselves.
*/
_genId() {
return `pt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Extract the raw record key from a SurrealDB id.
* e.g. "paper_positions:pt_123_abc" -> "pt_123_abc"
* "pt_123_abc" -> "pt_123_abc"
*/
_rawId(id) {
if (!id) return id;
const str = typeof id === 'object' && id.id ? String(id.id) : String(id);
const idx = str.indexOf(':');
return idx >= 0 ? str.slice(idx + 1) : str;
}
/**
* Build the full `table:id` string for use in raw queries.
*/
_recordId(id) {
const raw = this._rawId(id);
return raw.startsWith('paper_positions:') ? raw : `paper_positions:⟨${raw}`;
}
async init() { async init() {
// Load state from SurrealDB
try { try {
const state = await db.query('SELECT * FROM paper_state ORDER BY timestamp DESC LIMIT 1'); const states = await db.query('SELECT * FROM paper_strategy_state ORDER BY timestamp DESC');
const saved = state[0]?.[0]; const rows = states[0] || [];
if (saved) { const seen = new Set();
this.balance = saved.balance; for (const saved of rows) {
this.totalPnL = saved.totalPnL; if (!saved.strategyName || seen.has(saved.strategyName)) continue;
this.wins = saved.wins; seen.add(saved.strategyName);
this.losses = saved.losses; const acct = this._getAccount(saved.strategyName);
console.log(`[Paper] Restored state: $${this.balance.toFixed(2)} balance, ${this.wins}W/${this.losses}L`); acct.balance = saved.balance;
acct.totalPnL = saved.totalPnL;
acct.wins = saved.wins;
acct.losses = saved.losses;
console.log(`[Paper:${saved.strategyName}] Restored: $${acct.balance.toFixed(2)}, ${acct.wins}W/${acct.losses}L`);
} }
// Load open positions
const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false'); const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false');
if (positions[0]) { if (positions[0]) {
for (const pos of positions[0]) { for (const pos of positions[0]) {
const list = this.openPositions.get(pos.ticker) || []; const acct = this._getAccount(pos.strategy);
const list = acct.openPositions.get(pos.ticker) || [];
list.push(pos); list.push(pos);
this.openPositions.set(pos.ticker, list); acct.openPositions.set(pos.ticker, list);
}
const totalOpen = positions[0].length;
if (totalOpen > 0) {
console.log(`[Paper] Loaded ${totalOpen} open position(s) from DB`);
} }
console.log(`[Paper] Restored ${this.openPositions.size} open position(s)`);
} }
} catch (e) { } catch (e) {
console.error('[Paper] Init error (fresh start):', e.message); console.error('[Paper] Init error (fresh start):', e.message);
} }
} }
/**
* Execute a paper trade from a strategy signal.
*/
async executeTrade(signal, marketState) { async executeTrade(signal, marketState) {
const cost = signal.size; // Each contract costs signal.price cents, but we simplify: $1 per contract unit if (this._resetting) return null;
if (this.balance < cost) {
console.log(`[Paper] Insufficient balance ($${this.balance.toFixed(2)}) for $${cost} trade`); const acct = this._getAccount(signal.strategy);
const cost = signal.size;
if (acct.balance < cost) {
console.log(`[Paper:${signal.strategy}] Insufficient balance ($${acct.balance.toFixed(2)}) for $${cost} trade`);
return null; return null;
} }
const trade = { const trade = {
id: `pt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, id: this._genId(),
strategy: signal.strategy, strategy: signal.strategy,
ticker: signal.ticker, ticker: signal.ticker,
side: signal.side, side: signal.side.toLowerCase(),
price: signal.price, // Entry price in cents price: signal.price,
size: signal.size, size: signal.size,
cost, cost,
reason: signal.reason, reason: signal.reason,
@@ -74,104 +145,269 @@ export class PaperEngine {
} }
}; };
this.balance -= cost; acct.balance -= cost;
const list = this.openPositions.get(trade.ticker) || []; const list = acct.openPositions.get(trade.ticker) || [];
list.push(trade); list.push(trade);
this.openPositions.set(trade.ticker, list); acct.openPositions.set(trade.ticker, list);
// Store in SurrealDB
try { try {
await db.create('paper_positions', trade); await db.create('paper_positions', trade);
await this._saveState(); await this._saveState(acct);
} catch (e) { } catch (e) {
console.error('[Paper] DB write error:', e.message); console.error('[Paper] DB write error:', e.message);
} }
const msg = `📝 PAPER ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.strategy} | ${trade.reason}`; const msg = `📝 PAPER [${trade.strategy}] ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.reason}`;
console.log(`[Paper] ${msg}`); console.log(`[Paper] ${msg}`);
await notify(msg, 'Paper Trade'); await notify(msg, `Paper: ${trade.strategy}`, '1');
return trade; return trade;
} }
/** async settle(ticker, rawResult) {
* Settle all positions for a ticker when the market resolves. const result = String(rawResult || '').toLowerCase();
*/
async settle(ticker, result) {
const positions = this.openPositions.get(ticker);
if (!positions || positions.length === 0) return;
console.log(`[Paper] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`); if (result !== 'yes' && result !== 'no') {
console.warn(`[Paper] Unknown settlement result "${rawResult}" for ${ticker}, skipping.`);
for (const pos of positions) { return null;
const won = pos.side === result;
// Payout: if won, pay out at $1 per contract (100¢), minus cost
// If lost, lose the cost
const payout = won ? (100 / pos.price) * pos.cost : 0;
const pnl = payout - pos.cost;
pos.settled = true;
pos.result = result;
pos.pnl = parseFloat(pnl.toFixed(2));
pos.settleTime = Date.now();
this.balance += payout;
this.totalPnL += pnl;
if (won) this.wins++;
else this.losses++;
// Update in SurrealDB
try {
await db.query(`UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`, {
id: pos.id,
result,
pnl: pos.pnl,
settleTime: pos.settleTime
});
} catch (e) {
console.error('[Paper] Settle DB error:', e.message);
}
const emoji = won ? '✅' : '❌';
const msg = `${emoji} ${pos.strategy} ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Balance: $${this.balance.toFixed(2)}`;
console.log(`[Paper] ${msg}`);
await notify(msg, won ? 'Paper Win!' : 'Paper Loss');
} }
this.openPositions.delete(ticker); const allSettled = [];
await this._saveState();
return positions; for (const [strategyName, acct] of this.accounts) {
const positions = acct.openPositions.get(ticker);
if (!positions || positions.length === 0) continue;
console.log(`[Paper:${strategyName}] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
for (const pos of positions) {
const side = String(pos.side || '').toLowerCase();
const won = side === result;
const price = pos.price > 0 ? pos.price : 50;
const payout = won ? (100 / price) * pos.cost : 0;
const pnl = payout - pos.cost;
pos.settled = true;
pos.result = result;
pos.pnl = parseFloat(pnl.toFixed(2));
pos.settleTime = Date.now();
acct.balance += payout;
acct.totalPnL += pnl;
if (won) acct.wins++;
else acct.losses++;
const recordId = this._recordId(pos.id);
try {
const updated = await db.query(
`UPDATE ${recordId} SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime`,
{ result, pnl: pos.pnl, settleTime: pos.settleTime }
);
const rows = updated[0] || [];
if (rows.length === 0) {
// Record vanished from DB — re-insert the full settled trade
await db.create('paper_positions', { ...pos, id: this._rawId(pos.id) });
}
} catch (e) {
console.error('[Paper] Settle DB error:', e.message);
try {
await db.create('paper_positions', { ...pos, id: this._rawId(pos.id) });
} catch (e2) {
console.error('[Paper] Settle DB fallback error:', e2.message);
}
}
const emoji = won ? '✅' : '❌';
const msg = `${emoji} [${strategyName}] ${side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Bal: $${acct.balance.toFixed(2)}`;
console.log(`[Paper] ${msg}`);
await notify(msg, won ? `${strategyName} Win!` : `${strategyName} Loss`, '1');
allSettled.push(pos);
}
acct.openPositions.delete(ticker);
await this._saveState(acct);
}
return allSettled.length > 0 ? allSettled : null;
}
getOpenTickers() {
const tickers = new Set();
for (const [, acct] of this.accounts) {
for (const ticker of acct.openPositions.keys()) {
tickers.add(ticker);
}
}
return Array.from(tickers);
}
async checkOrphans(getMarketFn) {
const orphanTickers = this.getOpenTickers();
if (!orphanTickers.length) return { settled: [], expired: [] };
const results = { settled: [], expired: [] };
for (const ticker of orphanTickers) {
try {
const market = await getMarketFn(ticker);
const status = String(market?.status || '').toLowerCase();
const result = market?.result;
if (result) {
console.log(`[Paper] Delayed result found for ${ticker}: "${result}"`);
const settledPos = await this.settle(ticker, result);
if (settledPos) results.settled.push(...settledPos);
} else if (['expired', 'cancelled'].includes(status)) {
console.log(`[Paper] Ticker ${ticker} marked as ${status} — force-settling as expired`);
const expiredPos = await this._forceExpirePositions(ticker);
if (expiredPos) results.expired.push(...expiredPos);
}
} catch (e) {
console.error(`[Paper] Orphan check failed for ${ticker}:`, e.message);
}
}
return results;
}
async _forceExpirePositions(ticker) {
const expired = [];
for (const [strategyName, acct] of this.accounts) {
const positions = acct.openPositions.get(ticker);
if (!positions || !positions.length) continue;
for (const pos of positions) {
pos.settled = true;
pos.result = 'expired';
pos.pnl = parseFloat((-pos.cost).toFixed(2));
pos.settleTime = Date.now();
acct.totalPnL -= pos.cost;
acct.losses++;
const recordId = this._recordId(pos.id);
try {
const updated = await db.query(
`UPDATE ${recordId} SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime`,
{ result: 'expired', pnl: pos.pnl, settleTime: pos.settleTime }
);
const rows = updated[0] || [];
if (rows.length === 0) {
await db.create('paper_positions', { ...pos, id: this._rawId(pos.id) });
}
} catch (e) {
console.error('[Paper] Force-expire DB error:', e.message);
}
console.log(`[Paper:${strategyName}] Force-expired position ${pos.id} for ${ticker} (lost $${pos.cost})`);
expired.push(pos);
}
acct.openPositions.delete(ticker);
await this._saveState(acct);
}
return expired;
}
async resetAll() {
this._resetting = true;
for (const [name, acct] of this.accounts) {
for (const [ticker, positions] of acct.openPositions) {
for (const pos of positions) {
acct.balance += pos.cost;
pos.settled = true;
pos.result = 'cancelled';
pos.pnl = 0;
pos.settleTime = Date.now();
try {
const recordId = this._recordId(pos.id);
await db.query(
`UPDATE ${recordId} SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime`,
{ result: 'cancelled', pnl: 0, settleTime: pos.settleTime }
);
} catch (e) {}
console.log(`[Paper:${name}] Cancelled open position ${pos.id} for ${ticker} (refunded $${pos.cost})`);
}
}
acct.openPositions.clear();
}
for (const [name, acct] of this.accounts) {
acct.balance = acct.initialBalance;
acct.totalPnL = 0;
acct.wins = 0;
acct.losses = 0;
acct.openPositions.clear();
console.log(`[Paper:${name}] Reset to $${acct.initialBalance}`);
}
try {
await db.query('DELETE paper_positions');
await db.query('DELETE paper_strategy_state');
console.log('[Paper] Cleared all DB records');
} catch (e) {
console.error('[Paper] Reset DB error:', e.message);
}
for (const [, acct] of this.accounts) {
await this._saveState(acct);
}
this._resetting = false;
} }
getStats() { getStats() {
const openPositionsList = []; const allOpen = [];
for (const [ticker, positions] of this.openPositions) { let totalBalance = 0;
openPositionsList.push(...positions); let totalPnL = 0;
let totalWins = 0;
let totalLosses = 0;
for (const [, acct] of this.accounts) {
const s = acct.getStats();
totalBalance += s.balance;
totalPnL += s.totalPnL;
totalWins += s.wins;
totalLosses += s.losses;
allOpen.push(...s.openPositions);
} }
return { return {
balance: parseFloat(this.balance.toFixed(2)), balance: parseFloat(totalBalance.toFixed(2)),
totalPnL: parseFloat(this.totalPnL.toFixed(2)), totalPnL: parseFloat(totalPnL.toFixed(2)),
wins: this.wins, wins: totalWins,
losses: this.losses, losses: totalLosses,
winRate: this.wins + this.losses > 0 winRate: totalWins + totalLosses > 0
? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1)) ? parseFloat(((totalWins / (totalWins + totalLosses)) * 100).toFixed(1))
: 0, : 0,
openPositions: openPositionsList, openPositions: allOpen,
totalTrades: this.wins + this.losses totalTrades: totalWins + totalLosses
}; };
} }
async _saveState() { getPerStrategyStats() {
const result = {};
for (const [name, acct] of this.accounts) {
result[name] = acct.getStats();
}
return result;
}
async _saveState(acct) {
try { try {
await db.create('paper_state', { await db.create('paper_strategy_state', {
balance: this.balance, strategyName: acct.strategyName,
totalPnL: this.totalPnL, balance: acct.balance,
wins: this.wins, totalPnL: acct.totalPnL,
losses: this.losses, wins: acct.wins,
losses: acct.losses,
timestamp: Date.now() timestamp: Date.now()
}); });
} catch (e) { } catch (e) {

View File

@@ -2,21 +2,26 @@
* Base strategy class. All strategies extend this. * Base strategy class. All strategies extend this.
* *
* Strategies receive market state updates and emit trade signals. * Strategies receive market state updates and emit trade signals.
* Signals are { side: 'yes'|'no', price: number, size: number, reason: string } * Signals are { side: 'yes'|'no', price: number, maxPrice: number, size: number, reason: string }
*
* `price` = ideal/target price in cents
* `maxPrice` = maximum acceptable fill price (slippage tolerance)
*/ */
export class BaseStrategy { export class BaseStrategy {
constructor(name, config = {}) { constructor(name, config = {}) {
this.name = name; this.name = name;
this.config = config; this.config = config;
this.enabled = true; this.enabled = true;
this.mode = 'paper'; // 'paper' | 'live' this.mode = 'paper';
} }
/** /**
* Called on every market state update. * Called on every market state update.
* @param {object} marketState - includes yesPct, noPct, orderbook, ticker, closeTime, etc.
* @param {string} caller - 'paper' | 'live' — so strategies can track per-caller state
* Return a signal object or null. * Return a signal object or null.
*/ */
evaluate(marketState) { evaluate(marketState, caller = 'paper') {
throw new Error(`${this.name}: evaluate() not implemented`); throw new Error(`${this.name}: evaluate() not implemented`);
} }

View File

@@ -0,0 +1,103 @@
import { BaseStrategy } from './base.js';
export class BullDipBuyer extends BaseStrategy {
constructor(config = {}) {
super('bull-dip-buyer', {
maxYesPrice: config.maxYesPrice || 45,
minYesPrice: config.minYesPrice || 15,
betSize: config.betSize || 2,
slippage: config.slippage || 3,
cooldownMs: config.cooldownMs || 20000,
marketDurationMin: config.marketDurationMin || 15,
entryWindowMin: config.entryWindowMin || 6,
...config
});
this._lastTrade = {
paper: { time: 0, ticker: null },
live: { time: 0, ticker: null }
};
}
evaluate(state, caller = 'paper') {
if (!state || !this.enabled || !state.closeTime) return null;
const track = this._lastTrade[caller] || this._lastTrade.paper;
const now = Date.now();
if (now - track.time < this.config.cooldownMs) return null;
if (state.ticker === track.ticker) return null;
const closeTs = new Date(state.closeTime).getTime();
const timeLeftMs = closeTs - now;
if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return null;
// Entry gate: only trade in first N minutes of each market cycle.
// With 15m markets: first 0-6m elapsed => roughly 15m down to 9m remaining.
const elapsedMin = (this.config.marketDurationMin * 60000 - timeLeftMs) / 60000;
if (elapsedMin < 0 || elapsedMin > this.config.entryWindowMin) return null;
const { yesPct } = state;
if (yesPct <= this.config.maxYesPrice && yesPct >= this.config.minYesPrice) {
// For live trading, require orderbook data — refuse to trade blind
if (caller === 'live') {
const bestAsk = this._getBestYesAsk(state);
if (bestAsk == null) return null;
// Verify the actual ask price is within our dip range + slippage
if (bestAsk > this.config.maxYesPrice + this.config.slippage) return null;
// Don't let slippage push us above 50¢ — that's not a dip
if (bestAsk > 50) return null;
}
const maxPrice = Math.min(
yesPct + this.config.slippage,
this.config.maxYesPrice + this.config.slippage,
50
);
const bestAsk = this._getBestYesAsk(state);
let reason = `Bullish dip buy (t+${elapsedMin.toFixed(1)}m): Yes @ ${yesPct}¢`;
if (bestAsk != null) {
if (bestAsk > maxPrice) return null;
reason = `Bullish dip buy (t+${elapsedMin.toFixed(1)}m): Yes @ ${yesPct}¢ (ask: ${bestAsk}¢)`;
}
const signal = {
strategy: this.name,
side: 'yes',
price: yesPct,
maxPrice,
size: this.config.betSize,
reason,
ticker: state.ticker
};
track.time = now;
track.ticker = state.ticker;
return signal;
}
return null;
}
_getBestYesAsk(state) {
const ob = state.orderbook;
if (!ob) return null;
if (ob.no?.length) {
const bestNoBid = ob.no[0]?.[0];
if (bestNoBid != null) return 100 - bestNoBid;
}
return null;
}
toJSON() {
return {
...super.toJSON(),
lastTradeTicker: this._lastTrade.live.ticker || this._lastTrade.paper.ticker
};
}
}

View File

@@ -0,0 +1,102 @@
import { BaseStrategy } from './base.js';
export class EarlyFaderStrategy extends BaseStrategy {
constructor(config = {}) {
super('early-fader', {
spikeThreshold: config.spikeThreshold || 82, // When a side spikes to this %
maxElapsedMin: config.maxElapsedMin || 6, // Only fade early spikes (first 6 mins)
betSize: config.betSize || 2,
slippage: config.slippage || 3,
cooldownMs: config.cooldownMs || 20000,
marketDurationMin: config.marketDurationMin || 15,
...config
});
this._lastTrade = {
paper: { time: 0, ticker: null },
live: { time: 0, ticker: null }
};
}
evaluate(state, caller = 'paper') {
if (!state || !this.enabled || !state.closeTime) return null;
const track = this._lastTrade[caller] || this._lastTrade.paper;
const now = Date.now();
if (now - track.time < this.config.cooldownMs) return null;
if (state.ticker === track.ticker) return null; // 1 trade per market
const closeTs = new Date(state.closeTime).getTime();
const timeLeftMs = closeTs - now;
if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return null;
const elapsedMin = (this.config.marketDurationMin * 60000 - timeLeftMs) / 60000;
// Only trade in the designated early window
if (elapsedMin < 0 || elapsedMin > this.config.maxElapsedMin) return null;
const { yesPct, noPct } = state;
const threshold = this.config.spikeThreshold;
let targetSide = null;
let targetPrice = null;
// If YES spikes early, we fade it by buying NO
if (yesPct >= threshold && yesPct <= 95) {
targetSide = 'no';
targetPrice = noPct;
}
// If NO spikes early, we fade it by buying YES
else if (noPct >= threshold && noPct <= 95) {
targetSide = 'yes';
targetPrice = yesPct;
}
if (!targetSide) return null;
// Ensure the opposite side is actually cheap (sanity check against weird book states)
if (targetPrice > (100 - threshold + 5)) return null;
// For live trading, check actual orderbook liquidity to avoid blind fills
if (caller === 'live') {
const bestAsk = this._getBestAsk(state, targetSide);
if (bestAsk == null) return null;
if (bestAsk > targetPrice + this.config.slippage) return null;
}
const spikeSide = targetSide === 'no' ? 'YES' : 'NO';
const signal = {
strategy: this.name,
side: targetSide,
price: targetPrice,
maxPrice: Math.min(targetPrice + this.config.slippage, 40),
size: this.config.betSize,
reason: `Fading early spike (t+${elapsedMin.toFixed(1)}m): ${spikeSide} > ${threshold}¢. Bought ${targetSide.toUpperCase()} @ ${targetPrice}¢`,
ticker: state.ticker
};
track.time = now;
track.ticker = state.ticker;
return signal;
}
_getBestAsk(state, side) {
const ob = state.orderbook;
if (!ob) return null;
const oppSide = side === 'yes' ? 'no' : 'yes';
if (ob[oppSide]?.length) {
const bestOppBid = ob[oppSide][0]?.[0];
if (bestOppBid != null) return 100 - bestOppBid;
}
return null;
}
toJSON() {
return {
...super.toJSON(),
lastTradeTicker: this._lastTrade.live.ticker || this._lastTrade.paper.ticker
};
}
}

View File

@@ -0,0 +1,80 @@
import { BaseStrategy } from './base.js';
/**
* Late Momentum Strategy
*
* Rules:
* - Buys momentum on YES or NO
* - Requires side price >= triggerPct (default 75)
* - Only allowed after first 6 minutes of market lifecycle
* - Default window: elapsed minute 6 through 15
*/
export class LateMomentumStrategy extends BaseStrategy {
constructor(config = {}) {
super('late-momentum', {
triggerPct: config.triggerPct || 75,
betSize: config.betSize || 4,
slippage: config.slippage || 3,
cooldownMs: config.cooldownMs || 20000,
marketDurationMin: config.marketDurationMin || 15,
minElapsedMin: config.minElapsedMin || 6,
maxElapsedMin: config.maxElapsedMin || 15,
...config
});
this._lastTrade = {
paper: { time: 0, ticker: null },
live: { time: 0, ticker: null }
};
}
evaluate(state, caller = 'paper') {
if (!state || !this.enabled || !state.closeTime) return null;
const track = this._lastTrade[caller] || this._lastTrade.paper;
const now = Date.now();
if (now - track.time < this.config.cooldownMs) return null;
if (state.ticker === track.ticker) return null;
const closeTs = new Date(state.closeTime).getTime();
const timeLeftMs = closeTs - now;
if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return null;
const elapsedMin = (this.config.marketDurationMin * 60000 - timeLeftMs) / 60000;
if (!Number.isFinite(elapsedMin)) return null;
// Skip first minutes; trade only in configured late window.
if (elapsedMin < this.config.minElapsedMin || elapsedMin > this.config.maxElapsedMin) return null;
const yesPct = Number(state.yesPct);
const noPct = Number(state.noPct);
if (!Number.isFinite(yesPct) || !Number.isFinite(noPct)) return null;
const trigger = this.config.triggerPct;
const candidates = [];
if (yesPct >= trigger && yesPct < 99) candidates.push({ side: 'yes', pct: yesPct });
if (noPct >= trigger && noPct < 99) candidates.push({ side: 'no', pct: noPct });
if (!candidates.length) return null;
// Prefer the stronger momentum side if both qualify.
candidates.sort((a, b) => b.pct - a.pct);
const pick = candidates[0];
const signal = {
strategy: this.name,
side: pick.side,
price: pick.pct,
maxPrice: Math.min(pick.pct + this.config.slippage, 95),
size: this.config.betSize,
reason: `Late momentum: t+${elapsedMin.toFixed(1)}m, ${pick.side.toUpperCase()} @ ${pick.pct}¢`,
ticker: state.ticker
};
track.time = now;
track.ticker = state.ticker;
return signal;
}
}

View File

@@ -1,109 +0,0 @@
import { BaseStrategy } from './base.js';
/**
* Martingale Strategy
*
* Logic:
* - If one side is ~70%+ (configurable), bet the opposite side.
* - On loss, double the bet size (Martingale).
* - On win, reset to base bet size.
* - Max consecutive losses cap to prevent blowup.
*/
export class MartingaleStrategy extends BaseStrategy {
constructor(config = {}) {
super('martingale', {
threshold: config.threshold || 70, // Trigger when one side >= this %
baseBet: config.baseBet || 1, // Base bet in dollars
maxDoublings: config.maxDoublings || 5, // Max consecutive losses before stopping
cooldownMs: config.cooldownMs || 60000, // Min time between trades (1 min)
...config
});
this.consecutiveLosses = 0;
this.currentBetSize = this.config.baseBet;
this.lastTradeTime = 0;
this.lastTradeTicker = null;
}
evaluate(state) {
if (!state || !this.enabled) return null;
const now = Date.now();
// Cooldown — don't spam trades
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
// Don't trade same ticker twice
if (state.ticker === this.lastTradeTicker) return null;
// Check if Martingale limit reached
if (this.consecutiveLosses >= this.config.maxDoublings) {
return null; // Paused — too many consecutive losses
}
const { yesPct, noPct } = state;
const threshold = this.config.threshold;
let signal = null;
// If "Yes" is at 70%+, bet "No" (the underdog)
if (yesPct >= threshold) {
signal = {
strategy: this.name,
side: 'no',
price: noPct,
size: this.currentBetSize,
reason: `Yes at ${yesPct}% (≥${threshold}%), betting No at ${noPct}¢`,
ticker: state.ticker
};
}
// If "No" is at 70%+, bet "Yes" (the underdog)
else if (noPct >= threshold) {
signal = {
strategy: this.name,
side: 'yes',
price: yesPct,
size: this.currentBetSize,
reason: `No at ${noPct}% (≥${threshold}%), betting Yes at ${yesPct}¢`,
ticker: state.ticker
};
}
if (signal) {
this.lastTradeTime = now;
this.lastTradeTicker = state.ticker;
}
return signal;
}
onSettlement(result, trade) {
if (!trade || trade.strategy !== this.name) return;
const won = (trade.side === 'yes' && result === 'yes') ||
(trade.side === 'no' && result === 'no');
if (won) {
console.log(`[Martingale] WIN — resetting to base bet $${this.config.baseBet}`);
this.consecutiveLosses = 0;
this.currentBetSize = this.config.baseBet;
} else {
this.consecutiveLosses++;
this.currentBetSize = this.config.baseBet * Math.pow(2, this.consecutiveLosses);
console.log(`[Martingale] LOSS #${this.consecutiveLosses} — next bet: $${this.currentBetSize}`);
if (this.consecutiveLosses >= this.config.maxDoublings) {
console.log(`[Martingale] MAX LOSSES REACHED. Strategy paused.`);
}
}
}
toJSON() {
return {
...super.toJSON(),
consecutiveLosses: this.consecutiveLosses,
currentBetSize: this.currentBetSize,
paused: this.consecutiveLosses >= this.config.maxDoublings
};
}
}

View File

@@ -0,0 +1,61 @@
import { BaseStrategy } from './base.js';
export class MomentumRiderStrategy extends BaseStrategy {
constructor(config = {}) {
super('momentum-rider', {
triggerPct: config.triggerPct || 75,
betSize: config.betSize || 4,
slippage: config.slippage || 3,
cooldownMs: config.cooldownMs || 20000,
...config
});
this._lastTrade = {
paper: { time: 0, ticker: null },
live: { time: 0, ticker: null }
};
}
evaluate(state, caller = 'paper') {
if (!state || !this.enabled) return null;
const track = this._lastTrade[caller] || this._lastTrade.paper;
const now = Date.now();
if (now - track.time < this.config.cooldownMs) return null;
if (state.ticker === track.ticker) return null;
const { yesPct, noPct } = state;
const trigger = this.config.triggerPct;
let signal = null;
if (yesPct >= trigger && yesPct < 99) {
signal = {
strategy: this.name,
side: 'yes',
price: yesPct,
maxPrice: Math.min(yesPct + this.config.slippage, 95),
size: this.config.betSize,
reason: `Riding Momentum! Yes is at ${yesPct}%`,
ticker: state.ticker
};
} else if (noPct >= trigger && noPct < 99) {
signal = {
strategy: this.name,
side: 'no',
price: noPct,
maxPrice: Math.min(noPct + this.config.slippage, 95),
size: this.config.betSize,
reason: `Riding Momentum! No is at ${noPct}%`,
ticker: state.ticker
};
}
if (signal) {
track.time = now;
track.ticker = state.ticker;
}
return signal;
}
}

View File

@@ -1,70 +0,0 @@
import { BaseStrategy } from './base.js';
/**
* Threshold (Contrarian) Strategy
*
* Logic:
* - If one side goes above a high threshold (e.g. 65%), bet the other.
* - Fixed bet size — no progression.
* - Simple mean-reversion assumption for short-term BTC markets.
*/
export class ThresholdStrategy extends BaseStrategy {
constructor(config = {}) {
super('threshold', {
triggerPct: config.triggerPct || 65,
betSize: config.betSize || 1,
cooldownMs: config.cooldownMs || 90000,
...config
});
this.lastTradeTime = 0;
this.lastTradeTicker = null;
}
evaluate(state) {
if (!state || !this.enabled) return null;
const now = Date.now();
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
if (state.ticker === this.lastTradeTicker) return null;
const { yesPct, noPct } = state;
const trigger = this.config.triggerPct;
let signal = null;
if (yesPct >= trigger) {
signal = {
strategy: this.name,
side: 'no',
price: noPct,
size: this.config.betSize,
reason: `Yes at ${yesPct}% (≥${trigger}%), contrarian No at ${noPct}¢`,
ticker: state.ticker
};
} else if (noPct >= trigger) {
signal = {
strategy: this.name,
side: 'yes',
price: yesPct,
size: this.config.betSize,
reason: `No at ${noPct}% (≥${trigger}%), contrarian Yes at ${yesPct}¢`,
ticker: state.ticker
};
}
if (signal) {
this.lastTradeTime = now;
this.lastTradeTicker = state.ticker;
}
return signal;
}
toJSON() {
return {
...super.toJSON(),
lastTradeTicker: this.lastTradeTicker
};
}
}

30
middleware.js Normal file
View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { verifySession } from './lib/auth';
export const config = {
matcher: [
'/dashboard/:path*',
'/paper/:path*',
'/dash/:path*',
'/api/state',
'/api/trades',
'/api/reset',
'/api/live-state',
'/api/live-trades',
'/api/live-toggle',
],
};
export async function middleware(req) {
const token = req.cookies.get('kalbot_session')?.value;
const isValid = await verifySession(token);
if (!isValid) {
if (req.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized. Nice try!' }, { status: 401 });
}
return NextResponse.redirect(new URL('/', req.url));
}
return NextResponse.next();
}

34
readme
View File

@@ -1,7 +1,29 @@
Kalshi bot @ kal.planetrenox.com Kalshi bot @ kal.planetrenox.com
JavaScript JavaScript
Next.js Next.js
surrealdb:v2.3.10 surrealdb:v2.3.10
Dokploy Dokploy
ntfy ntfy
kxbtc15m/bitcoin-price-up-down kxbtc15m/bitcoin-price-up-down
## Kalshi API Compatibility Notes (March 2026)
Kalshi migrated API payloads from legacy integer-centric fields to fixed-point fields.
- **March 10, 2026:** fixed-point migration docs updated
- **March 12, 2026:** legacy integer fields removed from API responses (per migration timeline)
### What changed
Use these fields when parsing order/fill data:
- `fill_count_fp` (string) instead of `fill_count`
- `taker_fill_cost_dollars` (string dollars) instead of `taker_fill_cost` (cents)
- `yes_price_dollars` / `no_price_dollars` for price strings
- similar `_fp` / `_dollars` fields across REST + WebSocket
### Why this matters for Kalbot
If code only reads legacy integer fields, a real fill can be interpreted as zero fill, causing:
- missing ntfy fill alerts
- position visible on Kalshi but not tracked correctly in local live state

302
worker.js
View File

@@ -1,118 +1,304 @@
import { MarketTracker } from './lib/market/tracker.js'; import { MarketTracker } from './lib/market/tracker.js';
import { PaperEngine } from './lib/paper/engine.js'; import { PaperEngine } from './lib/paper/engine.js';
import { MartingaleStrategy } from './lib/strategies/martingale.js'; import { LiveEngine } from './lib/live/engine.js';
import { ThresholdStrategy } from './lib/strategies/threshold.js'; import { getMarket } from './lib/kalshi/rest.js';
import { db } from './lib/db.js'; import { db } from './lib/db.js';
import { notify } from './lib/notify.js'; import { notify } from './lib/notify.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
// Shared state file for the Next.js frontend to read const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATE_FILE = '/tmp/kalbot-state.json'; const STATE_FILE = '/tmp/kalbot-state.json';
const LIVE_STATE_FILE = '/tmp/kalbot-live-state.json';
const HEARTBEAT_MS = 2000;
const BALANCE_POLL_MS = 30000;
let isSettling = false;
async function lockSettling() {
while (isSettling) await new Promise((r) => setTimeout(r, 50));
isSettling = true;
}
function unlockSettling() {
isSettling = false;
}
async function main() { async function main() {
console.log('=== Kalbot Worker Starting ==='); console.log('=== Kalbot Worker Starting ===');
// Connect to SurrealDB
await db.connect(); await db.connect();
// Initialize paper engine
const paper = new PaperEngine(1000); const paper = new PaperEngine(1000);
await paper.init(); await paper.init();
// Initialize strategies const live = new LiveEngine();
const strategies = [ await live.init();
new MartingaleStrategy({ threshold: 70, baseBet: 1, maxDoublings: 5 }),
new ThresholdStrategy({ triggerPct: 65, betSize: 1 })
];
console.log(`[Worker] Loaded ${strategies.length} strategies: ${strategies.map(s => s.name).join(', ')}`); // Dynamically load all strategies
const strategies = [];
const strategiesDir = path.join(__dirname, 'lib', 'strategies');
const stratFiles = fs.readdirSync(strategiesDir).filter((f) => f.endsWith('.js') && f !== 'base.js');
for (const file of stratFiles) {
try {
const fileUrl = pathToFileURL(path.join(strategiesDir, file)).href;
const mod = await import(fileUrl);
for (const key in mod) {
if (typeof mod[key] === 'function') {
strategies.push(new mod[key]());
break;
}
}
} catch (err) {
console.error(`[Worker] Failed to load strategy ${file}:`, err.message);
}
}
for (const s of strategies) {
paper._getAccount(s.name);
}
console.log(`[Worker] Loaded ${strategies.length} strategies: ${strategies.map((s) => s.name).join(', ')}`);
let latestMarketState = null;
async function pollBalance() {
try {
await live.fetchBalance();
} catch {}
try {
await live.fetchPositions();
} catch {}
}
await pollBalance();
setInterval(pollBalance, BALANCE_POLL_MS);
async function processOrphans() {
if (paper._resetting) return;
await lockSettling();
try {
const { settled, expired } = await paper.checkOrphans(getMarket);
const settledLive = await live.checkOrphans(getMarket);
const allResolved = [...settled, ...expired, ...settledLive];
if (allResolved.length > 0) {
for (const strategy of strategies) {
for (const trade of allResolved) {
if (trade.strategy === strategy.name) {
strategy.onSettlement(trade.result, trade);
}
}
}
writeState(latestMarketState, paper, live, strategies);
}
} catch (e) {
console.error('[Worker] Orphan check error:', e.message);
} finally {
unlockSettling();
}
}
await processOrphans();
setInterval(processOrphans, 10000);
// Initialize market tracker
const tracker = new MarketTracker(); const tracker = new MarketTracker();
let heartbeatTimer = null;
writeState(latestMarketState, paper, live, strategies);
// On every market update, run strategies
tracker.on('update', async (state) => { tracker.on('update', async (state) => {
if (!state) return; latestMarketState = state || null;
writeState(latestMarketState, paper, live, strategies);
// Write state to file for frontend if (!state || paper._resetting || isSettling) return;
writeState(state, paper, strategies);
// Run each strategy
for (const strategy of strategies) { for (const strategy of strategies) {
if (!strategy.enabled) continue; if (!strategy.enabled) continue;
const signal = strategy.evaluate(state); // ===== PAPER TRADING (isolated evaluation) =====
if (signal) { const paperAcct = paper._getAccount(strategy.name);
console.log(`[Worker] Signal from ${strategy.name}: ${signal.side} @ ${signal.price}¢ — ${signal.reason}`); if (paperAcct.openPositions.size === 0) {
const paperSignal = strategy.evaluate(state, 'paper');
if (strategy.mode === 'paper') { if (paperSignal) {
await paper.executeTrade(signal, state); console.log(`[Worker] Paper signal from ${strategy.name}: ${paperSignal.side} @ ${paperSignal.price}¢`);
await paper.executeTrade(paperSignal, state);
}
}
// ===== LIVE TRADING (separate evaluation, won't poison paper) =====
if (live.isStrategyEnabled(strategy.name) && !live.hasOpenPositionForStrategy(strategy.name)) {
const liveSignal = strategy.evaluate(state, 'live');
if (liveSignal) {
console.log(
`[Worker] LIVE signal from ${strategy.name}: ${liveSignal.side} @ ${liveSignal.price}¢ (max: ${liveSignal.maxPrice}¢)`
);
await live.executeTrade(liveSignal, state);
} }
// TODO: Live mode — use placeOrder() from rest.js
} }
} }
// Update state file after potential trades writeState(latestMarketState, paper, live, strategies);
writeState(state, paper, strategies);
}); });
// On market settlement, settle paper positions and notify strategies
tracker.on('settled', async ({ ticker, result }) => { tracker.on('settled', async ({ ticker, result }) => {
console.log(`[Worker] Market ${ticker} settled: ${result}`); console.log(`[Worker] Market ${ticker} settled. Result: ${result || 'pending'}`);
const settledPositions = await paper.settle(ticker, result); if (paper._resetting) return;
// Notify strategies about settlement if (result) {
for (const strategy of strategies) { await lockSettling();
if (settledPositions) { try {
for (const trade of settledPositions) { const settledPaper = await paper.settle(ticker, result);
strategy.onSettlement(result, trade); if (settledPaper) {
for (const strategy of strategies) {
for (const trade of settledPaper) {
strategy.onSettlement(trade.result, trade);
}
}
} }
const settledLive = await live.settle(ticker, result);
if (settledLive) {
for (const strategy of strategies) {
for (const trade of settledLive) {
strategy.onSettlement(trade.result, trade);
}
}
}
if (settledPaper || settledLive) {
await notify(
`Market ${ticker} settled: ${result.toUpperCase()}`,
'Market Settled',
'default',
'chart_with_upwards_trend'
);
}
} finally {
unlockSettling();
} }
} else {
// Market rotated but result not posted yet. Recover via orphan reconciliation shortly.
setTimeout(() => {
processOrphans().catch((e) => console.error('[Worker] delayed orphan reconcile failed:', e.message));
}, 4000);
} }
await notify( writeState(latestMarketState, paper, live, strategies);
`Market ${ticker} settled: ${result?.toUpperCase() || 'unknown'}`,
'Market Settled',
'default',
'chart_with_upwards_trend'
);
}); });
// Start tracking
await tracker.start(); await tracker.start();
await notify('🤖 Kalbot Worker started!', 'Kalbot Online', 'low', 'robot,green_circle'); await notify(`🤖 Kalbot Worker started with ${strategies.length} strats!`, 'Kalbot Online', 'low', 'robot,green_circle');
heartbeatTimer = setInterval(() => {
writeState(latestMarketState, paper, live, strategies);
}, HEARTBEAT_MS);
// Poll for paper reset flag
setInterval(async () => {
try {
if (fs.existsSync('/tmp/kalbot-reset-flag')) {
fs.unlinkSync('/tmp/kalbot-reset-flag');
console.log('[Worker] Reset flag detected — resetting all paper data');
await lockSettling();
try {
await paper.resetAll();
for (const s of strategies) {
if (s.consecutiveLosses !== undefined) s.consecutiveLosses = 0;
if (s.currentBetSize !== undefined) s.currentBetSize = s.config.baseBet;
if (s.round !== undefined) s.round = 0;
if (s.cycleWins !== undefined) s.cycleWins = 0;
if (s.cycleLosses !== undefined) s.cycleLosses = 0;
if (s.totalCycles !== undefined) s.totalCycles = 0;
if (s._lastTrade) {
s._lastTrade.paper = { time: 0, ticker: null };
s._lastTrade.live = { time: 0, ticker: null };
}
}
writeState(latestMarketState, paper, live, strategies);
await notify('🔄 Paper trading reset by admin', 'Kalbot Reset', 'default', 'recycle');
} finally {
unlockSettling();
}
}
} catch {}
}, 1000);
// Poll for live commands (enable/disable/pause/resume)
setInterval(async () => {
try {
if (fs.existsSync('/tmp/kalbot-live-cmd')) {
const raw = fs.readFileSync('/tmp/kalbot-live-cmd', 'utf-8');
fs.unlinkSync('/tmp/kalbot-live-cmd');
const cmd = JSON.parse(raw);
switch (cmd.action) {
case 'enable':
if (cmd.strategy) {
live.enableStrategy(cmd.strategy);
await notify(`⚡ Strategy "${cmd.strategy}" ENABLED for live trading`, 'Live Enable', 'high', 'zap');
}
break;
case 'disable':
if (cmd.strategy) {
live.disableStrategy(cmd.strategy);
await notify(`🔴 Strategy "${cmd.strategy}" DISABLED`, 'Live Disable', 'default', 'red_circle');
}
break;
case 'pause':
live.pause();
await notify('⏸ Live trading PAUSED by admin', 'Live Paused', 'urgent', 'double_vertical_bar');
break;
case 'resume':
live.resume();
await notify('▶️ Live trading RESUMED by admin', 'Live Resumed', 'high', 'arrow_forward');
break;
}
writeState(latestMarketState, paper, live, strategies);
}
} catch {}
}, 500);
console.log('[Worker] Running. Press Ctrl+C to stop.'); console.log('[Worker] Running. Press Ctrl+C to stop.');
// Graceful shutdown const shutdown = async (signal) => {
process.on('SIGINT', async () => { console.log(`\n[Worker] ${signal} received. Shutting down...`);
console.log('\n[Worker] Shutting down...'); clearInterval(heartbeatTimer);
tracker.stop(); tracker.stop();
await notify('🔴 Kalbot Worker stopped', 'Kalbot Offline', 'high', 'robot,red_circle'); await notify('🔴 Kalbot Worker stopped', 'Kalbot Offline', 'high', 'robot,red_circle');
process.exit(0); process.exit(0);
}); };
process.on('SIGTERM', async () => { process.on('SIGINT', () => shutdown('SIGINT'));
tracker.stop(); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.exit(0);
});
} }
function writeState(marketState, paper, strategies) { function writeState(marketState, paper, live, strategies) {
const data = { const paperData = {
market: marketState, market: marketState ? { ...marketState, orderbook: undefined } : null,
paper: paper.getStats(), paper: paper.getStats(),
strategies: strategies.map(s => s.toJSON()), paperByStrategy: paper.getPerStrategyStats(),
strategies: strategies.map((s) => s.toJSON()),
workerUptime: process.uptime(),
lastUpdate: Date.now()
};
const liveData = {
market: marketState ? { ...marketState, orderbook: undefined } : null,
live: live.getStats(),
strategies: strategies.map((s) => s.toJSON()),
workerUptime: process.uptime(), workerUptime: process.uptime(),
lastUpdate: Date.now() lastUpdate: Date.now()
}; };
try { try {
fs.writeFileSync(STATE_FILE, JSON.stringify(data)); fs.writeFileSync(STATE_FILE, JSON.stringify(paperData));
} catch (e) { } catch {}
// Non-critical try {
} fs.writeFileSync(LIVE_STATE_FILE, JSON.stringify(liveData));
} catch {}
} }
main().catch((err) => { main().catch((err) => {