// Terminal variant — dual-mode bootstrapper. // Reads config / runs / currentRun via window.BacktestDataSource, then renders // the dense mono-terminal UI from the handoff design. const THEMES = [ { id: 'terminal-dark', label: 'TERMINAL DARK' }, { id: 'bright', label: 'BRIGHT' }, { id: 'amber-crt', label: 'AMBER CRT' }, { id: 'solarized-dark', label: 'SOLARIZED DARK' }, { id: 'solarized-light', label: 'SOLARIZED LIGHT' }, { id: 'nord', label: 'NORD' }, { id: 'synthwave', label: 'SYNTHWAVE' }, ]; // Slider + manual number input. Defined at module scope (not inside TerminalVariant) // so React doesn't rebuild the component type on every parent render — keeps the // internal `draft` state alive while the user is typing. const ParamSlider = ({ pdef, value, isStatic, updateParam, dim }) => { const clamped = Math.max(pdef.min, Math.min(pdef.max, Number(value))); const pct = ((clamped - pdef.min) / (pdef.max - pdef.min || 1)) * 100; const isInt = Number.isInteger(pdef.step); const display = isInt ? String(clamped) : Number(clamped).toFixed(3); const [draft, setDraft] = React.useState(display); const [focused, setFocused] = React.useState(false); React.useEffect(() => { if (!focused) setDraft(display); }, [display, focused]); const commit = () => { const n = parseFloat(draft); if (!Number.isFinite(n)) { setDraft(display); return; } const c = Math.max(pdef.min, Math.min(pdef.max, n)); updateParam(pdef.name, c); setDraft(isInt ? String(c) : Number(c).toFixed(3)); }; return (
updateParam(pdef.name, parseFloat(e.target.value))} />
setDraft(e.target.value)} onFocus={(e) => { setFocused(true); e.target.select(); }} onBlur={() => { setFocused(false); commit(); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); } else if (e.key === 'Escape') { setDraft(display); e.currentTarget.blur(); } }} />
); }; const TerminalVariant = () => { const [data, setData] = React.useState(null); // {config, runs, currentRun} const [running, setRunning] = React.useState(false); const [error, setError] = React.useState(null); const [asset, setAsset] = React.useState('BTC'); const [timeframe, setTimeframe] = React.useState('1h'); const [params, setParams] = React.useState({}); const [capital, setCapital] = React.useState({ initial: '10000', leverage: '3', sizing_mode: 'percent_of_equity', size_pct: '25', }); const [fees, setFees] = React.useState({ fee_rate: '0.0005', slippage_ticks: 2 }); const [dateRange, setDateRange] = React.useState(() => { // Default: trailing 30 days ending today (UTC date slice so the // stays stable regardless of user timezone). const end = new Date(); const start = new Date(end); start.setUTCDate(start.getUTCDate() - 30); const iso = (d) => d.toISOString().slice(0, 10); return [iso(start), iso(end)]; }); const [strategy, setStrategy] = React.useState('DualScaleCRSI'); const [previewBars, setPreviewBars] = React.useState(null); const [previewIndicators, setPreviewIndicators] = React.useState([]); const [previewError, setPreviewError] = React.useState(null); const [tab, setTab] = React.useState('Overview'); const [fetchStatus, setFetchStatus] = React.useState('idle'); // idle | loading | slow-loading — matches FETCH_STATUS in trading-chart.jsx const [fetchContext, setFetchContext] = React.useState({ mode: 'preview', symbol: 'BTC', timeframe: '1h', rangeLabel: '' }); // mode matches FETCH_MODE in trading-chart.jsx const slowTimerRef = React.useRef(null); const [theme, setTheme] = React.useState( () => localStorage.getItem('backtest.theme') || 'terminal-dark' ); // Resizable column widths (config left / summary right). Persisted across sessions. const [configWidth, setConfigWidth] = React.useState(() => { const v = parseInt(localStorage.getItem('backtest.configWidth') || '', 10); return Number.isFinite(v) && v >= 180 && v <= 600 ? v : 260; }); const [summaryWidth, setSummaryWidth] = React.useState(() => { const v = parseInt(localStorage.getItem('backtest.summaryWidth') || '', 10); return Number.isFinite(v) && v >= 200 && v <= 700 ? v : 300; }); React.useEffect(() => { localStorage.setItem('backtest.configWidth', String(configWidth)); }, [configWidth]); React.useEffect(() => { localStorage.setItem('backtest.summaryWidth', String(summaryWidth)); }, [summaryWidth]); // Trade-table column widths. Keys must match TRADE_COLUMNS below; defaults sized // to the longest plausible value of each column (timestamps need ~200px, qty < 80px). const TRADE_COL_DEFAULTS = { idx: 40, side: 80, entry_ts: 200, exit_ts: 200, entry: 100, exit: 100, qty: 70, pnl: 90, pnl_pct: 80, bars: 60, signal: 80, }; const [colWidths, setColWidths] = React.useState(() => { try { const saved = JSON.parse(localStorage.getItem('backtest.tradeColWidths') || '{}'); return { ...TRADE_COL_DEFAULTS, ...saved }; } catch { return { ...TRADE_COL_DEFAULTS }; } }); React.useEffect(() => { localStorage.setItem('backtest.tradeColWidths', JSON.stringify(colWidths)); }, [colWidths]); const colDragRef = React.useRef(null); const onColResizeDown = (colId) => (e) => { e.preventDefault(); e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.dataset.dragging = 'true'; colDragRef.current = { colId, startX: e.clientX, startWidth: colWidths[colId] }; }; const onColResizeMove = (e) => { const drag = colDragRef.current; if (!drag) return; const dx = e.clientX - drag.startX; const next = Math.max(40, Math.min(500, drag.startWidth + dx)); setColWidths((prev) => (prev[drag.colId] === next ? prev : { ...prev, [drag.colId]: next })); }; const onColResizeUp = (e) => { e.currentTarget.dataset.dragging = 'false'; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* ignore */ } colDragRef.current = null; }; // Drag-to-resize for the two splitters. Pointer events + setPointerCapture so // drag survives pointer leaving the splitter element (and the browser window). const splitterDragRef = React.useRef(null); const onSplitterPointerDown = (which) => (e) => { e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.dataset.dragging = 'true'; splitterDragRef.current = { which, startX: e.clientX, startWidth: which === 'config' ? configWidth : summaryWidth, }; }; const onSplitterPointerMove = (e) => { const drag = splitterDragRef.current; if (!drag) return; const dx = e.clientX - drag.startX; if (drag.which === 'config') { const next = Math.max(180, Math.min(600, drag.startWidth + dx)); setConfigWidth(next); } else { const next = Math.max(200, Math.min(700, drag.startWidth - dx)); setSummaryWidth(next); } }; const onSplitterPointerUp = (e) => { e.currentTarget.dataset.dragging = 'false'; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* ignore — capture may already be lost */ } splitterDragRef.current = null; }; const [cssKick, setCssKick] = React.useState(0); React.useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('backtest.theme', theme); setCssKick((k) => k + 1); }, [theme]); // Read from CSS vars — values defined in index.html; cssKick forces re-read after theme change. const cssVar = (name, fallback) => { if (typeof window === 'undefined') return fallback; const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; }; // cssKick in the expression ensures this block re-evaluates after the DOM attr is applied. const _kick = cssKick; // eslint-disable-line no-unused-vars const green = cssVar('--green', '#00ff9c'); const amber = cssVar('--amber', '#ffb627'); const red = cssVar('--red', '#ff5a5a'); const bg = cssVar('--bg', '#05070a'); const bg2 = cssVar('--bg2', '#0a0e13'); const line = cssVar('--line', '#1e2a35'); const dim = cssVar('--dim', '#4a5d6e'); const fg = cssVar('--fg', '#c8d9e3'); React.useEffect(() => { window.BacktestDataSource.loadBootstrap() .then((d) => { setData(d); // Initialize params from the selected strategy's defaults, if config is available. const sel = d.config?.strategies?.find((s) => s.name === strategy); if (sel) { setParams( Object.fromEntries(sel.params.map((p) => [p.name, p.default])) ); } }) .catch((e) => setError(String(e))); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); window.ChartUtils.useDebouncedEffect( () => { if (window.BacktestDataSource.isStatic()) return; const [start, end] = dateRange; if (!start || !end) return; const selectedCfg = data?.config?.strategies?.find((s) => s.name === strategy); const indicators = selectedCfg?.indicators || []; const paramKeys = { supertrend_period: params.st_period, supertrend_mult: params.st_mult, crsi_dom1: params.dom1, crsi_dom2: params.dom2, crsi_vibration: params.vibration, crsi_leveling: params.leveling, crsi_lookback: params.lookback, }; setPreviewError(null); setFetchStatus(window.ChartConstants.FETCH_STATUS.LOADING); setFetchContext({ mode: window.ChartConstants.FETCH_MODE.PREVIEW, symbol: asset, timeframe, rangeLabel: `${start} → ${end}` }); clearTimeout(slowTimerRef.current); slowTimerRef.current = setTimeout(() => setFetchStatus(window.ChartConstants.FETCH_STATUS.SLOW), 500); window.BacktestDataSource.loadBars({ symbol: asset, timeframe, start, end, indicators, params: paramKeys, }) .then((resp) => { setPreviewBars(resp.price || []); setPreviewIndicators(resp.chart_indicators || []); }) .catch((e) => { setPreviewError(String(e)); }) .finally(() => { clearTimeout(slowTimerRef.current); setFetchStatus(window.ChartConstants.FETCH_STATUS.IDLE); }); }, [asset, timeframe, dateRange[0], dateRange[1], strategy, JSON.stringify(params)], 300 ); if (error) { return (
ERROR: {error}
); } if (!data) { return (
Loading…
); } const run = data.currentRun; const isStatic = window.BacktestDataSource.isStatic(); const selectedStrategy = data.config?.strategies?.find((s) => s.name === strategy) || null; const strategyList = data.config?.strategies?.map((s) => s.name) || [strategy]; const symbols = data.config?.symbols || ['BTC', 'SOL', 'HYPE']; const timeframes = data.config?.timeframes || ['1m', '5m', '15m', '1h', '4h', '1d']; const { fmt, fmtSigned } = window.ChartUtils; const priceRows = (previewBars && previewBars.length ? previewBars : run?.price) || []; const tradesRows = (run && run.trades) || []; const metrics = (run && run.metrics) || {}; // Indicators / markers grouped by pane. Prefer live preview if available. const effectiveIndicators = previewIndicators && previewIndicators.length ? previewIndicators : (run?.chart_indicators || []); const indicatorsByPane = { price: [], subpane_1: [], subpane_2: [] }; for (const ci of effectiveIndicators) { if (indicatorsByPane[ci.pane]) indicatorsByPane[ci.pane].push(ci); } const buildRunRequest = () => ({ symbol: asset, timeframe, date_range: dateRange, strategy, params, capital, fee_rate: fees.fee_rate, slippage_ticks: parseInt(fees.slippage_ticks, 10), }); const runBacktest = async () => { if (isStatic) return; setRunning(true); setError(null); setFetchStatus(window.ChartConstants.FETCH_STATUS.LOADING); setFetchContext({ mode: window.ChartConstants.FETCH_MODE.RUN, strategy, symbol: asset, timeframe, rangeLabel: `${dateRange[0]} → ${dateRange[1]}` }); clearTimeout(slowTimerRef.current); slowTimerRef.current = setTimeout(() => setFetchStatus(window.ChartConstants.FETCH_STATUS.SLOW), 500); try { const req = buildRunRequest(); const result = await window.BacktestDataSource.runBacktest(req); setData((prev) => ({ ...prev, currentRun: result, runs: [ { id: result.run?.id || '', strategy: result.config?.strategy || strategy, symbol: result.config?.symbol || asset, timeframe: result.config?.timeframe || timeframe, date_range: result.config?.date_range || dateRange, net_profit_pct: result.metrics?.net_profit_pct || '0', }, ...prev.runs, ], })); try { const fresh = await window.BacktestDataSource.loadRuns(); setData((prev) => ({ ...prev, runs: fresh })); } catch (e) { console.warn('loadRuns refresh failed:', e); } setPreviewBars(null); setPreviewIndicators([]); } catch (e) { setError(String(e.message || e)); } finally { setRunning(false); clearTimeout(slowTimerRef.current); setFetchStatus(window.ChartConstants.FETCH_STATUS.IDLE); } }; const loadRun = async (id) => { try { const result = await window.BacktestDataSource.getRun(id); setData((prev) => ({ ...prev, currentRun: result })); } catch (e) { setError(String(e.message || e)); } }; const updateParam = (name, value) => setParams((p) => ({ ...p, [name]: value })); const styleCss = ` .tv-root { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; background: ${bg}; color: ${fg}; font-size: 11px; line-height: 1.35; letter-spacing: 0.01em; } .tv-root * { box-sizing: border-box; } .tv-header { background: ${bg2}; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; } .tv-header .title { color: ${green}; font-weight: 700; } .tv-header .kv { color: ${dim}; } .tv-header .kv b { color: ${fg}; font-weight: 500; margin-left: 4px; } .tv-header .dot { width: 6px; height: 6px; border-radius: 50%; background: ${green}; box-shadow: 0 0 8px ${green}; display: inline-block; margin-right: 5px; animation: pulse 1.6s infinite; } @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } } .ph { display:flex; justify-content:space-between; align-items:center; color: ${green}; font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 10px; border-bottom: 1px dashed ${line}; padding-bottom: 6px; } .ph .tag { color: ${dim}; } .row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px dotted ${line}; } .row .k { color: ${dim}; } .row .v { color: ${fg}; } .row .v.g { color: ${green}; } .row .v.r { color: ${red}; } .row .v.a { color: ${amber}; } .asset-tabs { display: flex; gap: 0; margin-bottom: 12px; border: 1px solid ${line}; } .asset-tabs button { flex: 1; background: transparent; border: none; border-right: 1px solid ${line}; color: ${dim}; padding: 6px 0; font-family: inherit; font-size: 10px; cursor: pointer; letter-spacing: 0.08em; } .asset-tabs button:last-child { border-right: none; } .asset-tabs button.on { background: ${green}; color: ${bg}; font-weight: 700; } .field { margin-bottom: 9px; } .field label { display: block; color: ${dim}; font-size: 9px; letter-spacing: 0.1em; margin-bottom: 3px; text-transform: uppercase; } .field input, .field select { width: 100%; background: ${bg}; border: 1px solid ${line}; color: ${fg}; font-family: inherit; font-size: 11px; padding: 5px 7px; } .field input:focus, .field select:focus { outline: none; border-color: ${green}; } .field .two { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } .pill-row { display: flex; gap: 0; border: 1px solid ${line}; } .pill-row button { flex: 1; background: transparent; border: none; border-right: 1px solid ${line}; color: ${dim}; padding: 4px 0; font-family: inherit; font-size: 10px; cursor: pointer; } .pill-row button:last-child { border-right: none; } .pill-row button.on { background: ${line}; color: ${green}; } .run-btn { width: 100%; background: ${green}; color: ${bg}; border: none; font-family: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.1em; padding: 9px 0; cursor: pointer; margin-top: 6px; text-transform: uppercase; } .run-btn:disabled { background: ${line}; color: ${dim}; cursor: not-allowed; } .run-btn:hover:not(:disabled) { box-shadow: 0 0 16px ${green}40; } .metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } .metric { border: 1px solid ${line}; padding: 7px 9px; } .metric .lbl { color: ${dim}; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; } .metric .val { color: ${green}; font-size: 18px; font-weight: 500; margin-top: 2px; } .metric .val.r { color: ${red}; } .metric .sub { color: ${dim}; font-size: 10px; margin-top: 1px; } .tabs { display: flex; border-bottom: 1px solid ${line}; margin-bottom: 0; } .tabs button { background: transparent; border: none; color: ${dim}; padding: 6px 14px; font-family: inherit; font-size: 10px; letter-spacing: 0.1em; cursor: pointer; text-transform: uppercase; border-right: 1px solid ${line}; } .tabs button.on { color: ${green}; background: ${bg2}; border-bottom: 1px solid ${green}; } .trade-table { border-collapse: collapse; font-size: 10px; table-layout: fixed; } .trade-table th { text-align: left; color: ${dim}; font-weight: 400; padding: 4px 8px; border-bottom: 1px solid ${line}; letter-spacing: 0.08em; text-transform: uppercase; position: sticky; top: 0; background: ${bg}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trade-table td { padding: 3px 8px; border-bottom: 1px dotted ${line}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trade-table tr:hover td { background: ${bg2}; } .col-resize { position: absolute; top: 0; right: -3px; width: 6px; height: 100%; cursor: col-resize; user-select: none; touch-action: none; z-index: 1; } .col-resize:hover, .col-resize[data-dragging="true"] { background: ${green}; opacity: 0.6; } .trade-table th { position: sticky; } /* sticky already set above; this preserves it */ .trade-table th .th-inner { position: relative; } .chart-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .chart-legend { display: flex; gap: 14px; font-size: 10px; color: ${dim}; } .legend-dot { display: inline-block; width: 10px; height: 2px; margin-right: 5px; vertical-align: middle; } .tv-slider { display: flex; align-items: center; gap: 10px; position: relative; } .tv-slider .track { flex: 1; height: 3px; background: ${line}; position: relative; } .tv-slider .track input[type=range] { position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; width: 100%; height: auto; opacity: 0; cursor: pointer; margin: 0; padding: 0; } .tv-slider .fill { position: absolute; left: 0; top: 0; bottom: 0; background: ${green}; box-shadow: 0 0 6px ${green}; pointer-events: none; } .tv-slider .thumb { position: absolute; top: 50%; width: 10px; height: 10px; background: ${green}; border: 1px solid ${bg}; transform: translate(-50%, -50%); box-shadow: 0 0 8px ${green}80; pointer-events: none; } .tv-slider input.num { background: transparent; border: 1px solid transparent; color: ${green}; font-family: inherit; font-size: 11px; width: 64px; text-align: right; font-variant-numeric: tabular-nums; outline: none; padding: 2px 4px; -moz-appearance: textfield; } .tv-slider input.num::-webkit-outer-spin-button, .tv-slider input.num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .tv-slider input.num:hover:not(:disabled) { border-color: ${line}; } .tv-slider input.num:focus { border-color: ${green}; } .tv-slider input.num:disabled { cursor: not-allowed; opacity: 0.6; } .hist-row { padding: 3px 0; border-bottom: 1px dotted ${line}; cursor: pointer; font-size: 10px; } .hist-row.on { background: ${bg2}; font-weight: 600; } .trades-section { border-top: 1px solid ${line}; margin-top: 12px; } .trades-section-body { overflow-x: auto; } `; const splitterProps = { onPointerMove: onSplitterPointerMove, onPointerUp: onSplitterPointerUp, onPointerCancel: onSplitterPointerUp, }; return (
{/* HEADER */}
◆ QUANT.TERM v{data?.version?.semver || '0.4.1'} {data?.version?.build ? ` #${data.version.build}` : ''} SESSION{' '} {run?.run?.id ? '#' + run.run.id.toString().slice(-4).toUpperCase() : previewBars?.length ? 'PREVIEW' : 'IDLE'} ENGINE {isStatic ? 'STATIC' : 'ONLINE'} BARS {run?.run?.bars ?? (previewBars?.length || '—')} TRADES {run?.run?.trades ?? tradesRows.length} MODE {isStatic ? 'STATIC' : 'SERVER'}
THEME
{/* CONFIG (left column) */}
[ config ] 01
{symbols.map((a) => ( ))}
{timeframes.map((t) => ( ))}
setDateRange([e.target.value, dateRange[1]])} /> setDateRange([dateRange[0], e.target.value])} />
{selectedStrategy && selectedStrategy.params.length > 0 && ( <>
[ params ]
{selectedStrategy.params.map((p) => { if (p.name === 'direction_mode') { const mode = params[p.name] ?? p.default; const opts = [ { val: 0, label: 'BOTH' }, { val: 1, label: 'LONG' }, { val: 2, label: 'SHORT' }, ]; return (
{opts.map((o) => ( ))}
); } return ( ); })} )}
[ capital ]
setCapital({ ...capital, initial: e.target.value })} />
setCapital({ ...capital, leverage: e.target.value })} />
setCapital({ ...capital, size_pct: e.target.value })} />
setFees({ ...fees, fee_rate: e.target.value })} /> setFees({ ...fees, slippage_ticks: parseInt(e.target.value, 10) || 0 }) } />
[ history ]
{(!data.runs || data.runs.length === 0) && (
No past runs.
)} {(data.runs || []).map((r) => { const net = parseFloat(r.net_profit_pct || '0'); const active = run?.run?.id === r.id; return (
loadRun(r.id)} title={r.id} > = 0 ? green : red }}> {net >= 0 ? '+' : ''} {r.net_profit_pct}% {' '} {(r.strategy || '').slice(0, 16)}{' '} {r.symbol}/{r.timeframe}
); })}
{/* CHART (centre column) */}
{previewError && (
preview error: {previewError}
)}
{/* TRADES (full-width row below chart — grid-area: trades) */}
{['Overview', 'Performance', 'Trades', 'Properties'].map((t) => ( ))}
showing {tradesRows.length} closed
{(() => { const cols = [ { id: 'idx', label: '#' }, { id: 'side', label: 'Side' }, { id: 'entry_ts', label: 'Entry time' }, { id: 'exit_ts', label: 'Exit time' }, { id: 'entry', label: 'Entry $' }, { id: 'exit', label: 'Exit $' }, { id: 'qty', label: 'Qty' }, { id: 'pnl', label: 'P&L $' }, { id: 'pnl_pct', label: 'P&L %' }, { id: 'bars', label: 'Bars' }, { id: 'signal', label: 'Signal' }, ]; const totalWidth = cols.reduce((s, c) => s + colWidths[c.id], 0); const ResizeHandle = ({ colId }) => ( ); return ( {cols.map((c) => )} {cols.map((c) => ( ))} {tradesRows.map((tr) => { const pnl = parseFloat(tr.pnl ?? 0); const pnlPct = parseFloat(tr.pnl_pct ?? 0); const isLong = window.ChartUtils.isLongSide(tr.side); return ( ); })}
{c.label}
{tr.id} {isLong ? '▲ LONG' : '▼ SHORT'} {tr.entry_ts} {tr.exit_ts} {fmt(tr.entry, 2)} {fmt(tr.exit, 2)} {fmt(tr.qty, 4)} = 0 ? green : red }}>{fmtSigned(pnl)} = 0 ? green : red }}>{fmtSigned(pnlPct, 2, '%')} {tr.bars} {tr.signal}
); })()}
{/* SUMMARY (right column) */}
[ summary ] {run ? 'LIVE' : 'IDLE'}
Net Profit
{metrics.net_profit_pct ? fmtSigned(metrics.net_profit_pct, 2, '%') : '—'}
{metrics.net_profit_closed ?? metrics.net_profit ?? '—'}
Max DD
{metrics.max_drawdown_pct ? fmtSigned(-Math.abs(parseFloat(metrics.max_drawdown_pct)), 2, '%') : '—'}
{metrics.max_drawdown ?? '—'}
Sharpe
{metrics.sharpe != null ? fmt(metrics.sharpe, 2) : '—'}
annualized
Sortino
{metrics.sortino != null ? fmt(metrics.sortino, 2) : '—'}
annualized
[ stats ]
Profit factor {metrics.profit_factor ?? '—'}
Total trades {metrics.total_closed_trades ?? tradesRows.length}
Win rate {metrics.percent_profitable ?? '—'}
{(() => { const isLong = window.ChartUtils.isLongSide; const counts = { all: { w: 0, l: 0 }, long: { w: 0, l: 0 }, short: { w: 0, l: 0 } }; for (const t of tradesRows) { const pnl = parseFloat(t.pnl ?? 0); if (pnl === 0) continue; // breakeven excluded — matches backend convention const bucket = isLong(t.side) ? counts.long : counts.short; const k = pnl > 0 ? 'w' : 'l'; bucket[k] += 1; counts.all[k] += 1; } const ratio = (w, l) => (l > 0 ? (w / l).toFixed(2) : (w > 0 ? '∞' : '—')); const Row = ({ label, w, l }) => (
{label} {w} / {l} {' • '}{ratio(w, l)}
); return ( <> ); })()}
Largest win {metrics.largest_win ?? '—'}
Largest loss {metrics.largest_loss ?? '—'}
Commission paid {metrics.commissions_paid ?? '—'}
); }; window.TerminalVariant = TerminalVariant;