From 90cf21521fc8349f0ce1445a5b9281f7d8a9c407 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 24 May 2026 00:48:16 +0000 Subject: [PATCH 1/3] feat(dashboard): TS types + API client for strategy + backtest BacktestRun, BacktestRunDetail, StrategyTicker, StrategyEquityCurve, StrategyPerformance types added to meetKevin.ts. New meetKevinStrategy.ts with 8 axios methods covering the backtest run/list/get/latest and strategy tickers/equity-curve/performance/close endpoints. --- dashboard/src/api/meetKevinStrategy.ts | 60 +++++++++++++++++ dashboard/src/types/meetKevin.ts | 91 ++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 dashboard/src/api/meetKevinStrategy.ts diff --git a/dashboard/src/api/meetKevinStrategy.ts b/dashboard/src/api/meetKevinStrategy.ts new file mode 100644 index 0000000..043c641 --- /dev/null +++ b/dashboard/src/api/meetKevinStrategy.ts @@ -0,0 +1,60 @@ +import client from './client'; +import type { + BacktestRun, + BacktestRunDetail, + StrategyEquityCurve, + StrategyPerformance, + StrategyTicker, +} from '../types/meetKevin'; + +export async function runBacktest(params: { + holding_days?: number; + slippage_pct?: number; + dedupe_policy?: 'roll' | 'ignore'; + initial_capital?: number; +}): Promise<{ run_uuid: string; status: string }> { + const { data } = await client.post('/meet-kevin/backtest/run', params); + return data; +} + +export async function listBacktestRuns(limit = 20): Promise { + const { data } = await client.get('/meet-kevin/backtest/runs', { + params: { limit }, + }); + return data; +} + +export async function getBacktestRun(runUuid: string): Promise { + const { data } = await client.get(`/meet-kevin/backtest/runs/${runUuid}`); + return data; +} + +export async function getLatestBacktest(): Promise { + const { data } = await client.get('/meet-kevin/backtest/latest'); + return data; +} + +export async function getStrategyTickers(): Promise { + const { data } = await client.get('/meet-kevin/strategy/tickers'); + return data; +} + +export async function getStrategyEquityCurve(params: { + from?: string; + to?: string; + include_benchmark?: 'spy'; +}): Promise { + const { data } = await client.get('/meet-kevin/strategy/equity-curve', { + params, + }); + return data; +} + +export async function getStrategyPerformance(): Promise { + const { data } = await client.get('/meet-kevin/strategy/performance'); + return data; +} + +export async function closeKevinPosition(symbol: string): Promise { + await client.post(`/meet-kevin/positions/${symbol}/close`); +} diff --git a/dashboard/src/types/meetKevin.ts b/dashboard/src/types/meetKevin.ts index fd656ca..8a67325 100644 --- a/dashboard/src/types/meetKevin.ts +++ b/dashboard/src/types/meetKevin.ts @@ -112,3 +112,94 @@ export interface DashboardData { count: number; }[]; } + +// --- Strategy + backtest types (v2) --- + +export type BacktestRunStatus = 'running' | 'completed' | 'failed'; + +export interface BacktestTrade { + symbol: string; + entry_at: string; + entry_price: number; + exit_at: string | null; + exit_price: number | null; + qty: number; + pnl_usd: number | null; + pnl_pct: number | null; + holding_days_actual: number | null; +} + +export interface BacktestMetrics { + total_return_pct: number; + annualized_return_pct: number | null; + sharpe_ratio: number | null; + max_drawdown_pct: number | null; + win_rate: number | null; + trade_count: number; + alpha_vs_spy_pct: number | null; + beta_vs_spy: number | null; + winners: number | null; + losers: number | null; + best_trade_pct: number | null; + worst_trade_pct: number | null; +} + +export interface BacktestRun { + run_uuid: string; + status: BacktestRunStatus; + started_at: string; + finished_at: string | null; + trigger_source: 'manual' | 'scheduled'; + params_json: Record; + metrics_json: BacktestMetrics | null; + error_message: string | null; +} + +export interface BacktestRunDetail extends BacktestRun { + trades: BacktestTrade[]; + equity_curve_json: Array<{ timestamp: string; value: number }> | null; + benchmark_curve_json: Array<{ timestamp: string; value: number }> | null; +} + +export type BridgeStatus = + | 'emitted' + | 'skipped_non_tradable' + | 'skipped_blocklist' + | 'skipped_caps' + | 'deferred' + | 'broker_rejected' + | 'dry_run'; + +export interface StrategyTicker { + symbol: string; + latest_action: TickerAction; + latest_conviction: number; + mention_count: number; + last_mention_at: string; + bridge_status: BridgeStatus | null; + is_held: boolean; + current_price: number | null; + unrealized_pnl_pct: number | null; +} + +export interface EquityCurvePoint { + timestamp: string; + value: number; +} + +export interface StrategyEquityCurve { + strategy: EquityCurvePoint[]; + benchmark: EquityCurvePoint[] | null; +} + +export interface StrategyPerformance { + total_return_pct: number; + annualized_return_pct: number | null; + sharpe_ratio: number | null; + max_drawdown_pct: number | null; + win_rate: number | null; + trade_count: number; + alpha_vs_spy_pct: number | null; + open_positions: number; + last_backtest_at: string | null; +} From 5f5529ef09d2d690ec87a7c95f49264a728f5e34 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 24 May 2026 00:48:22 +0000 Subject: [PATCH 2/3] feat(dashboard): 3 components for the strategy page TickerScorecardTable: bridge-status badges (WOULD-TRADE/HOLDING/etc), conviction bar, unrealised P&L, manual-close button. BacktestRunHistory: sortable run list with return/sharpe/alpha columns, row click selects a run for detail view. StrategyVsBenchmarkCurve: dual lightweight-charts line (strategy blue, SPY dashed grey) with legend. --- .../meetKevin/BacktestRunHistory.tsx | 122 +++++++++++++++ .../meetKevin/StrategyVsBenchmarkCurve.tsx | 108 ++++++++++++++ .../meetKevin/TickerScorecardTable.tsx | 141 ++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 dashboard/src/components/meetKevin/BacktestRunHistory.tsx create mode 100644 dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx create mode 100644 dashboard/src/components/meetKevin/TickerScorecardTable.tsx 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 && ( + + )} +
+
+ ); +} From 6636054742ca19fe47b784614e17e09b40e22e95 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 24 May 2026 00:48:27 +0000 Subject: [PATCH 3/3] feat(dashboard): /meet-kevin/strategy page wired Strategy.tsx composes 6-up metrics header, StrategyVsBenchmarkCurve equity chart, TickerScorecardTable and BacktestRunHistory. Selecting a backtest run replaces the chart with that run's equity curve. Run Backtest button fires POST and polls for completion. App.tsx: +route /meet-kevin/strategy. Layout.tsx: +sidebar entry 'MK Strategy' under Meet Kevin group. --- dashboard/src/App.tsx | 2 + dashboard/src/components/Layout.tsx | 1 + dashboard/src/pages/meetKevin/Strategy.tsx | 213 +++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 dashboard/src/pages/meetKevin/Strategy.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 96bb67f..a98d37b 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -13,6 +13,7 @@ import MeetKevinVideos from './pages/meetKevin/Videos'; import MeetKevinVideoDetail from './pages/meetKevin/VideoDetail'; import MeetKevinStocks from './pages/meetKevin/Stocks'; import MeetKevinStockDetail from './pages/meetKevin/StockDetail'; +import MeetKevinStrategy from './pages/meetKevin/Strategy'; export default function App() { return ( @@ -41,6 +42,7 @@ export default function App() { } /> } /> } /> + } /> {/* Catch-all redirect */} diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx index f4fe45c..a49bc45 100644 --- a/dashboard/src/components/Layout.tsx +++ b/dashboard/src/components/Layout.tsx @@ -9,6 +9,7 @@ const navItems = [ { to: '/news', label: 'News', icon: 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2' }, { to: '/backtest', label: 'Backtest', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, { to: '/meet-kevin', label: 'Meet Kevin', icon: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' }, + { to: '/meet-kevin/strategy', label: 'MK Strategy', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, ]; export function Layout() { diff --git a/dashboard/src/pages/meetKevin/Strategy.tsx b/dashboard/src/pages/meetKevin/Strategy.tsx new file mode 100644 index 0000000..6ee8197 --- /dev/null +++ b/dashboard/src/pages/meetKevin/Strategy.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { TickerScorecardTable } from '../../components/meetKevin/TickerScorecardTable'; +import { BacktestRunHistory } from '../../components/meetKevin/BacktestRunHistory'; +import { StrategyVsBenchmarkCurve } from '../../components/meetKevin/StrategyVsBenchmarkCurve'; +import { + runBacktest, + listBacktestRuns, + getBacktestRun, + getStrategyTickers, + getStrategyEquityCurve, + getStrategyPerformance, + closeKevinPosition, +} from '../../api/meetKevinStrategy'; +import type { BacktestRunStatus } from '../../types/meetKevin'; + +function MetricCard({ label, value, color = 'text-white' }: { label: string; value: string; color?: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function fmt(v: number | null | undefined, suffix = '%', decimals = 2): string { + if (v == null) return '—'; + return `${v >= 0 ? '+' : ''}${v.toFixed(decimals)}${suffix}`; +} + +const POLL_STATUSES: BacktestRunStatus[] = ['running']; + +export default function MeetKevinStrategy() { + const queryClient = useQueryClient(); + const [selectedRunUuid, setSelectedRunUuid] = useState(null); + + const { data: performance } = useQuery({ + queryKey: ['meet-kevin', 'strategy', 'performance'], + queryFn: getStrategyPerformance, + refetchInterval: 60_000, + }); + + const { data: tickers, isLoading: tickersLoading } = useQuery({ + queryKey: ['meet-kevin', 'strategy', 'tickers'], + queryFn: getStrategyTickers, + refetchInterval: 60_000, + }); + + const { data: equityCurve } = useQuery({ + queryKey: ['meet-kevin', 'strategy', 'equity-curve'], + queryFn: () => getStrategyEquityCurve({ include_benchmark: 'spy' }), + refetchInterval: 120_000, + }); + + const { data: runs, isLoading: runsLoading } = useQuery({ + queryKey: ['meet-kevin', 'backtest', 'runs'], + queryFn: () => listBacktestRuns(20), + refetchInterval: 30_000, + }); + + const { data: selectedRun } = useQuery({ + queryKey: ['meet-kevin', 'backtest', 'run', selectedRunUuid], + queryFn: () => getBacktestRun(selectedRunUuid!), + enabled: selectedRunUuid != null, + refetchInterval: (query) => { + const data = query.state.data; + if (data && POLL_STATUSES.includes(data.status)) return 3_000; + return false; + }, + }); + + const backtestMutation = useMutation({ + mutationFn: runBacktest, + onSuccess: (data) => { + setSelectedRunUuid(data.run_uuid); + void queryClient.invalidateQueries({ queryKey: ['meet-kevin', 'backtest', 'runs'] }); + }, + }); + + const closeMutation = useMutation({ + mutationFn: closeKevinPosition, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['meet-kevin', 'strategy', 'tickers'] }); + }, + }); + + const equityData = selectedRun?.equity_curve_json ?? equityCurve?.strategy ?? []; + const benchmarkData = selectedRun?.benchmark_curve_json ?? equityCurve?.benchmark ?? null; + + return ( +
+ {/* Page header */} +
+
+

Meet Kevin — Strategy

+

+ Backtest history, ticker scorecard, and equity vs SPY +

+
+ +
+ + {/* Headline metrics */} + {performance && ( +
+ = 0 ? 'text-green-400' : 'text-red-400'} + /> + = 0 ? 'text-green-400' : 'text-red-400'} + /> + = 1 ? 'text-green-400' : 'text-yellow-400'} + /> + + = 0 ? 'text-green-400' : 'text-red-400'} + /> + +
+ )} + + {/* Equity curve */} + {equityData.length > 0 && ( +
+

+ {selectedRunUuid ? 'Backtest Equity Curve' : 'Strategy vs SPY'} +

+ +
+ )} + + {/* Ticker scorecard */} +
+

Ticker Scorecard

+ closeMutation.mutate(symbol)} + /> +
+ + {/* Backtest run history */} +
+

Backtest History

+ +
+ + {/* Selected run detail */} + {selectedRun?.status === 'running' && ( +
+ +

Backtest running…

+

Results will appear automatically

+
+ )} + + {selectedRun?.status === 'failed' && ( +
+

Backtest Failed

+

+ {selectedRun.error_message ?? 'An unknown error occurred'} +

+
+ )} +
+ ); +}