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>
140 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|