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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user