From 3a2d5486109fd69991e13bc3e2093ecb0d1ed43f Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 11 Aug 2026 23:25:11 +0200 Subject: [PATCH] feat(backtest): make the tiles answer "is that good?", and stop the layout jumping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/signals/BacktestPanel.tsx | 75 +++++++++------- .../signals/BacktestRecommendationCard.tsx | 88 +++++++++++++------ .../signals/PortfolioMonitorPanel.tsx | 52 +++++++---- frontend/src/components/ui/Dropdown.tsx | 7 +- frontend/src/components/ui/StatTile.tsx | 60 ++++++++++--- frontend/src/lib/metricBands.ts | 77 ++++++++++++++++ 6 files changed, 273 insertions(+), 86 deletions(-) create mode 100644 frontend/src/lib/metricBands.ts diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index c1e314e..a777f58 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -70,23 +70,30 @@ export function BacktestPanel() { return (
-
- -

- 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. -

-

- Live GTL is the exact target path the scanner and the - scheduled backtest use; Structural S/R is a comparison - arm sourcing targets from chart structure. Weekly steps - five sessions at a time and is what the server runs; Daily - {' '}is roughly 5× the replay work. -

-
+ {/* Run status and the controls that start a new run, on one line. The + explainer sits BELOW this row rather than beside it — sharing a flex + row meant expanding it shoved every control down the page. */} +
+
+

Last run

+ {report ? ( +

+ {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 + )} + {' · '} + + {report.params.target_model_label ?? 'Unknown (legacy report)'} + +

+ ) : ( +

Never run

+ )} +
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a narrow viewport otherwise. */} @@ -112,11 +119,30 @@ export function BacktestPanel() { />
+
+ +

+ 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. +

+

+ Live GTL is the exact target path the scanner and the + scheduled backtest use; Structural S/R is a comparison + arm sourcing targets from chart structure. Weekly steps + five sessions at a time and is what the server runs; Daily + {' '}is roughly 5× the replay work. +

+
+
+ {/* Only surfaced for non-default choices — zero noise on the common path, but a non-production selection still announces itself, which is what the old always-amber cards were really for. */} @@ -142,19 +168,6 @@ export function BacktestPanel() { {report && ( <> -

- 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:{' '} - - {report.params.target_model_label ?? 'Unknown (legacy report)'} - -

- 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 ( +
  • + {label && ( + + {label} + + )} + + {detail} + +
  • + ); +} + export function BacktestRecommendationCard({ recommendation, }: { @@ -45,30 +81,34 @@ export function BacktestRecommendationCard({

    What this backtest recommends

    - {warningCount > 0 && ( + {warningCount > 0 ? ( ⚠ {warningCount} warning{warningCount > 1 ? 's' : ''} + ) : ( + + no warnings + )}
    - {recommendation.headline && ( -

    {recommendation.headline}

    - )} - {primary.length > 0 && ( -
      +
        {primary.map((item) => ( -
      • - {item.text} -
      • + ))}
      )} + {/* The config description, demoted: it says what the strategy IS, which + is context for the findings above rather than a finding itself. */} + {recommendation.headline && ( +
      +

      Configuration under test

      +

      {recommendation.headline}

      +
      + )} + {recommendation.note && (

      {recommendation.note}

      )} @@ -77,12 +117,10 @@ export function BacktestRecommendationCard({ {/* Outside the card body on purpose: Disclosure renders its own glass-sm panel, so nesting it inside the bordered card double-frames it. */} {secondary.length > 0 && ( - -
        + +
          {secondary.map((item) => ( -
        • - {item.text} -
        • + ))}
        diff --git a/frontend/src/components/signals/PortfolioMonitorPanel.tsx b/frontend/src/components/signals/PortfolioMonitorPanel.tsx index 29d296e..091ba3b 100644 --- a/frontend/src/components/signals/PortfolioMonitorPanel.tsx +++ b/frontend/src/components/signals/PortfolioMonitorPanel.tsx @@ -70,7 +70,10 @@ export function PortfolioMonitorPanel({ onChange={onStrategyChange} options={monitor.strategies.map((s) => ({ 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}`, }))} />
    @@ -97,7 +100,12 @@ export function PortfolioMonitorPanel({ /> - + @@ -109,39 +117,49 @@ export function PortfolioMonitorPanel({

    ) : (
    -

    Risk-adjusted quality

    +
    +

    Risk-adjusted quality

    +

    + Bands are set stricter than textbook ranges — this universe is today's + survivors replayed backward, which flatters every ratio. +

    +
    + -
    )} diff --git a/frontend/src/components/ui/Dropdown.tsx b/frontend/src/components/ui/Dropdown.tsx index 3a5f742..4d02ccf 100644 --- a/frontend/src/components/ui/Dropdown.tsx +++ b/frontend/src/components/ui/Dropdown.tsx @@ -86,7 +86,12 @@ export function Dropdown({ 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" > - + {/* truncate, not wrap: a long option name used to push the trigger to + three lines and shove the whole control row out of alignment. */} + {selected ? selected.label : placeholder} +

    {label}

    -

    {value}

    +

    {value}

    + + {style && metric && ( +
    +
    +
    + {/* Band edges — where "fair" becomes "good", and so on. */} + {bandTicks(metric).map((t) => ( + + ))} +
    +

    {style.label}

    +
    + )} + {sub &&

    {sub}

    }
    ); diff --git a/frontend/src/lib/metricBands.ts b/frontend/src/lib/metricBands.ts new file mode 100644 index 0000000..a732534 --- /dev/null +++ b/frontend/src/lib/metricBands.ts @@ -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 = { + 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 = { + 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' }, +};