Files
signal-platform/frontend/src/components/signals/BacktestRecommendationCard.tsx
T
dennisthiessenandClaude Opus 5 21a5fc8a52 fix(backtest): flag a lookback the recommendation was not computed on
Selecting a different window or a comparison strategy silently made the tiles
stop matching the recommendation below, which is baked into the report and
cannot follow a dropdown. On load they now agree by construction; moving off
that basis says so.

Also: an absent production row produced no headline and no benchmark, but any
passing gate finding still rendered a green "no warnings" chip — a success badge
for missing data, directly beside "this report predates the portfolio monitor".
Missing baseline now reads "baseline unavailable".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:03:12 +02:00

140 lines
5.6 KiB
TypeScript

import { Disclosure } from '../ui/Disclosure';
import type { BacktestRecommendation } from '../../lib/types';
/**
* The verdict, ahead of the tuning detail.
*
* Two problems this solves. All eight findings used to render as equal-weight
* bullets, so "does this strategy work" sat in the same register as "which
* cutoff scored best". And the headline — which is a *description of the
* config*, not a verdict — was the loudest thing on the card while every actual
* finding was small grey text.
*
* So: findings first, each split into a label and its detail; the config
* description demoted to a footer where it belongs.
*/
const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']);
/**
* Mirrors how the backend phrases a bad result — `_build_recommendation` emits
* "Robustness WARNING: …" and "Book vs SPY: LAGS …". There is deliberately no
* `severity` field on the payload; if that changes, this is the one place to fix.
*/
function isWarning(text: string): boolean {
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({
recommendation,
}: {
recommendation: BacktestRecommendation;
}) {
const items = recommendation.items;
if (items.length === 0) return null;
// A warning is always visible, whatever its topic — burying "the edge
// disappears without the top 5% of winners" behind a disclosure would defeat
// the point of surfacing it at all.
const primary = items.filter((i) => PRIMARY_TOPICS.has(i.topic) || isWarning(i.text));
const secondary = items.filter((i) => !PRIMARY_TOPICS.has(i.topic) && !isWarning(i.text));
const warningCount = items.filter((i) => isWarning(i.text)).length;
return (
<div className="space-y-2">
<div className="glass border border-blue-400/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="section-index">What this backtest recommends</p>
{/* No headline means the backend found no production monitor row, so
nothing here describes the production book. Zero keyword warnings
is then absence of data, not a clean bill of health — a green chip
beside "this report predates the portfolio monitor" would be a
success badge for missing data. */}
{!recommendation.headline ? (
<span className="rounded-full border border-white/15 bg-white/[0.05] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
baseline unavailable
</span>
) : 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">
{warningCount} warning{warningCount > 1 ? 's' : ''}
</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>
{primary.length > 0 && (
<ul className="mt-3 space-y-2.5">
{primary.map((item) => (
<Finding key={item.topic + item.text} text={item.text} primary />
))}
</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 && (
<p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p>
)}
</div>
{/* 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 && (
<Disclosure summary={`Gate and cutoff detail (${secondary.length})`}>
<ul className="space-y-2">
{secondary.map((item) => (
<Finding key={item.topic + item.text} text={item.text} primary={false} />
))}
</ul>
</Disclosure>
)}
</div>
);
}