feat(backtest): add Sortino, Gain-to-Pain and dollar profit factor
Three portfolio metrics computed where their inputs already live in _simulate_portfolio: Sortino off the existing daily return series, Gain-to-Pain off a monthly aggregation of the equity curve, profit factor off closed-trade dollar P&L. Gain-to-Pain follows Schwager — sum of ALL monthly returns over the absolute sum of the negative ones. The profit-factor-shaped variant, sum(positive)/|sum(negative)|, sits exactly 1.0 higher for every input since sum(all) = sum(pos) - |sum(neg)|; the test asserts against both so the wrong one cannot pass. Sortino divides by len(rets), the full-sample lower partial moment, not by the count of down days, which would shrink the denominator and inflate the ratio. No MAR field: calmar is already CAGR / max drawdown, the same number under the other name (docs/research/effective-risk-floor-ab.md). All three keys are emitted unconditionally even when None — the UI reads an absent key as "report predates these metrics", so presence is a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usePerformance } from '../../hooks/usePerformance';
|
||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||
import { triggerJob, resetTrackRecord } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { BacktestPanel } from './BacktestPanel';
|
||||
import { MyTradesPanel } from './MyTradesPanel';
|
||||
|
||||
// Need at least this many matured setups before the pipeline check means anything;
|
||||
// below it the live sample is too noisy to compare.
|
||||
const MIN_MATURED = 20;
|
||||
// Live expectancy this far (in R) below the backtest counts as drift, not noise.
|
||||
const DRIFT_TOLERANCE_R = 0.2;
|
||||
|
||||
type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
|
||||
}
|
||||
|
||||
function rColor(value: number | null): string {
|
||||
if (value === null) return 'text-gray-400';
|
||||
if (value > 0) return 'text-emerald-400';
|
||||
if (value < 0) return 'text-red-400';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
function StatusChip({ status }: { status: PipelineStatus }) {
|
||||
const styles: Record<PipelineStatus, { cls: string; label: string }> = {
|
||||
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' },
|
||||
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' },
|
||||
};
|
||||
const s = styles[status];
|
||||
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
||||
}
|
||||
|
||||
export function TrackRecordPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
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({
|
||||
mutationFn: () => triggerJob('outcome_evaluator'),
|
||||
onSuccess: () => {
|
||||
toast.addToast('success', 'Outcome evaluation triggered. Stats will refresh shortly.');
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['performance'] }), 3000);
|
||||
},
|
||||
onError: () => {
|
||||
toast.addToast('error', 'Failed to trigger outcome evaluation');
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => resetTrackRecord(),
|
||||
onSuccess: (data) => {
|
||||
toast.addToast('success', `Track record reset — ${data.trade_setups} setups cleared. Run the scanner to rebuild.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['performance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trades'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.addToast('error', 'Failed to reset track record');
|
||||
},
|
||||
});
|
||||
|
||||
const onReset = () => {
|
||||
if (
|
||||
window.confirm(
|
||||
'Reset the track record? This permanently deletes ALL trade setups and their outcomes. ' +
|
||||
'Live setups will regenerate on the next R:R scan. This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Your real, realized results come first; the strategy simulation follows. */}
|
||||
<MyTradesPanel />
|
||||
<div className="border-t border-white/[0.06]" />
|
||||
<BacktestPanel />
|
||||
|
||||
<Disclosure summary="Track-record maintenance">
|
||||
<div className="space-y-4 pt-1">
|
||||
<p className="max-w-2xl text-xs text-gray-500">
|
||||
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
||||
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
|
||||
initial stop / ATR trail / max hold — see paper trades and the portfolio monitor above.
|
||||
Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
|
||||
days = expired at 0R. Only matured windows count. Scores{' '}
|
||||
<span className="text-gray-300">all</span> setups as a control group; 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-baseline gap-x-5 gap-y-1">
|
||||
<span className="text-sm text-gray-300">Gate barrier pipeline check</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · not ATR-trail book
|
||||
</span>
|
||||
</div>
|
||||
<StatusChip status={status} />
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-gray-500">{statusNote[status]}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap 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>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user