mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-16 21:41:02 +00:00
Compare commits
20 Commits
62f9f780fd
...
de38920499
| Author | SHA1 | Date | |
|---|---|---|---|
| de38920499 | |||
| ae8761ada2 | |||
| b44bd4b9d1 | |||
| 7e1b133344 | |||
| 79d465eb88 | |||
| a957793be3 | |||
| 1ccba9eef1 | |||
| 2924ff6098 | |||
| 13904dc641 | |||
| 95fb54dd5a | |||
| 8c0b750085 | |||
| 0019f088c4 | |||
| 491465dbde | |||
| 72d313f286 | |||
| d1683eaa11 | |||
| 807e065436 | |||
| 0599b05ffe | |||
| d8c6bfe24f | |||
| 6c395b7c30 | |||
| eb9cc8e46b |
@@ -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-----"
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -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
23
app/api/state/route.js
Normal 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
25
app/api/trades/route.js
Normal 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
360
app/dashboard/page.js
Normal 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
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
|
||||||
66
lib/db.js
Normal file
66
lib/db.js
Normal 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
34
lib/kalshi/auth.js
Normal 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
65
lib/kalshi/rest.js
Normal 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
119
lib/kalshi/websocket.js
Normal 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
197
lib/market/tracker.js
Normal 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
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
lib/paper/engine.js
Normal file
181
lib/paper/engine.js
Normal 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
39
lib/strategies/base.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/strategies/martingale.js
Normal file
109
lib/strategies/martingale.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
70
lib/strategies/threshold.js
Normal file
70
lib/strategies/threshold.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
1
readme
@@ -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
121
worker.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user