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' }, +};