Compare commits

..

20 Commits

Author SHA1 Message Date
de38920499 Feat: Externalize ws and surrealdb from Next.js bundle 2026-03-15 13:09:47 -07:00
ae8761ada2 Feat: Add ws + surrealdb dependencies 2026-03-15 13:09:42 -07:00
b44bd4b9d1 Feat: Add worker process + ws dependency to Docker 2026-03-15 13:09:37 -07:00
7e1b133344 Feat: Entrypoint runs both Next.js and worker process 2026-03-15 13:09:33 -07:00
79d465eb88 Feat: Mobile-first dashboard for market + paper trading 2026-03-15 13:09:26 -07:00
a957793be3 Feat: API route for paper trade history 2026-03-15 13:09:22 -07:00
1ccba9eef1 Feat: API route to expose worker state to frontend 2026-03-15 13:09:17 -07:00
2924ff6098 Feat: Background worker — market tracking + paper trading 2026-03-15 13:09:12 -07:00
13904dc641 Feat: ntfy notification helper 2026-03-15 13:09:08 -07:00
95fb54dd5a Feat: SurrealDB client singleton 2026-03-15 13:09:03 -07:00
8c0b750085 Feat: Paper trading engine with virtual balance 2026-03-15 13:08:56 -07:00
0019f088c4 Feat: Threshold strategy — contrarian bet at extremes 2026-03-15 13:08:51 -07:00
491465dbde Feat: Martingale strategy — bets against 70%+ side 2026-03-15 13:08:46 -07:00
72d313f286 Feat: Base strategy interface for all strategies 2026-03-15 13:08:42 -07:00
d1683eaa11 Feat: Market tracker auto-rotates BTC 15m markets 2026-03-15 13:08:36 -07:00
807e065436 Feat: Kalshi WebSocket client for live orderbook 2026-03-15 13:08:30 -07:00
0599b05ffe Feat: Kalshi REST client for markets and orderbook 2026-03-15 13:08:25 -07:00
d8c6bfe24f Feat: Kalshi RSA auth signing for API requests 2026-03-15 13:08:21 -07:00
6c395b7c30 Update readme 2026-03-15 12:28:50 -07:00
eb9cc8e46b Update .env.example 2026-03-15 12:23:05 -07:00
20 changed files with 1471 additions and 5 deletions

View File

@@ -3,5 +3,8 @@ ADMIN_PASS=super_secure_password_meow
NTFY_URL=https://ntfy.sh/my_secret_kalbot_topic NTFY_URL=https://ntfy.sh/my_secret_kalbot_topic
CAPTCHA_SECRET=change_me_to_a_random_string_in_dokploy CAPTCHA_SECRET=change_me_to_a_random_string_in_dokploy
PORT=3004 PORT=3004
SURREAL_URL=
SURREAL_USER=
SURREAL_PASS=
KALSHI_API_KEY_ID=your-key-id-here KALSHI_API_KEY_ID=your-key-id-here
KALSHI_RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nPASTE_YOUR_FULL_KEY_HERE\n-----END RSA PRIVATE KEY-----" KALSHI_RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nPASTE_YOUR_FULL_KEY_HERE\n-----END RSA PRIVATE KEY-----"

View File

@@ -18,7 +18,6 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3004 ENV PORT=3004
# Next.js standalone requires libc6-compat on alpine
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@@ -27,9 +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/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 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 USER nextjs
EXPOSE 3004 EXPOSE 3004
# Run the Next.js server stably with Node CMD ["./entrypoint.sh"]
CMD ["node", "server.js"]

23
app/api/state/route.js Normal file
View File

@@ -0,0 +1,23 @@
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 },
strategies: [],
workerUptime: 0,
lastUpdate: null,
error: 'Worker not running or no data yet'
});
}
}

