One tab stacked three things that all called themselves a track record: realized paper P&L, setup-outcome grading under the rejected take-profit model, and the backtest portfolio simulation. Split into Setups | Paper Trades | Backtest, one subject each. `track` stays the Paper Trades slug so the legacy /performance redirect keeps working. The grading diagnostic and its Evaluate / Reset controls go with Backtest, not Paper Trades — reset_track_record deletes trade_setups, not paper trades. BacktestPanel 439 -> 175 lines. Its run settings alone were 106 lines of hand-rolled sr-only radio cards for two binary choices; they are now two Dropdowns and a button on one wrapping row, with the per-option prose moved into the existing explainer. The amber warnings survive as a conditional slot, so a non-default choice still announces itself but the common path is silent. The recommendation printed eight findings at equal weight, burying the verdict in tuning detail. `topic` now splits them: production, benchmark and robustness stay inline, gate/exit/cutoff collapse behind a disclosure, and any WARNING or LAGS item is promoted out of the collapsed group regardless of topic. No topic chips — every backend string already self-prefixes, so a chip would render "GATE | Gate: ...". Portfolio metrics are now two tiers: five headline tiles for what the book returned, then a smaller labelled row for how good that return was (Sortino, Calmar (MAR), Gain/Pain, Profit Factor $, EV/trade). Reports cached before those metrics existed hide the second row rather than showing a half-populated line of dashes. Extracted EquityCurveChart, PortfolioMonitorPanel and BacktestRecommendationCard, plus a StatTile primitive and shared formatters for the duplication in the files this touched. DashboardPage and OpenTradesPanel deliberately keep their own copies — migrating them is separate scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
3.5 KiB
TypeScript
93 lines
3.5 KiB
TypeScript
import { Disclosure } from '../ui/Disclosure';
|
|
import type { BacktestRecommendation } from '../../lib/types';
|
|
|
|
/**
|
|
* The verdict, ahead of the tuning detail.
|
|
*
|
|
* All eight findings used to render as equal-weight bullets, so "does this
|
|
* strategy work" sat in the same visual register as "which cutoff scored best".
|
|
* `topic` splits them: the three that answer the question stay inline, the rest
|
|
* collapse.
|
|
*
|
|
* No topic chips — every backend string already self-prefixes ("Gate: …",
|
|
* "Robustness: …"), so a chip would render "GATE │ Gate: …", and stripping the
|
|
* prefix would drop real information ("(3y)" carries the lookback, "Legacy"
|
|
* qualifies the diagnostic).
|
|
*/
|
|
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');
|
|
}
|
|
|
|
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>
|
|
{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>
|
|
)}
|
|
</div>
|
|
|
|
{recommendation.headline && (
|
|
<p className="mt-1.5 text-sm font-semibold text-gray-100">{recommendation.headline}</p>
|
|
)}
|
|
|
|
{primary.length > 0 && (
|
|
<ul className="mt-3 space-y-1.5 border-t border-white/[0.06] pt-3">
|
|
{primary.map((item) => (
|
|
<li
|
|
key={item.topic + item.text}
|
|
className={`text-xs ${isWarning(item.text) ? 'text-amber-400' : 'text-gray-300'}`}
|
|
>
|
|
{item.text}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{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, exit and cutoff detail (${secondary.length})`}>
|
|
<ul className="space-y-1.5">
|
|
{secondary.map((item) => (
|
|
<li key={item.topic + item.text} className="text-xs text-gray-400">
|
|
{item.text}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Disclosure>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|