mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-16 21:41:02 +00:00
Compare commits
128 Commits
446ec4410b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7ba11ecdcb | |||
| 2948312619 | |||
| caca6d29b6 | |||
| cffb156231 | |||
| f3910603fb | |||
| 4feed18ce0 | |||
| 1999377682 | |||
| 04bd2fada6 | |||
| 11339a0900 | |||
| 9f0ff58118 | |||
| cf35715302 | |||
| bd0811e113 | |||
| 02651535e6 | |||
| 83ab2830b6 | |||
| b8f2406622 | |||
| aa96eac863 | |||
| 684ba9173c | |||
| a1c81c8c46 | |||
| c4fc90094e | |||
| 8363c85f38 | |||
| 23d8df2116 | |||
| 3b1c594636 | |||
| eb36190254 | |||
| 0bcb9666b0 | |||
| 1e04e0c558 | |||
| 0acc63c512 | |||
| 9c82b49ed9 | |||
| b6b0d990d4 | |||
| 32341e76b0 | |||
| 5ce2fa6924 | |||
| 8d90c92d3f | |||
| c16ef77beb | |||
| b35fcfe13f | |||
| 2cd79d45d1 | |||
| c377c56975 | |||
| 0adcc947ce | |||
| 647b46d1b8 | |||
| c2f878b23d | |||
| aedb6aeda5 | |||
| e93381c9f1 | |||
| eda38cb58e | |||
| d7dabea20f | |||
| 3c48e2bd50 | |||
| 1c57c60770 | |||
| 96f1f9359e | |||
| d57c0402d1 | |||
| 51177b5b8a | |||
| b95430e863 | |||
| 0a5f2af3ae | |||
| 6921fb1cdd | |||
| 677050a224 | |||
| 8c76087b6a | |||
| c3b9bf1475 | |||
| b92c8fab4b | |||
| e5565327ec | |||
| d2d742df3b | |||
| 688e40edd3 | |||
| de38920499 | |||
| ae8761ada2 | |||
| b44bd4b9d1 | |||
| 7e1b133344 | |||
| 79d465eb88 | |||
| a957793be3 | |||
| 1ccba9eef1 | |||
| 2924ff6098 | |||
| 13904dc641 | |||
| 95fb54dd5a | |||
| 8c0b750085 | |||
| 0019f088c4 | |||
| 491465dbde | |||
| 72d313f286 | |||
| d1683eaa11 | |||
| 807e065436 | |||
| 0599b05ffe | |||
| d8c6bfe24f | |||
| 6c395b7c30 | |||
| eb9cc8e46b | |||
| 62f9f780fd | |||
| 99d0f1ed46 | |||
| 1a76668eae | |||
| 73854a6b48 | |||
| 4d93add4df | |||
| 9b5044c054 | |||
| 4b443e1d89 | |||
| d1c3a65fa0 | |||
| 22cbb2db7d | |||
| da131e4549 | |||
| 74d49935be | |||
| bc02328acd |
13
.env.example
13
.env.example
@@ -1,12 +1,11 @@
|
||||
# Admin Credentials
|
||||
ADMIN_EMAIL=admin@kal.planetrenox.com
|
||||
ADMIN_PASS=super_secure_password_meow
|
||||
|
||||
# Ntfy Alerts
|
||||
NTFY_URL=https://ntfy.sh/my_secret_kalbot_topic
|
||||
|
||||
# Captcha Security (Random string)
|
||||
CAPTCHA_SECRET=change_me_to_a_random_string_in_dokploy
|
||||
|
||||
# Application Port
|
||||
PORT=3004
|
||||
SURREAL_URL=
|
||||
SURREAL_USER=
|
||||
SURREAL_PASS=
|
||||
KALSHI_API_BASE=https://api.elections.kalshi.com
|
||||
KALSHI_API_KEY_ID=your-key-id-here
|
||||
KALSHI_RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nPASTE_YOUR_FULL_KEY_HERE\n-----END RSA PRIVATE KEY-----"
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -43,3 +43,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Trigger Dokploy Deployment
|
||||
run: curl -s "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
|
||||
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,26 +1,24 @@
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Stage 1: Fast dependencies using Bun
|
||||
FROM oven/bun:alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
RUN bun install
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
# Stage 2: Fast builds using Bun
|
||||
FROM oven/bun:alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN bun run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Stage 3: Stable production runtime using Node.js
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3004
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -28,8 +26,18 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copy worker + lib files
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/worker.js ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/entrypoint.sh ./
|
||||
|
||||
# Install ws for worker (not bundled by Next.js standalone)
|
||||
RUN npm install ws surrealdb
|
||||
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3004
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["./entrypoint.sh"]
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { signSession } from '../../../lib/auth';
|
||||
|
||||
export async function POST(req) {
|
||||
try {
|
||||
@@ -15,19 +16,24 @@ export async function POST(req) {
|
||||
}
|
||||
|
||||
if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASS) {
|
||||
// Real implementation would set a JWT or session cookie here
|
||||
return NextResponse.json({ success: true, message: 'Welcome back, Master!' });
|
||||
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 {
|
||||
// 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'
|
||||
}
|
||||
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 });
|
||||
|
||||
12
app/api/reset/route.js
Normal file
12
app/api/reset/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
// Write a flag file that the worker polls for
|
||||
fs.writeFileSync('/tmp/kalbot-reset-flag', 'reset');
|
||||
return NextResponse.json({ success: true, message: 'Reset signal sent. Data will clear momentarily.' });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
app/api/state/route.js
Normal file
24
app/api/state/route.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STATE_FILE = '/tmp/kalbot-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,
|
||||
paper: { balance: 1000, totalPnL: 0, wins: 0, losses: 0, winRate: 0, openPositions: [], totalTrades: 0 },
|
||||
paperByStrategy: {},
|
||||
strategies: [],
|
||||
workerUptime: 0,
|
||||
lastUpdate: null,
|
||||
error: 'Worker not running or no data yet'
|
||||
});
|
||||
}
|
||||
}
|
||||
54
app/api/trades/route.js
Normal file
54
app/api/trades/route.js
Normal file
@@ -0,0 +1,54 @@
|
||||
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 paper_positions 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);
|
||||
const trades = normalizeRows(result);
|
||||
|
||||
return NextResponse.json({ trades });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ trades: [], error: e.message });
|
||||
} finally {
|
||||
try {
|
||||
await client?.close?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
360
app/dash/page.js
Normal file
360
app/dash/page.js
Normal file
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const GREEN = '#16A34A';
|
||||
const RED = '#DC2626';
|
||||
const BLUE = '#2563EB';
|
||||
const AMBER = '#D97706';
|
||||
|
||||
export default function LiveDashboard() {
|
||||
const [data, setData] = useState(null);
|
||||
const [trades, setTrades] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toggling, setToggling] = useState(null);
|
||||
const [activeTrade, setActiveTrade] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/live-state');
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
console.error('State fetch error:', e);
|
||||
}
|
||||
};
|
||||
fetchState();
|
||||
const interval = setInterval(fetchState, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrades = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/live-trades');
|
||||
const json = await res.json();
|
||||
setTrades(json.trades || []);
|
||||
} catch (e) {
|
||||
console.error('Trades fetch error:', e);
|
||||
}
|
||||
};
|
||||
fetchTrades();
|
||||
const interval = setInterval(fetchTrades, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const sendCommand = async (action, strategy = null) => {
|
||||
setToggling(strategy || action);
|
||||
try {
|
||||
await fetch('/api/live-toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, strategy })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Command error:', e);
|
||||
}
|
||||
setTimeout(() => setToggling(null), 1500);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="text-green-400 text-lg animate-pulse">Loading Live Trading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const market = data?.market;
|
||||
const live = data?.live || {};
|
||||
const strategies = data?.strategies || [];
|
||||
const enabledSet = new Set(live.enabledStrategies || []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 font-sans pb-20">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-gray-900/95 backdrop-blur border-b border-gray-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-green-400">Kalbot</h1>
|
||||
<span className="text-xs bg-red-900/50 text-red-400 px-2 py-0.5 rounded-full font-bold border border-red-800">LIVE</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/paper" className="text-[10px] px-2 py-1 rounded bg-gray-800 text-gray-400 border border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
📝 Paper
|
||||
</a>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${data?.lastUpdate ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-gray-500">{data?.lastUpdate ? 'Live' : 'Offline'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 pt-4 space-y-4">
|
||||
{/* Kill Switch + Balance */}
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Kalshi Balance</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{live.balance != null ? `$${live.balance.toFixed(2)}` : '—'}
|
||||
</p>
|
||||
{live.portfolioValue != null && (
|
||||
<p className="text-[10px] text-gray-500">Portfolio: ${live.portfolioValue.toFixed(2)}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => sendCommand(live.paused ? 'resume' : 'pause')}
|
||||
disabled={toggling === 'pause' || toggling === 'resume'}
|
||||
className={`px-4 py-2 rounded-lg font-bold text-sm transition-all ${
|
||||
live.paused
|
||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||
: 'bg-red-600 hover:bg-red-500 text-white animate-pulse'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{live.paused ? '▶ Resume' : '⏸ Kill Switch'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<StatBox label="PnL" value={`${live.totalPnL >= 0 ? '+' : ''}$${live.totalPnL?.toFixed(2) || '0.00'}`} color={live.totalPnL >= 0 ? GREEN : RED} dark />
|
||||
<StatBox label="Win Rate" value={`${live.winRate || 0}%`} color={(live.winRate || 0) >= 50 ? GREEN : RED} dark />
|
||||
<StatBox label="Trades" value={live.totalTrades || 0} dark />
|
||||
<StatBox label="Daily Loss" value={`$${live.dailyLoss?.toFixed(2) || '0.00'}`} color={live.dailyLoss > 0 ? AMBER : null} dark />
|
||||
</div>
|
||||
|
||||
{live.paused && (
|
||||
<div className="mt-3 text-xs text-red-400 bg-red-900/30 border border-red-800 rounded-lg p-2 text-center font-medium">
|
||||
⚠️ TRADING PAUSED — No new orders will be placed
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-[10px] text-gray-600 mt-3">
|
||||
<span>Per-trade cap: ${live.maxPerTrade?.toFixed(2) || '5.00'}</span>
|
||||
<span>Daily limit: ${live.maxDailyLoss?.toFixed(2) || '20.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Market Card */}
|
||||
<MarketCard market={market} />
|
||||
|
||||
{/* Strategy Toggles */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-800">
|
||||
<h3 className="text-xs text-gray-400 uppercase tracking-wider font-bold">⚡ Strategy Controls</h3>
|
||||
</div>
|
||||
{strategies.map((s, i) => {
|
||||
const isEnabled = enabledSet.has(s.name);
|
||||
const isToggling = toggling === s.name;
|
||||
return (
|
||||
<div key={s.name} className={`flex items-center justify-between px-4 py-3 ${i < strategies.length - 1 ? 'border-b border-gray-800' : ''}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${isEnabled ? 'bg-green-500' : 'bg-gray-600'}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize text-gray-200">{s.name}</p>
|
||||
{s.config && (
|
||||
<p className="text-[10px] text-gray-600">
|
||||
${s.config.betSize || 1}/trade • {s.config.cooldownMs ? `${(s.config.cooldownMs/1000).toFixed(0)}s cd` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => sendCommand(isEnabled ? 'disable' : 'enable', s.name)}
|
||||
disabled={isToggling}
|
||||
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
|
||||
isEnabled ? 'bg-green-600' : 'bg-gray-700'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all duration-300 ${
|
||||
isEnabled ? 'left-6' : 'left-0.5'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{strategies.length === 0 && (
|
||||
<p className="text-gray-600 text-xs text-center py-4">No strategies loaded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Orders */}
|
||||
{live.openOrders?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] text-gray-500 uppercase tracking-wider mb-2 font-bold">
|
||||
Open Orders ({live.openOrders.length})
|
||||
</h4>
|
||||
{live.openOrders.map((o, i) => (
|
||||
<LiveOrderRow key={o.orderId || i} order={o} isOpen />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kalshi Positions */}
|
||||
{live.positions?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] text-gray-500 uppercase tracking-wider mb-2 font-bold">
|
||||
Kalshi Positions ({live.positions.length})
|
||||
</h4>
|
||||
{live.positions.map((p, i) => (
|
||||
<div key={i} className="bg-gray-900 rounded-lg p-3 border border-gray-800 mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-gray-300">{p.market_ticker || p.ticker}</span>
|
||||
<span className="text-xs text-gray-400">{p.position_fp || p.position || 0} contracts</span>
|
||||
</div>
|
||||
{p.realized_pnl_dollars && (
|
||||
<p className="text-[10px] text-gray-500 mt-1">
|
||||
Realized PnL: <span className={Number(p.realized_pnl_dollars) >= 0 ? 'text-green-400' : 'text-red-400'}>
|
||||
${Number(p.realized_pnl_dollars).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trade History */}
|
||||
<div>
|
||||
<h4 className="text-[10px] text-gray-500 uppercase tracking-wider mb-2 font-bold">
|
||||
Trade History ({trades.length})
|
||||
</h4>
|
||||
{trades.length === 0 ? (
|
||||
<p className="text-gray-600 text-xs text-center py-4">No settled trades yet. Enable a strategy to start.</p>
|
||||
) : (
|
||||
trades.map((t, i) => <LiveOrderRow key={i} order={t} />)
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-gray-800 py-2 px-4">
|
||||
<div className="max-w-lg mx-auto flex justify-between text-xs text-gray-600">
|
||||
<span>Worker: {formatUptime(data?.workerUptime)}</span>
|
||||
<span>{data?.lastUpdate ? new Date(data.lastUpdate).toLocaleTimeString() : 'never'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketCard({ market }) {
|
||||
if (!market) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<p className="text-gray-600 text-center text-sm">No active market — waiting...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeLeft = market.closeTime ? getTimeLeft(market.closeTime) : null;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="font-bold text-sm text-gray-100">BTC Up or Down</h2>
|
||||
<p className="text-[10px] text-gray-600">15 min</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{timeLeft && (
|
||||
<span className="text-[10px] bg-gray-800 px-2 py-0.5 rounded-full text-gray-400">⏱ {timeLeft}</span>
|
||||
)}
|
||||
<span className="text-lg">₿</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-400">Up</span>
|
||||
<span style={{ color: GREEN }} className="font-medium">{market.yesPct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.yesPct}%`, backgroundColor: GREEN }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-400">Down</span>
|
||||
<span style={{ color: BLUE }} className="font-medium">{market.noPct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.noPct}%`, backgroundColor: BLUE }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-600 mt-2">
|
||||
<span>${(market.volume || 0).toLocaleString()} vol</span>
|
||||
<span className="font-mono">{market.ticker}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveOrderRow({ order, isOpen }) {
|
||||
const won = order.result && order.side?.toLowerCase() === order.result?.toLowerCase();
|
||||
const isNeutral = order.result === 'cancelled' || order.result === 'expired';
|
||||
const pnlVal = order.pnl != null ? (typeof order.pnl === 'number' && Math.abs(order.pnl) > 50 ? order.pnl / 100 : order.pnl) : null;
|
||||
const pnlColor = pnlVal == null ? 'text-gray-600' : pnlVal > 0 ? 'text-green-400' : pnlVal < 0 ? 'text-red-400' : 'text-gray-400';
|
||||
|
||||
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>
|
||||
{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`;
|
||||
}
|
||||
5
app/dashboard/page.js
Normal file
5
app/dashboard/page.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Dashboard() {
|
||||
redirect('/dash');
|
||||
}
|
||||
66
app/page.js
66
app/page.js
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [captcha, setCaptcha] = useState('');
|
||||
@@ -33,70 +35,36 @@ export default function LoginPage() {
|
||||
setCaptcha('');
|
||||
} else {
|
||||
setSuccess(data.message);
|
||||
router.push(data.redirect || '/dash');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900 text-white font-sans">
|
||||
<div className="bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center text-indigo-400">Kalbot Access</h1>
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center text-[#28CC95]">Kalbot Access</h1>
|
||||
|
||||
{error && <div className="bg-red-500/20 border border-red-500 text-red-300 p-3 rounded mb-4 text-sm">{error}</div>}
|
||||
{success && <div className="bg-green-500/20 border border-green-500 text-green-300 p-3 rounded mb-4 text-sm">{success}</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>}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" required className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" required className="w-full bg-white border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-[#28CC95] focus:ring-1 focus:ring-[#28CC95] transition-shadow" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Captcha Verification</label>
|
||||
<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-600"
|
||||
onClick={refreshCaptcha}
|
||||
title="Click to refresh"
|
||||
/>
|
||||
<button type="button" onClick={refreshCaptcha} className="text-sm text-indigo-400 hover:text-indigo-300">
|
||||
Refresh
|
||||
</button>
|
||||
<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-gray-700 border border-gray-600 rounded px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
value={captcha}
|
||||
onChange={(e) => setCaptcha(e.target.value)}
|
||||
/>
|
||||
<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-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded transition-colors mt-6"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<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>
|
||||
|
||||
397
app/paper/page.js
Normal file
397
app/paper/page.js
Normal file
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const GREEN = '#16A34A';
|
||||
const RED = '#DC2626';
|
||||
const BLUE = '#2563EB';
|
||||
|
||||
export default function PaperDashboard() {
|
||||
const [data, setData] = useState(null);
|
||||
const [trades, setTrades] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeStrat, setActiveStrat] = useState(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
if (!activeStrat && json.strategies?.length) {
|
||||
setActiveStrat(json.strategies[0].name);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
console.error('State fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchState();
|
||||
const interval = setInterval(fetchState, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeStrat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeStrat) return;
|
||||
|
||||
const fetchTrades = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trades?strategy=${encodeURIComponent(activeStrat)}`);
|
||||
const json = await res.json();
|
||||
setTrades(prev => ({ ...prev, [activeStrat]: json.trades || [] }));
|
||||
} catch (e) {
|
||||
console.error('Trades fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTrades();
|
||||
const interval = setInterval(fetchTrades, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeStrat]);
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset ALL paper trading data? This clears all history, stats, and open positions for every strategy.')) return;
|
||||
setResetting(true);
|
||||
try {
|
||||
await fetch('/api/reset', { method: 'POST' });
|
||||
setTrades({});
|
||||
} catch (e) {
|
||||
console.error('Reset error:', e);
|
||||
}
|
||||
setTimeout(() => setResetting(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-green-600 text-lg animate-pulse">Loading Paper Trading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const market = data?.market;
|
||||
const strategies = data?.strategies || [];
|
||||
const paperByStrategy = data?.paperByStrategy || {};
|
||||
const activeStratData = strategies.find(s => s.name === activeStrat);
|
||||
const activeStats = paperByStrategy[activeStrat];
|
||||
const activeTrades = trades[activeStrat] || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans pb-20">
|
||||
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur border-b border-gray-200 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold" style={{ color: GREEN }}>Kalbot</h1>
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">PAPER</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/dash" className="text-[10px] px-2 py-1 rounded bg-gray-900 text-green-400 border border-gray-700 hover:bg-gray-800 transition-colors font-bold">
|
||||
💰 Live
|
||||
</a>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="text-[10px] px-2 py-1 rounded bg-red-50 text-red-600 border border-red-200 hover:bg-red-100 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{resetting ? 'Resetting...' : '🗑 Reset All'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${data?.lastUpdate ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{data?.lastUpdate ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 pt-4 space-y-4">
|
||||
<MarketCardCompact market={market} />
|
||||
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1 overflow-x-auto">
|
||||
{strategies.map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => setActiveStrat(s.name)}
|
||||
className={`flex-shrink-0 py-2 px-3 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||
activeStrat === s.name
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||
s.enabled && !s.paused ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeStrat && activeStratData && (
|
||||
<StrategyDetailView
|
||||
strategy={activeStratData}
|
||||
stats={activeStats}
|
||||
trades={activeTrades}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AllStrategiesOverview paperByStrategy={paperByStrategy} strategies={strategies} />
|
||||
</main>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white/95 backdrop-blur border-t border-gray-200 py-2 px-4">
|
||||
<div className="max-w-lg mx-auto flex justify-between text-xs text-gray-400">
|
||||
<span>Worker: {formatUptime(data?.workerUptime)}</span>
|
||||
<span>{data?.lastUpdate ? new Date(data.lastUpdate).toLocaleTimeString() : 'never'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketCardCompact({ market }) {
|
||||
if (!market) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
|
||||
<p className="text-gray-400 text-center text-sm">No active market — waiting...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeLeft = market.closeTime ? getTimeLeft(market.closeTime) : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="font-bold text-sm text-gray-900">BTC Up or Down</h2>
|
||||
<p className="text-[10px] text-gray-400">15 min</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{timeLeft && (
|
||||
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded-full text-gray-600">⏱ {timeLeft}</span>
|
||||
)}
|
||||
<span className="text-lg">₿</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-600">Up</span>
|
||||
<span style={{ color: GREEN }} className="font-medium">{market.yesPct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.yesPct}%`, backgroundColor: GREEN }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-600">Down</span>
|
||||
<span style={{ color: BLUE }} className="font-medium">{market.noPct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full transition-all duration-500" style={{ width: `${market.noPct}%`, backgroundColor: BLUE }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-400 mt-2">
|
||||
<span>${(market.volume || 0).toLocaleString()} vol</span>
|
||||
<span className="font-mono">{market.ticker}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyDetailView({ strategy, stats, trades }) {
|
||||
const s = stats || { balance: 1000, totalPnL: 0, wins: 0, losses: 0, winRate: 0, totalTrades: 0, openPositions: [] };
|
||||
const pnlColor = s.totalPnL >= 0 ? GREEN : RED;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${strategy.enabled && !strategy.paused ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<h3 className="font-bold text-sm capitalize text-gray-900">{strategy.name}</h3>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">{strategy.mode}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
<StatBox label="Balance" value={`$${s.balance}`} />
|
||||
<StatBox label="PnL" value={`${s.totalPnL >= 0 ? '+' : ''}$${s.totalPnL}`} color={pnlColor} />
|
||||
<StatBox label="Win Rate" value={`${s.winRate}%`} color={s.winRate >= 50 ? GREEN : RED} />
|
||||
<StatBox label="Trades" value={s.totalTrades} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-500 border-t border-gray-100 pt-3">
|
||||
{strategy.config && Object.entries(strategy.config).map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span>{k}</span>
|
||||
<span className="text-gray-700">{typeof v === 'number' ? v : String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
{strategy.consecutiveLosses !== undefined && (
|
||||
<div className="flex justify-between mt-1 pt-1 border-t border-gray-100">
|
||||
<span>Consecutive Losses</span>
|
||||
<span className={strategy.consecutiveLosses > 0 ? 'text-red-600' : 'text-gray-700'}>
|
||||
{strategy.consecutiveLosses}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{strategy.currentBetSize !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span>Next Bet</span>
|
||||
<span className="text-gray-700">${strategy.currentBetSize}</span>
|
||||
</div>
|
||||
)}
|
||||
{strategy.round !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span>Cycle Round</span>
|
||||
<span className="text-gray-700">{strategy.round}/{strategy.maxRounds}</span>
|
||||
</div>
|
||||
)}
|
||||
{strategy.cycleWins !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span>Cycles Won/Lost</span>
|
||||
<span className="text-gray-700">{strategy.cycleWins}W / {strategy.cycleLosses}L</span>
|
||||
</div>
|
||||
)}
|
||||
{strategy.cycleWinRate !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span>Cycle Win Rate</span>
|
||||
<span className={strategy.cycleWinRate >= 50 ? 'text-green-600' : 'text-red-600'}>
|
||||
{strategy.cycleWinRate}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{strategy.paused && (
|
||||
<div className="text-red-600 font-medium mt-1">⚠️ PAUSED — max losses reached</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{s.openPositions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] text-gray-400 uppercase tracking-wider mb-2 font-bold">Open Positions ({s.openPositions.length})</h4>
|
||||
{s.openPositions.map((t, i) => <TradeRow key={i} trade={t} isOpen />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] text-gray-400 uppercase tracking-wider mb-2 font-bold">
|
||||
Trade History ({trades.length})
|
||||
</h4>
|
||||
{trades.length === 0 ? (
|
||||
<p className="text-gray-400 text-xs text-center py-4">No settled trades yet.</p>
|
||||
) : (
|
||||
trades.map((t, i) => <TradeRow key={i} trade={t} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllStrategiesOverview({ paperByStrategy, strategies }) {
|
||||
const entries = strategies.map(s => ({
|
||||
name: s.name,
|
||||
stats: paperByStrategy[s.name] || { balance: 1000, totalPnL: 0, winRate: 0, totalTrades: 0 },
|
||||
enabled: s.enabled,
|
||||
paused: s.paused
|
||||
}));
|
||||
|
||||
if (!entries.length) return null;
|
||||
entries.sort((a, b) => b.stats.totalPnL - a.stats.totalPnL);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wider font-bold">📊 Strategy Leaderboard</h3>
|
||||
</div>
|
||||
{entries.map((e, i) => {
|
||||
const pnlColor = e.stats.totalPnL >= 0 ? GREEN : RED;
|
||||
return (
|
||||
<div key={e.name} className={`flex items-center justify-between px-4 py-3 ${i < entries.length - 1 ? 'border-b border-gray-100' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${e.enabled && !e.paused ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm font-medium capitalize text-gray-800">{e.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-gray-400">{e.stats.totalTrades} trades</span>
|
||||
<span className="text-gray-400">{e.stats.winRate}% wr</span>
|
||||
<span className="font-bold" style={{ color: pnlColor }}>
|
||||
{e.stats.totalPnL >= 0 ? '+' : ''}${e.stats.totalPnL}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color }) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-2 border border-gray-100 text-center">
|
||||
<p className="text-[9px] text-gray-400 uppercase tracking-wider">{label}</p>
|
||||
<p className="text-xs font-bold mt-0.5" style={color ? { color } : { color: '#111827' }}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeRow({ trade, isOpen }) {
|
||||
const won = trade.result && trade.side.toLowerCase() === trade.result.toLowerCase();
|
||||
const isNeutral = trade.result === 'cancelled' || trade.result === 'expired';
|
||||
const pnlColor = trade.pnl == null ? 'text-gray-400' : trade.pnl > 0 ? 'text-green-600' : trade.pnl < 0 ? 'text-red-600' : 'text-gray-600';
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-200 mb-2 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? (
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
) : (
|
||||
<span>{isNeutral ? '➖' : won ? '✅' : '❌'}</span>
|
||||
)}
|
||||
<span className="text-sm font-medium capitalize text-gray-900">{trade.side}</span>
|
||||
<span className="text-xs text-gray-400">@ {trade.price}¢</span>
|
||||
<span className="text-[10px] text-gray-400">${trade.size}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${pnlColor}`}>
|
||||
{trade.pnl != null ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl}` : 'open'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[10px] text-gray-400">{trade.reason}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-0.5">
|
||||
{trade.result && !isOpen && (
|
||||
<span className="text-[10px] text-gray-400">Result: {trade.result}</span>
|
||||
)}
|
||||
{!trade.result && <span />}
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{trade.entryTime ? new Date(trade.entryTime).toLocaleTimeString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeLeft(closeTime) {
|
||||
const diff = new Date(closeTime).getTime() - Date.now();
|
||||
if (diff <= 0) return 'Closing...';
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const secs = Math.floor((diff % 60000) / 1000);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return '0s';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
19
entrypoint.sh
Normal file
19
entrypoint.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
echo "[Entrypoint] Starting Kalbot worker..."
|
||||
node worker.js &
|
||||
WORKER_PID=$!
|
||||
|
||||
echo "[Entrypoint] Starting Next.js server..."
|
||||
node server.js &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Trap signals and forward to both processes
|
||||
trap "kill $WORKER_PID $SERVER_PID 2>/dev/null; exit 0" SIGTERM SIGINT
|
||||
|
||||
# Wait for either to exit
|
||||
wait -n
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo "[Entrypoint] A process exited with code $EXIT_CODE. Shutting down..."
|
||||
kill $WORKER_PID $SERVER_PID 2>/dev/null
|
||||
exit $EXIT_CODE
|
||||
57
lib/auth.js
Normal file
57
lib/auth.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Edge-compatible session signer/verifier using Web Crypto API.
|
||||
*/
|
||||
|
||||
async function getSessionKey() {
|
||||
const secret = process.env.CAPTCHA_SECRET || 'dev_secret_meow';
|
||||
const encoder = new TextEncoder();
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export async function signSession() {
|
||||
const expires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours validity
|
||||
const data = `admin.${expires}`;
|
||||
const encoder = new TextEncoder();
|
||||
const key = await getSessionKey();
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureArray = Array.from(new Uint8Array(signatureBuffer));
|
||||
const signatureHex = signatureArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return `${data}.${signatureHex}`;
|
||||
}
|
||||
|
||||
export async function verifySession(token) {
|
||||
if (!token) return false;
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
const [user, expires, signatureHex] = parts;
|
||||
if (user !== 'admin') return false;
|
||||
|
||||
// Check if token expired
|
||||
if (Date.now() > parseInt(expires, 10)) return false;
|
||||
|
||||
const data = `${user}.${expires}`;
|
||||
const encoder = new TextEncoder();
|
||||
const key = await getSessionKey();
|
||||
|
||||
// Convert hex string back to Uint8Array
|
||||
const signatureBytes = new Uint8Array(
|
||||
signatureHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
|
||||
);
|
||||
|
||||
try {
|
||||
// Verify the HMAC signature ensures the token hasn't been tampered with
|
||||
return await crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(data));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
116
lib/db.js
Normal file
116
lib/db.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import Surreal from 'surrealdb';
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const url = process.env.SURREAL_URL;
|
||||
const user = process.env.SURREAL_USER;
|
||||
const pass = process.env.SURREAL_PASS;
|
||||
|
||||
if (!url) {
|
||||
console.warn('[DB] No SURREAL_URL set — running in memory-only mode');
|
||||
this.connected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.client) {
|
||||
this.client = new Surreal();
|
||||
}
|
||||
await this.client.connect(url);
|
||||
await this.client.signin({ username: user, password: pass });
|
||||
await this.client.use({ namespace: 'kalbot', database: 'kalbot' });
|
||||
this.connected = true;
|
||||
console.log('[DB] Connected to SurrealDB');
|
||||
} catch (e) {
|
||||
console.error('[DB] Connection failed:', e.message);
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeQueryResult(raw) {
|
||||
if (!Array.isArray(raw)) return [[]];
|
||||
|
||||
return raw.map((entry) => {
|
||||
if (Array.isArray(entry)) return entry;
|
||||
if (entry && typeof entry === 'object' && 'result' in entry) {
|
||||
return Array.isArray(entry.result) ? entry.result : [entry.result];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async _handleTokenExpiration(e) {
|
||||
if (e.message && e.message.toLowerCase().includes('token has expired')) {
|
||||
console.log('[DB] Session token expired! Attempting to re-authenticate...');
|
||||
this.connected = false;
|
||||
await this.connect();
|
||||
return this.connected; // Returns true if reconnection was successful
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async query(sql, vars = {}) {
|
||||
if (!this.connected) return [[]];
|
||||
|
||||
try {
|
||||
const raw = await this.client.query(sql, vars);
|
||||
return this._normalizeQueryResult(raw);
|
||||
} catch (e) {
|
||||
// Check if it's an expiration issue, if so, reconnect and retry once
|
||||
if (await this._handleTokenExpiration(e)) {
|
||||
try {
|
||||
const retryRaw = await this.client.query(sql, vars);
|
||||
return this._normalizeQueryResult(retryRaw);
|
||||
} catch (retryErr) {
|
||||
console.error('[DB] Query retry error:', retryErr.message);
|
||||
return [[]];
|
||||
}
|
||||
}
|
||||
console.error('[DB] Query error:', e.message);
|
||||
return [[]];
|
||||
}
|
||||
}
|
||||
|
||||
async create(table, data) {
|
||||
if (!this.connected) return null;
|
||||
try {
|
||||
return await this.client.create(table, data);
|
||||
} catch (e) {
|
||||
if (await this._handleTokenExpiration(e)) {
|
||||
try {
|
||||
return await this.client.create(table, data);
|
||||
} catch (retryErr) {
|
||||
console.error('[DB] Create retry error:', retryErr.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
console.error('[DB] Create error:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async select(table) {
|
||||
if (!this.connected) return [];
|
||||
try {
|
||||
return await this.client.select(table);
|
||||
} catch (e) {
|
||||
if (await this._handleTokenExpiration(e)) {
|
||||
try {
|
||||
return await this.client.select(table);
|
||||
} catch (retryErr) {
|
||||
console.error('[DB] Select retry error:', retryErr.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
console.error('[DB] Select error:', e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database();
|
||||
58
lib/kalshi/auth.js
Normal file
58
lib/kalshi/auth.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const DEFAULT_KALSHI_API_BASE = 'https://api.elections.kalshi.com';
|
||||
const KALSHI_API_BASE = (process.env.KALSHI_API_BASE || DEFAULT_KALSHI_API_BASE).trim().replace(/\/+$/, '');
|
||||
|
||||
function normalizePrivateKey(value) {
|
||||
if (!value) return '';
|
||||
let key = String(value).trim();
|
||||
if (
|
||||
(key.startsWith('"') && key.endsWith('"')) ||
|
||||
(key.startsWith("'") && key.endsWith("'"))
|
||||
) {
|
||||
key = key.slice(1, -1);
|
||||
}
|
||||
return key
|
||||
.replace(/\\r\\n/g, '\n')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a Kalshi API request using RSA-PSS with SHA-256.
|
||||
* Returns headers needed for authenticated requests.
|
||||
*/
|
||||
export function signRequest(method, path, timestampMs = Date.now()) {
|
||||
const keyId = process.env.KALSHI_API_KEY_ID?.trim();
|
||||
const privateKeyPem = normalizePrivateKey(process.env.KALSHI_RSA_PRIVATE_KEY);
|
||||
|
||||
if (!keyId || !privateKeyPem) {
|
||||
throw new Error('Missing KALSHI_API_KEY_ID or KALSHI_RSA_PRIVATE_KEY');
|
||||
}
|
||||
|
||||
// Strip query parameters before signing per Kalshi docs
|
||||
const pathWithoutQuery = path.split('?')[0];
|
||||
|
||||
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,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST
|
||||
});
|
||||
|
||||
return {
|
||||
'KALSHI-ACCESS-KEY': keyId,
|
||||
'KALSHI-ACCESS-SIGNATURE': signature.toString('base64'),
|
||||
'KALSHI-ACCESS-TIMESTAMP': ts,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
export { KALSHI_API_BASE };
|
||||
231
lib/kalshi/rest.js
Normal file
231
lib/kalshi/rest.js
Normal file
@@ -0,0 +1,231 @@
|
||||
import { signRequest, KALSHI_API_BASE } from './auth.js';
|
||||
|
||||
const SERIES_TICKER = (process.env.KALSHI_SERIES_TICKER || 'KXBTC15M').trim().toUpperCase();
|
||||
const OPEN_EVENT_STATUSES = new Set(['open', 'active', 'initialized', 'trading']);
|
||||
const TRADABLE_EVENT_STATUSES = new Set(['open', 'active', 'trading']);
|
||||
|
||||
const DEFAULT_HTTP_RETRIES = Math.max(0, Number(process.env.KALSHI_HTTP_RETRIES || 3));
|
||||
const BASE_BACKOFF_MS = Math.max(100, Number(process.env.KALSHI_HTTP_BACKOFF_MS || 350));
|
||||
|
||||
const EVENTS_CACHE_TTL_MS = Math.max(1000, Number(process.env.KALSHI_EVENTS_CACHE_TTL_MS || 5000));
|
||||
const eventsCache = new Map();
|
||||
const inflightEvents = new Map();
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function parseRetryAfterMs(value) {
|
||||
if (!value) return null;
|
||||
const asSeconds = Number(value);
|
||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) return asSeconds * 1000;
|
||||
const asDate = new Date(value).getTime();
|
||||
if (Number.isFinite(asDate)) return Math.max(0, asDate - Date.now());
|
||||
return null;
|
||||
}
|
||||
|
||||
function backoffMs(attempt) {
|
||||
const exp = BASE_BACKOFF_MS * Math.pow(2, attempt);
|
||||
const jitter = Math.floor(Math.random() * 200);
|
||||
return exp + jitter;
|
||||
}
|
||||
|
||||
async function kalshiFetch(method, path, body = null, opts = {}) {
|
||||
const retries = Number.isFinite(opts.retries) ? opts.retries : DEFAULT_HTTP_RETRIES;
|
||||
const payload = body == null ? null : JSON.stringify(body);
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const headers = signRequest(method, path);
|
||||
const req = { method, headers };
|
||||
if (payload) req.body = payload;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${KALSHI_API_BASE}${path}`, req);
|
||||
} catch (e) {
|
||||
if (attempt < retries) {
|
||||
await sleep(backoffMs(attempt));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Kalshi API ${method} ${path} network error: ${e.message}`);
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
if (res.status === 204) return {};
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
try { return JSON.parse(text); } catch { return {}; }
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const retryable = res.status === 429 || (res.status >= 500 && res.status < 600);
|
||||
|
||||
if (retryable && attempt < retries) {
|
||||
const retryAfter = parseRetryAfterMs(res.headers.get('retry-after'));
|
||||
await sleep(retryAfter ?? backoffMs(attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Kalshi API ${method} ${path} → ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
throw new Error(`Kalshi API ${method} ${path} failed after retries`);
|
||||
}
|
||||
|
||||
function getTimeMs(value) {
|
||||
if (!value) return null;
|
||||
const ts = new Date(value).getTime();
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
function getEventCloseTimeMs(event) {
|
||||
return (
|
||||
getTimeMs(event?.close_time) ||
|
||||
getTimeMs(event?.expiration_time) ||
|
||||
getTimeMs(event?.settlement_time) ||
|
||||
getTimeMs(event?.end_date) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function rankEvents(events = []) {
|
||||
const now = Date.now();
|
||||
return events
|
||||
.filter(Boolean)
|
||||
.map((event) => {
|
||||
const status = String(event.status || '').toLowerCase();
|
||||
const closeTs = getEventCloseTimeMs(event);
|
||||
const openLike = OPEN_EVENT_STATUSES.has(status);
|
||||
const tradableLike = TRADABLE_EVENT_STATUSES.has(status);
|
||||
const notClearlyExpired = closeTs == null || closeTs > now - 60_000;
|
||||
const delta = closeTs == null ? Number.MAX_SAFE_INTEGER : closeTs - now;
|
||||
const closenessScore = delta < 0 ? Math.abs(delta) + 3_600_000 : delta;
|
||||
return { event, openLike, tradableLike, notClearlyExpired, closenessScore };
|
||||
})
|
||||
.filter((x) => x.openLike || x.notClearlyExpired)
|
||||
.sort((a, b) => {
|
||||
if (a.tradableLike !== b.tradableLike) return a.tradableLike ? -1 : 1;
|
||||
if (a.openLike !== b.openLike) return a.openLike ? -1 : 1;
|
||||
if (a.notClearlyExpired !== b.notClearlyExpired) return a.notClearlyExpired ? -1 : 1;
|
||||
return a.closenessScore - b.closenessScore;
|
||||
})
|
||||
.map((x) => x.event);
|
||||
}
|
||||
|
||||
function getCachedEvents(key) {
|
||||
const hit = eventsCache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() > hit.expiresAt) { eventsCache.delete(key); return null; }
|
||||
return hit.data;
|
||||
}
|
||||
|
||||
function setCachedEvents(key, events) {
|
||||
eventsCache.set(key, { expiresAt: Date.now() + EVENTS_CACHE_TTL_MS, data: events });
|
||||
}
|
||||
|
||||
async function fetchEvents(series, query) {
|
||||
const normalizedSeries = String(series || '').trim().toUpperCase();
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
const key = `${normalizedSeries}|${normalizedQuery}`;
|
||||
|
||||
const cached = getCachedEvents(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = inflightEvents.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const path = `/trade-api/v2/events?series_ticker=${encodeURIComponent(normalizedSeries)}&${normalizedQuery}`;
|
||||
const data = await kalshiFetch('GET', path);
|
||||
const events = Array.isArray(data.events) ? data.events : [];
|
||||
setCachedEvents(key, events);
|
||||
return events;
|
||||
} finally {
|
||||
inflightEvents.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
inflightEvents.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function getActiveBTCEvents(limit = 12) {
|
||||
const seriesCandidates = [SERIES_TICKER];
|
||||
const eventMap = new Map();
|
||||
|
||||
for (const series of seriesCandidates) {
|
||||
try {
|
||||
const openEvents = await fetchEvents(series, 'status=open&limit=25');
|
||||
for (const event of openEvents) {
|
||||
if (event?.event_ticker) eventMap.set(event.event_ticker, event);
|
||||
}
|
||||
if (!openEvents.length) {
|
||||
const fallbackEvents = await fetchEvents(series, 'limit=25');
|
||||
for (const event of fallbackEvents) {
|
||||
if (event?.event_ticker) eventMap.set(event.event_ticker, event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Kalshi] Event fetch failed (${series}):`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return rankEvents([...eventMap.values()]).slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getActiveBTCEvent() {
|
||||
const events = await getActiveBTCEvents(1);
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
export async function getEventMarkets(eventTicker) {
|
||||
const data = await kalshiFetch('GET', `/trade-api/v2/events/${eventTicker}`);
|
||||
const markets = data?.event?.markets ?? data?.markets ?? data?.event_markets ?? [];
|
||||
return Array.isArray(markets) ? markets : [];
|
||||
}
|
||||
|
||||
export async function getOrderbook(ticker) {
|
||||
const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}/orderbook`);
|
||||
return data.orderbook || data;
|
||||
}
|
||||
|
||||
export async function getMarket(ticker) {
|
||||
const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}`);
|
||||
return data.market || data;
|
||||
}
|
||||
|
||||
export async function placeOrder(params) {
|
||||
return kalshiFetch('POST', '/trade-api/v2/portfolio/orders', params);
|
||||
}
|
||||
|
||||
export async function getBalance() {
|
||||
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 };
|
||||
163
lib/kalshi/websocket.js
Normal file
163
lib/kalshi/websocket.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import WebSocket from 'ws';
|
||||
import { signRequest } from './auth.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const WS_URL = 'wss://api.elections.kalshi.com/trade-api/ws/v2';
|
||||
|
||||
function unwrapPacket(packet) {
|
||||
if (!packet || typeof packet !== 'object') return { type: null, payload: null };
|
||||
const type = packet.type || null;
|
||||
const meta = { id: packet.id, sid: packet.sid, seq: packet.seq, type };
|
||||
if (packet.msg && typeof packet.msg === 'object') {
|
||||
return { type, payload: { ...meta, ...packet.msg } };
|
||||
}
|
||||
return { type, payload: { ...meta, ...packet } };
|
||||
}
|
||||
|
||||
export class KalshiWS extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.ws = null;
|
||||
this.subscribedTickers = new Set();
|
||||
this.alive = false;
|
||||
this.reconnectTimer = null;
|
||||
this.pingInterval = null;
|
||||
this.shouldReconnect = true;
|
||||
this._cmdId = 1;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws) this.disconnect();
|
||||
this.shouldReconnect = true;
|
||||
|
||||
const path = '/trade-api/ws/v2';
|
||||
const headers = signRequest('GET', path);
|
||||
|
||||
this.ws = new WebSocket(WS_URL, { headers });
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('[WS] Connected to Kalshi');
|
||||
this.alive = true;
|
||||
this._startPing();
|
||||
|
||||
for (const ticker of this.subscribedTickers) this._sendSubscribe(ticker);
|
||||
this.emit('connected');
|
||||
});
|
||||
|
||||
this.ws.on('message', (raw) => {
|
||||
try {
|
||||
const packet = JSON.parse(raw.toString());
|
||||
this._handleMessage(packet);
|
||||
} catch (e) {
|
||||
console.error('[WS] Parse error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', (code) => {
|
||||
console.log(`[WS] Disconnected (code: ${code}).`);
|
||||
this.alive = false;
|
||||
this._stopPing();
|
||||
if (this.shouldReconnect) {
|
||||
console.log('[WS] Reconnecting in 3s...');
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('[WS] Error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
subscribeTicker(ticker) {
|
||||
if (!ticker) return;
|
||||
this.subscribedTickers.add(ticker);
|
||||
if (this.alive) this._sendSubscribe(ticker);
|
||||
}
|
||||
|
||||
unsubscribeTicker(ticker) {
|
||||
if (!ticker) return;
|
||||
this.subscribedTickers.delete(ticker);
|
||||
if (this.alive && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
id: this._cmdId++,
|
||||
cmd: 'unsubscribe',
|
||||
params: { channels: ['orderbook_delta'], market_ticker: ticker }
|
||||
}));
|
||||
this.ws.send(JSON.stringify({
|
||||
id: this._cmdId++,
|
||||
cmd: 'unsubscribe',
|
||||
params: { channels: ['ticker'], market_ticker: ticker }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this._stopPing();
|
||||
clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.alive = false;
|
||||
}
|
||||
|
||||
_sendSubscribe(ticker) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Subscribe orderbook_delta (private channel) with market_ticker
|
||||
this.ws.send(JSON.stringify({
|
||||
id: this._cmdId++,
|
||||
cmd: 'subscribe',
|
||||
params: { channels: ['orderbook_delta'], market_ticker: ticker }
|
||||
}));
|
||||
|
||||
// Subscribe ticker (public channel) with market_ticker
|
||||
this.ws.send(JSON.stringify({
|
||||
id: this._cmdId++,
|
||||
cmd: 'subscribe',
|
||||
params: { channels: ['ticker'], market_ticker: ticker }
|
||||
}));
|
||||
}
|
||||
|
||||
_handleMessage(packet) {
|
||||
const { type, payload } = unwrapPacket(packet);
|
||||
if (!type || !payload) return;
|
||||
|
||||
if (type === 'orderbook_snapshot' || type === 'orderbook_delta') {
|
||||
this.emit('orderbook', payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'ticker') {
|
||||
this.emit('ticker', payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'subscribed' || type === 'ok') {
|
||||
console.log(`[WS] ${type}:`, JSON.stringify(payload).slice(0, 200));
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
console.error('[WS] Server error:', JSON.stringify(payload).slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
_startPing() {
|
||||
this._stopPing();
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.alive && this.ws?.readyState === WebSocket.OPEN) this.ws.ping();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
_stopPing() {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
453
lib/market/tracker.js
Normal file
453
lib/market/tracker.js
Normal file
@@ -0,0 +1,453 @@
|
||||
import { getActiveBTCEvents, getEventMarkets, getOrderbook, getMarket } from '../kalshi/rest.js';
|
||||
import { KalshiWS } from '../kalshi/websocket.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const OPEN_MARKET_STATUSES = new Set(['open', 'active', 'initialized', 'trading']);
|
||||
const TRADABLE_MARKET_STATUSES = new Set(['open', 'active', 'trading']);
|
||||
|
||||
/**
|
||||
* Converts a dollar string like "0.4200" to cents integer (42).
|
||||
* Returns null if not parseable.
|
||||
*/
|
||||
function dollarsToCents(val) {
|
||||
if (val == null) return null;
|
||||
const n = Number(val);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the currently active BTC 15-min market.
|
||||
* Auto-rotates when the current market expires.
|
||||
* Emits 'update' with full market state on every change.
|
||||
*/
|
||||
export class MarketTracker extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.ws = new KalshiWS();
|
||||
this.currentTicker = null;
|
||||
this.currentEvent = null;
|
||||
this.marketData = null;
|
||||
this.orderbook = { yes: [], no: [] };
|
||||
this.rotateInterval = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
console.log('[Tracker] Starting market tracker...');
|
||||
|
||||
this.ws.connect();
|
||||
this.ws.on('orderbook', (msg) => this._onOrderbook(msg));
|
||||
this.ws.on('ticker', (msg) => this._onTicker(msg));
|
||||
|
||||
await this._findAndSubscribe();
|
||||
this.rotateInterval = setInterval(() => this._checkRotation(), 30000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this.rotateInterval);
|
||||
this.ws.disconnect();
|
||||
}
|
||||
|
||||
getState() {
|
||||
if (!this.marketData) return null;
|
||||
|
||||
const quotes = this._extractMarketQuotes(this.marketData);
|
||||
const bestYesBook = this._bestBookPrice(this.orderbook.yes);
|
||||
const bestNoBook = this._bestBookPrice(this.orderbook.no);
|
||||
|
||||
const yesBid = quotes.yesBid ?? bestYesBook;
|
||||
const noBid = quotes.noBid ?? bestNoBook;
|
||||
const yesAsk = quotes.yesAsk ?? (noBid != null ? 100 - noBid : null);
|
||||
const noAsk = quotes.noAsk ?? (yesBid != null ? 100 - yesBid : null);
|
||||
|
||||
let yesPct = yesAsk ?? yesBid ?? bestYesBook;
|
||||
let noPct = noAsk ?? noBid ?? bestNoBook;
|
||||
|
||||
if (yesPct == null && noPct != null) yesPct = 100 - noPct;
|
||||
if (noPct == null && yesPct != null) noPct = 100 - yesPct;
|
||||
|
||||
if (yesPct == null && noPct == null && quotes.lastPrice != null) {
|
||||
yesPct = quotes.lastPrice;
|
||||
noPct = 100 - quotes.lastPrice;
|
||||
}
|
||||
|
||||
if (yesPct == null || noPct == null) {
|
||||
yesPct = 50;
|
||||
noPct = 50;
|
||||
}
|
||||
|
||||
yesPct = this._clampPct(yesPct) ?? 50;
|
||||
noPct = this._clampPct(noPct) ?? 50;
|
||||
|
||||
const yesOdds = yesPct > 0 ? (100 / yesPct).toFixed(2) : '0.00';
|
||||
const noOdds = noPct > 0 ? (100 / noPct).toFixed(2) : '0.00';
|
||||
|
||||
return {
|
||||
ticker: this.currentTicker,
|
||||
eventTicker: this.currentEvent,
|
||||
title: this.marketData.title || 'BTC Up or Down - 15 min',
|
||||
subtitle: this.marketData.subtitle || '',
|
||||
yesPct,
|
||||
noPct,
|
||||
yesOdds: parseFloat(yesOdds),
|
||||
noOdds: parseFloat(noOdds),
|
||||
yesBid: this._clampPct(yesBid),
|
||||
yesAsk: this._clampPct(yesAsk),
|
||||
noBid: this._clampPct(noBid),
|
||||
noAsk: this._clampPct(noAsk),
|
||||
volume: this._num(this.marketData.volume) ?? 0,
|
||||
volume24h: this._num(this.marketData.volume_24h) ?? 0,
|
||||
openInterest: this._num(this.marketData.open_interest) ?? 0,
|
||||
lastPrice: this._clampPct(quotes.lastPrice),
|
||||
closeTime: this.marketData.close_time || this.marketData.expiration_time,
|
||||
status: this.marketData.status,
|
||||
result: this.marketData.result,
|
||||
timestamp: Date.now(),
|
||||
// Expose raw orderbook for strategies and live engine
|
||||
orderbook: {
|
||||
yes: this.orderbook.yes,
|
||||
no: this.orderbook.no
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_num(value) {
|
||||
if (value == null) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
_clampPct(value) {
|
||||
const n = this._num(value);
|
||||
if (n == null) return null;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
_toTs(value) {
|
||||
if (!value) return null;
|
||||
const ts = new Date(value).getTime();
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
_extractMarketQuotes(market) {
|
||||
const pick = (...keys) => {
|
||||
for (const key of keys) {
|
||||
const v = this._num(market?.[key]);
|
||||
if (v != null) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let yesBid = pick('yes_bid', 'yesBid');
|
||||
let yesAsk = pick('yes_ask', 'yesAsk');
|
||||
let noBid = pick('no_bid', 'noBid');
|
||||
let noAsk = pick('no_ask', 'noAsk');
|
||||
let lastPrice = pick('last_price', 'lastPrice', 'yes_price', 'yesPrice');
|
||||
|
||||
if (yesBid == null) yesBid = dollarsToCents(market?.yes_bid_dollars);
|
||||
if (yesAsk == null) yesAsk = dollarsToCents(market?.yes_ask_dollars);
|
||||
if (noBid == null) noBid = dollarsToCents(market?.no_bid_dollars);
|
||||
if (noAsk == null) noAsk = dollarsToCents(market?.no_ask_dollars);
|
||||
if (lastPrice == null) lastPrice = dollarsToCents(market?.price_dollars);
|
||||
|
||||
return { yesBid, yesAsk, noBid, noAsk, lastPrice };
|
||||
}
|
||||
|
||||
_normalizeBookSide(levels) {
|
||||
if (!Array.isArray(levels)) return [];
|
||||
const out = [];
|
||||
|
||||
for (const level of levels) {
|
||||
let price = null;
|
||||
let qty = null;
|
||||
|
||||
if (Array.isArray(level)) {
|
||||
const rawPrice = level[0];
|
||||
const rawQty = level[1];
|
||||
|
||||
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
|
||||
price = dollarsToCents(rawPrice);
|
||||
qty = this._num(rawQty);
|
||||
} else {
|
||||
price = this._num(rawPrice);
|
||||
qty = this._num(rawQty);
|
||||
}
|
||||
} else if (level && typeof level === 'object') {
|
||||
const rawPrice = level.price ?? level.price_dollars ?? level[0];
|
||||
const rawQty = level.qty ?? level.quantity ?? level.size ?? level.count ?? level[1];
|
||||
|
||||
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
|
||||
price = dollarsToCents(rawPrice);
|
||||
} else {
|
||||
price = this._num(rawPrice);
|
||||
}
|
||||
qty = this._num(rawQty);
|
||||
}
|
||||
|
||||
if (price == null || qty == null || qty <= 0) continue;
|
||||
out.push([price, qty]);
|
||||
}
|
||||
|
||||
return out.sort((a, b) => b[0] - a[0]);
|
||||
}
|
||||
|
||||
_normalizeOrderbook(book) {
|
||||
const root = book?.orderbook && typeof book.orderbook === 'object' ? book.orderbook : book;
|
||||
return {
|
||||
yes: this._normalizeBookSide(root?.yes ?? root?.yes_dollars_fp ?? root?.yes_dollars),
|
||||
no: this._normalizeBookSide(root?.no ?? root?.no_dollars_fp ?? root?.no_dollars)
|
||||
};
|
||||
}
|
||||
|
||||
_bestBookPrice(sideBook) {
|
||||
if (!Array.isArray(sideBook) || !sideBook.length) return null;
|
||||
return this._num(sideBook[0][0]);
|
||||
}
|
||||
|
||||
_pickBestMarket(markets = []) {
|
||||
const now = Date.now();
|
||||
|
||||
const ranked = markets
|
||||
.filter(Boolean)
|
||||
.map((market) => {
|
||||
const status = String(market?.status || '').toLowerCase();
|
||||
const closeTs =
|
||||
this._toTs(market?.close_time) ||
|
||||
this._toTs(market?.expiration_time) ||
|
||||
this._toTs(market?.settlement_time) ||
|
||||
null;
|
||||
|
||||
const tradable = TRADABLE_MARKET_STATUSES.has(status);
|
||||
const openLike = OPEN_MARKET_STATUSES.has(status);
|
||||
const notClearlyExpired = closeTs == null || closeTs > now - 60_000;
|
||||
|
||||
return { market, tradable, openLike, notClearlyExpired, closeTs };
|
||||
})
|
||||
.filter((x) => x.openLike || x.notClearlyExpired);
|
||||
|
||||
if (!ranked.length) return markets[0] || null;
|
||||
|
||||
ranked.sort((a, b) => {
|
||||
if (a.tradable !== b.tradable) return a.tradable ? -1 : 1;
|
||||
if (a.openLike !== b.openLike) return a.openLike ? -1 : 1;
|
||||
if (a.notClearlyExpired !== b.notClearlyExpired) return a.notClearlyExpired ? -1 : 1;
|
||||
const aTs = a.closeTs ?? Number.MAX_SAFE_INTEGER;
|
||||
const bTs = b.closeTs ?? Number.MAX_SAFE_INTEGER;
|
||||
return aTs - bTs;
|
||||
});
|
||||
|
||||
return ranked[0].market;
|
||||
}
|
||||
|
||||
async _findAndSubscribe() {
|
||||
try {
|
||||
const candidates = await getActiveBTCEvents(12);
|
||||
|
||||
if (!candidates.length) {
|
||||
if (!this.currentTicker) this.emit('update', null);
|
||||
console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...');
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedEvent = null;
|
||||
let selectedMarket = null;
|
||||
|
||||
for (const event of candidates) {
|
||||
const eventTicker = event?.event_ticker;
|
||||
if (!eventTicker) continue;
|
||||
|
||||
let markets = Array.isArray(event.markets) ? event.markets : [];
|
||||
if (!markets.length) {
|
||||
try {
|
||||
markets = await getEventMarkets(eventTicker);
|
||||
} catch (e) {
|
||||
console.error(`[Tracker] Failed loading markets for ${eventTicker}:`, e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!markets.length) continue;
|
||||
|
||||
const market = this._pickBestMarket(markets);
|
||||
if (!market?.ticker) continue;
|
||||
|
||||
selectedEvent = event;
|
||||
selectedMarket = market;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!selectedEvent || !selectedMarket) {
|
||||
if (!this.currentTicker) this.emit('update', null);
|
||||
console.log('[Tracker] No tradable BTC 15m market found yet. Retrying...');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTicker = selectedMarket.ticker;
|
||||
|
||||
if (newTicker === this.currentTicker) {
|
||||
this.currentEvent = selectedEvent.event_ticker || this.currentEvent;
|
||||
this.marketData = { ...(this.marketData || {}), ...selectedMarket };
|
||||
this.emit('update', this.getState());
|
||||
return;
|
||||
}
|
||||
|
||||
const oldTicker = this.currentTicker;
|
||||
|
||||
if (oldTicker) {
|
||||
console.log(`[Tracker] Rotating from ${oldTicker} → ${newTicker}`);
|
||||
this.ws.unsubscribeTicker(oldTicker);
|
||||
}
|
||||
|
||||
this.currentTicker = newTicker;
|
||||
this.currentEvent = selectedEvent.event_ticker;
|
||||
this.marketData = selectedMarket;
|
||||
this.orderbook = { yes: [], no: [] };
|
||||
|
||||
try {
|
||||
const [freshMarket, ob] = await Promise.all([
|
||||
getMarket(newTicker).catch(() => null),
|
||||
getOrderbook(newTicker).catch(() => null)
|
||||
]);
|
||||
|
||||
if (freshMarket) this.marketData = { ...selectedMarket, ...freshMarket };
|
||||
if (ob) this.orderbook = this._normalizeOrderbook(ob);
|
||||
} catch (e) {
|
||||
console.error('[Tracker] Initial market bootstrap error:', e.message);
|
||||
}
|
||||
|
||||
this.ws.subscribeTicker(newTicker);
|
||||
|
||||
console.log(
|
||||
`[Tracker] Now tracking: ${newTicker} (${this.marketData?.title || this.marketData?.subtitle || selectedEvent.event_ticker})`
|
||||
);
|
||||
|
||||
this.emit('update', this.getState());
|
||||
this.emit('market-rotated', { from: oldTicker, to: newTicker });
|
||||
} catch (err) {
|
||||
console.error('[Tracker] Discovery error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async _checkRotation() {
|
||||
if (this.currentTicker) {
|
||||
try {
|
||||
const fresh = await getMarket(this.currentTicker);
|
||||
this.marketData = { ...(this.marketData || {}), ...(fresh || {}) };
|
||||
|
||||
const state = this.getState();
|
||||
this.emit('update', state);
|
||||
|
||||
const status = String(fresh?.status || '').toLowerCase();
|
||||
const settledLike = status === 'closed' || status === 'settled' || status === 'expired' || status === 'finalized';
|
||||
|
||||
if (settledLike || fresh?.result) {
|
||||
console.log(`[Tracker] Market ${this.currentTicker} settled (result: ${fresh.result}). Rotating...`);
|
||||
this.emit('settled', { ticker: this.currentTicker, result: fresh.result });
|
||||
this.currentTicker = null;
|
||||
await this._findAndSubscribe();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Tracker] Refresh error:', e.message);
|
||||
}
|
||||
} else {
|
||||
await this._findAndSubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
_onOrderbook(msg) {
|
||||
if (msg.market_ticker !== this.currentTicker) return;
|
||||
|
||||
if (msg.type === 'orderbook_snapshot') {
|
||||
this.orderbook = this._normalizeOrderbook(msg);
|
||||
console.log(`[Tracker] Orderbook snapshot: ${this.orderbook.yes.length} yes levels, ${this.orderbook.no.length} no levels`);
|
||||
} else if (msg.type === 'orderbook_delta') {
|
||||
const side = String(msg.side || '').toLowerCase();
|
||||
|
||||
let price = this._num(msg.price);
|
||||
if (price == null) price = dollarsToCents(msg.price_dollars);
|
||||
|
||||
let delta = this._num(msg.delta);
|
||||
if (delta == null) delta = this._num(msg.delta_fp);
|
||||
|
||||
const absoluteQty = this._num(msg.qty ?? msg.quantity ?? msg.size);
|
||||
|
||||
if ((side === 'yes' || side === 'no') && price != null) {
|
||||
const book = this.orderbook[side] || [];
|
||||
const map = new Map(book);
|
||||
|
||||
const current = this._num(map.get(price)) ?? 0;
|
||||
const next = delta != null ? current + delta : (absoluteQty ?? current);
|
||||
|
||||
if (next <= 0) map.delete(price);
|
||||
else map.set(price, next);
|
||||
|
||||
this.orderbook[side] = [...map.entries()].sort((a, b) => b[0] - a[0]);
|
||||
} else {
|
||||
const yesArr = msg.yes ?? msg.yes_dollars_fp;
|
||||
const noArr = msg.no ?? msg.no_dollars_fp;
|
||||
if (Array.isArray(yesArr)) this.orderbook.yes = this._applyDelta(this.orderbook.yes, yesArr);
|
||||
if (Array.isArray(noArr)) this.orderbook.no = this._applyDelta(this.orderbook.no, noArr);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('update', this.getState());
|
||||
}
|
||||
|
||||
_onTicker(msg) {
|
||||
if (msg.market_ticker !== this.currentTicker) return;
|
||||
|
||||
if (this.marketData) {
|
||||
const fields = [
|
||||
'yes_bid', 'yes_ask', 'no_bid', 'no_ask', 'last_price', 'volume',
|
||||
'yes_bid_dollars', 'yes_ask_dollars', 'no_bid_dollars', 'no_ask_dollars',
|
||||
'price_dollars', 'volume_fp', 'open_interest_fp',
|
||||
'dollar_volume', 'dollar_open_interest'
|
||||
];
|
||||
|
||||
for (const key of fields) {
|
||||
if (msg[key] != null) this.marketData[key] = msg[key];
|
||||
}
|
||||
|
||||
if (msg.dollar_volume != null) this.marketData.volume = this._num(msg.dollar_volume) ?? this.marketData.volume;
|
||||
if (msg.dollar_open_interest != null) this.marketData.open_interest = this._num(msg.dollar_open_interest) ?? this.marketData.open_interest;
|
||||
if (msg.volume_fp != null && this.marketData.volume == null) this.marketData.volume = this._num(msg.volume_fp);
|
||||
}
|
||||
|
||||
this.emit('update', this.getState());
|
||||
}
|
||||
|
||||
_applyDelta(book, deltas) {
|
||||
const map = new Map(book || []);
|
||||
|
||||
for (const delta of Array.isArray(deltas) ? deltas : []) {
|
||||
let price = null;
|
||||
let qty = null;
|
||||
|
||||
if (Array.isArray(delta)) {
|
||||
const rawPrice = delta[0];
|
||||
const rawQty = delta[1];
|
||||
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
|
||||
price = dollarsToCents(rawPrice);
|
||||
} else {
|
||||
price = this._num(rawPrice);
|
||||
}
|
||||
qty = this._num(rawQty);
|
||||
} else if (delta && typeof delta === 'object') {
|
||||
const rawPrice = delta.price ?? delta.price_dollars ?? delta[0];
|
||||
const rawQty = delta.qty ?? delta.quantity ?? delta.size ?? delta[1];
|
||||
if (typeof rawPrice === 'string' && rawPrice.includes('.')) {
|
||||
price = dollarsToCents(rawPrice);
|
||||
} else {
|
||||
price = this._num(rawPrice);
|
||||
}
|
||||
qty = this._num(rawQty);
|
||||
}
|
||||
|
||||
if (price == null || qty == null) continue;
|
||||
if (qty <= 0) map.delete(price);
|
||||
else map.set(price, qty);
|
||||
}
|
||||
|
||||
return [...map.entries()].sort((a, b) => b[0] - a[0]);
|
||||
}
|
||||
}
|
||||
21
lib/notify.js
Normal file
21
lib/notify.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Send a push notification via ntfy.
|
||||
*/
|
||||
export async function notify(message, title = 'Kalbot', priority = 'default', tags = 'robot') {
|
||||
const url = process.env.NTFY_URL;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
body: message,
|
||||
headers: {
|
||||
'Title': title,
|
||||
'Priority': priority,
|
||||
'Tags': tags
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Notify] Error:', e.message);
|
||||
}
|
||||
}
|
||||
417
lib/paper/engine.js
Normal file
417
lib/paper/engine.js
Normal file
@@ -0,0 +1,417 @@
|
||||
import { db } from '../db.js';
|
||||
import { notify } from '../notify.js';
|
||||
|
||||
/**
|
||||
* Per-Strategy Paper Trading Engine.
|
||||
* Each strategy gets its own isolated balance, PnL, and trade history.
|
||||
*/
|
||||
class StrategyPaperAccount {
|
||||
constructor(strategyName, initialBalance = 1000) {
|
||||
this.strategyName = strategyName;
|
||||
this.balance = initialBalance;
|
||||
this.initialBalance = initialBalance;
|
||||
this.openPositions = new Map(); // ticker -> [positions]
|
||||
this.totalPnL = 0;
|
||||
this.wins = 0;
|
||||
this.losses = 0;
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const openPositionsList = [];
|
||||
for (const [, positions] of this.openPositions) {
|
||||
openPositionsList.push(...positions);
|
||||
}
|
||||
return {
|
||||
strategy: this.strategyName,
|
||||
balance: parseFloat(this.balance.toFixed(2)),
|
||||
initialBalance: this.initialBalance,
|
||||
totalPnL: parseFloat(this.totalPnL.toFixed(2)),
|
||||
wins: this.wins,
|
||||
losses: this.losses,
|
||||
winRate: this.wins + this.losses > 0
|
||||
? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1))
|
||||
: 0,
|
||||
openPositions: openPositionsList,
|
||||
totalTrades: this.wins + this.losses
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PaperEngine {
|
||||
constructor(initialBalancePerStrategy = 1000) {
|
||||
this.initialBalancePerStrategy = initialBalancePerStrategy;
|
||||
this.accounts = new Map(); // strategyName -> StrategyPaperAccount
|
||||
this._resetting = false;
|
||||
}
|
||||
|
||||
_getAccount(strategyName) {
|
||||
if (!this.accounts.has(strategyName)) {
|
||||
this.accounts.set(strategyName, new StrategyPaperAccount(strategyName, this.initialBalancePerStrategy));
|
||||
}
|
||||
return this.accounts.get(strategyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short unique ID safe for SurrealDB v2 record keys.
|
||||
* Avoids colons so we control the full `table:id` format ourselves.
|
||||
*/
|
||||
_genId() {
|
||||
return `pt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw record key from a SurrealDB id.
|
||||
* e.g. "paper_positions:pt_123_abc" -> "pt_123_abc"
|
||||
* "pt_123_abc" -> "pt_123_abc"
|
||||
*/
|
||||
_rawId(id) {
|
||||
if (!id) return id;
|
||||
const str = typeof id === 'object' && id.id ? String(id.id) : String(id);
|
||||
const idx = str.indexOf(':');
|
||||
return idx >= 0 ? str.slice(idx + 1) : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full `table:id` string for use in raw queries.
|
||||
*/
|
||||
_recordId(id) {
|
||||
const raw = this._rawId(id);
|
||||
return raw.startsWith('paper_positions:') ? raw : `paper_positions:⟨${raw}⟩`;
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const states = await db.query('SELECT * FROM paper_strategy_state ORDER BY timestamp DESC');
|
||||
const rows = states[0] || [];
|
||||
const seen = new Set();
|
||||
for (const saved of rows) {
|
||||
if (!saved.strategyName || seen.has(saved.strategyName)) continue;
|
||||
seen.add(saved.strategyName);
|
||||
const acct = this._getAccount(saved.strategyName);
|
||||
acct.balance = saved.balance;
|
||||
acct.totalPnL = saved.totalPnL;
|
||||
acct.wins = saved.wins;
|
||||
acct.losses = saved.losses;
|
||||
console.log(`[Paper:${saved.strategyName}] Restored: $${acct.balance.toFixed(2)}, ${acct.wins}W/${acct.losses}L`);
|
||||
}
|
||||
|
||||
const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false');
|
||||
if (positions[0]) {
|
||||
for (const pos of positions[0]) {
|
||||
const acct = this._getAccount(pos.strategy);
|
||||
const list = acct.openPositions.get(pos.ticker) || [];
|
||||
list.push(pos);
|
||||
acct.openPositions.set(pos.ticker, list);
|
||||
}
|
||||
const totalOpen = positions[0].length;
|
||||
if (totalOpen > 0) {
|
||||
console.log(`[Paper] Loaded ${totalOpen} open position(s) from DB`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Paper] Init error (fresh start):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async executeTrade(signal, marketState) {
|
||||
if (this._resetting) return null;
|
||||
|
||||
const acct = this._getAccount(signal.strategy);
|
||||
const cost = signal.size;
|
||||
|
||||
if (acct.balance < cost) {
|
||||
console.log(`[Paper:${signal.strategy}] Insufficient balance ($${acct.balance.toFixed(2)}) for $${cost} trade`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trade = {
|
||||
id: this._genId(),
|
||||
strategy: signal.strategy,
|
||||
ticker: signal.ticker,
|
||||
side: signal.side.toLowerCase(),
|
||||
price: signal.price,
|
||||
size: signal.size,
|
||||
cost,
|
||||
reason: signal.reason,
|
||||
entryTime: Date.now(),
|
||||
settled: false,
|
||||
result: null,
|
||||
pnl: null,
|
||||
marketState: {
|
||||
yesPct: marketState.yesPct,
|
||||
noPct: marketState.noPct,
|
||||
yesOdds: marketState.yesOdds,
|
||||
noOdds: marketState.noOdds
|
||||
}
|
||||
};
|
||||
|
||||
acct.balance -= cost;
|
||||
|
||||
const list = acct.openPositions.get(trade.ticker) || [];
|
||||
list.push(trade);
|
||||
acct.openPositions.set(trade.ticker, list);
|
||||
|
||||
try {
|
||||
await db.create('paper_positions', trade);
|
||||
await this._saveState(acct);
|
||||
} catch (e) {
|
||||
console.error('[Paper] DB write error:', e.message);
|
||||
}
|
||||
|
||||
const msg = `📝 PAPER [${trade.strategy}] ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.reason}`;
|
||||
console.log(`[Paper] ${msg}`);
|
||||
await notify(msg, `Paper: ${trade.strategy}`, '1');
|
||||
|
||||
return trade;
|
||||
}
|
||||
|
||||
async settle(ticker, rawResult) {
|
||||
const result = String(rawResult || '').toLowerCase();
|
||||
|
||||
if (result !== 'yes' && result !== 'no') {
|
||||
console.warn(`[Paper] Unknown settlement result "${rawResult}" for ${ticker}, skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const allSettled = [];
|
||||
|
||||
for (const [strategyName, acct] of this.accounts) {
|
||||
const positions = acct.openPositions.get(ticker);
|
||||
if (!positions || positions.length === 0) continue;
|
||||
|
||||
console.log(`[Paper:${strategyName}] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
|
||||
|
||||
for (const pos of positions) {
|
||||
const side = String(pos.side || '').toLowerCase();
|
||||
const won = side === result;
|
||||
|
||||
const price = pos.price > 0 ? pos.price : 50;
|
||||
const payout = won ? (100 / price) * pos.cost : 0;
|
||||
const pnl = payout - pos.cost;
|
||||
|
||||
pos.settled = true;
|
||||
pos.result = result;
|
||||
pos.pnl = parseFloat(pnl.toFixed(2));
|
||||
pos.settleTime = Date.now();
|
||||
|
||||
acct.balance += payout;
|
||||
acct.totalPnL += pnl;
|
||||
|
||||
if (won) acct.wins++;
|
||||
else acct.losses++;
|
||||
|
||||
const recordId = this._recordId(pos.id);
|
||||
|
||||
try {
|
||||
const updated = await db.query(
|
||||
`UPDATE ${recordId} SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime`,
|
||||
{ result, pnl: pos.pnl, settleTime: pos.settleTime }
|
||||
);
|
||||
const rows = updated[0] || [];
|
||||
if (rows.length === 0) {
|
||||
// Record vanished from DB — re-insert the full settled trade
|
||||
await db.create('paper_positions', { ...pos, id: this._rawId(pos.id) });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Paper] Settle DB error:', e.message);
|
||||
try {
|
||||
await db.create('paper_positions', { ...pos, id: this._rawId(pos.id) });
|
||||
} catch (e2) {
|
||||
console.error('[Paper] Settle DB fallback error:', e2.message);
|
||||
}
|
||||
}
|
||||
|
||||
const emoji = won ? '✅' : '❌';
|
||||
const msg = `${emoji} [${strategyName}] ${side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Bal: $${acct.balance.toFixed(2)}`;
|
||||
console.log(`[Paper] ${msg}`);
|
||||
await notify(msg, won ? `${strategyName} Win!` : `${strategyName} Loss`, '1');
|
||||
|
||||
allSettled.push(pos);
|
||||
}
|
||||
|
||||
acct.openPositions.delete(ticker);
|
||||
await this._saveState(acct);
|
||||
}
|
||||
|
||||
return allSettled.length > 0 ? allSettled : null;
|
||||
}
|
||||
|
||||
getOpenTickers() {
|
||||
const tickers = new Set();
|
||||
for (const [, acct] of this.accounts) {
|
||||
for (const ticker of acct.openPositions.keys()) {
|
||||
tickers.add(ticker);
|
||||
}
|
||||
}
|
||||
return Array.from(tickers);
|
||||
}
|
||||
|
||||
async checkOrphans(getMarketFn) {
|
||||
const orphanTickers = this.getOpenTickers();
|
||||
if (!orphanTickers.length) return { settled: [], expired: [] };
|
||||
|
||||
const results = { settled: [], expired: [] };
|
||||
|
||||
for (const ticker of orphanTickers) {
|
||||
try {
|
||||
const market = await getMarketFn(ticker);
|
||||
const status = String(market?.status || '').toLowerCase();
|
||||
const result = market?.result;
|
||||
|
||||
if (result) {
|
||||
console.log(`[Paper] Delayed result found for ${ticker}: "${result}"`);
|
||||
const settledPos = await this.settle(ticker, result);
|
||||
if (settledPos) results.settled.push(...settledPos);
|
||||
} else if (['expired', 'cancelled'].includes(status)) {
|
||||
console.log(`[Paper] Ticker ${ticker} marked as ${status} — force-settling as expired`);
|
||||
const expiredPos = await this._forceExpirePositions(ticker);
|
||||
if (expiredPos) results.expired.push(...expiredPos);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Paper] Orphan check failed for ${ticker}:`, e.message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async _forceExpirePositions(ticker) {
|
||||
const expired = [];
|
||||
for (const [strategyName, acct] of this.accounts) {
|
||||
const positions = acct.openPositions.get(ticker);
|
||||
if (!positions || !positions.length) continue;
|
||||
|
||||
for (const pos of positions) {
|
||||
pos.settled = true;
|
||||
pos.result = 'expired';
|
||||
pos.pnl = parseFloat((-pos.cost).toFixed(2));
|
||||
pos.settleTime = Date.now();
|
||||
|
||||
acct.totalPnL -= pos.cost;
|
||||
acct.losses++;
|
||||
|
||||
const recordId = this._recordId(pos.id);
|
||||
|
||||
try {
|
||||
const updated = await db.query(
|
||||
`UPDATE ${recordId} SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime`,
|
||||
{ result: 'expired', pnl: pos.pnl, settleTime: pos.settleTime }
|
||||
);
|
||||
const rows = updated[0] || [];
|
||||
if (rows.length === 0) {
|
||||
await db.create('paper_positions', { ...pos, id: this._rawId(pos.id) });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Paper] Force-expire DB error:', e.message);
|
||||
}
|
||||
|
||||
console.log(`[Paper:${strategyName}] Force-expired position ${pos.id} for ${ticker} (lost $${pos.cost})`);
|
||||
expired.push(pos);
|
||||
}
|
||||
|
||||
acct.openPositions.delete(ticker);
|
||||
await this._saveState(acct);
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
async resetAll() {
|
||||
this._resetting = true;
|
||||
|
||||
for (const [name, acct] of this.accounts) {
|
||||
for (const [ticker, positions] of acct.openPositions) {
|
||||
for (const pos of positions) {
|
||||
acct.balance += pos.cost;
|
||||
pos.settled = true;
|
||||
pos.result = 'cancelled';
|
||||
pos.pnl = 0;
|
||||
pos.settleTime = Date.now();
|
||||
|
||||
try {
|
||||
const recordId = this._recordId(pos.id);
|
||||
await db.query(
|
||||
`UPDATE ${recordId} SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime`,
|
||||
{ result: 'cancelled', pnl: 0, settleTime: pos.settleTime }
|
||||
);
|
||||
} catch (e) {}
|
||||
console.log(`[Paper:${name}] Cancelled open position ${pos.id} for ${ticker} (refunded $${pos.cost})`);
|
||||
}
|
||||
}
|
||||
acct.openPositions.clear();
|
||||
}
|
||||
|
||||
for (const [name, acct] of this.accounts) {
|
||||
acct.balance = acct.initialBalance;
|
||||
acct.totalPnL = 0;
|
||||
acct.wins = 0;
|
||||
acct.losses = 0;
|
||||
acct.openPositions.clear();
|
||||
console.log(`[Paper:${name}] Reset to $${acct.initialBalance}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query('DELETE paper_positions');
|
||||
await db.query('DELETE paper_strategy_state');
|
||||
console.log('[Paper] Cleared all DB records');
|
||||
} catch (e) {
|
||||
console.error('[Paper] Reset DB error:', e.message);
|
||||
}
|
||||
|
||||
for (const [, acct] of this.accounts) {
|
||||
await this._saveState(acct);
|
||||
}
|
||||
|
||||
this._resetting = false;
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const allOpen = [];
|
||||
let totalBalance = 0;
|
||||
let totalPnL = 0;
|
||||
let totalWins = 0;
|
||||
let totalLosses = 0;
|
||||
|
||||
for (const [, acct] of this.accounts) {
|
||||
const s = acct.getStats();
|
||||
totalBalance += s.balance;
|
||||
totalPnL += s.totalPnL;
|
||||
totalWins += s.wins;
|
||||
totalLosses += s.losses;
|
||||
allOpen.push(...s.openPositions);
|
||||
}
|
||||
|
||||
return {
|
||||
balance: parseFloat(totalBalance.toFixed(2)),
|
||||
totalPnL: parseFloat(totalPnL.toFixed(2)),
|
||||
wins: totalWins,
|
||||
losses: totalLosses,
|
||||
winRate: totalWins + totalLosses > 0
|
||||
? parseFloat(((totalWins / (totalWins + totalLosses)) * 100).toFixed(1))
|
||||
: 0,
|
||||
openPositions: allOpen,
|
||||
totalTrades: totalWins + totalLosses
|
||||
};
|
||||
}
|
||||
|
||||
getPerStrategyStats() {
|
||||
const result = {};
|
||||
for (const [name, acct] of this.accounts) {
|
||||
result[name] = acct.getStats();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async _saveState(acct) {
|
||||
try {
|
||||
await db.create('paper_strategy_state', {
|
||||
strategyName: acct.strategyName,
|
||||
balance: acct.balance,
|
||||
totalPnL: acct.totalPnL,
|
||||
wins: acct.wins,
|
||||
losses: acct.losses,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Paper] State save error:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
lib/strategies/base.js
Normal file
44
lib/strategies/base.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Base strategy class. All strategies extend this.
|
||||
*
|
||||
* Strategies receive market state updates and emit trade signals.
|
||||
* 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 {
|
||||
constructor(name, config = {}) {
|
||||
this.name = name;
|
||||
this.config = config;
|
||||
this.enabled = true;
|
||||
this.mode = 'paper';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
evaluate(marketState, caller = 'paper') {
|
||||
throw new Error(`${this.name}: evaluate() not implemented`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a market settles. Useful for strategies that
|
||||
* need to know outcomes (like Martingale).
|
||||
*/
|
||||
onSettlement(result, tradeHistory) {
|
||||
// Override in subclass if needed
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
103
lib/strategies/bull-dip-buyer.js
Normal file
103
lib/strategies/bull-dip-buyer.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { BaseStrategy } from './base.js';
|
||||
|
||||
export class BullDipBuyer extends BaseStrategy {
|
||||
constructor(config = {}) {
|
||||
super('bull-dip-buyer', {
|
||||
maxYesPrice: config.maxYesPrice || 45,
|
||||
minYesPrice: config.minYesPrice || 15,
|
||||
betSize: config.betSize || 1,
|
||||
slippage: config.slippage || 3,
|
||||
cooldownMs: config.cooldownMs || 20000,
|
||||
marketDurationMin: config.marketDurationMin || 15,
|
||||
entryWindowMin: config.entryWindowMin || 6,
|
||||
...config
|
||||
});
|
||||
|
||||
this._lastTrade = {
|
||||
paper: { time: 0, ticker: null },
|
||||
live: { time: 0, ticker: null }
|
||||
};
|
||||
}
|
||||
|
||||
evaluate(state, caller = 'paper') {
|
||||
if (!state || !this.enabled || !state.closeTime) return null;
|
||||
|
||||
const track = this._lastTrade[caller] || this._lastTrade.paper;
|
||||
const now = Date.now();
|
||||
if (now - track.time < this.config.cooldownMs) return null;
|
||||
if (state.ticker === track.ticker) return null;
|
||||
|
||||
const closeTs = new Date(state.closeTime).getTime();
|
||||
const timeLeftMs = closeTs - now;
|
||||
if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return null;
|
||||
|
||||
// Entry gate: only trade in first N minutes of each market cycle.
|
||||
// With 15m markets: first 0-6m elapsed => roughly 15m down to 9m remaining.
|
||||
const elapsedMin = (this.config.marketDurationMin * 60000 - timeLeftMs) / 60000;
|
||||
if (elapsedMin < 0 || elapsedMin > this.config.entryWindowMin) return null;
|
||||
|
||||
const { yesPct } = state;
|
||||
|
||||
if (yesPct <= this.config.maxYesPrice && yesPct >= this.config.minYesPrice) {
|
||||
// For live trading, require orderbook data — refuse to trade blind
|
||||
if (caller === 'live') {
|
||||
const bestAsk = this._getBestYesAsk(state);
|
||||
if (bestAsk == null) return null;
|
||||
|
||||
// Verify the actual ask price is within our dip range + slippage
|
||||
if (bestAsk > this.config.maxYesPrice + this.config.slippage) return null;
|
||||
|
||||
// Don't let slippage push us above 50¢ — that's not a dip
|
||||
if (bestAsk > 50) return null;
|
||||
}
|
||||
|
||||
const maxPrice = Math.min(
|
||||
yesPct + this.config.slippage,
|
||||
this.config.maxYesPrice + this.config.slippage,
|
||||
50
|
||||
);
|
||||
|
||||
const bestAsk = this._getBestYesAsk(state);
|
||||
let reason = `Bullish dip buy (t+${elapsedMin.toFixed(1)}m): Yes @ ${yesPct}¢`;
|
||||
|
||||
if (bestAsk != null) {
|
||||
if (bestAsk > maxPrice) return null;
|
||||
reason = `Bullish dip buy (t+${elapsedMin.toFixed(1)}m): Yes @ ${yesPct}¢ (ask: ${bestAsk}¢)`;
|
||||
}
|
||||
|
||||
const signal = {
|
||||
strategy: this.name,
|
||||
side: 'yes',
|
||||
price: yesPct,
|
||||
maxPrice,
|
||||
size: this.config.betSize,
|
||||
reason,
|
||||
ticker: state.ticker
|
||||
};
|
||||
|
||||
track.time = now;
|
||||
track.ticker = state.ticker;
|
||||
return signal;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_getBestYesAsk(state) {
|
||||
const ob = state.orderbook;
|
||||
if (!ob) return null;
|
||||
|
||||
if (ob.no?.length) {
|
||||
const bestNoBid = ob.no[0]?.[0];
|
||||
if (bestNoBid != null) return 100 - bestNoBid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
lastTradeTicker: this._lastTrade.live.ticker || this._lastTrade.paper.ticker
|
||||
};
|
||||
}
|
||||
}
|
||||
54
lib/strategies/dont-doubt-bull.js
Normal file
54
lib/strategies/dont-doubt-bull.js
Normal file
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
slippage: config.slippage || 3,
|
||||
cooldownMs: config.cooldownMs || 20000,
|
||||
...config
|
||||
});
|
||||
|
||||
this._lastTrade = {
|
||||
paper: { time: 0, ticker: null },
|
||||
live: { time: 0, ticker: null }
|
||||
};
|
||||
}
|
||||
|
||||
evaluate(state, caller = 'paper') {
|
||||
if (!state || !this.enabled || !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 timeLeftMs = new Date(state.closeTime).getTime() - now;
|
||||
const minsLeft = timeLeftMs / 60000;
|
||||
if (minsLeft > 14 || minsLeft < 10) return null;
|
||||
|
||||
const { yesPct } = state;
|
||||
|
||||
if (yesPct >= this.config.minYesPct && yesPct <= this.config.maxYesPct) {
|
||||
const maxPrice = Math.min(yesPct + this.config.slippage, 95);
|
||||
|
||||
const signal = {
|
||||
strategy: this.name,
|
||||
side: 'yes',
|
||||
price: yesPct,
|
||||
maxPrice,
|
||||
size: this.config.betSize,
|
||||
reason: `Early Bullish Dip: ${minsLeft.toFixed(1)}m left, Yes @ ${yesPct}¢`,
|
||||
ticker: state.ticker
|
||||
};
|
||||
|
||||
track.time = now;
|
||||
track.ticker = state.ticker;
|
||||
return signal;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
126
lib/strategies/martingale-alpha.js
Normal file
126
lib/strategies/martingale-alpha.js
Normal file
@@ -0,0 +1,126 @@
|
||||
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,
|
||||
slippage: config.slippage || 3,
|
||||
cooldownMs: config.cooldownMs || 20000,
|
||||
...config
|
||||
});
|
||||
|
||||
this.round = 0;
|
||||
this.currentBetSize = this.config.baseBet;
|
||||
this.cycleWins = 0;
|
||||
this.cycleLosses = 0;
|
||||
this.totalCycles = 0;
|
||||
|
||||
this._lastTrade = {
|
||||
paper: { time: 0, ticker: null },
|
||||
live: { time: 0, ticker: null }
|
||||
};
|
||||
}
|
||||
|
||||
evaluate(state, caller = 'paper') {
|
||||
if (!state || !this.enabled) return null;
|
||||
|
||||
const track = this._lastTrade[caller] || this._lastTrade.paper;
|
||||
const now = Date.now();
|
||||
if (now - track.time < this.config.cooldownMs) return null;
|
||||
if (state.ticker === track.ticker) return null;
|
||||
|
||||
const { yesPct, noPct } = state;
|
||||
const { minPct, maxPct } = this.config;
|
||||
|
||||
if (yesPct < minPct || yesPct > maxPct) return null;
|
||||
if (noPct < minPct || noPct > maxPct) return null;
|
||||
|
||||
const flip = crypto.randomInt(0, 2);
|
||||
const side = flip === 0 ? 'yes' : 'no';
|
||||
const price = side === 'yes' ? yesPct : noPct;
|
||||
const maxPrice = Math.min(price + this.config.slippage, 95);
|
||||
|
||||
const roundIndex = this.round;
|
||||
const betSize = this.config.baseBet * Math.pow(2, roundIndex);
|
||||
|
||||
const signal = {
|
||||
strategy: this.name,
|
||||
side,
|
||||
price,
|
||||
maxPrice,
|
||||
size: betSize,
|
||||
reason: `R${roundIndex + 1}/${this.config.maxRounds} coin-flip ${side.toUpperCase()} @ ${price}¢ ($${betSize}) | Market: ${yesPct}/${noPct}`,
|
||||
ticker: state.ticker
|
||||
};
|
||||
|
||||
track.time = now;
|
||||
track.ticker = 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;
|
||||
// Reset both callers' ticker locks so new cycle can trade same market
|
||||
this._lastTrade.paper.ticker = null;
|
||||
this._lastTrade.live.ticker = null;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
61
lib/strategies/momentum-rider.js
Normal file
61
lib/strategies/momentum-rider.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BaseStrategy } from './base.js';
|
||||
|
||||
export class MomentumRiderStrategy extends BaseStrategy {
|
||||
constructor(config = {}) {
|
||||
super('momentum-rider', {
|
||||
triggerPct: config.triggerPct || 75,
|
||||
betSize: config.betSize || 2,
|
||||
slippage: config.slippage || 3,
|
||||
cooldownMs: config.cooldownMs || 20000,
|
||||
...config
|
||||
});
|
||||
|
||||
this._lastTrade = {
|
||||
paper: { time: 0, ticker: null },
|
||||
live: { time: 0, ticker: null }
|
||||
};
|
||||
}
|
||||
|
||||
evaluate(state, caller = 'paper') {
|
||||
if (!state || !this.enabled) return null;
|
||||
|
||||
const track = this._lastTrade[caller] || this._lastTrade.paper;
|
||||
const now = Date.now();
|
||||
if (now - track.time < this.config.cooldownMs) return null;
|
||||
if (state.ticker === track.ticker) return null;
|
||||
|
||||
const { yesPct, noPct } = state;
|
||||
const trigger = this.config.triggerPct;
|
||||
|
||||
let signal = null;
|
||||
|
||||
if (yesPct >= trigger && yesPct < 99) {
|
||||
signal = {
|
||||
strategy: this.name,
|
||||
side: 'yes',
|
||||
price: yesPct,
|
||||
maxPrice: Math.min(yesPct + this.config.slippage, 95),
|
||||
size: this.config.betSize,
|
||||
reason: `Riding Momentum! Yes is at ${yesPct}%`,
|
||||
ticker: state.ticker
|
||||
};
|
||||
} else if (noPct >= trigger && noPct < 99) {
|
||||
signal = {
|
||||
strategy: this.name,
|
||||
side: 'no',
|
||||
price: noPct,
|
||||
maxPrice: Math.min(noPct + this.config.slippage, 95),
|
||||
size: this.config.betSize,
|
||||
reason: `Riding Momentum! No is at ${noPct}%`,
|
||||
ticker: state.ticker
|
||||
};
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
track.time = now;
|
||||
track.ticker = state.ticker;
|
||||
}
|
||||
|
||||
return signal;
|
||||
}
|
||||
}
|
||||
30
middleware.js
Normal file
30
middleware.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifySession } from './lib/auth';
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/dashboard/:path*',
|
||||
'/paper/:path*',
|
||||
'/dash/:path*',
|
||||
'/api/state',
|
||||
'/api/trades',
|
||||
'/api/reset',
|
||||
'/api/live-state',
|
||||
'/api/live-trades',
|
||||
'/api/live-toggle',
|
||||
],
|
||||
};
|
||||
|
||||
export async function middleware(req) {
|
||||
const token = req.cookies.get('kalbot_session')?.value;
|
||||
const isValid = await verifySession(token);
|
||||
|
||||
if (!isValid) {
|
||||
if (req.nextUrl.pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: 'Unauthorized. Nice try!' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.redirect(new URL('/', req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone'
|
||||
output: 'standalone',
|
||||
serverExternalPackages: ['ws', 'surrealdb']
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
22
package.json
22
package.json
@@ -2,23 +2,27 @@
|
||||
"name": "kalbot",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3004",
|
||||
"dev:worker": "node worker.js",
|
||||
"build": "next build",
|
||||
"start": "next start -p $PORT",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"trek-captcha": "^0.4.0"
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"surrealdb": "^1.0.0",
|
||||
"trek-captcha": "^0.4.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.3"
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
24
readme
24
readme
@@ -1,5 +1,29 @@
|
||||
Kalshi bot @ kal.planetrenox.com
|
||||
JavaScript
|
||||
Next.js
|
||||
surrealdb:v2.3.10
|
||||
Dokploy
|
||||
ntfy
|
||||
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
|
||||
|
||||
307
worker.js
Normal file
307
worker.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { MarketTracker } from './lib/market/tracker.js';
|
||||
import { PaperEngine } from './lib/paper/engine.js';
|
||||
import { LiveEngine } from './lib/live/engine.js';
|
||||
import { getMarket } from './lib/kalshi/rest.js';
|
||||
import { db } from './lib/db.js';
|
||||
import { notify } from './lib/notify.js';
|
||||
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 LIVE_STATE_FILE = '/tmp/kalbot-live-state.json';
|
||||
const HEARTBEAT_MS = 2000;
|
||||
const BALANCE_POLL_MS = 30000;
|
||||
|
||||
let isSettling = false;
|
||||
async function lockSettling() {
|
||||
while (isSettling) await new Promise((r) => setTimeout(r, 50));
|
||||
isSettling = true;
|
||||
}
|
||||
function unlockSettling() {
|
||||
isSettling = false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Kalbot Worker Starting ===');
|
||||
|
||||
await db.connect();
|
||||
|
||||
const paper = new PaperEngine(1000);
|
||||
await paper.init();
|
||||
|
||||
const live = new LiveEngine();
|
||||
await live.init();
|
||||
|
||||
// Dynamically load all strategies
|
||||
const strategies = [];
|
||||
const strategiesDir = path.join(__dirname, 'lib', 'strategies');
|
||||
const stratFiles = fs.readdirSync(strategiesDir).filter((f) => f.endsWith('.js') && f !== 'base.js');
|
||||
|
||||
for (const file of stratFiles) {
|
||||
try {
|
||||
const fileUrl = pathToFileURL(path.join(strategiesDir, file)).href;
|
||||
const mod = await import(fileUrl);
|
||||
for (const key in mod) {
|
||||
if (typeof mod[key] === 'function') {
|
||||
strategies.push(new mod[key]());
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Worker] Failed to load strategy ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of strategies) {
|
||||
paper._getAccount(s.name);
|
||||
}
|
||||
|
||||
console.log(`[Worker] Loaded ${strategies.length} strategies: ${strategies.map((s) => s.name).join(', ')}`);
|
||||
|
||||
let latestMarketState = null;
|
||||
|
||||
async function pollBalance() {
|
||||
try {
|
||||
await live.fetchBalance();
|
||||
} catch {}
|
||||
try {
|
||||
await live.fetchPositions();
|
||||
} catch {}
|
||||
}
|
||||
await pollBalance();
|
||||
setInterval(pollBalance, BALANCE_POLL_MS);
|
||||
|
||||
async function processOrphans() {
|
||||
if (paper._resetting) return;
|
||||
await lockSettling();
|
||||
try {
|
||||
const { settled, expired } = await paper.checkOrphans(getMarket);
|
||||
const settledLive = await live.checkOrphans(getMarket);
|
||||
|
||||
const allResolved = [...settled, ...expired, ...settledLive];
|
||||
|
||||
if (allResolved.length > 0) {
|
||||
for (const strategy of strategies) {
|
||||
for (const trade of allResolved) {
|
||||
if (trade.strategy === strategy.name) {
|
||||
strategy.onSettlement(trade.result, trade);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Worker] Orphan check error:', e.message);
|
||||
} finally {
|
||||
unlockSettling();
|
||||
}
|
||||
}
|
||||
|
||||
await processOrphans();
|
||||
setInterval(processOrphans, 10000);
|
||||
|
||||
const tracker = new MarketTracker();
|
||||
let heartbeatTimer = null;
|
||||
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
|
||||
tracker.on('update', async (state) => {
|
||||
latestMarketState = state || null;
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
|
||||
if (!state || paper._resetting || isSettling) return;
|
||||
|
||||
for (const strategy of strategies) {
|
||||
if (!strategy.enabled) continue;
|
||||
|
||||
// ===== PAPER TRADING (isolated evaluation) =====
|
||||
const paperAcct = paper._getAccount(strategy.name);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LIVE TRADING (separate evaluation, won't poison paper) =====
|
||||
if (live.isStrategyEnabled(strategy.name) && !live.hasOpenPositionForStrategy(strategy.name)) {
|
||||
const liveSignal = strategy.evaluate(state, 'live');
|
||||
if (liveSignal) {
|
||||
console.log(
|
||||
`[Worker] LIVE signal from ${strategy.name}: ${liveSignal.side} @ ${liveSignal.price}¢ (max: ${liveSignal.maxPrice}¢)`
|
||||
);
|
||||
await live.executeTrade(liveSignal, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
});
|
||||
|
||||
tracker.on('settled', async ({ ticker, result }) => {
|
||||
console.log(`[Worker] Market ${ticker} settled. Result: ${result || 'pending'}`);
|
||||
|
||||
if (paper._resetting) return;
|
||||
|
||||
if (result) {
|
||||
await lockSettling();
|
||||
try {
|
||||
const settledPaper = await paper.settle(ticker, result);
|
||||
if (settledPaper) {
|
||||
for (const strategy of strategies) {
|
||||
for (const trade of settledPaper) {
|
||||
strategy.onSettlement(trade.result, trade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const settledLive = await live.settle(ticker, result);
|
||||
if (settledLive) {
|
||||
for (const strategy of strategies) {
|
||||
for (const trade of settledLive) {
|
||||
strategy.onSettlement(trade.result, trade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settledPaper || settledLive) {
|
||||
await notify(
|
||||
`Market ${ticker} settled: ${result.toUpperCase()}`,
|
||||
'Market Settled',
|
||||
'default',
|
||||
'chart_with_upwards_trend'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
unlockSettling();
|
||||
}
|
||||
} else {
|
||||
// Market rotated but result not posted yet. Recover via orphan reconciliation shortly.
|
||||
setTimeout(() => {
|
||||
processOrphans().catch((e) => console.error('[Worker] delayed orphan reconcile failed:', e.message));
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
});
|
||||
|
||||
await tracker.start();
|
||||
await notify(`🤖 Kalbot Worker started with ${strategies.length} strats!`, 'Kalbot Online', 'low', 'robot,green_circle');
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
// Poll for paper reset flag
|
||||
setInterval(async () => {
|
||||
try {
|
||||
if (fs.existsSync('/tmp/kalbot-reset-flag')) {
|
||||
fs.unlinkSync('/tmp/kalbot-reset-flag');
|
||||
console.log('[Worker] Reset flag detected — resetting all paper data');
|
||||
await lockSettling();
|
||||
try {
|
||||
await paper.resetAll();
|
||||
for (const s of strategies) {
|
||||
if (s.consecutiveLosses !== undefined) s.consecutiveLosses = 0;
|
||||
if (s.currentBetSize !== undefined) s.currentBetSize = s.config.baseBet;
|
||||
if (s.round !== undefined) s.round = 0;
|
||||
if (s.cycleWins !== undefined) s.cycleWins = 0;
|
||||
if (s.cycleLosses !== undefined) s.cycleLosses = 0;
|
||||
if (s.totalCycles !== undefined) s.totalCycles = 0;
|
||||
if (s._lastTrade) {
|
||||
s._lastTrade.paper = { time: 0, ticker: null };
|
||||
s._lastTrade.live = { time: 0, ticker: null };
|
||||
}
|
||||
}
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
await notify('🔄 Paper trading reset by admin', 'Kalbot Reset', 'default', 'recycle');
|
||||
} finally {
|
||||
unlockSettling();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, 1000);
|
||||
|
||||
// Poll for live commands (enable/disable/pause/resume)
|
||||
setInterval(async () => {
|
||||
try {
|
||||
if (fs.existsSync('/tmp/kalbot-live-cmd')) {
|
||||
const raw = fs.readFileSync('/tmp/kalbot-live-cmd', 'utf-8');
|
||||
fs.unlinkSync('/tmp/kalbot-live-cmd');
|
||||
const cmd = JSON.parse(raw);
|
||||
|
||||
switch (cmd.action) {
|
||||
case 'enable':
|
||||
if (cmd.strategy) {
|
||||
live.enableStrategy(cmd.strategy);
|
||||
await notify(`⚡ Strategy "${cmd.strategy}" ENABLED for live trading`, 'Live Enable', 'high', 'zap');
|
||||
}
|
||||
break;
|
||||
case 'disable':
|
||||
if (cmd.strategy) {
|
||||
live.disableStrategy(cmd.strategy);
|
||||
await notify(`🔴 Strategy "${cmd.strategy}" DISABLED`, 'Live Disable', 'default', 'red_circle');
|
||||
}
|
||||
break;
|
||||
case 'pause':
|
||||
live.pause();
|
||||
await notify('⏸ Live trading PAUSED by admin', 'Live Paused', 'urgent', 'double_vertical_bar');
|
||||
break;
|
||||
case 'resume':
|
||||
live.resume();
|
||||
await notify('▶️ Live trading RESUMED by admin', 'Live Resumed', 'high', 'arrow_forward');
|
||||
break;
|
||||
}
|
||||
|
||||
writeState(latestMarketState, paper, live, strategies);
|
||||
}
|
||||
} catch {}
|
||||
}, 500);
|
||||
|
||||
console.log('[Worker] Running. Press Ctrl+C to stop.');
|
||||
|
||||
const shutdown = async (signal) => {
|
||||
console.log(`\n[Worker] ${signal} received. Shutting down...`);
|
||||
clearInterval(heartbeatTimer);
|
||||
tracker.stop();
|
||||
await notify('🔴 Kalbot Worker stopped', 'Kalbot Offline', 'high', 'robot,red_circle');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
function writeState(marketState, paper, live, strategies) {
|
||||
const paperData = {
|
||||
market: marketState ? { ...marketState, orderbook: undefined } : null,
|
||||
paper: paper.getStats(),
|
||||
paperByStrategy: paper.getPerStrategyStats(),
|
||||
strategies: strategies.map((s) => s.toJSON()),
|
||||
workerUptime: process.uptime(),
|
||||
lastUpdate: Date.now()
|
||||
};
|
||||
|
||||
const liveData = {
|
||||
market: marketState ? { ...marketState, orderbook: undefined } : null,
|
||||
live: live.getStats(),
|
||||
strategies: strategies.map((s) => s.toJSON()),
|
||||
workerUptime: process.uptime(),
|
||||
lastUpdate: Date.now()
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(paperData));
|
||||
} catch {}
|
||||
try {
|
||||
fs.writeFileSync(LIVE_STATE_FILE, JSON.stringify(liveData));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[Worker] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user