25
app/api/trades/route.js Normal file
View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import Surreal from 'surrealdb';
export const dynamic = 'force-dynamic';
export async function GET() {
const url = process.env.SURREAL_URL;
if (!url) {
return NextResponse.json({ trades: [], error: 'No DB configured' });
}
try {
const 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' });
const result = await client.query('SELECT * FROM paper_positions ORDER BY entryTime DESC LIMIT 50');
const trades = result[0] || [];
return NextResponse.json({ trades });
} catch (e) {
return NextResponse.json({ trades: [], error: e.message });
}
}

360
app/dashboard/page.js Normal file
View File

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

19
entrypoint.sh Normal file
View 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

66
lib/db.js Normal file
View File

@@ -0,0 +1,66 @@
import Surreal from 'surrealdb';
class Database {
constructor() {
this.client = null;
this.connected = false;
}
async connect() {
if (this.connected) return;
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 {
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;
}
}
async query(sql, vars = {}) {
if (!this.connected) return [[]];
try {
return await this.client.query(sql, vars);
} catch (e) {
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) {
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) {
console.error('[DB] Select error:', e.message);
return [];
}
}
}
export const db = new Database();

34
lib/kalshi/auth.js Normal file
View File

@@ -0,0 +1,34 @@
import crypto from 'crypto';
const KALSHI_API_BASE = 'https://api.elections.kalshi.com';
/**
* 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;
const privateKeyPem = process.env.KALSHI_RSA_PRIVATE_KEY?.replace(/\\n/g, '\n');
if (!keyId || !privateKeyPem) {
throw new Error('Missing KALSHI_API_KEY_ID or KALSHI_RSA_PRIVATE_KEY');
}
const ts = String(timestampMs);
const message = `${ts}${method.toUpperCase()}${path}`;
const signature = crypto.sign('sha256', Buffer.from(message), {
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 };

65
lib/kalshi/rest.js Normal file
View File

@@ -0,0 +1,65 @@
import { signRequest, KALSHI_API_BASE } from './auth.js';
async function kalshiFetch(method, path, body = null) {
const headers = signRequest(method, path);
const opts = { method, headers };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${KALSHI_API_BASE}${path}`, opts);
if (!res.ok) {
const text = await res.text();
throw new Error(`Kalshi API ${method} ${path}${res.status}: ${text}`);
}
return res.json();
}
/**
* Get events for the BTC 15-min series.
* Returns the currently active event + its markets.
*/
export async function getActiveBTCEvent() {
const data = await kalshiFetch('GET', '/trade-api/v2/events?series_ticker=KXBTC15M&status=open&limit=1');
const event = data.events?.[0];
if (!event) return null;
return event;
}
/**
* Get markets for a specific event ticker.
*/
export async function getEventMarkets(eventTicker) {
const data = await kalshiFetch('GET', `/trade-api/v2/events/${eventTicker}`);
return data.event?.markets || [];
}
/**
* Get orderbook for a specific market ticker.
*/
export async function getOrderbook(ticker) {
const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}/orderbook`);
return data.orderbook || data;
}
/**
* Get single market details.
*/
export async function getMarket(ticker) {
const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}`);
return data.market || data;
}
/**
* Place a real order on Kalshi. NOT used in paper mode.
*/
export async function placeOrder(params) {
return kalshiFetch('POST', '/trade-api/v2/portfolio/orders', params);
}
/**
* Get wallet balance.
*/
export async function getBalance() {
return kalshiFetch('GET', '/trade-api/v2/portfolio/balance');
}
export { kalshiFetch };

119
lib/kalshi/websocket.js Normal file
View File

