diff --git a/dashboard/src/components/meetKevin/BacktestRunHistory.tsx b/dashboard/src/components/meetKevin/BacktestRunHistory.tsx new file mode 100644 index 0000000..c8a3169 --- /dev/null +++ b/dashboard/src/components/meetKevin/BacktestRunHistory.tsx @@ -0,0 +1,122 @@ +import type { BacktestRun, BacktestRunStatus } from '../../types/meetKevin'; + +const STATUS_BADGE: Record = { + running: { label: 'Running', cls: 'bg-blue-600/30 text-blue-300 border-blue-500/40' }, + completed: { label: 'Completed', cls: 'bg-green-600/30 text-green-300 border-green-500/40' }, + failed: { label: 'Failed', cls: 'bg-red-600/30 text-red-300 border-red-500/40' }, +}; + +function fmt(v: number | null | undefined, suffix = '%', decimals = 2): string { + if (v == null) return '—'; + return `${v >= 0 ? '+' : ''}${v.toFixed(decimals)}${suffix}`; +} + +interface Props { + runs: BacktestRun[]; + isLoading: boolean; + selectedRunUuid: string | null; + onSelect: (runUuid: string) => void; +} + +export function BacktestRunHistory({ runs, isLoading, selectedRunUuid, onSelect }: Props) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (runs.length === 0) { + return ( +
+ No backtest runs yet — click “Run Backtest” to start one. +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + {runs.map((run) => { + const badge = STATUS_BADGE[run.status]; + const m = run.metrics_json; + const isSelected = run.run_uuid === selectedRunUuid; + return ( + onSelect(run.run_uuid)} + className={`cursor-pointer transition-colors ${ + isSelected + ? 'bg-blue-600/10' + : 'hover:bg-slate-700/30' + }`} + > + + + + + + + + + + ); + })} + +
+ Started + + Status + + Return + + Sharpe + + Max DD + + Alpha + + Trades + + Source +
+ {new Date(run.started_at).toLocaleString()} + + + {badge.label} + + {run.error_message && ( + + {run.error_message} + + )} + = 0 ? 'text-green-400' : 'text-red-400'}`}> + {m ? fmt(m.total_return_pct) : '—'} + + {m?.sharpe_ratio != null ? m.sharpe_ratio.toFixed(2) : '—'} + + {m?.max_drawdown_pct != null ? `${m.max_drawdown_pct.toFixed(2)}%` : '—'} + = 0 ? 'text-green-400' : 'text-red-400'}`}> + {m ? fmt(m.alpha_vs_spy_pct) : '—'} + + {m?.trade_count ?? '—'} + + {run.trigger_source} +
+
+ ); +} diff --git a/dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx b/dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx new file mode 100644 index 0000000..cdbf0f0 --- /dev/null +++ b/dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { createChart, type IChartApi, LineSeries } from 'lightweight-charts'; +import type { EquityCurvePoint } from '../../types/meetKevin'; + +interface Props { + strategy: EquityCurvePoint[]; + benchmark: EquityCurvePoint[] | null; + height?: number; +} + +function toChartData(points: EquityCurvePoint[]) { + const byDay = new Map(); + for (const p of points) { + const day = p.timestamp.split('T')[0]; + byDay.set(day, p.value); + } + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([time, value]) => ({ time, value })); +} + +export function StrategyVsBenchmarkCurve({ strategy, benchmark, height = 350 }: Props) { + const containerRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const chart = createChart(containerRef.current, { + height, + layout: { + background: { color: '#1e293b' }, + textColor: '#94a3b8', + }, + grid: { + vertLines: { color: '#334155' }, + horzLines: { color: '#334155' }, + }, + crosshair: { mode: 0 }, + rightPriceScale: { borderColor: '#475569' }, + timeScale: { borderColor: '#475569', timeVisible: true }, + }); + + const strategySeries = chart.addSeries(LineSeries, { + color: '#3b82f6', + lineWidth: 2, + title: 'Strategy', + priceFormat: { + type: 'custom', + formatter: (price: number) => + '$' + price.toLocaleString('en-US', { minimumFractionDigits: 2 }), + }, + }); + + const benchmarkSeries = chart.addSeries(LineSeries, { + color: '#94a3b8', + lineWidth: 1, + title: 'SPY', + lineStyle: 1, // dashed + priceFormat: { + type: 'custom', + formatter: (price: number) => + '$' + price.toLocaleString('en-US', { minimumFractionDigits: 2 }), + }, + }); + + chartRef.current = chart; + + if (strategy.length > 0) { + strategySeries.setData(toChartData(strategy) as { time: string; value: number }[]); + } + if (benchmark && benchmark.length > 0) { + benchmarkSeries.setData(toChartData(benchmark) as { time: string; value: number }[]); + } + + chart.timeScale().fitContent(); + + const handleResize = () => { + if (containerRef.current) { + chart.applyOptions({ width: containerRef.current.clientWidth }); + } + }; + window.addEventListener('resize', handleResize); + handleResize(); + + return () => { + window.removeEventListener('resize', handleResize); + chart.remove(); + chartRef.current = null; + }; + }, [height, strategy, benchmark]); + + return ( +
+
+ + + Strategy + + + + SPY benchmark + +
+
+
+ ); +} diff --git a/dashboard/src/components/meetKevin/TickerScorecardTable.tsx b/dashboard/src/components/meetKevin/TickerScorecardTable.tsx new file mode 100644 index 0000000..1943fdd --- /dev/null +++ b/dashboard/src/components/meetKevin/TickerScorecardTable.tsx @@ -0,0 +1,141 @@ +import type { BridgeStatus, StrategyTicker } from '../../types/meetKevin'; +import { ActionChip } from './ActionChip'; +import { ConvictionBar } from './ConvictionBar'; + +const BRIDGE_BADGE: Record = { + emitted: { label: 'WOULD-TRADE', cls: 'bg-blue-600/30 text-blue-300 border-blue-500/40' }, + dry_run: { label: 'WOULD-TRADE', cls: 'bg-blue-600/30 text-blue-300 border-blue-500/40' }, + skipped_non_tradable: { label: 'NOT TRADABLE', cls: 'bg-slate-600/30 text-slate-400 border-slate-500/40' }, + skipped_blocklist: { label: 'BLOCKLISTED', cls: 'bg-rose-600/30 text-rose-300 border-rose-500/40' }, + skipped_caps: { label: 'CAP HIT', cls: 'bg-yellow-600/30 text-yellow-300 border-yellow-500/40' }, + deferred: { label: 'DEFERRED', cls: 'bg-slate-600/30 text-slate-400 border-slate-500/40' }, + broker_rejected: { label: 'REJECTED', cls: 'bg-red-600/30 text-red-300 border-red-500/40' }, +}; + +interface Props { + tickers: StrategyTicker[]; + isLoading: boolean; + onClose: (symbol: string) => void; +} + +export function TickerScorecardTable({ tickers, isLoading, onClose }: Props) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (tickers.length === 0) { + return ( +
+ No ticker signals yet +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + {tickers.map((t) => { + const badge = t.bridge_status ? BRIDGE_BADGE[t.bridge_status] : null; + const pnl = t.unrealized_pnl_pct; + return ( + + + + + + + + + + + + ); + })} + +
+ Symbol + + Signal + + Conviction + + Price + + P&L + + Status + + Mentions + + Last seen + +
+ ${t.symbol} + {t.is_held && ( + + HOLDING + + )} + + + +
+
+ +
+ + {(t.latest_conviction * 100).toFixed(0)}% + +
+
+ {t.current_price != null + ? `$${t.current_price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + : '—'} + + {pnl != null ? ( + = 0 ? 'text-green-400' : 'text-red-400'}> + {pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}% + + ) : ( + + )} + + {badge ? ( + + {badge.label} + + ) : ( + + )} + {t.mention_count} + {new Date(t.last_mention_at).toLocaleDateString()} + + {t.is_held && ( + + )} +
+
+ ); +}