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>
113 lines
6.1 KiB
TypeScript
113 lines
6.1 KiB
TypeScript
import { useMemo } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { usePaperTrades } from '../../hooks/usePaperTrades';
|
||
import { tradePnl } from '../../lib/paperTrade';
|
||
import { formatPrice, fmtR, fmtSignedMoney, rColor } from '../../lib/format';
|
||
import { Section } from '../ui/Section';
|
||
import { Callout } from '../ui/Callout';
|
||
import { StatTile } from '../ui/StatTile';
|
||
|
||
// How the trade was closed — useful context on real trades at almost no cost.
|
||
function reasonMeta(reason: string | null): { label: string; cls: string } {
|
||
switch (reason) {
|
||
case 'stop': return { label: 'Stop', cls: 'text-red-400' };
|
||
case 'trailing': return { label: 'Trail', cls: 'text-amber-400' };
|
||
case 'target': return { label: 'Target', cls: 'text-emerald-400' };
|
||
case 'time': return { label: 'Time', cls: 'text-gray-400' };
|
||
case 'manual': return { label: 'Manual', cls: 'text-blue-300' };
|
||
default: return { label: '—', cls: 'text-gray-500' };
|
||
}
|
||
}
|
||
|
||
export function MyTradesPanel() {
|
||
const { data: closed, isLoading } = usePaperTrades('closed');
|
||
|
||
const stats = useMemo(() => {
|
||
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) }));
|
||
const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
|
||
const pnls = rows.map((r) => r.p?.pnl ?? 0);
|
||
const alphas = rows.map((r) => r.t.alpha_usd).filter((a): a is number => a != null);
|
||
const wins = pnls.filter((p) => p > 0).length;
|
||
const losses = pnls.filter((p) => p < 0).length;
|
||
const decided = wins + losses;
|
||
return {
|
||
total: rows.length,
|
||
wins,
|
||
losses,
|
||
hitRate: decided ? (wins / decided) * 100 : null,
|
||
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
|
||
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
|
||
totalPnl: pnls.reduce((a, b) => a + b, 0),
|
||
totalAlpha: alphas.length ? alphas.reduce((a, b) => a + b, 0) : null,
|
||
rows,
|
||
};
|
||
}, [closed]);
|
||
|
||
if (isLoading) return null;
|
||
|
||
return (
|
||
<Section
|
||
title="Closed Trades"
|
||
hint="realized paper-trading results — open positions are on the Dashboard"
|
||
>
|
||
{stats.total === 0 ? (
|
||
<Callout variant="empty">
|
||
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
|
||
the stop or target (or when you sell).
|
||
</Callout>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||
<StatTile label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||
<StatTile label="Expectancy" value={fmtR(stats.avgR)} valueClass={rColor(stats.avgR)} sub="avg R per closed trade" />
|
||
<StatTile label="Total R" value={fmtR(stats.totalR)} valueClass={rColor(stats.totalR)} sub={`${stats.total} closed`} />
|
||
<StatTile label="Total P&L" value={fmtSignedMoney(stats.totalPnl)} valueClass={rColor(stats.totalPnl)} sub="realized, all closed" />
|
||
<StatTile label="Alpha vs S&P 500" value={stats.totalAlpha != null ? fmtSignedMoney(stats.totalAlpha) : '—'} valueClass={rColor(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
||
</div>
|
||
|
||
<div className="glass overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||
<th className="px-4 py-2.5">Ticker</th>
|
||
<th className="px-4 py-2.5">Dir</th>
|
||
<th className="px-4 py-2.5 text-right">Entry</th>
|
||
<th className="px-4 py-2.5 text-right">Exit Px</th>
|
||
<th className="px-4 py-2.5 text-right">P&L</th>
|
||
<th className="px-4 py-2.5 text-right">R</th>
|
||
<th className="px-4 py-2.5 text-right">Alpha</th>
|
||
<th className="px-4 py-2.5">Reason</th>
|
||
<th className="px-4 py-2.5 text-right">Closed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{stats.rows.map(({ t, p }) => (
|
||
<tr key={t.id} className="border-b border-white/[0.04] hover:bg-white/[0.03]">
|
||
<td className="px-4 py-2.5">
|
||
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">{t.symbol}</Link>
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>{t.direction}</span>
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
||
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? rColor(p.pnl) : 'text-gray-500'}`}>{p ? fmtSignedMoney(p.pnl) : '—'}</td>
|
||
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? rColor(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
||
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? rColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
||
<td className="px-4 py-2.5">
|
||
<span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed">
|
||
{reasonMeta(t.close_reason).label}
|
||
</span>
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|