@@ -0,0 +1,119 @@
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';
export class KalshiWS extends EventEmitter {
constructor() {
super();
this.ws = null;
this.subscribedTickers = new Set();
this.alive = false;
this.reconnectTimer = null;
this.pingInterval = null;
}
connect() {
if (this.ws) this.disconnect();
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();
// Resubscribe to any tickers we were watching
for (const ticker of this.subscribedTickers) {
this._sendSubscribe(ticker);
}
this.emit('connected');
});
this.ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
this._handleMessage(msg);
} catch (e) {
console.error('[WS] Parse error:', e.message);
}
});
this.ws.on('close', (code) => {
console.log(`[WS] Disconnected (code: ${code}). Reconnecting in 3s...`);
this.alive = false;
this._stopPing();
this._scheduleReconnect();
});
this.ws.on('error', (err) => {
console.error('[WS] Error:', err.message);
});
}
subscribeTicker(ticker) {
this.subscribedTickers.add(ticker);
if (this.alive) this._sendSubscribe(ticker);
}
unsubscribeTicker(ticker) {
this.subscribedTickers.delete(ticker);
if (this.alive) {
this.ws.send(JSON.stringify({
id: Date.now(),
cmd: 'unsubscribe',
params: { channels: ['orderbook_delta', 'ticker'], market_tickers: [ticker] }
}));
}
}
disconnect() {
this._stopPing();
clearTimeout(this.reconnectTimer);
if (this.ws) {
this.ws.removeAllListeners();
this.ws.close();
this.ws = null;
}
this.alive = false;
}
_sendSubscribe(ticker) {
this.ws.send(JSON.stringify({
id: Date.now(),
cmd: 'subscribe',
params: { channels: ['orderbook_delta', 'ticker'], market_tickers: [ticker] }
}));
}
_handleMessage(msg) {
const { type } = msg;
if (type === 'orderbook_snapshot' || type === 'orderbook_delta') {
this.emit('orderbook', msg);
} else if (type === 'ticker') {
this.emit('ticker', msg);
} else if (type === 'subscribed') {
console.log(`[WS] Subscribed to: ${msg.msg?.channels || 'unknown'}`);
}
}
_startPing() {
this.pingInterval = setInterval(() => {
if (this.alive && this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
}
}, 15000);
}
_stopPing() {
clearInterval(this.pingInterval);
}
_scheduleReconnect() {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
}
}

197
lib/market/tracker.js Normal file
View File

