mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-17 05:51:02 +00:00
Compare commits
54 Commits
7ba11ecdcb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| babeb7605a | |||
| 0bf1150d62 | |||
| fd53f12f63 | |||
| 149f5091fe | |||
| ab06fa683d | |||
| 6314ed7d5e | |||
| fa5303d9dc | |||
| 0301b8a0ae | |||
| 135f623789 | |||
| 6f208da27a | |||
| f9cb0e1d7a | |||
| 05fd36ca1e | |||
| 55573ed7aa | |||
| a034b26069 | |||
| 076480d05d | |||
| 0cb4a082b1 | |||
| 10827a817c | |||
| 0437bdb1db | |||
| 1c8dec1f17 | |||
| 2b9f2c5c2b | |||
| ca26f499f7 | |||
| 24f1405a93 | |||
| 6faad2b28e | |||
| 4553a82b0d | |||
| 8a06b9b668 | |||
| 9e138b22c6 | |||
| b9c55fb650 | |||
| c050829b3a | |||
| 7ba25a1eaa | |||
| 0fc244fcf1 | |||
| 2beb261bad | |||
| 3b302a671c | |||
| 83df308c37 | |||
| 00e613e27a | |||
| 4b403e4aba | |||
| b1a442e129 | |||
| 9577e55c95 | |||
| 556cdc0ff1 | |||
| e7198fede5 | |||
| c1bc4750bc | |||
| 32e1fa31f7 | |||
| da882a7d46 | |||
| 72dc03dffd | |||
| 3da40a1bf9 | |||
| 7115c0ae08 | |||
| 1d99902ca3 | |||
| 9015d0f524 | |||
| b92e03370a | |||
| 551db9d5bc | |||
| 3eef181780 | |||
| d4b2830943 | |||
| c649e6fb8f | |||
| fc8565e562 | |||
| 29fd889acb |
29
app/api/live-state/route.js
Normal file
29
app/api/live-state/route.js
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/api/live-toggle/route.js
Normal file
17
app/api/live-toggle/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/api/live-trades/route.js
Normal file
43
app/api/live-trades/route.js
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,50 +3,42 @@ import crypto from 'crypto';
|
|||||||
import { signSession } from '../../../lib/auth';
|
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) {
|
||||||
// Generate our secure edge-compatible token
|
return NextResponse.json({ error: 'Invalid or expired captcha' }, { status: 400 });
|
||||||
const token = await signSession();
|
|
||||||
|
|
||||||
const response = NextResponse.json({ success: true, message: 'Welcome back, Master!' });
|
|
||||||
|
|
||||||
// Set it as an HttpOnly cookie so JavaScript can't touch it
|
|
||||||
response.cookies.set('kalbot_session', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 // 1 day in seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
392
app/dash/page.js
392
app/dash/page.js
@@ -1,18 +1,394 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
const GREEN = '#16A34A';
|
const GREEN = '#16A34A';
|
||||||
|
const RED = '#DC2626';
|
||||||
|
const BLUE = '#2563EB';
|
||||||
|
const AMBER = '#D97706';
|
||||||
|
|
||||||
export default function LiveDashboard() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-950 text-gray-100 font-sans pb-20">
|
||||||
<div className="text-center space-y-4">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold" style={{ color: GREEN }}>Live Trading</h1>
|
<header className="sticky top-0 z-50 bg-gray-900/95 backdrop-blur border-b border-gray-800 px-4 py-3">
|
||||||
<p className="text-gray-500 text-sm">Coming soon, Meowster.</p>
|
<div className="flex items-center justify-between max-w-lg mx-auto">
|
||||||
<p className="text-gray-400 text-xs">Find a profitable paper strategy first.</p>
|
<div className="flex items-center gap-2">
|
||||||
<a href="/paper" className="inline-block text-sm px-4 py-2 rounded-lg bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 transition-all shadow-sm">
|
<h1 className="text-lg font-bold text-green-400">Kalbot</h1>
|
||||||
← Back to Paper Trading
|
<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>
|
||||||
</a>
|
</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>
|
||||||
</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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
redirect('/paper');
|
redirect('/dash');
|
||||||
}
|
}
|
||||||
|
|||||||
151
app/page.js
151
app/page.js
@@ -3,105 +3,70 @@ import { useState, useRef } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [captcha, setCaptcha] = useState('');
|
const [captcha, setCaptcha] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const captchaImgRef = useRef(null);
|
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('/paper');
|
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>}
|
{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>}
|
{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">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
<input
|
<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)} />
|
||||||
type="email"
|
</div>
|
||||||
required
|
<div>
|
||||||
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"
|
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
value={email}
|
<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)} />
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Captcha Verification</label>
|
||||||
<div>
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
<img ref={captchaImgRef} src="/api/captcha" alt="captcha" className="h-12 rounded cursor-pointer border border-gray-300" onClick={refreshCaptcha} title="Click to refresh" />
|
||||||
<input
|
<button type="button" onClick={refreshCaptcha} className="text-sm font-medium text-[#28CC95] hover:opacity-80 transition-opacity">Refresh</button>
|
||||||
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
|
|
||||||
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>
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export default function PaperDashboard() {
|
|||||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">PAPER</span>
|
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">PAPER</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={resetting}
|
disabled={resetting}
|
||||||
@@ -298,7 +301,6 @@ function AllStrategiesOverview({ paperByStrategy, strategies }) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (!entries.length) return null;
|
if (!entries.length) return null;
|
||||||
|
|
||||||
entries.sort((a, b) => b.stats.totalPnL - a.stats.totalPnL);
|
entries.sort((a, b) => b.stats.totalPnL - a.stats.totalPnL);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -339,12 +341,18 @@ function StatBox({ label, value, color }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TradeRow({ trade, isOpen }) {
|
function TradeRow({ trade, isOpen }) {
|
||||||
// Fix: Check side vs result for actual win condition, not strictly PNL > 0
|
|
||||||
const won = trade.result && trade.side.toLowerCase() === trade.result.toLowerCase();
|
const won = trade.result && trade.side.toLowerCase() === trade.result.toLowerCase();
|
||||||
const isNeutral = trade.result === 'cancelled' || trade.result === 'expired';
|
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 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 (
|
return (
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-200 mb-2 shadow-sm">
|
<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 justify-between">
|
||||||
@@ -362,9 +370,25 @@ function TradeRow({ trade, isOpen }) {
|
|||||||
{trade.pnl != null ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl}` : 'open'}
|
{trade.pnl != null ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl}` : 'open'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between mt-1">
|
<div className="flex justify-between mt-1">
|
||||||
<span className="text-[10px] text-gray-400">{trade.reason}</span>
|
<span className="text-[10px] text-gray-400">{trade.reason}</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between items-center mt-0.5">
|
||||||
{trade.result && !isOpen && (
|
{trade.result && !isOpen && (
|
||||||
<span className="text-[10px] text-gray-400">Result: {trade.result}</span>
|
<span className="text-[10px] text-gray-400">Result: {trade.result}</span>
|
||||||
|
|||||||
@@ -5,18 +5,13 @@ const KALSHI_API_BASE = (process.env.KALSHI_API_BASE || DEFAULT_KALSHI_API_BASE)
|
|||||||
|
|
||||||
function normalizePrivateKey(value) {
|
function normalizePrivateKey(value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
|
|
||||||
let key = String(value).trim();
|
let key = String(value).trim();
|
||||||
|
|
||||||
// Strip accidental wrapping quotes from env UIs
|
|
||||||
if (
|
if (
|
||||||
(key.startsWith('"') && key.endsWith('"')) ||
|
(key.startsWith('"') && key.endsWith('"')) ||
|
||||||
(key.startsWith("'") && key.endsWith("'"))
|
(key.startsWith("'") && key.endsWith("'"))
|
||||||
) {
|
) {
|
||||||
key = key.slice(1, -1);
|
key = key.slice(1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize line breaks from various env formats
|
|
||||||
return key
|
return key
|
||||||
.replace(/\\r\\n/g, '\n')
|
.replace(/\\r\\n/g, '\n')
|
||||||
.replace(/\r\n/g, '\n')
|
.replace(/\r\n/g, '\n')
|
||||||
@@ -36,10 +31,17 @@ export function signRequest(method, path, timestampMs = Date.now()) {
|
|||||||
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
|
||||||
|
|||||||
@@ -8,20 +8,17 @@ const DEFAULT_HTTP_RETRIES = Math.max(0, Number(process.env.KALSHI_HTTP_RETRIES
|
|||||||
const BASE_BACKOFF_MS = Math.max(100, Number(process.env.KALSHI_HTTP_BACKOFF_MS || 350));
|
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 EVENTS_CACHE_TTL_MS = Math.max(1000, Number(process.env.KALSHI_EVENTS_CACHE_TTL_MS || 5000));
|
||||||
const eventsCache = new Map(); // key -> { expiresAt, data }
|
const eventsCache = new Map();
|
||||||
const inflightEvents = new Map(); // key -> Promise<events>
|
const inflightEvents = new Map();
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
function parseRetryAfterMs(value) {
|
function parseRetryAfterMs(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
const asSeconds = Number(value);
|
const asSeconds = Number(value);
|
||||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) return asSeconds * 1000;
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) return asSeconds * 1000;
|
||||||
|
|
||||||
const asDate = new Date(value).getTime();
|
const asDate = new Date(value).getTime();
|
||||||
if (Number.isFinite(asDate)) return Math.max(0, asDate - Date.now());
|
if (Number.isFinite(asDate)) return Math.max(0, asDate - Date.now());
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +52,7 @@ async function kalshiFetch(method, path, body = null, opts = {}) {
|
|||||||
if (res.status === 204) return {};
|
if (res.status === 204) return {};
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!text) return {};
|
if (!text) return {};
|
||||||
try {
|
try { return JSON.parse(text); } catch { return {}; }
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -95,20 +88,16 @@ function getEventCloseTimeMs(event) {
|
|||||||
|
|
||||||
function rankEvents(events = []) {
|
function rankEvents(events = []) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
return events
|
return events
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const status = String(event.status || '').toLowerCase();
|
const status = String(event.status || '').toLowerCase();
|
||||||
const closeTs = getEventCloseTimeMs(event);
|
const closeTs = getEventCloseTimeMs(event);
|
||||||
|
|
||||||
const openLike = OPEN_EVENT_STATUSES.has(status);
|
const openLike = OPEN_EVENT_STATUSES.has(status);
|
||||||
const tradableLike = TRADABLE_EVENT_STATUSES.has(status);
|
const tradableLike = TRADABLE_EVENT_STATUSES.has(status);
|
||||||
const notClearlyExpired = closeTs == null || closeTs > now - 60_000;
|
const notClearlyExpired = closeTs == null || closeTs > now - 60_000;
|
||||||
|
|
||||||
const delta = closeTs == null ? Number.MAX_SAFE_INTEGER : closeTs - now;
|
const delta = closeTs == null ? Number.MAX_SAFE_INTEGER : closeTs - now;
|
||||||
const closenessScore = delta < 0 ? Math.abs(delta) + 3_600_000 : delta;
|
const closenessScore = delta < 0 ? Math.abs(delta) + 3_600_000 : delta;
|
||||||
|
|
||||||
return { event, openLike, tradableLike, notClearlyExpired, closenessScore };
|
return { event, openLike, tradableLike, notClearlyExpired, closenessScore };
|
||||||
})
|
})
|
||||||
.filter((x) => x.openLike || x.notClearlyExpired)
|
.filter((x) => x.openLike || x.notClearlyExpired)
|
||||||
@@ -124,18 +113,12 @@ function rankEvents(events = []) {
|
|||||||
function getCachedEvents(key) {
|
function getCachedEvents(key) {
|
||||||
const hit = eventsCache.get(key);
|
const hit = eventsCache.get(key);
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (Date.now() > hit.expiresAt) {
|
if (Date.now() > hit.expiresAt) { eventsCache.delete(key); return null; }
|
||||||
eventsCache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return hit.data;
|
return hit.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedEvents(key, events) {
|
function setCachedEvents(key, events) {
|
||||||
eventsCache.set(key, {
|
eventsCache.set(key, { expiresAt: Date.now() + EVENTS_CACHE_TTL_MS, data: events });
|
||||||
expiresAt: Date.now() + EVENTS_CACHE_TTL_MS,
|
|
||||||
data: events
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchEvents(series, query) {
|
async function fetchEvents(series, query) {
|
||||||
@@ -165,22 +148,16 @@ async function fetchEvents(series, query) {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return ranked candidate events for BTC 15m.
|
|
||||||
*/
|
|
||||||
export async function getActiveBTCEvents(limit = 12) {
|
export async function getActiveBTCEvents(limit = 12) {
|
||||||
const seriesCandidates = [SERIES_TICKER];
|
const seriesCandidates = [SERIES_TICKER];
|
||||||
const eventMap = new Map();
|
const eventMap = new Map();
|
||||||
|
|
||||||
for (const series of seriesCandidates) {
|
for (const series of seriesCandidates) {
|
||||||
try {
|
try {
|
||||||
// Use only known-good filter to avoid 400s from unsupported statuses.
|
|
||||||
const openEvents = await fetchEvents(series, 'status=open&limit=25');
|
const openEvents = await fetchEvents(series, 'status=open&limit=25');
|
||||||
for (const event of openEvents) {
|
for (const event of openEvents) {
|
||||||
if (event?.event_ticker) eventMap.set(event.event_ticker, event);
|
if (event?.event_ticker) eventMap.set(event.event_ticker, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if endpoint returns empty.
|
|
||||||
if (!openEvents.length) {
|
if (!openEvents.length) {
|
||||||
const fallbackEvents = await fetchEvents(series, 'limit=25');
|
const fallbackEvents = await fetchEvents(series, 'limit=25');
|
||||||
for (const event of fallbackEvents) {
|
for (const event of fallbackEvents) {
|
||||||
@@ -195,51 +172,60 @@ export async function getActiveBTCEvents(limit = 12) {
|
|||||||
return rankEvents([...eventMap.values()]).slice(0, limit);
|
return rankEvents([...eventMap.values()]).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Backward-compatible: return single best candidate event.
|
|
||||||
*/
|
|
||||||
export async function getActiveBTCEvent() {
|
export async function getActiveBTCEvent() {
|
||||||
const events = await getActiveBTCEvents(1);
|
const events = await getActiveBTCEvents(1);
|
||||||
return events[0] || null;
|
return events[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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}`);
|
||||||
const markets = data?.event?.markets ?? data?.markets ?? data?.event_markets ?? [];
|
const markets = data?.event?.markets ?? data?.markets ?? data?.event_markets ?? [];
|
||||||
return Array.isArray(markets) ? 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 };
|
||||||
|
|||||||
497
lib/live/engine.js
Normal file
497
lib/live/engine.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,12 @@ export class MarketTracker extends EventEmitter {
|
|||||||
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
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +138,12 @@ export class MarketTracker extends EventEmitter {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try cents first (from REST API), then dollar-string fields (from WS ticker)
|
|
||||||
let yesBid = pick('yes_bid', 'yesBid');
|
let yesBid = pick('yes_bid', 'yesBid');
|
||||||
let yesAsk = pick('yes_ask', 'yesAsk');
|
let yesAsk = pick('yes_ask', 'yesAsk');
|
||||||
let noBid = pick('no_bid', 'noBid');
|
let noBid = pick('no_bid', 'noBid');
|
||||||
let noAsk = pick('no_ask', 'noAsk');
|
let noAsk = pick('no_ask', 'noAsk');
|
||||||
let lastPrice = pick('last_price', 'lastPrice', 'yes_price', 'yesPrice');
|
let lastPrice = pick('last_price', 'lastPrice', 'yes_price', 'yesPrice');
|
||||||
|
|
||||||
// WS ticker sends dollar strings — convert to cents
|
|
||||||
if (yesBid == null) yesBid = dollarsToCents(market?.yes_bid_dollars);
|
if (yesBid == null) yesBid = dollarsToCents(market?.yes_bid_dollars);
|
||||||
if (yesAsk == null) yesAsk = dollarsToCents(market?.yes_ask_dollars);
|
if (yesAsk == null) yesAsk = dollarsToCents(market?.yes_ask_dollars);
|
||||||
if (noBid == null) noBid = dollarsToCents(market?.no_bid_dollars);
|
if (noBid == null) noBid = dollarsToCents(market?.no_bid_dollars);
|
||||||
@@ -159,12 +162,9 @@ export class MarketTracker extends EventEmitter {
|
|||||||
let qty = null;
|
let qty = null;
|
||||||
|
|
||||||
if (Array.isArray(level)) {
|
if (Array.isArray(level)) {
|
||||||
// New API format: ["0.4200", "300.00"] (dollar strings)
|
|
||||||
// Or old format: [42, 300] (cents integers)
|
|
||||||
const rawPrice = level[0];
|
const rawPrice = level[0];
|
||||||
const rawQty = level[1];
|
const rawQty = level[1];
|
||||||
|
|
||||||
// Detect dollar-string format (contains a decimal point and is < 1.01)
|
|
||||||
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
|
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
|
||||||
price = dollarsToCents(rawPrice);
|
price = dollarsToCents(rawPrice);
|
||||||
qty = this._num(rawQty);
|
qty = this._num(rawQty);
|
||||||
@@ -173,7 +173,6 @@ export class MarketTracker extends EventEmitter {
|
|||||||
qty = this._num(rawQty);
|
qty = this._num(rawQty);
|
||||||
}
|
}
|
||||||
} else if (level && typeof level === 'object') {
|
} else if (level && typeof level === 'object') {
|
||||||
// Object format
|
|
||||||
const rawPrice = level.price ?? level.price_dollars ?? level[0];
|
const rawPrice = level.price ?? level.price_dollars ?? level[0];
|
||||||
const rawQty = level.qty ?? level.quantity ?? level.size ?? level.count ?? level[1];
|
const rawQty = level.qty ?? level.quantity ?? level.size ?? level.count ?? level[1];
|
||||||
|
|
||||||
@@ -195,7 +194,6 @@ export class MarketTracker extends EventEmitter {
|
|||||||
_normalizeOrderbook(book) {
|
_normalizeOrderbook(book) {
|
||||||
const root = book?.orderbook && typeof book.orderbook === 'object' ? book.orderbook : book;
|
const root = book?.orderbook && typeof book.orderbook === 'object' ? book.orderbook : book;
|
||||||
return {
|
return {
|
||||||
// Support both old fields (yes/no) and new fields (yes_dollars_fp/no_dollars_fp)
|
|
||||||
yes: this._normalizeBookSide(root?.yes ?? root?.yes_dollars_fp ?? root?.yes_dollars),
|
yes: this._normalizeBookSide(root?.yes ?? root?.yes_dollars_fp ?? root?.yes_dollars),
|
||||||
no: this._normalizeBookSide(root?.no ?? root?.no_dollars_fp ?? root?.no_dollars)
|
no: this._normalizeBookSide(root?.no ?? root?.no_dollars_fp ?? root?.no_dollars)
|
||||||
};
|
};
|
||||||
@@ -360,13 +358,11 @@ 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') {
|
||||||
// New format: yes_dollars_fp / no_dollars_fp
|
|
||||||
this.orderbook = this._normalizeOrderbook(msg);
|
this.orderbook = this._normalizeOrderbook(msg);
|
||||||
console.log(`[Tracker] Orderbook snapshot: ${this.orderbook.yes.length} yes levels, ${this.orderbook.no.length} no levels`);
|
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') {
|
||||||
const side = String(msg.side || '').toLowerCase();
|
const side = String(msg.side || '').toLowerCase();
|
||||||
|
|
||||||
// New format uses price_dollars + delta_fp
|
|
||||||
let price = this._num(msg.price);
|
let price = this._num(msg.price);
|
||||||
if (price == null) price = dollarsToCents(msg.price_dollars);
|
if (price == null) price = dollarsToCents(msg.price_dollars);
|
||||||
|
|
||||||
@@ -387,7 +383,6 @@ export class MarketTracker extends EventEmitter {
|
|||||||
|
|
||||||
this.orderbook[side] = [...map.entries()].sort((a, b) => b[0] - a[0]);
|
this.orderbook[side] = [...map.entries()].sort((a, b) => b[0] - a[0]);
|
||||||
} else {
|
} else {
|
||||||
// Batch delta arrays (old format fallback)
|
|
||||||
const yesArr = msg.yes ?? msg.yes_dollars_fp;
|
const yesArr = msg.yes ?? msg.yes_dollars_fp;
|
||||||
const noArr = msg.no ?? msg.no_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(yesArr)) this.orderbook.yes = this._applyDelta(this.orderbook.yes, yesArr);
|
||||||
@@ -402,7 +397,6 @@ export class MarketTracker extends EventEmitter {
|
|||||||
if (msg.market_ticker !== this.currentTicker) return;
|
if (msg.market_ticker !== this.currentTicker) return;
|
||||||
|
|
||||||
if (this.marketData) {
|
if (this.marketData) {
|
||||||
// New API sends dollar-string fields; store them for _extractMarketQuotes
|
|
||||||
const fields = [
|
const fields = [
|
||||||
'yes_bid', 'yes_ask', 'no_bid', 'no_ask', 'last_price', 'volume',
|
'yes_bid', 'yes_ask', 'no_bid', 'no_ask', 'last_price', 'volume',
|
||||||
'yes_bid_dollars', 'yes_ask_dollars', 'no_bid_dollars', 'no_ask_dollars',
|
'yes_bid_dollars', 'yes_ask_dollars', 'no_bid_dollars', 'no_ask_dollars',
|
||||||
@@ -414,7 +408,6 @@ export class MarketTracker extends EventEmitter {
|
|||||||
if (msg[key] != null) this.marketData[key] = msg[key];
|
if (msg[key] != null) this.marketData[key] = msg[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also map dollar_volume / dollar_open_interest to standard fields
|
|
||||||
if (msg.dollar_volume != null) this.marketData.volume = this._num(msg.dollar_volume) ?? this.marketData.volume;
|
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.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);
|
if (msg.volume_fp != null && this.marketData.volume == null) this.marketData.volume = this._num(msg.volume_fp);
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,42 +3,101 @@ import { BaseStrategy } from './base.js';
|
|||||||
export class BullDipBuyer extends BaseStrategy {
|
export class BullDipBuyer extends BaseStrategy {
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
super('bull-dip-buyer', {
|
super('bull-dip-buyer', {
|
||||||
maxYesPrice: config.maxYesPrice || 45, // Buy the dip when Yes is cheap
|
maxYesPrice: config.maxYesPrice || 45,
|
||||||
minYesPrice: config.minYesPrice || 15, // Avoid completely dead markets
|
minYesPrice: config.minYesPrice || 15,
|
||||||
betSize: config.betSize || 2,
|
betSize: config.betSize || 2,
|
||||||
cooldownMs: config.cooldownMs || 60000,
|
slippage: config.slippage || 3,
|
||||||
|
cooldownMs: config.cooldownMs || 20000,
|
||||||
|
marketDurationMin: config.marketDurationMin || 15,
|
||||||
|
entryWindowMin: config.entryWindowMin || 6,
|
||||||
...config
|
...config
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lastTradeTime = 0;
|
this._lastTrade = {
|
||||||
this.lastTradeTicker = null;
|
paper: { time: 0, ticker: null },
|
||||||
|
live: { time: 0, ticker: null }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(state) {
|
evaluate(state, caller = 'paper') {
|
||||||
if (!state || !this.enabled) return null;
|
if (!state || !this.enabled || !state.closeTime) return null;
|
||||||
|
|
||||||
|
const track = this._lastTrade[caller] || this._lastTrade.paper;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
|
if (now - track.time < this.config.cooldownMs) return null;
|
||||||
if (state.ticker === this.lastTradeTicker) 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;
|
const { yesPct } = state;
|
||||||
|
|
||||||
// Only buy YES when it dips into our target buy zone
|
|
||||||
if (yesPct <= this.config.maxYesPrice && yesPct >= this.config.minYesPrice) {
|
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 = {
|
const signal = {
|
||||||
strategy: this.name,
|
strategy: this.name,
|
||||||
side: 'yes',
|
side: 'yes',
|
||||||
price: yesPct,
|
price: yesPct,
|
||||||
|
maxPrice,
|
||||||
size: this.config.betSize,
|
size: this.config.betSize,
|
||||||
reason: `Bullish dip buy: Yes dropped to ${yesPct}¢`,
|
reason,
|
||||||
ticker: state.ticker
|
ticker: state.ticker
|
||||||
};
|
};
|
||||||
|
|
||||||
this.lastTradeTime = now;
|
track.time = now;
|
||||||
this.lastTradeTicker = state.ticker;
|
track.ticker = state.ticker;
|
||||||
return signal;
|
return signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { BaseStrategy } from './base.js';
|
|
||||||
|
|
||||||
export class DontDoubtBullStrategy extends BaseStrategy {
|
|
||||||
constructor(config = {}) {
|
|
||||||
super('dont-doubt-bull', {
|
|
||||||
minYesPct: config.minYesPct || 30,
|
|
||||||
maxYesPct: config.maxYesPct || 40,
|
|
||||||
betSize: config.betSize || 2,
|
|
||||||
cooldownMs: config.cooldownMs || 60000,
|
|
||||||
...config
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lastTradeTime = 0;
|
|
||||||
this.lastTradeTicker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
evaluate(state) {
|
|
||||||
if (!state || !this.enabled || !state.closeTime) return null;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
|
|
||||||
if (state.ticker === this.lastTradeTicker) return null;
|
|
||||||
|
|
||||||
// 15 minute market total. First 1-5 minutes means 10 to 14 mins left.
|
|
||||||
const timeLeftMs = new Date(state.closeTime).getTime() - now;
|
|
||||||
const minsLeft = timeLeftMs / 60000;
|
|
||||||
|
|
||||||
if (minsLeft > 14 || minsLeft < 10) return null; // Outside our time window
|
|
||||||
|
|
||||||
const { yesPct } = state;
|
|
||||||
|
|
||||||
// Buy Yes if it's struggling early on
|
|
||||||
if (yesPct >= this.config.minYesPct && yesPct <= this.config.maxYesPct) {
|
|
||||||
const signal = {
|
|
||||||
strategy: this.name,
|
|
||||||
side: 'yes',
|
|
||||||
price: yesPct,
|
|
||||||
size: this.config.betSize,
|
|
||||||
reason: `Early Bullish Dip: ${minsLeft.toFixed(1)}m left, Yes @ ${yesPct}¢`,
|
|
||||||
ticker: state.ticker
|
|
||||||
};
|
|
||||||
|
|
||||||
this.lastTradeTime = now;
|
|
||||||
this.lastTradeTicker = state.ticker;
|
|
||||||
return signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
102
lib/strategies/early-fader.js
Normal file
102
lib/strategies/early-fader.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/strategies/late-momentum.js
Normal file
80
lib/strategies/late-momentum.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { BaseStrategy } from './base.js';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Martingale Alpha Strategy
|
|
||||||
*
|
|
||||||
* When odds are between 40-60% for both sides (a ~coin-flip market):
|
|
||||||
* - Use crypto.randomInt to pick yes/no
|
|
||||||
* - Round 1: bet $1, Round 2: bet $2, Round 3: bet $4
|
|
||||||
* - If any round wins, reset to round 1
|
|
||||||
* - If all 3 lose, reset to round 1 anyway (cap losses at $7 per cycle)
|
|
||||||
*
|
|
||||||
* Probability of losing 3 consecutive 50/50s = 12.5%
|
|
||||||
* Probability of winning at least 1 of 3 = 87.5%
|
|
||||||
*/
|
|
||||||
export class MartingaleAlphaStrategy extends BaseStrategy {
|
|
||||||
constructor(config = {}) {
|
|
||||||
super('martingale-alpha', {
|
|
||||||
minPct: config.minPct || 40,
|
|
||||||
maxPct: config.maxPct || 60,
|
|
||||||
baseBet: config.baseBet || 1,
|
|
||||||
maxRounds: config.maxRounds || 3,
|
|
||||||
cooldownMs: config.cooldownMs || 60000,
|
|
||||||
...config
|
|
||||||
});
|
|
||||||
|
|
||||||
this.round = 0; // 0 = waiting, 1-3 = active round
|
|
||||||
this.currentBetSize = this.config.baseBet;
|
|
||||||
this.lastTradeTime = 0;
|
|
||||||
this.lastTradeTicker = null;
|
|
||||||
this.cycleWins = 0;
|
|
||||||
this.cycleLosses = 0;
|
|
||||||
this.totalCycles = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { minPct, maxPct } = this.config;
|
|
||||||
|
|
||||||
// Only trade when both sides are in the 40-60% range (coin-flip territory)
|
|
||||||
if (yesPct < minPct || yesPct > maxPct) return null;
|
|
||||||
if (noPct < minPct || noPct > maxPct) return null;
|
|
||||||
|
|
||||||
// Secure random coin flip: 0 = yes, 1 = no
|
|
||||||
const flip = crypto.randomInt(0, 2);
|
|
||||||
const side = flip === 0 ? 'yes' : 'no';
|
|
||||||
const price = side === 'yes' ? yesPct : noPct;
|
|
||||||
|
|
||||||
// Determine bet size based on current round
|
|
||||||
const roundIndex = this.round; // 0, 1, or 2
|
|
||||||
const betSize = this.config.baseBet * Math.pow(2, roundIndex);
|
|
||||||
|
|
||||||
const signal = {
|
|
||||||
strategy: this.name,
|
|
||||||
side,
|
|
||||||
price,
|
|
||||||
size: betSize,
|
|
||||||
reason: `R${roundIndex + 1}/${this.config.maxRounds} coin-flip ${side.toUpperCase()} @ ${price}¢ ($${betSize}) | Market: ${yesPct}/${noPct}`,
|
|
||||||
ticker: state.ticker
|
|
||||||
};
|
|
||||||
|
|
||||||
this.lastTradeTime = now;
|
|
||||||
this.lastTradeTicker = state.ticker;
|
|
||||||
this.currentBetSize = betSize;
|
|
||||||
|
|
||||||
return signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSettlement(result, trade) {
|
|
||||||
if (!trade || trade.strategy !== this.name) return;
|
|
||||||
|
|
||||||
const won = trade.side === result;
|
|
||||||
|
|
||||||
if (won) {
|
|
||||||
console.log(`[MartingaleAlpha] WIN on round ${this.round + 1} — cycle complete, resetting`);
|
|
||||||
this.cycleWins++;
|
|
||||||
this.totalCycles++;
|
|
||||||
this._resetCycle();
|
|
||||||
} else {
|
|
||||||
this.round++;
|
|
||||||
if (this.round >= this.config.maxRounds) {
|
|
||||||
console.log(`[MartingaleAlpha] LOST all ${this.config.maxRounds} rounds — cycle failed, resetting`);
|
|
||||||
this.cycleLosses++;
|
|
||||||
this.totalCycles++;
|
|
||||||
this._resetCycle();
|
|
||||||
} else {
|
|
||||||
const nextBet = this.config.baseBet * Math.pow(2, this.round);
|
|
||||||
console.log(`[MartingaleAlpha] LOSS round ${this.round}/${this.config.maxRounds} — next bet: $${nextBet}`);
|
|
||||||
this.currentBetSize = nextBet;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_resetCycle() {
|
|
||||||
this.round = 0;
|
|
||||||
this.currentBetSize = this.config.baseBet;
|
|
||||||
this.lastTradeTicker = null; // Allow trading same ticker in new cycle
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
...super.toJSON(),
|
|
||||||
round: this.round + 1,
|
|
||||||
maxRounds: this.config.maxRounds,
|
|
||||||
currentBetSize: this.currentBetSize,
|
|
||||||
cycleWins: this.cycleWins,
|
|
||||||
cycleLosses: this.cycleLosses,
|
|
||||||
totalCycles: this.totalCycles,
|
|
||||||
cycleWinRate: this.totalCycles > 0
|
|
||||||
? parseFloat(((this.cycleWins / this.totalCycles) * 100).toFixed(1))
|
|
||||||
: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { BaseStrategy } from './base.js';
|
|
||||||
|
|
||||||
export class MartingaleStrategy extends BaseStrategy {
|
|
||||||
constructor(config = {}) {
|
|
||||||
super('martingale', {
|
|
||||||
threshold: config.threshold || 70,
|
|
||||||
baseBet: config.baseBet || 1,
|
|
||||||
maxDoublings: config.maxDoublings || 5,
|
|
||||||
cooldownMs: config.cooldownMs || 60000,
|
|
||||||
...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();
|
|
||||||
|
|
||||||
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
|
|
||||||
if (state.ticker === this.lastTradeTicker) return null;
|
|
||||||
if (this.consecutiveLosses >= this.config.maxDoublings) return null;
|
|
||||||
|
|
||||||
const { yesPct, noPct } = state;
|
|
||||||
const threshold = this.config.threshold;
|
|
||||||
|
|
||||||
let signal = null;
|
|
||||||
|
|
||||||
// Prevent buying useless contracts at >= 99¢ (which would result in $0 or 0.01¢ profit)
|
|
||||||
if (yesPct >= threshold && noPct < 99) {
|
|
||||||
signal = {
|
|
||||||
strategy: this.name,
|
|
||||||
side: 'no',
|
|
||||||
price: noPct,
|
|
||||||
size: this.currentBetSize,
|
|
||||||
reason: `Yes at ${yesPct}% (≥${threshold}%), betting No at ${noPct}¢`,
|
|
||||||
ticker: state.ticker
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if (noPct >= threshold && yesPct < 99) {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,33 +4,37 @@ export class MomentumRiderStrategy extends BaseStrategy {
|
|||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
super('momentum-rider', {
|
super('momentum-rider', {
|
||||||
triggerPct: config.triggerPct || 75,
|
triggerPct: config.triggerPct || 75,
|
||||||
betSize: config.betSize || 2,
|
betSize: config.betSize || 4,
|
||||||
cooldownMs: config.cooldownMs || 60000,
|
slippage: config.slippage || 3,
|
||||||
|
cooldownMs: config.cooldownMs || 20000,
|
||||||
...config
|
...config
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lastTradeTime = 0;
|
this._lastTrade = {
|
||||||
this.lastTradeTicker = null;
|
paper: { time: 0, ticker: null },
|
||||||
|
live: { time: 0, ticker: null }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(state) {
|
evaluate(state, caller = 'paper') {
|
||||||
if (!state || !this.enabled) return null;
|
if (!state || !this.enabled) return null;
|
||||||
|
|
||||||
|
const track = this._lastTrade[caller] || this._lastTrade.paper;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
|
if (now - track.time < this.config.cooldownMs) return null;
|
||||||
if (state.ticker === this.lastTradeTicker) return null;
|
if (state.ticker === track.ticker) return null;
|
||||||
|
|
||||||
const { yesPct, noPct } = state;
|
const { yesPct, noPct } = state;
|
||||||
const trigger = this.config.triggerPct;
|
const trigger = this.config.triggerPct;
|
||||||
|
|
||||||
let signal = null;
|
let signal = null;
|
||||||
|
|
||||||
// Buy the favorite!
|
|
||||||
if (yesPct >= trigger && yesPct < 99) {
|
if (yesPct >= trigger && yesPct < 99) {
|
||||||
signal = {
|
signal = {
|
||||||
strategy: this.name,
|
strategy: this.name,
|
||||||
side: 'yes',
|
side: 'yes',
|
||||||
price: yesPct,
|
price: yesPct,
|
||||||
|
maxPrice: Math.min(yesPct + this.config.slippage, 95),
|
||||||
size: this.config.betSize,
|
size: this.config.betSize,
|
||||||
reason: `Riding Momentum! Yes is at ${yesPct}%`,
|
reason: `Riding Momentum! Yes is at ${yesPct}%`,
|
||||||
ticker: state.ticker
|
ticker: state.ticker
|
||||||
@@ -40,6 +44,7 @@ export class MomentumRiderStrategy extends BaseStrategy {
|
|||||||
strategy: this.name,
|
strategy: this.name,
|
||||||
side: 'no',
|
side: 'no',
|
||||||
price: noPct,
|
price: noPct,
|
||||||
|
maxPrice: Math.min(noPct + this.config.slippage, 95),
|
||||||
size: this.config.betSize,
|
size: this.config.betSize,
|
||||||
reason: `Riding Momentum! No is at ${noPct}%`,
|
reason: `Riding Momentum! No is at ${noPct}%`,
|
||||||
ticker: state.ticker
|
ticker: state.ticker
|
||||||
@@ -47,8 +52,8 @@ export class MomentumRiderStrategy extends BaseStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
this.lastTradeTime = now;
|
track.time = now;
|
||||||
this.lastTradeTicker = state.ticker;
|
track.ticker = state.ticker;
|
||||||
}
|
}
|
||||||
|
|
||||||
return signal;
|
return signal;
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { BaseStrategy } from './base.js';
|
|
||||||
|
|
||||||
export class SniperReversalStrategy extends BaseStrategy {
|
|
||||||
constructor(config = {}) {
|
|
||||||
super('sniper-reversal', {
|
|
||||||
triggerPct: config.triggerPct || 95, // Bet against a 95% favorite
|
|
||||||
minsLeft: config.minsLeft || 3, // Only in the last 3 minutes
|
|
||||||
betSize: config.betSize || 1, // Cheap lotto tickets
|
|
||||||
cooldownMs: config.cooldownMs || 60000,
|
|
||||||
...config
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lastTradeTime = 0;
|
|
||||||
this.lastTradeTicker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
evaluate(state) {
|
|
||||||
if (!state || !this.enabled || !state.closeTime) return null;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastTradeTime < this.config.cooldownMs) return null;
|
|
||||||
if (state.ticker === this.lastTradeTicker) return null;
|
|
||||||
|
|
||||||
const timeLeftMs = new Date(state.closeTime).getTime() - now;
|
|
||||||
const minsLeft = timeLeftMs / 60000;
|
|
||||||
|
|
||||||
// Only strike in the final minutes
|
|
||||||
if (minsLeft > this.config.minsLeft || minsLeft <= 0) return null;
|
|
||||||
|
|
||||||
const { yesPct, noPct } = state;
|
|
||||||
const trigger = this.config.triggerPct;
|
|
||||||
|
|
||||||
let signal = null;
|
|
||||||
|
|
||||||
if (yesPct >= trigger && noPct > 0) {
|
|
||||||
signal = {
|
|
||||||
strategy: this.name,
|
|
||||||
side: 'no',
|
|
||||||
price: noPct,
|
|
||||||
size: this.config.betSize,
|
|
||||||
reason: `Buzzer beater No at ${noPct}¢ (Yes is ${yesPct}%) with ${minsLeft.toFixed(1)}m left`,
|
|
||||||
ticker: state.ticker
|
|
||||||
};
|
|
||||||
} else if (noPct >= trigger && yesPct > 0) {
|
|
||||||
signal = {
|
|
||||||
strategy: this.name,
|
|
||||||
side: 'yes',
|
|
||||||
price: yesPct,
|
|
||||||
size: this.config.betSize,
|
|
||||||
reason: `Buzzer beater Yes at ${yesPct}¢ (No is ${noPct}%) with ${minsLeft.toFixed(1)}m left`,
|
|
||||||
ticker: state.ticker
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
this.lastTradeTime = now;
|
|
||||||
this.lastTradeTicker = state.ticker;
|
|
||||||
}
|
|
||||||
|
|
||||||
return signal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { BaseStrategy } from './base.js';
|
|
||||||
|
|
||||||
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 && noPct < 99) {
|
|
||||||
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 && yesPct < 99) {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,10 @@ export const config = {
|
|||||||
'/dash/:path*',
|
'/dash/:path*',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/trades',
|
'/api/trades',
|
||||||
'/api/reset'
|
'/api/reset',
|
||||||
|
'/api/live-state',
|
||||||
|
'/api/live-trades',
|
||||||
|
'/api/live-toggle',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
34
readme
34
readme
@@ -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
|
||||||
|
|||||||
257
worker.js
257
worker.js
@@ -1,19 +1,27 @@
|
|||||||
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 { MartingaleAlphaStrategy } from './lib/strategies/martingale-alpha.js';
|
|
||||||
import { ThresholdStrategy } from './lib/strategies/threshold.js';
|
|
||||||
import { BullDipBuyer } from './lib/strategies/bull-dip-buyer.js';
|
|
||||||
import { SniperReversalStrategy } from './lib/strategies/sniper-reversal.js';
|
|
||||||
import { MomentumRiderStrategy } from './lib/strategies/momentum-rider.js';
|
|
||||||
import { DontDoubtBullStrategy } from './lib/strategies/dont-doubt-bull.js';
|
|
||||||
import { getMarket } from './lib/kalshi/rest.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';
|
||||||
|
|
||||||
|
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 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 ===');
|
||||||
@@ -23,16 +31,28 @@ async function main() {
|
|||||||
const paper = new PaperEngine(1000);
|
const paper = new PaperEngine(1000);
|
||||||
await paper.init();
|
await paper.init();
|
||||||
|
|
||||||
// Load all 7 strategies!
|
const live = new LiveEngine();
|
||||||
const strategies = [
|
await live.init();
|
||||||
new MartingaleStrategy({ threshold: 70, baseBet: 1, maxDoublings: 5 }),
|
|
||||||
new MartingaleAlphaStrategy({ minPct: 40, maxPct: 60, baseBet: 1, maxRounds: 3 }),
|
// Dynamically load all strategies
|
||||||
new ThresholdStrategy({ triggerPct: 65, betSize: 1 }),
|
const strategies = [];
|
||||||
new BullDipBuyer({ maxYesPrice: 45, minYesPrice: 15, betSize: 2 }),
|
const strategiesDir = path.join(__dirname, 'lib', 'strategies');
|
||||||
new SniperReversalStrategy({ triggerPct: 95, minsLeft: 3, betSize: 1 }),
|
const stratFiles = fs.readdirSync(strategiesDir).filter((f) => f.endsWith('.js') && f !== 'base.js');
|
||||||
new MomentumRiderStrategy({ triggerPct: 75, betSize: 2 }),
|
|
||||||
new DontDoubtBullStrategy({ minYesPct: 30, maxYesPct: 40, betSize: 2 })
|
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) {
|
for (const s of strategies) {
|
||||||
paper._getAccount(s.name);
|
paper._getAccount(s.name);
|
||||||
@@ -42,11 +62,26 @@ async function main() {
|
|||||||
|
|
||||||
let latestMarketState = null;
|
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() {
|
async function processOrphans() {
|
||||||
if (paper._resetting) return;
|
if (paper._resetting) return;
|
||||||
|
await lockSettling();
|
||||||
try {
|
try {
|
||||||
const { settled, expired } = await paper.checkOrphans(getMarket);
|
const { settled, expired } = await paper.checkOrphans(getMarket);
|
||||||
const allResolved = [...settled, ...expired];
|
const settledLive = await live.checkOrphans(getMarket);
|
||||||
|
|
||||||
|
const allResolved = [...settled, ...expired, ...settledLive];
|
||||||
|
|
||||||
if (allResolved.length > 0) {
|
if (allResolved.length > 0) {
|
||||||
for (const strategy of strategies) {
|
for (const strategy of strategies) {
|
||||||
for (const trade of allResolved) {
|
for (const trade of allResolved) {
|
||||||
@@ -55,106 +90,177 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeState(latestMarketState, paper, strategies);
|
writeState(latestMarketState, paper, live, strategies);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Worker] Orphan check error:', e.message);
|
console.error('[Worker] Orphan check error:', e.message);
|
||||||
|
} finally {
|
||||||
|
unlockSettling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await processOrphans();
|
await processOrphans();
|
||||||
setInterval(processOrphans, 60000);
|
setInterval(processOrphans, 10000);
|
||||||
|
|
||||||
const tracker = new MarketTracker();
|
const tracker = new MarketTracker();
|
||||||
let heartbeatTimer = null;
|
let heartbeatTimer = null;
|
||||||
|
|
||||||
writeState(latestMarketState, paper, strategies);
|
writeState(latestMarketState, paper, live, strategies);
|
||||||
|
|
||||||
tracker.on('update', async (state) => {
|
tracker.on('update', async (state) => {
|
||||||
latestMarketState = state || null;
|
latestMarketState = state || null;
|
||||||
writeState(latestMarketState, paper, strategies);
|
writeState(latestMarketState, paper, live, strategies);
|
||||||
|
|
||||||
if (!state || paper._resetting) return;
|
if (!state || paper._resetting || isSettling) return;
|
||||||
|
|
||||||
for (const strategy of strategies) {
|
for (const strategy of strategies) {
|
||||||
if (!strategy.enabled) continue;
|
if (!strategy.enabled) continue;
|
||||||
|
|
||||||
const acct = paper._getAccount(strategy.name);
|
// ===== PAPER TRADING (isolated evaluation) =====
|
||||||
if (acct.openPositions.size > 0) {
|
const paperAcct = paper._getAccount(strategy.name);
|
||||||
continue;
|
if (paperAcct.openPositions.size === 0) {
|
||||||
|
const paperSignal = strategy.evaluate(state, 'paper');
|
||||||
|
if (paperSignal) {
|
||||||
|
console.log(`[Worker] Paper signal from ${strategy.name}: ${paperSignal.side} @ ${paperSignal.price}¢`);
|
||||||
|
await paper.executeTrade(paperSignal, state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const signal = strategy.evaluate(state);
|
// ===== LIVE TRADING (separate evaluation, won't poison paper) =====
|
||||||
if (signal) {
|
if (live.isStrategyEnabled(strategy.name) && !live.hasOpenPositionForStrategy(strategy.name)) {
|
||||||
console.log(`[Worker] Signal from ${strategy.name}: ${signal.side} @ ${signal.price}¢ — ${signal.reason}`);
|
const liveSignal = strategy.evaluate(state, 'live');
|
||||||
|
if (liveSignal) {
|
||||||
if (strategy.mode === 'paper') {
|
console.log(
|
||||||
await paper.executeTrade(signal, state);
|
`[Worker] LIVE signal from ${strategy.name}: ${liveSignal.side} @ ${liveSignal.price}¢ (max: ${liveSignal.maxPrice}¢)`
|
||||||
|
);
|
||||||
|
await live.executeTrade(liveSignal, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeState(latestMarketState, paper, strategies);
|
writeState(latestMarketState, paper, live, strategies);
|
||||||
});
|
});
|
||||||
|
|
||||||
tracker.on('settled', async ({ ticker, result }) => {
|
tracker.on('settled', async ({ ticker, result }) => {
|
||||||
console.log(`[Worker] Market ${ticker} rotated/closed. Result: ${result || 'pending'}`);
|
console.log(`[Worker] Market ${ticker} settled. Result: ${result || 'pending'}`);
|
||||||
|
|
||||||
if (paper._resetting) return;
|
if (paper._resetting) return;
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
const settledPositions = await paper.settle(ticker, result);
|
await lockSettling();
|
||||||
if (settledPositions) {
|
try {
|
||||||
for (const strategy of strategies) {
|
const settledPaper = await paper.settle(ticker, result);
|
||||||
for (const trade of settledPositions) {
|
if (settledPaper) {
|
||||||
strategy.onSettlement(trade.result, trade);
|
for (const strategy of strategies) {
|
||||||
|
for (const trade of settledPaper) {
|
||||||
|
strategy.onSettlement(trade.result, trade);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await notify(
|
|
||||||
`Market ${ticker} settled: ${result.toUpperCase()}`,
|
const settledLive = await live.settle(ticker, result);
|
||||||
'Market Settled',
|
if (settledLive) {
|
||||||
'default',
|
for (const strategy of strategies) {
|
||||||
'chart_with_upwards_trend'
|
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 {
|
} else {
|
||||||
console.log(`[Worker] Result for ${ticker} pending.`);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeState(latestMarketState, paper, strategies);
|
writeState(latestMarketState, paper, live, strategies);
|
||||||
});
|
});
|
||||||
|
|
||||||
await tracker.start();
|
await tracker.start();
|
||||||
await notify('🤖 Kalbot Worker started with 7 strats!', 'Kalbot Online', 'low', 'robot,green_circle');
|
await notify(`🤖 Kalbot Worker started with ${strategies.length} strats!`, 'Kalbot Online', 'low', 'robot,green_circle');
|
||||||
|
|
||||||
heartbeatTimer = setInterval(() => {
|
heartbeatTimer = setInterval(() => {
|
||||||
writeState(latestMarketState, paper, strategies);
|
writeState(latestMarketState, paper, live, strategies);
|
||||||
}, HEARTBEAT_MS);
|
}, HEARTBEAT_MS);
|
||||||
|
|
||||||
|
// Poll for paper reset flag
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync('/tmp/kalbot-reset-flag')) {
|
if (fs.existsSync('/tmp/kalbot-reset-flag')) {
|
||||||
fs.unlinkSync('/tmp/kalbot-reset-flag');
|
fs.unlinkSync('/tmp/kalbot-reset-flag');
|
||||||
console.log('[Worker] Reset flag detected — resetting all paper data');
|
console.log('[Worker] Reset flag detected — resetting all paper data');
|
||||||
await paper.resetAll();
|
await lockSettling();
|
||||||
for (const s of strategies) {
|
try {
|
||||||
if (s.consecutiveLosses !== undefined) s.consecutiveLosses = 0;
|
await paper.resetAll();
|
||||||
if (s.currentBetSize !== undefined) s.currentBetSize = s.config.baseBet;
|
for (const s of strategies) {
|
||||||
if (s.round !== undefined) s.round = 0;
|
if (s.consecutiveLosses !== undefined) s.consecutiveLosses = 0;
|
||||||
if (s.cycleWins !== undefined) s.cycleWins = 0;
|
if (s.currentBetSize !== undefined) s.currentBetSize = s.config.baseBet;
|
||||||
if (s.cycleLosses !== undefined) s.cycleLosses = 0;
|
if (s.round !== undefined) s.round = 0;
|
||||||
if (s.totalCycles !== undefined) s.totalCycles = 0;
|
if (s.cycleWins !== undefined) s.cycleWins = 0;
|
||||||
s.lastTradeTicker = null;
|
if (s.cycleLosses !== undefined) s.cycleLosses = 0;
|
||||||
s.lastTradeTime = 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();
|
||||||
}
|
}
|
||||||
writeState(latestMarketState, paper, strategies);
|
|
||||||
await notify('🔄 Paper trading reset by admin', 'Kalbot Reset', 'default', 'recycle');
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, 1000);
|
}, 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.');
|
||||||
|
|
||||||
const shutdown = async (signal) => {
|
const shutdown = async (signal) => {
|
||||||
@@ -169,9 +275,9 @@ async function main() {
|
|||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
}
|
}
|
||||||
|
|
||||||
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(),
|
||||||
paperByStrategy: paper.getPerStrategyStats(),
|
paperByStrategy: paper.getPerStrategyStats(),
|
||||||
strategies: strategies.map((s) => s.toJSON()),
|
strategies: strategies.map((s) => s.toJSON()),
|
||||||
@@ -179,11 +285,20 @@ function writeState(marketState, paper, strategies) {
|
|||||||
lastUpdate: Date.now()
|
lastUpdate: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const liveData = {
|
||||||
|
market: marketState ? { ...marketState, orderbook: undefined } : null,
|
||||||
|
live: live.getStats(),
|
||||||
|
strategies: strategies.map((s) => s.toJSON()),
|
||||||
|
workerUptime: process.uptime(),
|
||||||
|
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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user