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:
@@ -92,6 +92,41 @@ export function updateScheduleSettings(payload: Partial<ScheduleConfig>) {
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export interface PerformanceConfig {
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
export function getPerformanceSettings() {
|
||||
return apiClient
|
||||
.get<PerformanceConfig>('admin/settings/performance')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updatePerformanceSettings(payload: Partial<PerformanceConfig>) {
|
||||
return apiClient
|
||||
.put<PerformanceConfig>('admin/settings/performance', payload)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export interface ShadowBookConfig {
|
||||
enabled: boolean;
|
||||
capacity: number;
|
||||
risk_pct: number;
|
||||
start_equity: number;
|
||||
}
|
||||
|
||||
export function getShadowBookSettings() {
|
||||
return apiClient
|
||||
.get<ShadowBookConfig>('admin/settings/shadow-book')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateShadowBookSettings(payload: Partial<ShadowBookConfig>) {
|
||||
return apiClient
|
||||
.put<ShadowBookConfig>('admin/settings/shadow-book', payload)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getSentimentSettings() {
|
||||
return apiClient
|
||||
.get<SentimentProviderConfig>('admin/settings/sentiment')
|
||||
|
||||
@@ -38,6 +38,39 @@ export function getEquityCurve() {
|
||||
return apiClient.get<EquityPoint[]>('paper-trades/equity-curve').then((r) => r.data);
|
||||
}
|
||||
|
||||
export interface PerfPoint {
|
||||
date: string;
|
||||
manual_pnl: number;
|
||||
shadow_pnl: number;
|
||||
spy_pct: number;
|
||||
}
|
||||
|
||||
export interface BookStats {
|
||||
trades: number;
|
||||
closed: number;
|
||||
open: number;
|
||||
win_rate: number | null;
|
||||
total_r: number;
|
||||
avg_r: number | null;
|
||||
pnl: number;
|
||||
}
|
||||
|
||||
export interface PerformanceSummary {
|
||||
start_date: string | null;
|
||||
series: PerfPoint[];
|
||||
stats: {
|
||||
manual?: BookStats;
|
||||
shadow?: BookStats;
|
||||
spy?: { pct: number };
|
||||
};
|
||||
}
|
||||
|
||||
export function getPerformance() {
|
||||
return apiClient
|
||||
.get<PerformanceSummary>('paper-trades/performance')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function closePaperTrade(id: number, closePrice?: number) {
|
||||
return apiClient
|
||||
.post<{ id: number; status: string }>(`paper-trades/${id}/close`, {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getEquityCurve } from '../../api/paperTrades';
|
||||
import { getPerformance, type BookStats } from '../../api/paperTrades';
|
||||
import { Section } from '../ui/Section';
|
||||
|
||||
const W = 760;
|
||||
const H = 220;
|
||||
const PAD = { top: 14, right: 84, bottom: 26, left: 56 };
|
||||
const W = 1040;
|
||||
const H = 260;
|
||||
const PAD = { top: 16, right: 92, bottom: 28, left: 60 };
|
||||
|
||||
const COLORS = {
|
||||
shadow: 'var(--up)',
|
||||
manual: 'var(--accent, #7aa2f7)',
|
||||
spy: 'var(--ink-3)',
|
||||
} as const;
|
||||
|
||||
function money(v: number): string {
|
||||
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
|
||||
@@ -25,17 +31,54 @@ function niceTicks(lo: number, hi: number, count = 4): number[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Paper book vs the same dollars riding SPY — cumulative P&L since first trade. */
|
||||
/** R-multiple stats read straight across; currency P&L does not, because the
|
||||
* books size differently. Kept adjacent so the comparison is hard to misread. */
|
||||
function StatCell({ label, stats, color }: { label: string; stats?: BookStats; color: string }) {
|
||||
if (!stats || stats.trades === 0) {
|
||||
return (
|
||||
<div className="min-w-[7rem]">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="num mt-1 text-sm text-gray-500">no trades yet</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-w-[7rem]">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="num mt-1 text-lg font-semibold text-gray-100">
|
||||
{stats.total_r > 0 ? '+' : ''}
|
||||
{stats.total_r.toFixed(2)}R
|
||||
</div>
|
||||
<div className="num text-[11px] leading-relaxed text-gray-500">
|
||||
{stats.trades} trades · {stats.win_rate ?? '—'}% win
|
||||
<br />
|
||||
avg {stats.avg_r === null ? '—' : `${stats.avg_r > 0 ? '+' : ''}${stats.avg_r.toFixed(2)}R`} · {money(stats.pnl)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shadow book (the strategy, traded automatically) vs the discretionary book
|
||||
* vs SPY. Both books share an exit policy, so the only difference is which
|
||||
* qualified setups get taken. */
|
||||
export function PerfChart() {
|
||||
const curve = useQuery({ queryKey: ['paper-trades', 'equity-curve'], queryFn: getEquityCurve });
|
||||
const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance });
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const data = curve.data ?? [];
|
||||
const data = perf.data?.series ?? [];
|
||||
const stats = perf.data?.stats ?? {};
|
||||
const startDate = perf.data?.start_date ?? null;
|
||||
|
||||
const geom = useMemo(() => {
|
||||
if (data.length < 2) return null;
|
||||
const values = data.flatMap((p) => [p.book_pnl, p.benchmark_pnl, 0]);
|
||||
const values = data.flatMap((p) => [p.manual_pnl, p.shadow_pnl, 0]);
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const pad = (hi - lo) * 0.08 || 1;
|
||||
@@ -45,9 +88,20 @@ export function PerfChart() {
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
|
||||
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
|
||||
const line = (key: 'book_pnl' | 'benchmark_pnl') =>
|
||||
const line = (key: 'manual_pnl' | 'shadow_pnl') =>
|
||||
data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
|
||||
// Month boundaries for the x axis.
|
||||
|
||||
// SPY is a percentage reference, so it rides its own scale pinned to the
|
||||
// same zero line — otherwise a flat book would squash it out of view.
|
||||
const spyLo = Math.min(...data.map((p) => p.spy_pct), 0);
|
||||
const spyHi = Math.max(...data.map((p) => p.spy_pct), 0);
|
||||
const spySpan = Math.max(Math.abs(spyLo), Math.abs(spyHi)) || 1;
|
||||
const bookSpan = Math.max(Math.abs(yLo), Math.abs(yHi)) || 1;
|
||||
const spyY = (v: number) => py((v / spySpan) * bookSpan);
|
||||
const spyLine = data
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${spyY(p.spy_pct).toFixed(1)}`)
|
||||
.join(' ');
|
||||
|
||||
const xTicks: { i: number; label: string }[] = [];
|
||||
let lastMonth = '';
|
||||
data.forEach((p, i) => {
|
||||
@@ -57,15 +111,24 @@ export function PerfChart() {
|
||||
xTicks.push({ i, label: new Date(`${p.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' }) });
|
||||
}
|
||||
});
|
||||
if (xTicks.length > 8) {
|
||||
const keep = Math.ceil(xTicks.length / 8);
|
||||
if (xTicks.length > 10) {
|
||||
const keep = Math.ceil(xTicks.length / 10);
|
||||
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
|
||||
}
|
||||
return { yLo, yHi, plotH, px, py, line, yTicks: niceTicks(yLo, yHi), xTicks };
|
||||
return { yLo, yHi, plotH, px, py, line, spyLine, spyY, yTicks: niceTicks(yLo, yHi), xTicks };
|
||||
}, [data]);
|
||||
|
||||
if (!geom) return null;
|
||||
const { px, py, line, yTicks, xTicks, plotH } = geom;
|
||||
if (!geom) {
|
||||
return (
|
||||
<Section title="Performance" hint="shadow book vs your picks vs SPY">
|
||||
<div className="glass p-5 text-sm text-gray-500">
|
||||
No trades in the selected window yet
|
||||
{startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled.
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom;
|
||||
|
||||
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
const rect = svgRef.current?.getBoundingClientRect();
|
||||
@@ -81,11 +144,32 @@ export function PerfChart() {
|
||||
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<Section title="Performance" hint="paper book vs the same dollars in SPY · cumulative P&L">
|
||||
<Section
|
||||
title="Performance"
|
||||
hint={`shadow book vs your picks vs SPY${startDate ? ` · since ${startDate}` : ''}`}
|
||||
>
|
||||
<div className="glass p-5 pb-2">
|
||||
<div className="flex justify-end gap-4 text-xs text-gray-400">
|
||||
<span><i className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: 'var(--up)' }} /> Book</span>
|
||||
<span><i className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: 'var(--ink-3)' }} /> Same $ in SPY</span>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||
<StatCell label="Shadow (strategy)" stats={stats.shadow} color={COLORS.shadow} />
|
||||
<StatCell label="Your picks" stats={stats.manual} color={COLORS.manual} />
|
||||
<div className="min-w-[7rem]">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<i className="inline-block h-2 w-2 rounded-full" style={{ background: COLORS.spy }} />
|
||||
SPY
|
||||
</div>
|
||||
<div className="num mt-1 text-lg font-semibold text-gray-300">
|
||||
{(stats.spy?.pct ?? 0) > 0 ? '+' : ''}
|
||||
{(stats.spy?.pct ?? 0).toFixed(1)}%
|
||||
</div>
|
||||
<div className="num text-[11px] text-gray-500">buy & hold</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-w-[22rem] text-[11px] leading-relaxed text-gray-500">
|
||||
Books share the same exit, so the difference is <b className="text-gray-400">selection</b>.
|
||||
Compare on <b className="text-gray-400">R</b>, not $ — sizing differs. Expect months of
|
||||
noise before a gap means anything.
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
@@ -94,7 +178,9 @@ export function PerfChart() {
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
role="img"
|
||||
aria-label={`Paper book cumulative P&L ${money(data[last].book_pnl)} versus ${money(data[last].benchmark_pnl)} for the same dollars in SPY`}
|
||||
aria-label={`Shadow book ${money(data[last].shadow_pnl)}, your picks ${money(
|
||||
data[last].manual_pnl,
|
||||
)}, SPY ${(data[last].spy_pct ?? 0).toFixed(1)} percent`}
|
||||
>
|
||||
{yTicks.map((t) => (
|
||||
<g key={t}>
|
||||
@@ -104,35 +190,40 @@ export function PerfChart() {
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* zero baseline slightly stronger when it's inside the plot */}
|
||||
<line x1={PAD.left} x2={W - PAD.right} y1={py(0)} y2={py(0)} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
||||
{xTicks.map(({ i, label }) => (
|
||||
<text key={`${label}-${i}`} x={px(i)} y={H - 6} textAnchor="middle" className="num" fill="var(--ink-3)" fontSize="10">
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
<path d={line('benchmark_pnl')} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" />
|
||||
<path d={line('book_pnl')} fill="none" stroke="var(--up)" strokeWidth="2" strokeLinejoin="round" />
|
||||
<path d={spyLine} fill="none" stroke={COLORS.spy} strokeWidth="1.5" strokeDasharray="4 3" strokeLinejoin="round" />
|
||||
<path d={line('manual_pnl')} fill="none" stroke={COLORS.manual} strokeWidth="2" strokeLinejoin="round" />
|
||||
<path d={line('shadow_pnl')} fill="none" stroke={COLORS.shadow} strokeWidth="2" strokeLinejoin="round" />
|
||||
{hover !== null && (
|
||||
<g>
|
||||
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
|
||||
<circle cx={px(hover)} cy={py(data[hover].book_pnl)} r="4.5" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" />
|
||||
<circle cx={px(hover)} cy={py(data[hover].benchmark_pnl)} r="4.5" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" />
|
||||
<circle cx={px(hover)} cy={py(data[hover].shadow_pnl)} r="4.5" fill={COLORS.shadow} stroke="var(--surface)" strokeWidth="2" />
|
||||
<circle cx={px(hover)} cy={py(data[hover].manual_pnl)} r="4.5" fill={COLORS.manual} stroke="var(--surface)" strokeWidth="2" />
|
||||
<circle cx={px(hover)} cy={spyY(data[hover].spy_pct)} r="4" fill={COLORS.spy} stroke="var(--surface)" strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
<circle cx={px(last)} cy={py(data[last].book_pnl)} r="4" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" />
|
||||
<circle cx={px(last)} cy={py(data[last].benchmark_pnl)} r="4" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" />
|
||||
<text x={px(last) + 10} y={py(data[last].book_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
|
||||
{money(data[last].book_pnl)}
|
||||
<text x={px(last) + 10} y={py(data[last].shadow_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
|
||||
{money(data[last].shadow_pnl)}
|
||||
</text>
|
||||
<text x={px(last) + 10} y={py(data[last].benchmark_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
|
||||
{money(data[last].benchmark_pnl)}
|
||||
<text x={px(last) + 10} y={py(data[last].manual_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
|
||||
{money(data[last].manual_pnl)}
|
||||
</text>
|
||||
</svg>
|
||||
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
|
||||
{hb
|
||||
? <>{fmtDate(hb.date)} — book <b className="text-gray-200">{money(hb.book_pnl)}</b> · SPY <b className="text-gray-200">{money(hb.benchmark_pnl)}</b></>
|
||||
: <>hover for daily values · realized + mark-to-market, since first paper trade</>}
|
||||
{hb ? (
|
||||
<>
|
||||
{fmtDate(hb.date)} — shadow <b className="text-gray-200">{money(hb.shadow_pnl)}</b> · yours{' '}
|
||||
<b className="text-gray-200">{money(hb.manual_pnl)}</b> · SPY{' '}
|
||||
<b className="text-gray-200">{hb.spy_pct.toFixed(1)}%</b>
|
||||
</>
|
||||
) : (
|
||||
<>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertSettings } from '../components/admin/AlertSettings';
|
||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
import { JobControls } from '../components/admin/JobControls';
|
||||
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
|
||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
||||
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
|
||||
@@ -36,6 +37,7 @@ export default function AdminPage() {
|
||||
<div className="space-y-4">
|
||||
<ActivationSettings />
|
||||
<ExitPolicySettings />
|
||||
<PerformanceSettings />
|
||||
<AlertSettings />
|
||||
<SentimentProviderSettings />
|
||||
<TickerUniverseBootstrap />
|
||||
|
||||
Reference in New Issue
Block a user