@@ -0,0 +1,197 @@
import { getActiveBTCEvent, getEventMarkets, getOrderbook, getMarket } from '../kalshi/rest.js';
import { KalshiWS } from '../kalshi/websocket.js';
import { EventEmitter } from 'events';
/**
* 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...');
// Connect WebSocket
this.ws.connect();
this.ws.on('orderbook', (msg) => this._onOrderbook(msg));
this.ws.on('ticker', (msg) => this._onTicker(msg));
// Initial market discovery
await this._findAndSubscribe();
// Check for market rotation every 30 seconds
this.rotateInterval = setInterval(() => this._checkRotation(), 30000);
}
stop() {
clearInterval(this.rotateInterval);
this.ws.disconnect();
}
getState() {
if (!this.marketData) return null;
const yesAsk = this.orderbook.yes?.[0]?.[0] || this.marketData.yes_ask;
const noAsk = this.orderbook.no?.[0]?.[0] || this.marketData.no_ask;
// Prices on Kalshi are in cents (1-99)
const yesPct = yesAsk || 50;
const noPct = noAsk || 50;
// Odds = 100 / price
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.marketData.yes_bid,
yesAsk: this.marketData.yes_ask,
noBid: this.marketData.no_bid,
noAsk: this.marketData.no_ask,
volume: this.marketData.volume || 0,
volume24h: this.marketData.volume_24h || 0,
openInterest: this.marketData.open_interest || 0,
lastPrice: this.marketData.last_price,
closeTime: this.marketData.close_time || this.marketData.expiration_time,
status: this.marketData.status,
result: this.marketData.result,
timestamp: Date.now()
};
}
async _findAndSubscribe() {
try {
const event = await getActiveBTCEvent();
if (!event) {
console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...');
return;
}
const markets = event.markets || await getEventMarkets(event.event_ticker);
// Find the up/down market (usually only one market per event)
const market = markets.find(m => m.status === 'active' || m.status === 'open') || markets[0];
if (!market) {
console.log('[Tracker] No active market in event. Retrying...');
return;
}
const newTicker = market.ticker;
if (newTicker === this.currentTicker) return;
// Unsubscribe from old
if (this.currentTicker) {
console.log(`[Tracker] Rotating from ${this.currentTicker}${newTicker}`);
this.ws.unsubscribeTicker(this.currentTicker);
}
this.currentTicker = newTicker;
this.currentEvent = event.event_ticker;
this.marketData = market;
this.orderbook = { yes: [], no: [] };
// Fetch fresh orderbook via REST
try {
const ob = await getOrderbook(newTicker);
this.orderbook = ob;
} catch (e) {
console.error('[Tracker] Orderbook fetch error:', e.message);
}
// Subscribe via WS
this.ws.subscribeTicker(newTicker);
console.log(`[Tracker] Now tracking: ${newTicker} (${market.title || market.subtitle})`);
this.emit('update', this.getState());
this.emit('market-rotated', { from: this.currentTicker, to: newTicker });
} catch (err) {
console.error('[Tracker] Discovery error:', err.message);
}
}
async _checkRotation() {
// Refresh market data via REST
if (this.currentTicker) {
try {
const fresh = await getMarket(this.currentTicker);
this.marketData = fresh;
const state = this.getState();
this.emit('update', state);
// If market closed/settled, find the next one
if (fresh.status === 'closed' || fresh.status === 'settled' || 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 = { yes: msg.yes || [], no: msg.no || [] };
} else if (msg.type === 'orderbook_delta') {
// Apply delta updates
if (msg.yes) this.orderbook.yes = this._applyDelta(this.orderbook.yes, msg.yes);
if (msg.no) this.orderbook.no = this._applyDelta(this.orderbook.no, msg.no);
}
this.emit('update', this.getState());
}
_onTicker(msg) {
if (msg.market_ticker !== this.currentTicker) return;
// Merge ticker data into marketData
if (this.marketData) {
Object.assign(this.marketData, {
yes_bid: msg.yes_bid ?? this.marketData.yes_bid,
yes_ask: msg.yes_ask ?? this.marketData.yes_ask,
no_bid: msg.no_bid ?? this.marketData.no_bid,
no_ask: msg.no_ask ?? this.marketData.no_ask,
last_price: msg.last_price ?? this.marketData.last_price,
volume: msg.volume ?? this.marketData.volume
});
}
this.emit('update', this.getState());
}
_applyDelta(book, deltas) {
const map = new Map(book);
for (const [price, qty] of deltas) {
if (qty === 0) map.delete(price);
else map.set(price, qty);
}
return [...map.entries()].sort((a, b) => a[0] - b[0]);
}
}

21
lib/notify.js Normal file
View 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);
}
}

181
lib/paper/engine.js Normal file
View File

@@ -0,0 +1,181 @@
import { db } from '../db.js';
import { notify } from '../notify.js';
/**
* Paper Trading Engine.
* Executes virtual trades, tracks PnL, stores in SurrealDB.
*/
export class PaperEngine {
constructor(initialBalance = 1000) {
this.balance = initialBalance;
this.openPositions = new Map(); // ticker -> [positions]
this.tradeHistory = [];
this.totalPnL = 0;
this.wins = 0;
this.losses = 0;
}
async init() {
// Load state from SurrealDB
try {
const state = await db.query('SELECT * FROM paper_state ORDER BY timestamp DESC LIMIT 1');
const saved = state[0]?.[0];
if (saved) {
this.balance = saved.balance;
this.totalPnL = saved.totalPnL;
this.wins = saved.wins;
this.losses = saved.losses;
console.log(`[Paper] Restored state: $${this.balance.toFixed(2)} balance, ${this.wins}W/${this.losses}L`);
}
// Load open positions
const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false');
if (positions[0]) {
for (const pos of positions[0]) {
const list = this.openPositions.get(pos.ticker) || [];
list.push(pos);
this.openPositions.set(pos.ticker, list);
}
console.log(`[Paper] Restored ${this.openPositions.size} open position(s)`);
}
} catch (e) {
console.error('[Paper] Init error (fresh start):', e.message);
}
}
/**
* Execute a paper trade from a strategy signal.
*/
async executeTrade(signal, marketState) {
const cost = signal.size; // Each contract costs signal.price cents, but we simplify: $1 per contract unit
if (this.balance < cost) {
console.log(`[Paper] Insufficient balance ($${this.balance.toFixed(2)}) for $${cost} trade`);
return null;
}
const trade = {
id: `pt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
strategy: signal.strategy,
ticker: signal.ticker,
side: signal.side,
price: signal.price, // Entry price in cents
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
}
};
this.balance -= cost;
const list = this.openPositions.get(trade.ticker) || [];
list.push(trade);
this.openPositions.set(trade.ticker, list);
// Store in SurrealDB
try {
await db.create('paper_positions', trade);
await this._saveState();
} catch (e) {
console.error('[Paper] DB write error:', e.message);
}
const msg = `📝 PAPER ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.strategy} | ${trade.reason}`;
console.log(`[Paper] ${msg}`);
await notify(msg, 'Paper Trade');
return trade;
}
/**
* Settle all positions for a ticker when the market resolves.
*/
async settle(ticker, result) {
const positions = this.openPositions.get(ticker);
if (!positions || positions.length === 0) return;
console.log(`[Paper] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
for (const pos of positions) {
const won = pos.side === result;
// Payout: if won, pay out at $1 per contract (100¢), minus cost
// If lost, lose the cost
const payout = won ? (100 / pos.price) * pos.cost : 0;
const pnl = payout - pos.cost;
pos.settled = true;
pos.result = result;
pos.pnl = parseFloat(pnl.toFixed(2));
pos.settleTime = Date.now();
this.balance += payout;
this.totalPnL += pnl;
if (won) this.wins++;
else this.losses++;
// Update in SurrealDB
try {
await db.query(`UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`, {
id: pos.id,
result,
pnl: pos.pnl,
settleTime: pos.settleTime
});
} catch (e) {
console.error('[Paper] Settle DB error:', e.message);
}
const emoji = won ? '✅' : '❌';
const msg = `${emoji} ${pos.strategy} ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Balance: $${this.balance.toFixed(2)}`;
console.log(`[Paper] ${msg}`);
await notify(msg, won ? 'Paper Win!' : 'Paper Loss');
}
this.openPositions.delete(ticker);
await this._saveState();
return positions;
}
getStats() {
const openPositionsList = [];
for (const [ticker, positions] of this.openPositions) {
openPositionsList.push(...positions);
}
return {
balance: parseFloat(this.balance.toFixed(2)),
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
};
}
async _saveState() {
try {
await db.create('paper_state', {
balance: this.balance,
totalPnL: this.totalPnL,
wins: this.wins,
losses: this.losses,
timestamp: Date.now()
});
} catch (e) {
console.error('[Paper] State save error:', e.message);
}
}
}

