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