feat: shadow book + shadow-vs-manual performance comparison

The manual paper book only contains trades taken by hand, inside a 20
minute window, on days someone was available. The backtest that validated
this strategy auto-takes the top-ranked qualified setups up to capacity
every session. The forward record was therefore measuring strategy plus
discretion plus availability -- and degrading silently on busy days.

The shadow book closes that gap: it mirrors the backtest's selection rule
(top strategy_rank qualified, up to capacity, 1% fixed-fractional risk)
and shares the manual book's exit policy, so the only difference between
the two books is which setups get taken. Selection ordering reuses the
strategy_rank the scanner already stores rather than recomputing it, so
the two cannot drift apart. It runs as a near-close pipeline step right
after the scan, marking entries at the same prices a human would see.

Gate-reset re-entry state is now scoped per book -- the books diverge as
soon as their entries differ, and each must see only its own stops.

Performance view rewritten around the comparison:
  - three series (shadow, manual, SPY) from a new endpoint
  - SPY changes from a per-trade cost-basis counterfactual to plain
    buy-and-hold %, since one line has to serve two books
  - headline stats are R-multiples, not currency: the books size
    differently, so only R compares across them
  - configurable start date, because the strategy has been revised
    repeatedly and pre-cutover trades ran under rules that no longer
    exist

Migration 024 also repairs the numeric weekday crons written by 023,
rewriting only rows still holding the broken form so hand-corrected
settings survive. Its literals are inlined because bound parameters
render as NULL under 'alembic upgrade --sql'.

The shadow book is opt-in and writes nothing until enabled. Verify its
first selections match a backtest of that day's cross-section before
trusting any point on the curve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:44:41 +02:00
co-authored by Claude Fable 5
parent 29715ef3d1
commit ba2df8b9fd
17 changed files with 1334 additions and 40 deletions
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
getPerformanceSettings,
getShadowBookSettings,
updatePerformanceSettings,
updateShadowBookSettings,
type ShadowBookConfig,
} from '../../api/admin';
import { SkeletonCard } from '../ui/Skeleton';
/** Performance window + the auto-traded shadow book.
*
* These belong together: the shadow book is what the comparison measures, and
* the start date is what keeps the comparison inside a single strategy
* configuration.
*/
export function PerformanceSettings() {
const qc = useQueryClient();
const window = useQuery({ queryKey: ['admin', 'performance'], queryFn: getPerformanceSettings });
const shadow = useQuery({ queryKey: ['admin', 'shadow-book'], queryFn: getShadowBookSettings });
const [startDate, setStartDate] = useState('');
const [book, setBook] = useState<ShadowBookConfig | null>(null);
useEffect(() => {
if (window.data) setStartDate(window.data.start_date ?? '');
}, [window.data]);
useEffect(() => {
if (shadow.data) setBook(shadow.data);
}, [shadow.data]);
const saveWindow = useMutation({
mutationFn: () => updatePerformanceSettings({ start_date: startDate }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'performance'] });
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
},
});
const saveBook = useMutation({
mutationFn: (payload: Partial<ShadowBookConfig>) => updateShadowBookSettings(payload),
onSuccess: (data) => {
setBook(data);
qc.invalidateQueries({ queryKey: ['admin', 'shadow-book'] });
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
},
});
if (window.isLoading || shadow.isLoading || !book) return <SkeletonCard />;
return (
<div className="glass space-y-5 p-5">
<div>
<h3 className="text-sm font-semibold text-gray-200">Performance &amp; Shadow Book</h3>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
The <span className="text-gray-300">shadow book</span> trades the validated strategy with no
human input: top-ranked qualified setups up to capacity, sized to a fixed risk, entered right
after the near-close scan. It shares the paper exit policy with your own trades, so the only
difference between the two books is <span className="text-gray-300">which setups get taken</span>.
</p>
</div>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Performance since</span>
<div className="flex gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="input-glass w-48 px-3 py-2 text-sm"
/>
<button
type="button"
onClick={() => saveWindow.mutate()}
disabled={saveWindow.isPending}
className="btn-glass px-3 py-2 text-sm"
>
{saveWindow.isPending ? 'Saving…' : 'Save'}
</button>
{startDate && (
<button
type="button"
onClick={() => {
setStartDate('');
updatePerformanceSettings({ start_date: '' }).then(() => {
qc.invalidateQueries({ queryKey: ['admin', 'performance'] });
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
});
}}
className="btn-glass px-3 py-2 text-sm text-gray-400"
>
Clear
</button>
)}
</div>
<span className="block text-[11px] leading-relaxed text-gray-500">
Trades opened before this date are excluded from the Performance card. The strategy has been
revised repeatedly pinning a start keeps the comparison inside one configuration instead of
averaging across rules that no longer exist. Empty shows all history.
</span>
</label>
<div className="border-t border-white/5 pt-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={book.enabled}
onChange={(e) => saveBook.mutate({ enabled: e.target.checked })}
className="mt-0.5"
/>
<span>
<span className="text-sm text-gray-200">Shadow book enabled</span>
<span className="block text-[11px] leading-relaxed text-gray-500">
Starts opening real paper positions automatically on the next near-close scan. Verify its
first selections match a backtest of that day's cross-section before trusting the curve.
</span>
</span>
</label>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Capacity (positions)</span>
<input
type="number"
min={1}
max={100}
value={book.capacity}
onChange={(e) => setBook({ ...book, capacity: Number(e.target.value) })}
onBlur={() => saveBook.mutate({ capacity: book.capacity })}
className="input-glass w-full px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Risk per trade (%)</span>
<input
type="number"
step="0.05"
min={0.05}
max={10}
value={book.risk_pct}
onChange={(e) => setBook({ ...book, risk_pct: Number(e.target.value) })}
onBlur={() => saveBook.mutate({ risk_pct: book.risk_pct })}
className="input-glass w-full px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Start equity ($)</span>
<input
type="number"
min={1000}
step={1000}
value={book.start_equity}
onChange={(e) => setBook({ ...book, start_equity: Number(e.target.value) })}
onBlur={() => saveBook.mutate({ start_equity: book.start_equity })}
className="input-glass w-full px-3 py-2 text-sm"
/>
</label>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-500">
Defaults match the validated configuration: 10 positions, 1% fixed-fractional risk. Start
equity is only a sizing base the books are compared in R-multiples, not currency.
</p>
</div>
</div>
);
}