39
lib/strategies/base.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* Base strategy class. All strategies extend this.
*
* Strategies receive market state updates and emit trade signals.
* Signals are { side: 'yes'|'no', price: number, size: number, reason: string }
*/
export class BaseStrategy {
constructor(name, config = {}) {
this.name = name;
this.config = config;
this.enabled = true;
this.mode = 'paper'; // 'paper' | 'live'
}
/**
* Called on every market state update.
* Return a signal object or null.
*/
evaluate(marketState) {
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
};
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone' output: 'standalone',
serverExternalPackages: ['ws', 'surrealdb']
}; };
export default nextConfig; export default nextConfig;

View File

@@ -2,8 +2,10 @@
"name": "kalbot", "name": "kalbot",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev -p 3004", "dev": "next dev -p 3004",
"dev:worker": "node worker.js",
"build": "next build", "build": "next build",
"start": "next start -p $PORT", "start": "next start -p $PORT",
"lint": "next lint" "lint": "next lint"
@@ -12,7 +14,9 @@
"next": "^15.0.0", "next": "^15.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"trek-captcha": "^0.4.0" "surrealdb": "^1.0.0",
"trek-captcha": "^0.4.0",
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",

1
readme
View File

@@ -1,6 +1,7 @@
Kalshi bot @ kal.planetrenox.com Kalshi bot @ kal.planetrenox.com
JavaScript JavaScript
Next.js Next.js
surrealdb:v2.3.10
Dokploy Dokploy
ntfy ntfy
kxbtc15m/bitcoin-price-up-down kxbtc15m/bitcoin-price-up-down

