feat(web): admin-only "Test against history" on /plans cards (Stage A)
Adds a "Test history" action button on each saved-setup card on /plans,
visible only when the authenticated profile has role=admin. Clicking
opens a portaled modal containing the existing BacktestRunnerPanel
pre-loaded with:
- the plan's TradeProfile.strategy_config (the actual rules + risk
limits the customer configured)
- the saved-setup's symbol (overrides profile.symbols when scoped)
- the profile's allocated_capital as initialCapitalUsd
Also adds a "Historical events" preset row to BacktestConfigurator with
5 pre-validated date ranges (COVID crash, COVID recovery, Russia/Ukraine
2022, 2022 bear market, SVB banking shock). Selecting a preset fills in
the from/to date inputs; manually editing dates clears the active preset
highlight.
This is a customer-experience-neutral change:
- No production feature flag is flipped (customer non-admins see no
new UI; existing useBacktestFeatureGate still gates the actual
backtest API call)
- No backend changes — reuses existing /api/backtest/run
- No new strategy code paths
Files:
+ web/src/backtest/components/HistoricalPresetPicker.tsx (new)
+ web/src/backtest/components/BacktestPlanModal.tsx (new, portaled)
~ web/src/backtest/components/BacktestConfigurator.tsx (preset wiring)
~ web/src/views/SimpleView.tsx (admin button + modal mount)
~ web/src/layout-fixes.css (§25 preset chips, §26 modal styles)
Stage A of docs/backtest/ENGINE_READINESS.md §4. Lets admins dogfood
the backtest UX and surface bugs before any customer-facing rollout
(stages B/C/D/F).
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
35efa786bd
commit
1a6c0352e3
@ -7,6 +7,7 @@ import type {
|
||||
} from '../types';
|
||||
import { parseSymbolsInput, toDateInputValue } from '../utils';
|
||||
import { Button, Input, Select } from '../../components/ui/Primitives';
|
||||
import { HistoricalPresetPicker } from './HistoricalPresetPicker';
|
||||
|
||||
type SourceType = 'csv' | 'json' | 'replay' | 'kraken';
|
||||
|
||||
@ -35,6 +36,21 @@ const defaultTo = toDateInputValue(now);
|
||||
const [timeframe, setTimeframe] = useState<BacktestTimeframe>('15m');
|
||||
const [fromDate, setFromDate] = useState(defaultFrom);
|
||||
const [toDate, setToDate] = useState(defaultTo);
|
||||
const [activePresetId, setActivePresetId] = useState<string | null>(null);
|
||||
|
||||
const handlePresetSelect = (presetFrom: string, presetTo: string, presetId: string) => {
|
||||
setFromDate(presetFrom);
|
||||
setToDate(presetTo);
|
||||
setActivePresetId(presetId);
|
||||
};
|
||||
const handleFromDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFromDate(event.target.value);
|
||||
setActivePresetId(null);
|
||||
};
|
||||
const handleToDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setToDate(event.target.value);
|
||||
setActivePresetId(null);
|
||||
};
|
||||
const [sourceType, setSourceType] = useState<SourceType>('csv');
|
||||
const [sourcePayload, setSourcePayload] = useState<any>(null);
|
||||
const [sourceName, setSourceName] = useState('');
|
||||
@ -163,7 +179,7 @@ const defaultTo = toDateInputValue(now);
|
||||
<Input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(event) => setFromDate(event.target.value)}
|
||||
onChange={handleFromDateChange}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</label>
|
||||
@ -172,11 +188,15 @@ const defaultTo = toDateInputValue(now);
|
||||
<Input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(event) => setToDate(event.target.value)}
|
||||
onChange={handleToDateChange}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<HistoricalPresetPicker
|
||||
selectedId={activePresetId}
|
||||
onSelect={handlePresetSelect}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<label className="space-y-1">
|
||||
|
||||
82
web/src/backtest/components/BacktestPlanModal.tsx
Normal file
82
web/src/backtest/components/BacktestPlanModal.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { BacktestRunnerPanel } from './BacktestRunnerPanel';
|
||||
import type { TradeProfilePayload } from '../../lib/profileApi';
|
||||
|
||||
/**
|
||||
* Admin-only modal that wraps BacktestRunnerPanel with the strategy_config
|
||||
* + symbols + initial capital pre-loaded from a saved trade plan's profile.
|
||||
*
|
||||
* Stage A of docs/backtest/ENGINE_READINESS.md §4. Surfaces existing
|
||||
* backtest infrastructure to admin users without changing the customer-
|
||||
* facing experience or the production feature flag.
|
||||
*/
|
||||
export interface BacktestPlanModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The trade profile whose strategy_config + symbols + capital seed the run. */
|
||||
profile: TradeProfilePayload | null;
|
||||
/** The symbol from the saved-setup card (overrides profile.symbols if set). */
|
||||
symbolOverride?: string;
|
||||
}
|
||||
|
||||
export const BacktestPlanModal: React.FC<BacktestPlanModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
profile,
|
||||
symbolOverride,
|
||||
}) => {
|
||||
if (!open || !profile) return null;
|
||||
|
||||
const profileSymbols = String(profile.symbols || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const symbols = symbolOverride
|
||||
? [symbolOverride.trim().toUpperCase()]
|
||||
: profileSymbols;
|
||||
|
||||
// Use the trade profile's allocated_capital as the starting capital so the
|
||||
// backtest reflects what the customer actually configured. Falls back to
|
||||
// 10k for unconfigured profiles.
|
||||
const initialCapitalUsd = Number(profile.allocated_capital) > 0
|
||||
? Number(profile.allocated_capital)
|
||||
: 10000;
|
||||
|
||||
return createPortal(
|
||||
<div className="backtest-plan-modal-backdrop" role="dialog" aria-modal="true" onClick={onClose}>
|
||||
<div className="backtest-plan-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="backtest-plan-modal-header">
|
||||
<div>
|
||||
<div className="backtest-plan-modal-eyebrow">Test plan against history (admin)</div>
|
||||
<h2 className="backtest-plan-modal-title">{profile.name || 'Trade plan'}</h2>
|
||||
<div className="backtest-plan-modal-sub">
|
||||
Symbols: <span className="font-mono">{symbols.join(', ') || '—'}</span>
|
||||
{' · '}
|
||||
Capital: ${initialCapitalUsd.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="backtest-plan-modal-close"
|
||||
aria-label="Close backtest modal"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="backtest-plan-modal-body">
|
||||
<BacktestRunnerPanel
|
||||
profileId={profile.id}
|
||||
strategyConfig={profile.strategy_config}
|
||||
symbols={symbols}
|
||||
initialCapitalUsd={initialCapitalUsd}
|
||||
title="Pick a date range or historical preset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
93
web/src/backtest/components/HistoricalPresetPicker.tsx
Normal file
93
web/src/backtest/components/HistoricalPresetPicker.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Preset historical event windows for the backtest configurator.
|
||||
*
|
||||
* These are pre-validated against typical Kraken/Alpaca data depth (5-7
|
||||
* years), so customers don't waste a backtest on a window that will return
|
||||
* "no data" for newer symbols. See docs/backtest/ENGINE_READINESS.md §3.4.
|
||||
*/
|
||||
export interface HistoricalEventPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
fromDate: string; // YYYY-MM-DD
|
||||
toDate: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export const DEFAULT_HISTORICAL_PRESETS: HistoricalEventPreset[] = [
|
||||
{
|
||||
id: 'covid-crash',
|
||||
label: 'COVID crash',
|
||||
description: 'Feb-Apr 2020 — ~30% drawdown across most assets in 5 weeks.',
|
||||
fromDate: '2020-02-15',
|
||||
toDate: '2020-04-30',
|
||||
},
|
||||
{
|
||||
id: 'covid-recovery',
|
||||
label: 'COVID recovery',
|
||||
description: 'Apr-Dec 2020 — fastest bull market in modern history.',
|
||||
fromDate: '2020-04-01',
|
||||
toDate: '2020-12-31',
|
||||
},
|
||||
{
|
||||
id: 'russia-ukraine',
|
||||
label: 'Russia/Ukraine 2022',
|
||||
description: 'Feb-Jun 2022 — energy + commodity shocks, risk-off pivot.',
|
||||
fromDate: '2022-02-01',
|
||||
toDate: '2022-06-30',
|
||||
},
|
||||
{
|
||||
id: 'bear-2022',
|
||||
label: '2022 bear market',
|
||||
description: 'Full year — sustained drawdown across crypto + tech equities.',
|
||||
fromDate: '2022-01-01',
|
||||
toDate: '2022-12-31',
|
||||
},
|
||||
{
|
||||
id: 'svb-banking',
|
||||
label: 'SVB banking shock',
|
||||
description: 'Mar 2023 — bank failures + emergency Fed response.',
|
||||
fromDate: '2023-03-01',
|
||||
toDate: '2023-04-15',
|
||||
},
|
||||
];
|
||||
|
||||
export interface HistoricalPresetPickerProps {
|
||||
/** Currently-selected preset id (or null when user is editing custom dates). */
|
||||
selectedId: string | null;
|
||||
/** Called with `(fromDate, toDate, presetId)` when a preset is clicked. */
|
||||
onSelect: (fromDate: string, toDate: string, presetId: string) => void;
|
||||
/** Override the default preset list. */
|
||||
presets?: HistoricalEventPreset[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const HistoricalPresetPicker: React.FC<HistoricalPresetPickerProps> = ({
|
||||
selectedId,
|
||||
onSelect,
|
||||
presets = DEFAULT_HISTORICAL_PRESETS,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`historical-preset-picker ${className || ''}`}>
|
||||
<div className="historical-preset-picker-label">Historical events</div>
|
||||
<div className="historical-preset-picker-row">
|
||||
{presets.map((preset) => {
|
||||
const active = selectedId === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(preset.fromDate, preset.toDate, preset.id)}
|
||||
className={`historical-preset-chip ${active ? 'is-active' : ''}`}
|
||||
title={`${preset.description} (${preset.fromDate} → ${preset.toDate})`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -494,3 +494,145 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Section 25 — Historical event preset picker (Stage A backtest POC)
|
||||
Used inside BacktestConfigurator. Compact chip row that lets the user
|
||||
pick a pre-validated date range (COVID, war periods) instead of typing
|
||||
dates manually.
|
||||
--------------------------------------------------------------------------- */
|
||||
.historical-preset-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.historical-preset-picker-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--bl-text-quiet);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.historical-preset-picker-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.historical-preset-chip {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font: inherit;
|
||||
border: 1px solid var(--bl-border-subtle);
|
||||
background: var(--bl-surface-muted);
|
||||
color: var(--bl-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.historical-preset-chip:hover {
|
||||
background: var(--bl-surface-highlight);
|
||||
border-color: var(--bl-border);
|
||||
color: var(--bl-text-primary);
|
||||
}
|
||||
.historical-preset-chip.is-active {
|
||||
background: var(--bl-accent-muted);
|
||||
border-color: var(--bl-accent);
|
||||
color: var(--bl-accent);
|
||||
}
|
||||
.historical-preset-chip:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--bl-bg-canvas, #0b0f17),
|
||||
0 0 0 4px var(--bl-focus-ring, var(--bl-accent, #5A8CFF));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Section 26 — Admin-only "Test against history" backtest modal (Stage A)
|
||||
Lives in BacktestPlanModal.tsx. Portaled to <body> so it escapes any
|
||||
parent overflow:hidden in the trade-plans card grid.
|
||||
--------------------------------------------------------------------------- */
|
||||
.backtest-plan-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: color-mix(in oklab, var(--bl-bg-canvas, #0b0f17) 70%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
padding: 5vh 16px 8vh;
|
||||
}
|
||||
.backtest-plan-modal-panel {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
background: var(--card-elevated);
|
||||
border: 1px solid var(--bl-border);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.backtest-plan-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--bl-border-subtle);
|
||||
background: var(--bl-surface-overlay);
|
||||
}
|
||||
.backtest-plan-modal-eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bl-warning);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.backtest-plan-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.backtest-plan-modal-sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.backtest-plan-modal-close {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--bl-border-subtle);
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bl-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.backtest-plan-modal-close:hover {
|
||||
background: var(--bl-surface-highlight);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.backtest-plan-modal-body {
|
||||
padding: 20px 24px 24px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(90vh - 120px);
|
||||
}
|
||||
.saved-setup-action.is-admin-only {
|
||||
border-color: color-mix(in oklab, var(--bl-warning) 30%, var(--bl-border));
|
||||
color: var(--bl-warning);
|
||||
}
|
||||
.saved-setup-action.is-admin-only:hover {
|
||||
background: color-mix(in oklab, var(--bl-warning) 10%, transparent);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import type { ChangeEvent, FormEvent } from 'react';
|
||||
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { History, Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { fetchChartBars, fetchResearchProfile } from '../lib/marketApi';
|
||||
@ -25,6 +25,8 @@ import {
|
||||
} from './tradePlansState';
|
||||
import { useTradePlansNavigationState } from './useTradePlansNavigationState';
|
||||
import { CardButton } from '@bytelyst/ui';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { BacktestPlanModal } from '../backtest/components/BacktestPlanModal';
|
||||
|
||||
type SimpleHolding = {
|
||||
symbol: string;
|
||||
@ -592,8 +594,11 @@ function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||
|
||||
export function SimpleView() {
|
||||
const { botState } = useAppContext();
|
||||
const { profile: authProfile } = useAuth();
|
||||
const isAdmin = authProfile?.role === 'admin';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]);
|
||||
const [backtestModalSetupId, setBacktestModalSetupId] = useState<string | null>(null);
|
||||
const [savedSetups, setSavedSetups] = useState<ManualEntryPayload[]>([]);
|
||||
const [uiState, dispatch] = useReducer(reduceTradePlansUiState, DEFAULT_TRADE_PLANS_UI_STATE);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@ -1454,6 +1459,21 @@ export function SimpleView() {
|
||||
Resume exit management
|
||||
</Button>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setBacktestModalSetupId(entryId)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="saved-setup-action is-admin-only"
|
||||
title="Test this plan against historical market data (admin only)"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<History size={14} />
|
||||
Test history
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleEdit(entry)}
|
||||
@ -1627,6 +1647,21 @@ export function SimpleView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<BacktestPlanModal
|
||||
open={Boolean(backtestModalSetupId)}
|
||||
onClose={() => setBacktestModalSetupId(null)}
|
||||
profile={(() => {
|
||||
if (!backtestModalSetupId) return null;
|
||||
const setup = savedSetups.find((s) => String(s.stock_instance_id || '') === backtestModalSetupId) || null;
|
||||
if (!setup) return null;
|
||||
return profiles.find((p) => p.id === String(setup.profile_id || '')) || null;
|
||||
})()}
|
||||
symbolOverride={(() => {
|
||||
if (!backtestModalSetupId) return undefined;
|
||||
const setup = savedSetups.find((s) => String(s.stock_instance_id || '') === backtestModalSetupId);
|
||||
return setup?.symbol ? String(setup.symbol) : undefined;
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user