Compare commits

...
4 Commits
Author SHA1 Message Date
dennisthiessenandClaude Opus 4.8 ce6035ee3c Fix stale copy, dedupe Exit columns, document local report review
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m26s
Deploy / deploy (push) Successful in 42s
Follow-ups from review of the Track Record slim:

- BacktestPanel: drop the stale "tracking check" sentence from the "How this is
  measured" explainer — that check moved to the maintenance disclosure last
  commit, so it no longer describes anything in this block.
- MyTradesPanel: rename the two identically-labelled "Exit" columns to "Exit Px"
  (exit price) and "Reason" (close_reason) so they're not confusable.
- README: add "Reading a local backtest report" under Local Backtest Snapshots —
  a section->decision map for reports/backtest-*.json. The strategy-tuning tables
  removed from the deployed page (sweep, gate_ablation, time_exit_sweep,
  signal_eval, strategy_variants) now live only in the local report, so this
  keeps "research lives local" from meaning the decision knowledge evaporates.

tsc -b && vite build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:01:59 +02:00
dennisthiessenandClaude Opus 4.8 2a4bdd16a8 Demote/relabel the setup-outcome check; add exit reason to My Trades
The "tracking/drift" chip compared the live target/stop/expired outcome cohort
against the backtest's target/stop bucket (overall_qualified) — a like-for-like
pipeline check — but sat directly under the portfolio monitor, which shows the
promoted 3x-ATR-trailing book. That juxtaposition (plus "faithfully implementing
it" copy) made a plumbing/QA signal read as validation of the ATR-trail strategy
you actually trade. It validates neither the trailing-stop book nor real trades.

- Move the check out of the monitor block into the "Track-record maintenance"
  disclosure, relabelled "Setup-outcome pipeline check" with copy that says it
  checks the setup-grading pipeline (no look-ahead/config/data drift), NOT the
  ATR-trail production book. The genuine live validation stays My Trades (real
  paper trades, same ATR-trail exits) up top.
- Add a compact "Exit" column to My Trades showing close_reason
  (Stop/Trail/Target/Time/Manual) — the field was already plumbed to the
  frontend PaperTrade type, so this is frontend-only.

tsc -b && vite build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:51:13 +02:00
dennisthiessenandClaude Opus 4.8 02b28f5ea6 Slim Track Record page to validation + how-to-trade
Strategy research now runs locally against DB snapshots (see README), so the
deployed Track Record page no longer needs the strategy-tuning output. Keep only
what answers "did my trades work / is the strategy working / what do I trade":

- Reshape BacktestPanel into an "Is the strategy working?" block: portfolio
  monitor (unchanged), a deliberate metric set (CAGR, Sharpe, Max DD, Total
  Return vs SPY, per-year returns), plus the folded-in live-vs-backtest verdict
  and the backtest recommendation.
- Fold the standalone portfolio-sim table's unique rows (per-year returns, avg
  hold, best/worst, avg P&L) into the monitor; drop the duplicate table.
- Slim TrackRecordPanel to My Trades -> Is it working? -> maintenance disclosure
  (Evaluate/Reset demoted).
- Cut the local-research tables: percentile sweep, gate ablation, time-exit
  sweep, strategy variants, signal-edge rank-IC, research candidates,
  by-action/by-confidence breakdowns, and the bucket comparison.

Frontend-only; the weekly server backtest still computes the cut tables (they
feed the local report). tsc -b && vite build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:36:41 +02:00
dennisthiessenandClaude Opus 4.8 ca42e1b28d Precompute ATR series for paper-trade trailing exits
Both _atr_trailing_close (scheduled) and _atr_trailing_level (dashboard
read path) recomputed ATR from scratch on every post-entry bar via
compute_atr(rows[:idx+1]) — O(n*k) per trade. Replace with a single O(n)
Wilder pass, _atr_series_from_rows, that stores round(running, 4) at each
index. compute_atr keeps its running ATR unrounded through the recurrence
and rounds only at return, so this reproduces its per-prefix value exactly
(no behavior change; live-vs-backtest atr_trail3 parity still byte-identical).