121
worker.js Normal file
View File

@@ -0,0 +1,121 @@
import { MarketTracker } from './lib/market/tracker.js';
import { PaperEngine } from './lib/paper/engine.js';
import { MartingaleStrategy } from './lib/strategies/martingale.js';
import { ThresholdStrategy } from './lib/strategies/threshold.js';
import { db } from './lib/db.js';
import { notify } from './lib/notify.js';
import fs from 'fs';
// Shared state file for the Next.js frontend to read
const STATE_FILE = '/tmp/kalbot-state.json';
async function main() {
console.log('=== Kalbot Worker Starting ===');
// Connect to SurrealDB
await db.connect();
// Initialize paper engine
const paper = new PaperEngine(1000);
await paper.init();
// Initialize strategies
const strategies = [
new MartingaleStrategy({ threshold: 70, baseBet: 1, maxDoublings: 5 }),
new ThresholdStrategy({ triggerPct: 65, betSize: 1 })
];
console.log(`[Worker] Loaded ${strategies.length} strategies: ${strategies.map(s => s.name).join(', ')}`);
// Initialize market tracker
const tracker = new MarketTracker();
// On every market update, run strategies
tracker.on('update', async (state) => {
if (!state) return;
// Write state to file for frontend
writeState(state, paper, strategies);
// Run each strategy
for (const strategy of strategies) {
if (!strategy.enabled) continue;
const signal = strategy.evaluate(state);
if (signal) {
console.log(`[Worker] Signal from ${strategy.name}: ${signal.side} @ ${signal.price}¢ — ${signal.reason}`);
if (strategy.mode === 'paper') {
await paper.executeTrade(signal, state);
}
// TODO: Live mode — use placeOrder() from rest.js
}
}
// Update state file after potential trades
writeState(state, paper, strategies);
});
// On market settlement, settle paper positions and notify strategies
tracker.on('settled', async ({ ticker, result }) => {
console.log(`[Worker] Market ${ticker} settled: ${result}`);
const settledPositions = await paper.settle(ticker, result);
// Notify strategies about settlement
for (const strategy of strategies) {
if (settledPositions) {
for (const trade of settledPositions) {
strategy.onSettlement(result, trade);
}
}
}
await notify(
`Market ${ticker} settled: ${result?.toUpperCase() || 'unknown'}`,
'Market Settled',
'default',
'chart_with_upwards_trend'
);
});
// Start tracking
await tracker.start();
await notify('🤖 Kalbot Worker started!', 'Kalbot Online', 'low', 'robot,green_circle');
console.log('[Worker] Running. Press Ctrl+C to stop.');
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n[Worker] Shutting down...');
tracker.stop();
await notify('🔴 Kalbot Worker stopped', 'Kalbot Offline', 'high', 'robot,red_circle');
process.exit(0);
});
process.on('SIGTERM', async () => {
tracker.stop();
process.exit(0);
});
}
function writeState(marketState, paper, strategies) {
const data = {
market: marketState,
paper: paper.getStats(),
strategies: strategies.map(s => s.toJSON()),
workerUptime: process.uptime(),
lastUpdate: Date.now()
};
try {
fs.writeFileSync(STATE_FILE, JSON.stringify(data));
} catch (e) {
// Non-critical
}
}
main().catch((err) => {
console.error('[Worker] Fatal error:', err);
process.exit(1);
});