feat(backtest): make the tiles answer "is that good?", and stop the layout jumping

Five UI problems, all reported from using the page.

Expanding "How this is measured" shoved every control down, because the
disclosure and the run controls shared one flex row. They no longer do: run
status and the controls that start a new run sit together on one line, and the
explainer is below them where growing it moves nothing.

A long strategy name wrapped the dropdown trigger onto three lines and dragged
the row out of alignment. The trigger now truncates with the full text on hover
— a wrapping dropdown is broken anywhere, so the fix is in the primitive — and
the twelve-character "Production: " prefix is a bullet.

"Sortino 2.72" answered nothing. Each risk-adjusted metric now carries a meter:
a track showing where the value sits, ticks at the band edges, and the band word.
Colour never travels alone. Bands are deliberately stricter than textbook ranges
because this universe is today's survivors replayed backward, which flatters
every ratio — that caveat is stated next to them rather than left implied.

The two tile rows were different sizes, which read as inconsistent rather than
as hierarchy. Every tile is the same size now and grouping carries the ranking:
top row is raw outcome and takes no meters, second row is risk-adjusted ratios
and all take meters. Sharpe moved down to join them — it is one of those ratios,
and leaving it above made it the only metered tile in a row of bare ones.

The recommendation led with a long bold sentence that describes the
configuration, not a verdict, while the actual findings were small grey text.
Findings now come first, each split into label and detail on the colon the
backend strings already carry, and the configuration is a footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:25:11 +02:00
co-authored by Claude Opus 5
parent 28b3273150
commit 3a2d548610
6 changed files with 273 additions and 86 deletions
@@ -70,23 +70,30 @@ export function BacktestPanel() {
return ( return (
<Section title="Is the strategy working?" hint="portfolio simulation of the promoted strategy vs S&P 500"> <Section title="Is the strategy working?" hint="portfolio simulation of the promoted strategy vs S&P 500">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"> {/* Run status and the controls that start a new run, on one line. The
<Disclosure summary="How this is measured"> explainer sits BELOW this row rather than beside it — sharing a flex
<p className="max-w-2xl text-xs text-gray-400"> row meant expanding it shoved every control down the page. */}
The backtest replays the current config at the selected cadence at each point the setup is <div className="flex flex-wrap items-end justify-between gap-3">
rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide <div className="min-w-0">
its outcome then simulates one capital-constrained book against the S&P 500. Sentiment and <p className="section-index">Last run</p>
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime, {report ? (
so read it as directional. <p className="mt-1 text-xs text-gray-400">
{timeAgo(report.generated_at)} · {report.tickers} tickers ·{' '}
{report.candidates} setups ({report.qualified} qualified) ·{' '}
{report.params.entry_cadence ?? 'weekly'},{' '}
{report.params.horizon_days}d horizon
{report.params.cost_per_side_pct != null && (
<> · net of {report.params.cost_per_side_pct}%/side</>
)}
{' · '}
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
{report.params.target_model_label ?? 'Unknown (legacy report)'}
</span>
</p> </p>
<p className="mt-2 max-w-2xl text-xs text-gray-400"> ) : (
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the <p className="mt-1 text-xs text-gray-500">Never run</p>
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison )}
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps </div>
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
{' '}is roughly 5× the replay work.
</p>
</Disclosure>
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a {/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
narrow viewport otherwise. */} narrow viewport otherwise. */}
@@ -112,11 +119,30 @@ export function BacktestPanel() {
/> />
</div> </div>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0"> <Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'} {run.isPending ? 'Starting…' : report ? 'Re-run' : 'Run backtest'}
</Button> </Button>
</div> </div>
</div> </div>
<div>
<Disclosure summary="How this is measured">
<p className="max-w-2xl text-xs text-gray-400">
The backtest replays the current config at the selected cadence at each point the setup is
rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide
its outcome then simulates one capital-constrained book against the S&P 500. Sentiment and
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
so read it as directional.
</p>
<p className="mt-2 max-w-2xl text-xs text-gray-400">
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
{' '}is roughly 5× the replay work.
</p>
</Disclosure>
</div>
{/* Only surfaced for non-default choices — zero noise on the common path, {/* Only surfaced for non-default choices — zero noise on the common path,
but a non-production selection still announces itself, which is what but a non-production selection still announces itself, which is what
the old always-amber cards were really for. */} the old always-amber cards were really for. */}
@@ -142,19 +168,6 @@ export function BacktestPanel() {
{report && ( {report && (
<> <>
<p className="text-[11px] text-gray-500">
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
({report.qualified} qualified) · {report.params.entry_cadence ?? 'weekly'} cadence,
{' '}{report.params.horizon_days}-day horizon
{report.params.cost_per_side_pct != null && (
<> · net of {report.params.cost_per_side_pct}%/side costs</>
)}
{' '}· target model:{' '}
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
{report.params.target_model_label ?? 'Unknown (legacy report)'}
</span>
</p>
<PortfolioMonitorPanel <PortfolioMonitorPanel
monitor={monitor} monitor={monitor}
monitorRun={monitorRun} monitorRun={monitorRun}
@@ -4,15 +4,14 @@ import type { BacktestRecommendation } from '../../lib/types';
/** /**
* The verdict, ahead of the tuning detail. * The verdict, ahead of the tuning detail.
* *
* All eight findings used to render as equal-weight bullets, so "does this * Two problems this solves. All eight findings used to render as equal-weight
* strategy work" sat in the same visual register as "which cutoff scored best". * bullets, so "does this strategy work" sat in the same register as "which
* `topic` splits them: the three that answer the question stay inline, the rest * cutoff scored best". And the headline — which is a *description of the
* collapse. * config*, not a verdict — was the loudest thing on the card while every actual
* finding was small grey text.
* *
* No topic chips — every backend string already self-prefixes ("Gate: …", * So: findings first, each split into a label and its detail; the config
* "Robustness: …"), so a chip would render "GATE │ Gate: …", and stripping the * description demoted to a footer where it belongs.
* prefix would drop real information ("(3y)" carries the lookback, "Legacy"
* qualifies the diagnostic).
*/ */
const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']); const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']);
@@ -25,6 +24,43 @@ function isWarning(text: string): boolean {
return text.includes('WARNING') || text.includes('LAGS'); return text.includes('WARNING') || text.includes('LAGS');
} }
/**
* Every backend string self-prefixes ("Gate: keep the R:R floor…"), so the
* prefix IS the label — no need for a chip that would just repeat it, and no
* need to reword anything server-side. Split on the first colon; if a string
* ever stops carrying one, it renders whole as detail.
*/
function splitLabel(text: string): { label: string | null; detail: string } {
const at = text.indexOf(': ');
if (at === -1 || at > 48) return { label: null, detail: text };
return { label: text.slice(0, at), detail: text.slice(at + 2) };
}
function Finding({ text, primary }: { text: string; primary: boolean }) {
const warn = isWarning(text);
const { label, detail } = splitLabel(text);
return (
<li className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
{label && (
<span
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider sm:w-44 sm:pt-0.5 ${
warn ? 'text-amber-400' : 'text-gray-500'
}`}
>
{label}
</span>
)}
<span
className={`${primary ? 'text-sm' : 'text-xs'} ${
warn ? 'text-amber-300' : primary ? 'text-gray-200' : 'text-gray-400'
}`}
>
{detail}
</span>
</li>
);
}
export function BacktestRecommendationCard({ export function BacktestRecommendationCard({
recommendation, recommendation,
}: { }: {
@@ -45,30 +81,34 @@ export function BacktestRecommendationCard({
<div className="glass border border-blue-400/20 p-4"> <div className="glass border border-blue-400/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="section-index">What this backtest recommends</p> <p className="section-index">What this backtest recommends</p>
{warningCount > 0 && ( {warningCount > 0 ? (
<span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-300"> <span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-300">
{warningCount} warning{warningCount > 1 ? 's' : ''} {warningCount} warning{warningCount > 1 ? 's' : ''}
</span> </span>
) : (
<span className="rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
no warnings
</span>
)} )}
</div> </div>
{recommendation.headline && (
<p className="mt-1.5 text-sm font-semibold text-gray-100">{recommendation.headline}</p>
)}
{primary.length > 0 && ( {primary.length > 0 && (
<ul className="mt-3 space-y-1.5 border-t border-white/[0.06] pt-3"> <ul className="mt-3 space-y-2.5">
{primary.map((item) => ( {primary.map((item) => (
<li <Finding key={item.topic + item.text} text={item.text} primary />
key={item.topic + item.text}
className={`text-xs ${isWarning(item.text) ? 'text-amber-400' : 'text-gray-300'}`}
>
{item.text}
</li>
))} ))}
</ul> </ul>
)} )}
{/* The config description, demoted: it says what the strategy IS, which
is context for the findings above rather than a finding itself. */}
{recommendation.headline && (
<div className="mt-3 border-t border-white/[0.06] pt-3">
<p className="section-index">Configuration under test</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">{recommendation.headline}</p>
</div>
)}
{recommendation.note && ( {recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p> <p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p>
)} )}
@@ -77,12 +117,10 @@ export function BacktestRecommendationCard({
{/* Outside the card body on purpose: Disclosure renders its own glass-sm {/* Outside the card body on purpose: Disclosure renders its own glass-sm
panel, so nesting it inside the bordered card double-frames it. */} panel, so nesting it inside the bordered card double-frames it. */}
{secondary.length > 0 && ( {secondary.length > 0 && (
<Disclosure summary={`Gate, exit and cutoff detail (${secondary.length})`}> <Disclosure summary={`Gate and cutoff detail (${secondary.length})`}>
<ul className="space-y-1.5"> <ul className="space-y-2">
{secondary.map((item) => ( {secondary.map((item) => (
<li key={item.topic + item.text} className="text-xs text-gray-400"> <Finding key={item.topic + item.text} text={item.text} primary={false} />
{item.text}
</li>
))} ))}
</ul> </ul>
</Disclosure> </Disclosure>
@@ -70,7 +70,10 @@ export function PortfolioMonitorPanel({
onChange={onStrategyChange} onChange={onStrategyChange}
options={monitor.strategies.map((s) => ({ options={monitor.strategies.map((s) => ({
value: s.strategy, value: s.strategy,
label: `${s.is_production ? 'Production: ' : ''}${s.label}`, // "Production: " prefix dropped — a bullet costs one character
// instead of twelve, and the full config is spelled out under
// the chart anyway.
label: `${s.is_production ? '● ' : ''}${s.label}`,
}))} }))}
/> />
</div> </div>
@@ -97,7 +100,12 @@ export function PortfolioMonitorPanel({
/> />
<StatTile label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} /> <StatTile label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
<StatTile label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" /> <StatTile label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
<StatTile label="Sharpe" value={fmtRatio(monitorRun.sharpe)} /> <StatTile
label="EV / trade"
value={fmtSignedMoney(monitorRun.avg_trade_pnl)}
valueClass={rColor(monitorRun.avg_trade_pnl)}
title="Average realized P&L per closed trade. Scales with position size, so it carries no quality band."
/>
<StatTile label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} /> <StatTile label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
</div> </div>
@@ -109,39 +117,49 @@ export function PortfolioMonitorPanel({
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="section-index">Risk-adjusted quality</p> <p className="section-index">Risk-adjusted quality</p>
<p className="text-[11px] text-gray-600">
Bands are set stricter than textbook ranges this universe is today's
survivors replayed backward, which flatters every ratio.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatTile <StatTile
size="sm" label="Sharpe"
value={fmtRatio(monitorRun.sharpe)}
metric="sharpe"
raw={monitorRun.sharpe}
title="Return per unit of total volatility (annualized). Penalizes upside swings as well as downside."
/>
<StatTile
label="Sortino" label="Sortino"
value={fmtRatio(monitorRun.sortino)} value={fmtRatio(monitorRun.sortino)}
title="Return per unit of downside deviation (annualized)." metric="sortino"
raw={monitorRun.sortino}
title="Return per unit of downside deviation (annualized). Punishes losing days only, unlike Sharpe."
/> />
<StatTile <StatTile
size="sm"
label="Calmar (MAR)" label="Calmar (MAR)"
value={fmtRatio(monitorRun.calmar)} value={fmtRatio(monitorRun.calmar)}
title="CAGR divided by maximum drawdown." metric="calmar"
raw={monitorRun.calmar}
title="CAGR divided by maximum drawdown — return earned per unit of worst-case pain."
/> />
<StatTile <StatTile
size="sm"
label="Gain / Pain" label="Gain / Pain"
value={fmtRatio(monitorRun.gain_to_pain)} value={fmtRatio(monitorRun.gain_to_pain)}
title="Sum of monthly returns divided by the absolute sum of the negative ones." metric="gain_to_pain"
raw={monitorRun.gain_to_pain}
title="Sum of monthly returns divided by the absolute sum of the negative ones (Schwager)."
/> />
<StatTile <StatTile
size="sm"
label="Profit Factor ($)" label="Profit Factor ($)"
value={fmtRatio(monitorRun.profit_factor)} value={fmtRatio(monitorRun.profit_factor)}
metric="profit_factor"
raw={monitorRun.profit_factor}
title="Gross winning dollars divided by gross losing dollars, across closed trades." title="Gross winning dollars divided by gross losing dollars, across closed trades."
/> />
<StatTile
size="sm"
label="EV / trade"
value={fmtSignedMoney(monitorRun.avg_trade_pnl)}
valueClass={rColor(monitorRun.avg_trade_pnl)}
title="Average realized P&L per closed trade."
/>
</div> </div>
</div> </div>
)} )}
+6 -1
View File
@@ -86,7 +86,12 @@ export function Dropdown({
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm" className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
> >
<span className={selected ? 'text-gray-200' : 'text-gray-500'}> {/* truncate, not wrap: a long option name used to push the trigger to
three lines and shove the whole control row out of alignment. */}
<span
className={`truncate ${selected ? 'text-gray-200' : 'text-gray-500'}`}
title={selected ? selected.label : undefined}
>
{selected ? selected.label : placeholder} {selected ? selected.label : placeholder}
</span> </span>
<svg <svg
+48 -12
View File
@@ -1,11 +1,21 @@
import {
BAND_STYLE,
bandTicks,
classifyMetric,
meterFraction,
} from '../../lib/metricBands';
/** /**
* One labelled metric. Lifted from the byte-identical `Stat` that lived in both * One labelled metric.
* BacktestPanel and MyTradesPanel.
* *
* `size` is the hierarchy lever: `md` (default) is the headline look those two * Optionally carries a quality meter: pass `metric` (a key in METRIC_BANDS) and
* panels already had; `sm` marks a metric as supporting detail, which is what * the numeric `raw` value. The meter is the answer to "2.72 — is that good?" —
* keeps a second row of ratios from reading as equally important as the returns * a track showing where the value sits, ticks at the band edges, and the band
* above it. * word. Colour never travels alone; the word is always rendered beside it.
*
* Every tile is the same size. Hierarchy comes from grouping and section
* labels, not from shrinking one row — two sizes read as inconsistent rather
* than as a deliberate ranking.
*/ */
export function StatTile({ export function StatTile({
label, label,
@@ -13,7 +23,8 @@ export function StatTile({
valueClass = 'text-gray-100', valueClass = 'text-gray-100',
sub, sub,
title, title,
size = 'md', metric,
raw,
}: { }: {
label: string; label: string;
value: string; value: string;
@@ -21,14 +32,39 @@ export function StatTile({
sub?: string; sub?: string;
/** Native tooltip — how the metric is defined. */ /** Native tooltip — how the metric is defined. */
title?: string; title?: string;
size?: 'md' | 'sm'; /** Key into METRIC_BANDS; enables the quality meter. */
metric?: string;
/** Numeric value the meter reads (the formatted `value` is display-only). */
raw?: number | null;
}) { }) {
const pad = size === 'sm' ? 'p-3' : 'p-4'; const band = metric ? classifyMetric(metric, raw) : null;
const text = size === 'sm' ? 'text-lg' : 'text-2xl'; const style = band ? BAND_STYLE[band] : null;
return ( return (
<div className={`glass ${pad}`} title={title}> <div className="glass flex flex-col p-4" title={title}>
<p className="section-index">{label}</p> <p className="section-index">{label}</p>
<p className={`num mt-1.5 ${text} font-semibold ${valueClass}`}>{value}</p> <p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
{style && metric && (
<div className="mt-2.5">
<div className="relative h-1.5 overflow-hidden rounded-full bg-white/[0.07]">
<div
className={`h-full rounded-full ${style.fill}`}
style={{ width: `${meterFraction(metric, raw) * 100}%` }}
/>
{/* Band edges — where "fair" becomes "good", and so on. */}
{bandTicks(metric).map((t) => (
<span
key={t}
className="absolute top-0 h-full w-px bg-black/50"
style={{ left: `${t * 100}%` }}
/>
))}
</div>
<p className={`mt-1.5 text-[11px] font-medium ${style.text}`}>{style.label}</p>
</div>
)}
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>} {sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div> </div>
); );
+77
View File
@@ -0,0 +1,77 @@
/**
* Quality bands for the risk-adjusted metrics.
*
* A tile reading "Sortino 2.72" answers nothing on its own. These bands turn
* each ratio into weak / fair / good / strong so the tile says whether the
* number is any good.
*
* The bands are deliberately STRICTER than the textbook ranges. This backtest
* replays today's ~512 tracked tickers backward, so every name that failed or
* was acquired inside the window is missing and every ratio here is flattered.
* Standard thresholds would print "strong" on numbers survivorship inflated.
* Treat a band as a claim about this book relative to itself, not a claim that
* the live strategy will reproduce it.
*
* Edges are lower-inclusive: a value exactly on an edge takes the higher band.
*/
export type BandName = 'weak' | 'fair' | 'good' | 'strong';
export interface MetricBand {
/** Lower edges for fair / good / strong. Below the first edge is weak. */
edges: [number, number, number];
/** Where the meter track ends. Values above clamp to full. */
max: number;
}
export const METRIC_BANDS: Record<string, MetricBand> = {
sharpe: { edges: [0.8, 1.5, 2.5], max: 3.5 },
sortino: { edges: [1.2, 2.0, 3.0], max: 4.0 },
calmar: { edges: [0.5, 1.0, 2.5], max: 3.5 },
gain_to_pain: { edges: [1.0, 1.5, 2.5], max: 3.5 },
profit_factor: { edges: [1.3, 1.8, 2.5], max: 3.5 },
};
const BAND_ORDER: BandName[] = ['weak', 'fair', 'good', 'strong'];
export function classifyMetric(
key: keyof typeof METRIC_BANDS | string,
value: number | null | undefined,
): BandName | null {
const band = METRIC_BANDS[key];
if (!band || value === null || value === undefined || !Number.isFinite(value)) {
return null;
}
const passed = band.edges.filter((edge) => value >= edge).length;
return BAND_ORDER[passed];
}
/** Fraction of the meter track a value fills, clamped to 0..1. */
export function meterFraction(
key: keyof typeof METRIC_BANDS | string,
value: number | null | undefined,
): number {
const band = METRIC_BANDS[key];
if (!band || value === null || value === undefined || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value / band.max));
}
/** Band edges as track fractions, for drawing the tick marks. */
export function bandTicks(key: keyof typeof METRIC_BANDS | string): number[] {
const band = METRIC_BANDS[key];
if (!band) return [];
return band.edges.map((edge) => edge / band.max);
}
/**
* Status colours, not the categorical palette — these encode state, so they are
* reserved and always paired with the band word rather than standing alone.
*/
export const BAND_STYLE: Record<BandName, { fill: string; text: string; label: string }> = {
weak: { fill: 'bg-red-400/70', text: 'text-red-400', label: 'weak' },
fair: { fill: 'bg-amber-400/70', text: 'text-amber-400', label: 'fair' },
good: { fill: 'bg-emerald-400/70', text: 'text-emerald-400', label: 'good' },
strong: { fill: 'bg-emerald-300/80', text: 'text-emerald-300', label: 'strong' },
};