Remove the now-unused _atr_from_rows and its compute_atr import. Add a
per-index parity test against compute_atr; existing ATR tests now mock
_atr_series_from_rows (same effect as the old fixed-ATR mock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:13:19 +02:00
6 changed files with 211 additions and 766 deletions
+22
View File
@@ -307,6 +307,28 @@ metrics. Keep the SSH tunnel open only while creating the snapshot; the backtest
run itself is local/offline. `backtest_snapshots/` and generated backtest reports run itself is local/offline. `backtest_snapshots/` and generated backtest reports
are git-ignored. are git-ignored.
### Reading a local backtest report
The deployed **Signals → Track Record** page is deliberately trimmed to validation
(portfolio monitor vs SPY, realized paper trades) and how-to-trade. The
strategy-tuning tables that used to live there now live **only** in the local
report — inspect these `reports/backtest-<timestamp>.json` sections and produce the
matching decision. Every change still goes through the factor harness first (see
**The iron rule for strategy changes** above).
| Report section | What to read | Decision it drives |
|---|---|---|
| `overall_qualified` vs `overall_all` | Is qualified net expectancy above the all-setups baseline? | Sanity — is the gate adding anything at all |
| `sweep` | Net avg R and trade count at each residual-momentum cutoff | Where to set the momentum percentile (Admin → Settings → Activation) |
| `gate_ablation` | Net expectancy with each floor removed | Drop a floor only if removing it doesn't hurt net expectancy |
| `time_exit_sweep` | Net avg R / net R-per-day by hold length | Whether a fixed time exit beats the promoted ATR trail |
| `portfolio_monitor`, `portfolio_sim`, `strategy_variants` | CAGR, Sharpe, max drawdown, per-year returns | Promote a strategy only if it beats the current baseline on CAGR/Sharpe/DD |
| `signal_eval` | Mean IC, t-stat, IC>0 %, `reliable` | Iron rule: wire a new factor in only if \|IC\| ≳ 0.03 with a consistent sign and `reliable: true` |
| `recommendation`, `research_recommendation` | The report's own headline read | A starting point, not a substitute for the sections above |
`recommendation` is the one section surfaced on the deployed page ("What this
backtest recommends"); everything else in this table is intentionally local-only.
## Environment Variables ## Environment Variables
Configure in `.env` (copy from `.env.example`): Configure in `.env` (copy from `.env.example`):
+31 -14
View File
@@ -12,7 +12,6 @@ from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.services import benchmark_service, settings_store from app.services import benchmark_service, settings_store
from app.services.indicator_service import compute_atr
from app.services.outcome_service import ( from app.services.outcome_service import (
OUTCOME_AMBIGUOUS, OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT, OUTCOME_STOP_HIT,
@@ -181,17 +180,33 @@ def _trailing_close(
return None return None
def _atr_from_rows(rows: list[tuple], idx: int) -> float | None: def _atr_series_from_rows(rows: list[tuple], period: int = 14) -> list[float | None]:
try: """ATR at each index i, equal to ``compute_atr(rows[: i + 1])["atr"]`` but
result = compute_atr( computed in a single O(n) Wilder pass instead of re-smoothing the whole
[float(r[2]) for r in rows[: idx + 1]], prefix per bar. None where there are fewer than ``period + 1`` bars or the
[float(r[3]) for r in rows[: idx + 1]], rounded ATR is non-positive. ``period`` mirrors ``compute_atr``'s default;
[float(r[4]) for r in rows[: idx + 1]], keep them in sync.
)
except Exception: Exactness: ``compute_atr`` keeps its running ATR unrounded through the
return None recurrence and rounds only at return, so storing ``round(running, 4)`` at
atr = result.get("atr") each index reproduces its per-prefix value bit-for-bit.
return float(atr) if atr and atr > 0 else None """
n = len(rows)
out: list[float | None] = [None] * n
if n < period + 1:
return out
tr = [0.0] * n
for i in range(1, n):
high, low, prev_close = float(rows[i][2]), float(rows[i][3]), float(rows[i - 1][4])
tr[i] = max(high - low, abs(high - prev_close), abs(low - prev_close))
running = sum(tr[1 : period + 1]) / period
rounded = round(running, 4)
out[period] = rounded if rounded > 0 else None
for j in range(period + 1, n):
running = (running * (period - 1) + tr[j]) / period
rounded = round(running, 4)
out[j] = rounded if rounded > 0 else None
return out
def _atr_trailing_level( def _atr_trailing_level(
@@ -206,11 +221,12 @@ def _atr_trailing_level(
long = direction == "long" long = direction == "long"
stop = float(init_stop) stop = float(init_stop)
anchor = float(entry) anchor = float(entry)
atr_by_idx = _atr_series_from_rows(rows)
for idx, (d, _, _, _, close) in enumerate(rows): for idx, (d, _, _, _, close) in enumerate(rows):
if d <= opened_on: if d <= opened_on:
continue continue
close = float(close) close = float(close)
atr = _atr_from_rows(rows, idx) atr = atr_by_idx[idx]
if long: if long:
anchor = max(anchor, close) anchor = max(anchor, close)
if atr is not None: if atr is not None:
@@ -244,6 +260,7 @@ def _atr_trailing_close(
stop = float(init_stop) stop = float(init_stop)
anchor = float(entry) anchor = float(entry)
bars_held = 0 bars_held = 0
atr_by_idx = _atr_series_from_rows(rows)
for idx, (d, open_, high, low, close) in enumerate(rows): for idx, (d, open_, high, low, close) in enumerate(rows):
if d <= opened_on: if d <= opened_on:
continue continue
@@ -265,7 +282,7 @@ def _atr_trailing_close(
if bars_held >= hold_days: if bars_held >= hold_days:
return close, d, "time" return close, d, "time"
atr = _atr_from_rows(rows, idx) atr = atr_by_idx[idx]
if long: if long:
anchor = max(anchor, close) anchor = max(anchor, close)
if atr is not None: if atr is not None:
+51 -540
View File
@@ -7,13 +7,7 @@ import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure'; import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import type { import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
BacktestBucket,
BacktestCurvePoint,
BacktestPortfolioMonitorRun,
BacktestPortfolioPolicy,
BacktestStrategyVariant,
} from '../../lib/types';
function fmtR(v: number | null | undefined): string { function fmtR(v: number | null | undefined): string {
if (v === null || v === undefined) return '—'; if (v === null || v === undefined) return '—';
@@ -36,10 +30,6 @@ function fmtDrawdown(v: number | null | undefined): string {
function fmtDays(v: number | null | undefined): string { function fmtDays(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`; return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
} }
function fmtRPerDay(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(3)}R`;
}
function rColor(v: number | null): string { function rColor(v: number | null): string {
if (v === null) return 'text-gray-400'; if (v === null) return 'text-gray-400';
if (v > 0) return 'text-emerald-400'; if (v > 0) return 'text-emerald-400';
@@ -47,49 +37,6 @@ function rColor(v: number | null): string {
return 'text-gray-300'; return 'text-gray-300';
} }
const SIGNAL_LABELS: Record<string, string> = {
mom_12_1: '121 month momentum',
mom_12_1_resid: '121 residual momentum',
mom_6_1: '61 month momentum',
mom_3_1: '31 month momentum',
reversal_1m: '1-month reversal',
trend_200: 'Price vs 200-day SMA',
high_52w: 'Proximity to 52-week high',
vol_6m: '6-month realized volatility',
};
const ABLATION_LABELS: Record<string, string> = {
all_floors: 'All floors (current gate)',
no_confidence_floor: 'Without confidence floor',
no_rr_floor: 'Without R:R floor',
no_neutral_exclusion: 'Without NEUTRAL exclusion',
momentum_only: 'Momentum only (no floors)',
};
const POLICY_LABELS: Record<string, string> = {
target: 'S/R target exit',
hold: 'Hold to horizon',
};
// Prefer the net-of-costs number when the report carries it; older cached
// reports (pre-cost model) fall back to gross.
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
return r.net_avg_r ?? r.avg_r;
}
// An |IC| this large, with a consistent sign, is a real (if small) edge worth
// building on; below it, ranking on the signal sorts essentially nothing.
const IC_EDGE_THRESHOLD = 0.03;
function icColor(v: number): string {
if (Math.abs(v) < 0.02) return 'text-gray-400';
return v > 0 ? 'text-emerald-400' : 'text-red-400';
}
function fmtSpread(v: number | null): string {
if (v === null) return '—';
return `${v > 0 ? '+' : ''}${(v * 100).toFixed(2)}%`;
}
function timeAgo(iso: string): string { function timeAgo(iso: string): string {
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (mins < 1) return 'just now'; if (mins < 1) return 'just now';
@@ -111,25 +58,6 @@ function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
); );
} }
function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
return (
<tr className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{b.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{b.wins}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{b.losses}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{b.expired}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(b.best_r)}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(b.worst_r)}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(b.avg_hold_days)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_r_per_day ?? null)}`}>{fmtRPerDay(b.net_r_per_day)}</td>
</tr>
);
}
function curvePath( function curvePath(
points: BacktestCurvePoint[], points: BacktestCurvePoint[],
min: number, min: number,
@@ -215,11 +143,6 @@ export function BacktestPanel() {
const [selectedStrategy, setSelectedStrategy] = useState(''); const [selectedStrategy, setSelectedStrategy] = useState('');
const [selectedLookback, setSelectedLookback] = useState(''); const [selectedLookback, setSelectedLookback] = useState('');
const bestTimeAvgR =
report?.time_exit_sweep && report.time_exit_sweep.length > 0
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
: null;
const sim = report?.portfolio_sim ?? null;
const monitor = report?.portfolio_monitor ?? null; const monitor = report?.portfolio_monitor ?? null;
const activeStrategy = const activeStrategy =
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || ''; selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
@@ -248,17 +171,16 @@ export function BacktestPanel() {
}); });
return ( return (
<Section title="Backtest" hint="historical replay of the current config"> <Section title="Is the strategy working?" hint="portfolio simulation of the promoted strategy vs S&P 500">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<Disclosure summary="How the backtest works"> <Disclosure summary="How this is measured">
<p className="text-xs text-gray-400"> <p className="max-w-2xl text-xs text-gray-400">
At each weekly point in history, the setup is rebuilt using only data up to that day The backtest replays the current config weekly through history at each point the setup is
(no lookahead), then the actual following ~30 trading days decide its outcome. This rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide
shows how the <em>current</em> settings would have performed. Sentiment and its outcome then simulates one capital-constrained book against the S&P 500. Sentiment and
fundamentals are held neutral (no point-in-time history), so this calibrates the fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
price / support-resistance / probability machinery. ~6 months of data is roughly one so read it as directional.
market regime read it as directional, not a guarantee.
</p> </p>
</Disclosure> </Disclosure>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0"> <Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
@@ -270,8 +192,8 @@ export function BacktestPanel() {
{!isLoading && !report && ( {!isLoading && !report && (
<Callout variant="empty"> <Callout variant="empty">
No backtest yet. Click Run backtest (or trigger it in Admin Jobs) it replays every No backtest yet. Click Run backtest (or trigger it in Admin Jobs) it replays every ticker
ticker over history and takes a minute or two. over history and takes a minute or two.
</Callout> </Callout>
)} )}
@@ -281,17 +203,17 @@ export function BacktestPanel() {
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon ({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
{report.params.cost_per_side_pct != null && ( {report.params.cost_per_side_pct != null && (
<> · net assumes {report.params.cost_per_side_pct}%/side costs</> <> · net of {report.params.cost_per_side_pct}%/side costs</>
)} )}
</p> </p>
{monitor && monitorRun && ( {monitor && monitorRun ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<p className="section-index">Portfolio monitor</p> <p className="section-index">Portfolio monitor</p>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Cached portfolio simulation for supported strategies, compared with S&P 500. Simulated book for the selected strategy and lookback, compared with the S&P 500.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -328,13 +250,44 @@ export function BacktestPanel() {
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} /> <Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} /> <Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" /> <Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
<Stat label="Total Return" value={fmtSignedPct(monitorRun.total_return_pct)} valueClass={rColor(monitorRun.total_return_pct)} /> <Stat
label="Total Return"
value={fmtSignedPct(monitorRun.total_return_pct)}
valueClass={rColor(monitorRun.total_return_pct)}
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
/>
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} /> <Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
</div> </div>
<EquityCurveChart run={monitorRun} /> <EquityCurveChart run={monitorRun} />
<p className="text-[11px] text-gray-500">
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
{fmtR(monitorRun.worst_trade_r)} · Avg P&amp;L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
</p>
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
<div className="glass overflow-x-auto p-4">
<p className="section-index mb-2">Per-year returns</p>
<div className="flex flex-wrap gap-2">
{monitorRun.yearly_returns.map((y) => (
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
{fmtSignedPct(y.return_pct)}
</span>
</div>
))}
</div>
</div>
)}
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>} {monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
</div> </div>
) : (
<Callout variant="empty">
This report predates the portfolio monitor re-run the backtest to populate it.
</Callout>
)} )}
{report.recommendation && report.recommendation.items.length > 0 && ( {report.recommendation && report.recommendation.items.length > 0 && (
@@ -361,453 +314,11 @@ export function BacktestPanel() {
</div> </div>
)} )}
{report.research_recommendation && report.research_recommendation.items.length > 0 && ( <p className="text-[11px] text-gray-600">
<div className="glass border border-emerald-400/15 p-4"> Strategy research gate tuning, exit sweeps, factor rank-IC now runs locally against a
<p className="section-index">Research candidates</p> database snapshot (see README). This page keeps only what says whether the promoted strategy
<ul className="mt-2 space-y-1"> is worth trading; your realized results up top show what it is actually delivering.
{report.research_recommendation.items.map((item) => ( </p>
<li
key={item.topic + item.text}
className={`text-xs ${item.candidate ? 'text-emerald-400' : 'text-gray-400'}`}
>
{item.text}
</li>
))}
</ul>
{report.research_recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{report.research_recommendation.note}</p>
)}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Qualified Hit Rate"
value={fmtPct(report.overall_qualified.hit_rate)}
sub={`${report.overall_qualified.wins}W / ${report.overall_qualified.losses}L`}
/>
<Stat
label="Qualified Expectancy"
value={fmtR(report.overall_qualified.avg_r)}
valueClass={rColor(report.overall_qualified.avg_r)}
sub="avg R per qualified setup"
/>
<Stat
label="All Setups Expectancy"
value={fmtR(report.overall_all.avg_r)}
valueClass={rColor(report.overall_all.avg_r)}
sub={`${report.overall_all.total} setups · baseline`}
/>
<Stat
label="Qualified Total R"
value={fmtR(report.overall_qualified.total_r)}
valueClass={rColor(report.overall_qualified.total_r)}
sub="cumulative, risk-adjusted"
/>
{report.overall_qualified.median_net_r != null && (
<Stat
label="Median Net R"
value={fmtR(report.overall_qualified.median_net_r)}
valueClass={rColor(report.overall_qualified.median_net_r)}
sub="qualified · the typical trade"
/>
)}
{report.overall_qualified.profit_factor != null && (
<Stat
label="Profit Factor"
value={report.overall_qualified.profit_factor.toFixed(2)}
valueClass={report.overall_qualified.profit_factor > 1 ? 'text-emerald-400' : 'text-red-400'}
sub="qualified · net wins / net losses"
/>
)}
{report.overall_qualified.net_avg_r_ex_top5 != null && (
<Stat
label="Ex-Top-5% Net R"
value={fmtR(report.overall_qualified.net_avg_r_ex_top5)}
valueClass={rColor(report.overall_qualified.net_avg_r_ex_top5)}
sub="expectancy without the biggest winners"
/>
)}
</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">Set</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
<th className="px-4 py-2.5 text-right">Expired</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Best R</th>
<th className="px-4 py-2.5 text-right">Worst R</th>
<th className="px-4 py-2.5 text-right">Avg Hold</th>
<th className="px-4 py-2.5 text-right">Net R/d</th>
</tr>
</thead>
<tbody>
<BucketRow label="Qualified" b={report.overall_qualified} />
<BucketRow label="All" b={report.overall_all} />
{report.by_direction.long && <BucketRow label="Long (qual.)" b={report.by_direction.long} />}
{report.by_direction.short && <BucketRow label="Short (qual.)" b={report.by_direction.short} />}
</tbody>
</table>
</div>
{/* Guard on the new field so a stale cached report (pre-momentum,
with min_expected_value rows) hides the sweep instead of crashing
the whole page. Re-running the backtest repopulates it. */}
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Residual-momentum percentile sweep
</p>
<p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each production-rank cutoff (floors
held fixed). 80 = only the top 20% of the universe by residual 12-1 momentum each week; 0 =
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation.
</p>
<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">Min residual %ile</th>
<th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
</tr>
</thead>
<tbody>
{report.sweep.map((row) => {
const current = Math.abs(row.min_momentum_percentile - report.min_momentum_percentile) < 0.001;
return (
<tr key={row.min_momentum_percentile} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200">
{current && <span className="mr-1 text-blue-300"></span>}
{row.min_momentum_percentile.toFixed(0)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{row.losses}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{report.gate_ablation && report.gate_ablation.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Gate ablation which floors earn their keep
</p>
<p className="mb-2 text-[11px] text-gray-500">
{report.gate_ablation_note ??
'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'}
</p>
<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">Variant</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
<th className="px-4 py-2.5 text-right">Hold Net Avg R</th>
<th className="px-4 py-2.5 text-right">Hold Total R</th>
</tr>
</thead>
<tbody>
{report.gate_ablation.map((row) => (
<tr
key={row.variant}
className={`border-b border-white/[0.04] ${row.variant === 'all_floors' ? 'bg-blue-400/10' : ''}`}
>
<td className="px-4 py-2.5 font-medium text-gray-200">
{ABLATION_LABELS[row.variant] ?? row.variant}
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>
{fmtR(row.net_avg_r ?? null)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.hold_net_avg_r ?? null)}`}>
{fmtR(row.hold_net_avg_r ?? null)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.hold_total_r ?? null)}`}>
{fmtR(row.hold_total_r ?? null)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.time_exit_sweep && report.time_exit_sweep.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Time-based exit
</p>
<p className="mb-2 text-[11px] text-gray-500">
Buy at detection, keep the initial ATR stop, and exit at the{' '}
<span className="text-gray-300">day-N close</span> no target, no trailing. This is the
classic cross-sectional momentum implementation (hold ~a month, re-rank).{' '}
<span className="text-gray-300">Win Rate = share closed in profit.</span> = best net avg R.
</p>
<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">Hold</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Profitable</th>
<th className="px-4 py-2.5 text-right">Win Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
<th className="px-4 py-2.5 text-right">Best R</th>
<th className="px-4 py-2.5 text-right">Worst R</th>
<th className="px-4 py-2.5 text-right">Avg Hold</th>
<th className="px-4 py-2.5 text-right">Net R/d</th>
<th className="px-4 py-2.5 text-right">Median Net R</th>
<th className="px-4 py-2.5 text-right">Ex-Top-5%</th>
</tr>
</thead>
<tbody>
{report.time_exit_sweep.map((row) => {
const best = netOrGross(row) != null && netOrGross(row) === bestTimeAvgR;
return (
<tr key={row.hold_days} className={`border-b border-white/[0.04] ${best ? 'bg-emerald-400/[0.06]' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200">
{best && <span className="mr-1 text-emerald-300"></span>}
{row.hold_days}d
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.win_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(row.best_r)}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(row.worst_r)}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(row.avg_hold_days)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_r_per_day ?? null)}`}>{fmtRPerDay(row.net_r_per_day)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.median_net_r ?? null)}`}>{fmtR(row.median_net_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r_ex_top5 ?? null)}`}>{fmtR(row.net_avg_r_ex_top5)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{sim && sim.policies.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Portfolio simulation
</p>
<p className="mb-2 text-[11px] text-gray-500">
{sim.note ?? 'One capital-constrained book over the qualified setups.'}{' '}
<span className="text-gray-300">
Start {fmtMoney(sim.params.starting_capital)} · max {sim.params.max_positions} positions ·{' '}
{sim.params.risk_per_trade_pct}% risk/trade · {sim.params.notional_cap_pct}% notional cap ·{' '}
{sim.params.cost_per_side_pct}%/side costs · {sim.policies[0].start_date} {sim.policies[0].end_date}
</span>
</p>
<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">Metric</th>
{sim.policies.map((p) => (
<th key={p.policy ?? 'policy'} className="px-4 py-2.5 text-right">
{POLICY_LABELS[p.policy ?? ''] ?? p.policy ?? 'Policy'}
</th>
))}
</tr>
</thead>
<tbody>
{(
[
['Final equity', (p) => fmtMoney(p.final_equity), (p) => rColor(p.final_equity - p.starting_capital)],
['Total return', (p) => fmtSignedPct(p.total_return_pct), (p) => rColor(p.total_return_pct)],
['SPY return (same window)', (p) => fmtSignedPct(p.spy_return_pct), () => 'text-gray-300'],
['CAGR', (p) => fmtSignedPct(p.cagr_pct), (p) => rColor(p.cagr_pct)],
['Max drawdown', (p) => `${p.max_drawdown_pct.toFixed(1)}%`, () => 'text-amber-400'],
['Sharpe (daily, annualized)', (p) => (p.sharpe === null ? '—' : p.sharpe.toFixed(2)), () => 'text-gray-200'],
['Trades', (p) => String(p.trades), () => 'text-gray-300'],
['Win rate', (p) => fmtPct(p.win_rate), () => 'text-gray-200'],
['Avg P&L / trade', (p) => fmtMoney(p.avg_trade_pnl), (p) => rColor(p.avg_trade_pnl)],
['Best / worst trade', (p) => `${fmtR(p.best_trade_r)} / ${fmtR(p.worst_trade_r)}`, () => 'text-gray-300'],
['Avg holding time', (p) => fmtDays(p.avg_hold_days), () => 'text-gray-300'],
[
'Per-year returns',
(p) =>
p.yearly_returns && p.yearly_returns.length > 0
? p.yearly_returns
.map((y) => `${y.year} ${fmtSignedPct(y.return_pct)}`)
.join(' · ')
: '—',
() => 'text-gray-300',
],
['Entries skipped (book full)', (p) => String(p.skipped_book_full), () => 'text-gray-500'],
] as [string, (p: BacktestPortfolioPolicy) => string, (p: BacktestPortfolioPolicy) => string][]
).map(([label, fmt, color]) => (
<tr key={label} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
{sim.policies.map((p) => (
<td key={p.policy ?? label} className={`num px-4 py-2.5 text-right ${color(p)}`}>
{fmt(p)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.strategy_variants && report.strategy_variants.variants.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Strategy variants
</p>
<p className="mb-2 text-[11px] text-gray-500">
{report.strategy_variants.note ?? 'Research-only portfolio variants.'}
</p>
<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">Variant</th>
<th className="px-4 py-2.5 text-right">Rank</th>
<th className="px-4 py-2.5 text-right">Cutoff</th>
<th className="px-4 py-2.5 text-right">Max Pos</th>
<th className="px-4 py-2.5 text-right">Risk</th>
<th className="px-4 py-2.5 text-right">CAGR</th>
<th className="px-4 py-2.5 text-right">Max DD</th>
<th className="px-4 py-2.5 text-right">Sharpe</th>
<th className="px-4 py-2.5 text-right">Total Ret</th>
<th className="px-4 py-2.5 text-right">Trades</th>
<th className="px-4 py-2.5 text-right">Skipped</th>
</tr>
</thead>
<tbody>
{report.strategy_variants.variants.map((row: BacktestStrategyVariant) => (
<tr key={row.variant} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{row.label}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.ranking}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.cutoff.toFixed(0)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.max_positions}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">
{`${row.risk_per_trade_pct.toFixed(1)}%`}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.cagr_pct)}`}>{fmtSignedPct(row.cagr_pct)}</td>
<td className="num px-4 py-2.5 text-right text-amber-400">{row.max_drawdown_pct.toFixed(1)}%</td>
<td className="num px-4 py-2.5 text-right text-gray-200">
{row.sharpe === null ? '—' : row.sharpe.toFixed(2)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_return_pct)}`}>{fmtSignedPct(row.total_return_pct)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.trades}</td>
<td className="num px-4 py-2.5 text-right text-gray-500">{row.skipped_book_full}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.signal_eval && report.signal_eval.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Signal edge (cross-sectional)
</p>
<p className="mb-2 text-[11px] text-gray-500">
Does ranking the universe by a signal predict the forward {report.params.horizon_days}-day
return? Mean IC is the rank correlation between signal and return, averaged over
non-overlapping windows. <span className="text-emerald-400">|IC| {IC_EDGE_THRESHOLD}</span> with a
consistent sign (high IC&gt;0 %) is a real, if small, edge; near 0 means it sorts nothing.
Momentum skips the last month; <em>reversal_1m is expected negative</em> if the universe
mean-reverts. Q5Q1 is the top-minus-bottom-quintile forward return. <span className="text-gray-600">Greyed
rows have too few independent windows to trust deepen history via the Data Backfill job.</span>
</p>
<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">Signal</th>
<th className="px-4 py-2.5 text-right">Weeks</th>
<th className="px-4 py-2.5 text-right">Avg N</th>
<th className="px-4 py-2.5 text-right">Mean IC</th>
<th className="px-4 py-2.5 text-right">t-stat</th>
<th className="px-4 py-2.5 text-right">IC&gt;0 %</th>
<th className="px-4 py-2.5 text-right">Q5Q1 fwd</th>
</tr>
</thead>
<tbody>
{report.signal_eval.map((row) => {
// Only trust the edge highlight when the IC rests on enough
// independent windows; thin signals are dimmed, not starred.
const edge = row.reliable && Math.abs(row.mean_ic) >= IC_EDGE_THRESHOLD;
return (
<tr
key={row.signal}
className={`border-b border-white/[0.04] ${edge ? 'bg-emerald-400/[0.06]' : ''} ${row.reliable ? '' : 'opacity-40'}`}
title={row.reliable ? undefined : `Only ${row.weeks} independent window(s) — not enough to trust`}
>
<td className="px-4 py-2.5 font-medium text-gray-200">
{edge && <span className="mr-1 text-emerald-300"></span>}
{SIGNAL_LABELS[row.signal] ?? row.signal}
</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.weeks}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.avg_cross_section ?? '—'}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${icColor(row.mean_ic)}`}>
{row.mean_ic.toFixed(3)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-300">
{row.ic_t_stat === null ? '—' : row.ic_t_stat.toFixed(2)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{fmtPct(row.ic_positive_pct)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.mean_quintile_spread)}`}>
{fmtSpread(row.mean_quintile_spread)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{report.signal_eval_note && (
<p className="mt-2 text-[11px] text-gray-600">{report.signal_eval_note}</p>
)}
</div>
)}
<p className="text-[11px] text-gray-600">{report.note}</p>
</> </>
)} )}
</div> </div>
@@ -19,6 +19,18 @@ function color(v: number | null): string {
return 'text-gray-300'; return 'text-gray-300';
} }
// 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' };
}
}
function Stat({ label, value, valueClass = 'text-gray-100', sub }: { function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
label: string; value: string; valueClass?: string; sub?: string; label: string; value: string; valueClass?: string; sub?: string;
}) { }) {
@@ -81,10 +93,11 @@ export function MyTradesPanel() {
<th className="px-4 py-2.5">Ticker</th> <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">Dir</th>
<th className="px-4 py-2.5 text-right">Entry</th> <th className="px-4 py-2.5 text-right">Entry</th>
<th className="px-4 py-2.5 text-right">Exit</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">P&L</th>
<th className="px-4 py-2.5 text-right">R</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 text-right">Alpha</th>
<th className="px-4 py-2.5">Reason</th>
<th className="px-4 py-2.5 text-right">Closed</th> <th className="px-4 py-2.5 text-right">Closed</th>
</tr> </tr>
</thead> </thead>
@@ -102,6 +115,11 @@ export function MyTradesPanel() {
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td> <td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td> <td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(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 ? color(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={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(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> <td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
</tr> </tr>
))} ))}
@@ -1,38 +1,26 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation';
import { activationSummary } from '../../lib/qualification';
import { usePerformance } from '../../hooks/usePerformance'; import { usePerformance } from '../../hooks/usePerformance';
import { useBacktestReport } from '../../hooks/useMarketRegime'; import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob, resetTrackRecord } from '../../api/admin'; import { triggerJob, resetTrackRecord } from '../../api/admin';
import { Button } from '../ui/Button'; import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure'; import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section';
import { SkeletonCard } from '../ui/Skeleton';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
import { BacktestPanel } from './BacktestPanel'; import { BacktestPanel } from './BacktestPanel';
import { MyTradesPanel } from './MyTradesPanel'; import { MyTradesPanel } from './MyTradesPanel';
import type { OutcomeBucketStats } from '../../lib/types';
// Need at least this many matured setups before a live-vs-backtest verdict means // Need at least this many matured setups before the pipeline check means anything;
// anything; below it the live sample is too noisy to compare. // below it the live sample is too noisy to compare.
const MIN_MATURED = 20; const MIN_MATURED = 20;
// Live expectancy this far (in R) below the backtest counts as drift, not noise. // Live expectancy this far (in R) below the backtest counts as drift, not noise.
const DRIFT_TOLERANCE_R = 0.2; const DRIFT_TOLERANCE_R = 0.2;
type TrackingStatus = 'building' | 'tracking' | 'drift' | 'no-backtest'; type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
function fmtR(value: number | null): string { function fmtR(value: number | null): string {
if (value === null) return '—'; if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`; return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
} }
function fmtPct(value: number | null): string {
return value === null ? '—' : `${value.toFixed(1)}%`;
}
function rColor(value: number | null): string { function rColor(value: number | null): string {
if (value === null) return 'text-gray-400'; if (value === null) return 'text-gray-400';
if (value > 0) return 'text-emerald-400'; if (value > 0) return 'text-emerald-400';
@@ -40,9 +28,9 @@ function rColor(value: number | null): string {
return 'text-gray-300'; return 'text-gray-300';
} }
function VerdictChip({ status }: { status: TrackingStatus }) { function StatusChip({ status }: { status: PipelineStatus }) {
const styles: Record<TrackingStatus, { cls: string; label: string }> = { const styles: Record<PipelineStatus, { cls: string; label: string }> = {
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ tracking' }, tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
drift: { cls: 'border-amber-500/30 bg-amber-500/15 text-amber-300', label: '⚠ drift' }, drift: { cls: 'border-amber-500/30 bg-amber-500/15 text-amber-300', label: '⚠ drift' },
building: { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'building' }, building: { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'building' },
'no-backtest': { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'no backtest' }, 'no-backtest': { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'no backtest' },
@@ -51,79 +39,32 @@ function VerdictChip({ status }: { status: TrackingStatus }) {
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>; return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
} }
function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
label: string;
value: string;
valueClass?: string;
sub?: string;
}) {
return (
<div className="glass p-5">
<p className="section-index">{label}</p>
<p className={`num mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
}
function actionLabel(key: string): string {
return RECOMMENDATION_ACTION_LABELS[key as keyof typeof RECOMMENDATION_ACTION_LABELS] ?? key;
}
function BreakdownTable({ rows, labelHeader, mapLabel }: {
rows: Record<string, OutcomeBucketStats>;
labelHeader: string;
mapLabel?: (key: string) => string;
}) {
const entries = Object.entries(rows);
if (entries.length === 0) {
return <Callout variant="empty">No matured setups in this breakdown yet.</Callout>;
}
return (
<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-3">{labelHeader}</th>
<th className="px-4 py-3 text-right">Setups</th>
<th className="px-4 py-3 text-right">Wins</th>
<th className="px-4 py-3 text-right">Losses</th>
<th className="px-4 py-3 text-right">Expired</th>
<th className="px-4 py-3 text-right">Hit Rate</th>
<th className="px-4 py-3 text-right">Avg R</th>
<th className="px-4 py-3 text-right">Total R</th>
</tr>
</thead>
<tbody>
{entries.map(([key, stats]) => (
<tr key={key} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
<td className="px-4 py-3 font-medium text-gray-200">{mapLabel ? mapLabel(key) : key}</td>
<td className="num px-4 py-3 text-right text-gray-300">{stats.total}</td>
<td className="num px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
<td className="num px-4 py-3 text-right text-red-400">{stats.losses}</td>
<td className="num px-4 py-3 text-right text-gray-400">{stats.expired}</td>
<td className="num px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
<td className={`num px-4 py-3 text-right ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
<td className={`num px-4 py-3 text-right ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function TrackRecordPanel() { export function TrackRecordPanel() {
const [qualifiedOnly, setQualifiedOnly] = useState(true);
const activation = useActivation();
const { data, isLoading, isError, error } = usePerformance(
qualifiedOnly ? { qualified_only: true } : undefined,
);
const backtest = useBacktestReport();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); const toast = useToast();
// Setup-outcome pipeline check: does the live outcome evaluator reproduce the
// backtest's target/stop grading? Both sides use the SAME target/stop/expired
// model — this is a plumbing/QA signal (no look-ahead, config or data drift),
// NOT validation of the ATR-trail production strategy shown in the monitor.
const { data: perf } = usePerformance({ qualified_only: true });
const { data: report } = useBacktestReport();
const liveAvgR = perf?.overall.avg_r ?? null;
const liveN = perf?.overall.total ?? 0;
const btAvgR = report?.overall_qualified.avg_r ?? null;
let status: PipelineStatus = 'building';
if (liveAvgR != null && liveN >= MIN_MATURED) {
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
}
const statusNote: Record<PipelineStatus, string> = {
building: `Fewer than ~${MIN_MATURED} matured setups so far — too few to compare.`,
'no-backtest': 'Run the backtest to get a target/stop baseline to check against.',
tracking:
"Live setup outcomes are resolving in line with the backtest's target/stop model — the outcome-evaluation pipeline shows no look-ahead, config or data drift. (Checks the setup-grading pipeline, not the ATR-trail production book above.)",
drift:
"Live setup outcomes are running materially below the backtest's target/stop model — small-sample noise, a regime shift, or a live/backtest pipeline gap. Worth a look.",
};
const evaluateMutation = useMutation({ const evaluateMutation = useMutation({
mutationFn: () => triggerJob('outcome_evaluator'), mutationFn: () => triggerJob('outcome_evaluator'),
onSuccess: () => { onSuccess: () => {
@@ -158,40 +99,29 @@ export function TrackRecordPanel() {
} }
}; };
// Live (matured cohort) vs the backtest, like-for-like with the qualified toggle.
const live = data?.overall ?? null;
const btBucket = qualifiedOnly ? backtest.data?.overall_qualified : backtest.data?.overall_all;
const liveAvgR = live?.avg_r ?? null;
const liveN = live?.total ?? 0;
const btAvgR = btBucket?.avg_r ?? null;
let status: TrackingStatus = 'building';
if (liveAvgR != null && liveN >= MIN_MATURED) {
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
}
const verdictNote: Record<TrackingStatus, string> = {
building: `Not enough matured setups yet (need ~${MIN_MATURED}). Only setups whose full ~30-day window has elapsed are counted — the rest are still maturing. Until then, the backtest is your edge estimate; this becomes a live check as setups age past ~6 weeks.`,
'no-backtest': 'Run the backtest below to get a baseline to compare the live record against.',
tracking: 'Live setups are resolving in line with the backtest — the running system is faithfully implementing it (no look-ahead, config or data drift).',
drift: 'Live expectancy is running materially below the backtest. Could be small-sample noise, a regime shift, or a config/data/look-ahead gap between live and the backtest — worth a look.',
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Your real, realized results come first; the live-vs-backtest check follows. */} {/* Your real, realized results come first; the strategy simulation follows. */}
<MyTradesPanel /> <MyTradesPanel />
<div className="border-t border-white/[0.06]" /> <div className="border-t border-white/[0.06]" />
<BacktestPanel />
<Section title="Live vs Backtest" hint="is the live system tracking the backtest?"> <Disclosure summary="Track-record maintenance">
{isError ? ( <div className="space-y-4 pt-1">
<Callout variant="error"> <p className="max-w-2xl text-xs text-gray-500">
{error instanceof Error ? error.message : 'Failed to load performance stats'} The live check replays every setup against the daily bars after detection: target before stop =
</Callout> win, stop first = loss (both in one bar counts conservatively as a loss), neither within 30
) : ( trading days = expired at 0R. Only setups whose full window has elapsed count; younger ones are
<div className="glass-sm space-y-2.5 p-4"> still maturing (near stops resolve fast, far targets need time, so early numbers skew negative).
The evaluator scores <span className="text-gray-300">all</span> setups qualified or not, so
unqualified ones stay a control group and runs nightly.
</p>
{/* Diagnostic, not strategy validation: live target/stop outcomes vs the backtest's target/stop model. */}
<div className="glass-sm space-y-2 p-4">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2"> <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
<span className="text-sm text-gray-300">Setup-outcome pipeline check</span>
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span> Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
</span> </span>
@@ -199,107 +129,24 @@ export function TrackRecordPanel() {
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span> Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
</span> </span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{liveN} matured{data ? ` · ${data.maturing} maturing` : ''} · {qualifiedOnly ? 'qualified' : 'all setups'} {liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · qualified target/stop
</span> </span>
</div> </div>
<VerdictChip status={status} /> <StatusChip status={status} />
</div> </div>
<p className="text-[11px] leading-relaxed text-gray-500">{verdictNote[status]}</p> <p className="text-[11px] leading-relaxed text-gray-500">{statusNote[status]}</p>
</div> </div>
)}
</Section>
<Disclosure summary="Outcome details (matured cohort)"> <div className="flex flex-wrap items-center gap-2">
<div className="space-y-4 pt-1"> <Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
<label className="flex w-fit cursor-pointer items-center gap-2.5 text-sm text-gray-300"> {evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
<input </Button>
type="checkbox" <Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
checked={qualifiedOnly} {resetMutation.isPending ? 'Resetting…' : 'Reset'}
onChange={(e) => setQualifiedOnly(e.target.checked)} </Button>
className="h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Qualified signals only
{activation.data && (
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
)}
</span>
</label>
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
)}
{data && data.overall.total === 0 && (
<Callout variant="empty">
{data.maturing > 0
? `No setups have completed their ~30-day window yet — ${data.maturing} still maturing. ` +
'Counting them earlier would skew toward quick stop-outs.'
: 'No matured setups yet. Outcomes appear once setups complete their evaluation window — the evaluator runs nightly, or click Evaluate Now.'}
</Callout>
)}
{data && data.overall.total > 0 && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Hit Rate"
value={fmtPct(data.overall.hit_rate)}
sub={`${data.overall.wins} wins / ${data.overall.losses} losses`}
/>
<StatCard
label="Expectancy"
value={fmtR(data.overall.avg_r)}
valueClass={rColor(data.overall.avg_r)}
sub="average R per trade"
/>
<StatCard
label="Total R"
value={fmtR(data.overall.total_r)}
valueClass={rColor(data.overall.total_r)}
sub="cumulative risk-adjusted result"
/>
<StatCard
label="Matured"
value={String(data.overall.total)}
sub={`${data.maturing} maturing · ${data.overall.expired} expired`}
/>
</div>
<Section title="By Recommended Action">
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
</Section>
<Section title="By Confidence" hint="at detection time · all setups">
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
</Section>
</>
)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] pt-3">
<p className="max-w-2xl text-xs text-gray-500">
Each setup is replayed against the daily bars after detection: target before stop = win,
stop first = loss (both in one bar counts conservatively as a loss), neither within 30
trading days = expired at 0R. Only setups whose full window has elapsed are counted; younger
ones are still <span className="text-gray-300">maturing</span> (near stops resolve fast, far
targets need time, so early numbers would skew negative). The evaluator runs nightly.
</p>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
</Button>
</div>
</div> </div>
</div> </div>
</Disclosure> </Disclosure>
<div className="border-t border-white/[0.06] pt-2" />
<BacktestPanel />
</div> </div>
); );
} }
+34 -4
View File
@@ -206,7 +206,7 @@ class TestTrailingClose:
class TestAtrTrailingClose: class TestAtrTrailingClose:
def test_long_uses_ratchet_on_next_bar(self, monkeypatch): def test_long_uses_ratchet_on_next_bar(self, monkeypatch):
monkeypatch.setattr(svc, "compute_atr", lambda *_args, **_kwargs: {"atr": 5.0}) monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [5.0] * len(rows))
rows = [ rows = [
_r(date(2026, 1, 1), 100, 100, 100, 100), _r(date(2026, 1, 1), 100, 100, 100, 100),
_r(date(2026, 1, 2), 115, 121, 114, 120), _r(date(2026, 1, 2), 115, 121, 114, 120),
@@ -222,7 +222,7 @@ class TestAtrTrailingClose:
assert reason == "trailing" assert reason == "trailing"
def test_max_hold_still_closes(self, monkeypatch): def test_max_hold_still_closes(self, monkeypatch):
monkeypatch.setattr(svc, "compute_atr", lambda *_args, **_kwargs: {"atr": 50.0}) monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [50.0] * len(rows))
rows = [ rows = [
_r(date(2026, 1, 1), 100, 100, 100, 100), _r(date(2026, 1, 1), 100, 100, 100, 100),
_r(date(2026, 1, 2), 101, 102, 100, 101), _r(date(2026, 1, 2), 101, 102, 100, 101),
@@ -264,6 +264,36 @@ def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple:
return (d, open_, hi, lo, close) return (d, open_, hi, lo, close)
def test_atr_series_matches_compute_atr_per_prefix():
"""_atr_series_from_rows[i] must equal compute_atr(rows[: i + 1])['atr'] (with
the same >0 / insufficient-history guards) at every index. The O(n) rewrite is
only valid because it reproduces the per-prefix value exactly."""
from app.services.indicator_service import compute_atr
price = 100.0
closes = []
for i in range(200):
price = max(1.0, price + (3.0 if i % 3 else -2.0) + (i % 7) * 0.25)
closes.append(price)
rows = [
_r(date(2024, 1, 1) + timedelta(days=i), c, c + 1.5, c - 1.2, c)
for i, c in enumerate(closes)
]
series = svc._atr_series_from_rows(rows)
highs = [r[2] for r in rows]
lows = [r[3] for r in rows]
closes_col = [r[4] for r in rows]
assert len(series) == len(rows)
for i in range(len(rows)):
if i < 14:
assert series[i] is None
else:
raw = compute_atr(highs[: i + 1], lows[: i + 1], closes_col[: i + 1])["atr"]
expected = float(raw) if raw and raw > 0 else None
assert series[i] == expected, f"index {i}: {series[i]} != {expected}"
class TestTimeClose: class TestTimeClose:
def test_closes_at_hold_days_close(self): def test_closes_at_hold_days_close(self):
rows = [ rows = [
@@ -322,7 +352,7 @@ async def test_resolve_trailing_closes_with_reason(session):
async def test_resolve_atr_trailing_closes_with_reason(session, monkeypatch): async def test_resolve_atr_trailing_closes_with_reason(session, monkeypatch):
monkeypatch.setattr(svc, "compute_atr", lambda *_args, **_kwargs: {"atr": 5.0}) monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [5.0] * len(rows))
await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0) await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0)
tid = await _seed(session, "AAA", close=100.0) tid = await _seed(session, "AAA", close=100.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
@@ -352,7 +382,7 @@ async def test_list_open_exposes_trailing_stop(session):
async def test_list_open_exposes_atr_trailing_stop(session, monkeypatch): async def test_list_open_exposes_atr_trailing_stop(session, monkeypatch):
monkeypatch.setattr(svc, "compute_atr", lambda *_args, **_kwargs: {"atr": 5.0}) monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [5.0] * len(rows))
await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0) await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0)
tid = await _seed(session, "AAA", close=120.0) tid = await _seed(session, "AAA", close=120.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)