feat: instrument picker + Pattern Lab instrument scan mode

- instruments.ts: 90 IB-options-tradable instruments in 12 categories
  (US Indices, Europe, Asia, EM, Sectors, Forex, Bonds, Metals, Energy,
   Agriculture, Crypto, Volatility) — EUR/CHF, Cotton, etc. all included

- PatternExplorer: replace text input in Instrument Lens with categorised
  grid picker (category pill filters + search + custom ticker fallback)

- PatternLab: add Instrument Scan tab alongside Event Presets
  - Pick any instrument from the shared categorised picker
  - Set period (start/end date) + horizon per pattern
  - AI scans the full period: identifies 4-6 key pattern instances each with
    their own entry date, expected move, strategy
  - 'Evaluate outcomes' fetches actual price at T+horizon per pattern
  - 'Save pattern' promotes any instance to the Pattern Library

- backend/services/pattern_lab.py: run_instrument_scan() + evaluate_instrument_outcomes()
  (per-pattern analysis_date vs shared date in event mode)
- backend/routers/pattern_lab.py: POST /instrument-scan + POST /evaluate-instrument/{id}
- useApi.ts: useInstrumentScan + useEvaluateInstrumentScan hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 18:20:22 +02:00
parent cbf989502c
commit 303ecc2a3a
6 changed files with 850 additions and 80 deletions

View File

@@ -39,6 +39,14 @@ class SavePatternRequest(BaseModel):
asset_class: Optional[str] = "indices"
class InstrumentScanRequest(BaseModel):
ticker: str
instrument_name: str
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
horizon_days: int = 90
# ── Helpers ────────────────────────────────────────────────────────────────────
def _get_run(run_id: str) -> dict:
@@ -165,6 +173,82 @@ def delete_run(run_id: str):
return {"deleted": run_id}
@router.post("/instrument-scan")
async def instrument_scan(req: InstrumentScanRequest):
"""Scan an instrument's historical price action to extract pattern instances."""
from services.pattern_lab import run_instrument_scan
openai_key = get_config("openai_api_key") or ""
if not openai_key:
raise HTTPException(400, "OpenAI API key not configured")
run_id = f"LAB_INS_{uuid.uuid4().hex[:8].upper()}"
now = datetime.utcnow().isoformat()
conn = get_conn()
conn.execute("""INSERT INTO backtest_lab_runs
(id, preset_id, theme, analysis_date, horizon_days, assets, theme_hint, status, created_at)
VALUES (?,?,?,?,?,?,?,'running',?)""", (
run_id,
"instrument_scan",
f"Instrument Scan: {req.instrument_name}",
req.start_date,
req.horizon_days,
json.dumps([req.ticker]),
json.dumps({"type": "instrument", "ticker": req.ticker,
"instrument_name": req.instrument_name,
"start_date": req.start_date, "end_date": req.end_date}),
now,
))
conn.commit()
conn.close()
try:
ai_result = await run_instrument_scan(
req.ticker, req.instrument_name,
req.start_date, req.end_date,
req.horizon_days, openai_key,
)
conn = get_conn()
conn.execute("""UPDATE backtest_lab_runs SET
ai_result=?, status='done'
WHERE id=?""", (json.dumps(ai_result), run_id))
conn.commit()
conn.close()
return {"run_id": run_id, "status": "done", "ai_result": ai_result}
except Exception as e:
conn = get_conn()
conn.execute("UPDATE backtest_lab_runs SET status='error', error_msg=? WHERE id=?",
(str(e), run_id))
conn.commit()
conn.close()
raise HTTPException(500, f"Instrument scan failed: {e}")
@router.post("/evaluate-instrument/{run_id}")
def evaluate_instrument_run(run_id: str):
"""Evaluate outcomes for an instrument scan (per-pattern dates)."""
from services.pattern_lab import evaluate_instrument_outcomes
run = _get_run(run_id)
if run["status"] not in ("done", "evaluated"):
raise HTTPException(400, f"Run status is '{run['status']}', must be 'done' first")
outcomes = evaluate_instrument_outcomes(run)
conn = get_conn()
conn.execute("""UPDATE backtest_lab_runs SET
outcome=?, status='evaluated', evaluated_at=datetime('now')
WHERE id=?""", (json.dumps(outcomes), run_id))
conn.commit()
conn.close()
return {"run_id": run_id, "outcomes": outcomes}
@router.post("/save-pattern")
def save_pattern_from_run(req: SavePatternRequest):
"""Promote one AI-suggested pattern (with its outcome) to the pattern library."""

View File

@@ -239,3 +239,192 @@ def evaluate_outcomes(run: dict) -> list:
})
return outcomes
# ── Instrument Scan ────────────────────────────────────────────────────────────
async def run_instrument_scan(
ticker: str, instrument_name: str,
start_date: str, end_date: str,
horizon_days: int, openai_key: str,
) -> dict:
"""
Analyse a specific instrument over a historical period.
The AI identifies 4-6 key pattern instances (each with its own entry date)
that drove significant moves in the underlying.
"""
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=openai_key)
# Fetch full period price history
fetch_start = (datetime.strptime(start_date, "%Y-%m-%d") - timedelta(days=5)).strftime("%Y-%m-%d")
fetch_end = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=5)).strftime("%Y-%m-%d")
df = _fetch_ticker(ticker, fetch_start, fetch_end)
monthly_lines = []
weekly_spikes = []
if df is not None and "Close" in df.columns:
df.index = pd.to_datetime(df.index).tz_localize(None)
s_dt = pd.Timestamp(start_date)
e_dt = pd.Timestamp(end_date)
sub = df[(df.index >= s_dt) & (df.index <= e_dt)].copy()
if not sub.empty:
# Monthly returns
monthly = sub["Close"].resample("ME").last().dropna()
for i in range(1, len(monthly)):
ret = (monthly.iloc[i] / monthly.iloc[i - 1] - 1) * 100
monthly_lines.append(f" {monthly.index[i].strftime('%Y-%m')}: {ret:+.1f}% (price {monthly.iloc[i]:.2f})")
# Weekly moves > 3%
weekly = sub["Close"].resample("W").last().dropna()
for i in range(1, len(weekly)):
wret = (weekly.iloc[i] / weekly.iloc[i - 1] - 1) * 100
if abs(wret) >= 3.0:
weekly_spikes.append(
f" week of {weekly.index[i].strftime('%Y-%m-%d')}: {wret:+.1f}%"
)
monthly_block = "\n".join(monthly_lines) if monthly_lines else " (no monthly data)"
spikes_block = "\n".join(weekly_spikes[:20]) if weekly_spikes else " (none > 3%)"
prompt = f"""You are a senior macro/options trader with deep historical market knowledge.
Instrument: {instrument_name} [{ticker}]
Period: {start_date}{end_date}
## ACTUAL MONTHLY PRICE RETURNS
{monthly_block}
## SIGNIFICANT WEEKLY MOVES (>3%)
{spikes_block}
## YOUR TASK
Based on the actual price data above and your historical knowledge of this period:
1. Identify 4 to 6 DISTINCT pattern/event instances that drove the most significant moves in {instrument_name}
2. Each pattern must have a specific ENTRY DATE (the trading signal date) within the period
3. Each pattern must be unique — different catalysts on different dates
4. The trade horizon per pattern is {horizon_days} days
For each pattern instance you MUST specify:
- `analysis_date`: the exact signal/entry date (YYYY-MM-DD, must be within {start_date} to {end_date})
- `underlying`: use "{ticker}" as the underlying
- `expected_direction`: "up", "down", or "any" (for volatility)
- `expected_move_pct`: realistic % move over {horizon_days} days (consistent with what actually happened)
- `confidence`: 0-100
Return ONLY this valid JSON:
{{
"instrument": "{instrument_name}",
"ticker": "{ticker}",
"period": "{start_date} to {end_date}",
"patterns": [
{{
"name": "Pattern name",
"description": "What caused this move and why it was tradeable",
"category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
"signal_direction": "bullish|bearish|volatility|neutral",
"analysis_date": "YYYY-MM-DD",
"underlying": "{ticker}",
"strategy": "long call|long put|straddle|call spread|put spread|...",
"expected_direction": "up|down|any",
"expected_move_pct": 5.0,
"horizon_days": {horizon_days},
"confidence": 75,
"rationale": "Why this was a high-conviction trade at this specific moment"
}}
]
}}"""
try:
resp = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
response_format={"type": "json_object"},
timeout=90,
)
raw = resp.choices[0].message.content or "{}"
return json.loads(raw)
except Exception as e:
_log.error(f"[PatternLab] Instrument scan AI failed: {e}")
return {"patterns": [], "error": str(e)}
def evaluate_instrument_outcomes(run: dict) -> list:
"""
Like evaluate_outcomes but each pattern has its own analysis_date.
Used for instrument scans where the AI generates time-distributed pattern instances.
"""
ai_result = json.loads(run.get("ai_result") or "{}")
patterns = ai_result.get("patterns", [])
default_horizon = int(run["horizon_days"])
outcomes = []
for pat in patterns:
ticker = pat.get("underlying", "")
pat_date = pat.get("analysis_date") or run["analysis_date"]
pat_horizon = int(pat.get("horizon_days") or default_horizon)
if not ticker or not pat_date:
continue
try:
dt = datetime.strptime(pat_date, "%Y-%m-%d")
eval_dt = dt + timedelta(days=pat_horizon)
except ValueError:
continue
if eval_dt.date() >= datetime.utcnow().date():
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
"analysis_date": pat_date, "error": "eval date in the future"})
continue
fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
fetch_end = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
df = _fetch_ticker(ticker, fetch_start, fetch_end)
if df is None or "Close" not in df.columns:
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
"analysis_date": pat_date, "error": "no data"})
continue
df.index = pd.to_datetime(df.index).tz_localize(None)
entry_sub = df[df.index.normalize() <= dt]
exit_sub = df[df.index.normalize() <= eval_dt]
if entry_sub.empty or exit_sub.empty:
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
"analysis_date": pat_date, "error": "insufficient history"})
continue
entry_price = float(entry_sub["Close"].dropna().iloc[-1])
exit_price = float(exit_sub["Close"].dropna().iloc[-1])
actual_move = round((exit_price / entry_price - 1) * 100, 2)
expected_dir = pat.get("expected_direction", "any")
expected_move = float(pat.get("expected_move_pct", 0))
threshold = max(expected_move * 0.5, 3.0)
if expected_dir == "up":
hit = actual_move >= threshold
elif expected_dir == "down":
hit = actual_move <= -threshold
else:
hit = abs(actual_move) >= threshold
outcomes.append({
"pattern_name": pat.get("name"),
"underlying": ticker,
"strategy": pat.get("strategy", ""),
"signal_direction": pat.get("signal_direction", ""),
"expected_direction": expected_dir,
"expected_move_pct": expected_move,
"actual_move_pct": actual_move,
"entry_price": round(entry_price, 4),
"exit_price": round(exit_price, 4),
"analysis_date": pat_date,
"entry_date": pat_date,
"exit_date": eval_dt.strftime("%Y-%m-%d"),
"hit": hit,
"confidence": pat.get("confidence", 0),
})
return outcomes

View File

@@ -0,0 +1,146 @@
/**
* Comprehensive list of IB-options-tradable instruments, organised by category.
* Used in PatternExplorer (Instrument Lens) and PatternLab (Instrument Scan).
*/
export interface Instrument {
ticker: string
name: string
category: string
flag?: string
}
export const INSTRUMENT_CATEGORIES = [
'US Indices',
'Europe',
'Asia-Pacific',
'Emerging Markets',
'Sectors',
'Forex',
'Bonds',
'Metals',
'Energy',
'Agriculture',
'Crypto',
'Volatility',
]
export const INSTRUMENTS: Instrument[] = [
// ── US Indices ──────────────────────────────────────────────────────────────
{ ticker: 'SPY', name: 'S&P 500', category: 'US Indices' },
{ ticker: 'QQQ', name: 'Nasdaq 100', category: 'US Indices' },
{ ticker: 'IWM', name: 'Russell 2000', category: 'US Indices' },
{ ticker: 'DIA', name: 'Dow Jones', category: 'US Indices' },
{ ticker: 'MDY', name: 'S&P MidCap 400', category: 'US Indices' },
{ ticker: 'SPY', name: 'S&P 500', category: 'US Indices' },
// ── Europe ──────────────────────────────────────────────────────────────────
{ ticker: 'EWG', name: 'Germany (DAX)', category: 'Europe', flag: '🇩🇪' },
{ ticker: 'EWU', name: 'UK (FTSE 100)', category: 'Europe', flag: '🇬🇧' },
{ ticker: 'EWI', name: 'Italy (MIB)', category: 'Europe', flag: '🇮🇹' },
{ ticker: 'EWP', name: 'Spain (IBEX)', category: 'Europe', flag: '🇪🇸' },
{ ticker: 'EWL', name: 'Switzerland', category: 'Europe', flag: '🇨🇭' },
{ ticker: 'EWQ', name: 'France (CAC)', category: 'Europe', flag: '🇫🇷' },
{ ticker: 'EWN', name: 'Netherlands', category: 'Europe', flag: '🇳🇱' },
{ ticker: 'EWD', name: 'Sweden', category: 'Europe', flag: '🇸🇪' },
// ── Asia-Pacific ─────────────────────────────────────────────────────────────
{ ticker: 'EWJ', name: 'Japan (Nikkei)', category: 'Asia-Pacific', flag: '🇯🇵' },
{ ticker: 'FXI', name: 'China (CSI 300)', category: 'Asia-Pacific', flag: '🇨🇳' },
{ ticker: 'KWEB', name: 'China Internet', category: 'Asia-Pacific', flag: '🇨🇳' },
{ ticker: 'EWY', name: 'South Korea', category: 'Asia-Pacific', flag: '🇰🇷' },
{ ticker: 'EWT', name: 'Taiwan', category: 'Asia-Pacific', flag: '🇹🇼' },
{ ticker: 'EWA', name: 'Australia', category: 'Asia-Pacific', flag: '🇦🇺' },
{ ticker: 'EWS', name: 'Singapore', category: 'Asia-Pacific', flag: '🇸🇬' },
{ ticker: 'INDA', name: 'India', category: 'Asia-Pacific', flag: '🇮🇳' },
// ── Emerging Markets ─────────────────────────────────────────────────────────
{ ticker: 'EEM', name: 'Emerging Markets', category: 'Emerging Markets' },
{ ticker: 'EWZ', name: 'Brazil', category: 'Emerging Markets', flag: '🇧🇷' },
{ ticker: 'TUR', name: 'Turkey', category: 'Emerging Markets', flag: '🇹🇷' },
{ ticker: 'EWW', name: 'Mexico', category: 'Emerging Markets', flag: '🇲🇽' },
{ ticker: 'EWH', name: 'Hong Kong', category: 'Emerging Markets', flag: '🇭🇰' },
{ ticker: 'EZA', name: 'South Africa', category: 'Emerging Markets', flag: '🇿🇦' },
{ ticker: 'THD', name: 'Thailand', category: 'Emerging Markets', flag: '🇹🇭' },
// ── Sectors ───────────────────────────────────────────────────────────────────
{ ticker: 'XLF', name: 'Financials', category: 'Sectors' },
{ ticker: 'XLE', name: 'Energy', category: 'Sectors' },
{ ticker: 'XLK', name: 'Technology', category: 'Sectors' },
{ ticker: 'XLV', name: 'Healthcare', category: 'Sectors' },
{ ticker: 'XLI', name: 'Industrials', category: 'Sectors' },
{ ticker: 'XLB', name: 'Materials', category: 'Sectors' },
{ ticker: 'XLU', name: 'Utilities', category: 'Sectors' },
{ ticker: 'XLP', name: 'Consumer Staples', category: 'Sectors' },
{ ticker: 'XLY', name: 'Consumer Discret.', category: 'Sectors' },
{ ticker: 'XLC', name: 'Communication', category: 'Sectors' },
{ ticker: 'SMH', name: 'Semiconductors', category: 'Sectors' },
{ ticker: 'IBB', name: 'Biotech', category: 'Sectors' },
{ ticker: 'ARKK', name: 'ARK Innovation', category: 'Sectors' },
// ── Forex ─────────────────────────────────────────────────────────────────────
{ ticker: 'EURUSD=X', name: 'EUR/USD', category: 'Forex' },
{ ticker: 'GBPUSD=X', name: 'GBP/USD', category: 'Forex' },
{ ticker: 'USDJPY=X', name: 'USD/JPY', category: 'Forex' },
{ ticker: 'USDCHF=X', name: 'USD/CHF', category: 'Forex' },
{ ticker: 'EURCHF=X', name: 'EUR/CHF', category: 'Forex' },
{ ticker: 'AUDUSD=X', name: 'AUD/USD', category: 'Forex' },
{ ticker: 'USDCAD=X', name: 'USD/CAD', category: 'Forex' },
{ ticker: 'USDCNH=X', name: 'USD/CNH', category: 'Forex' },
{ ticker: 'NZDUSD=X', name: 'NZD/USD', category: 'Forex' },
{ ticker: 'USDMXN=X', name: 'USD/MXN', category: 'Forex' },
{ ticker: 'EURGBP=X', name: 'EUR/GBP', category: 'Forex' },
{ ticker: 'EURJPY=X', name: 'EUR/JPY', category: 'Forex' },
{ ticker: 'GBPJPY=X', name: 'GBP/JPY', category: 'Forex' },
{ ticker: 'USDSEK=X', name: 'USD/SEK', category: 'Forex' },
{ ticker: 'USDNOK=X', name: 'USD/NOK', category: 'Forex' },
{ ticker: 'USDTRY=X', name: 'USD/TRY', category: 'Forex' },
{ ticker: 'USDZAR=X', name: 'USD/ZAR', category: 'Forex' },
// ── Bonds ─────────────────────────────────────────────────────────────────────
{ ticker: 'TLT', name: '20Y+ US Treasury', category: 'Bonds' },
{ ticker: 'IEF', name: '7-10Y Treasury', category: 'Bonds' },
{ ticker: 'SHY', name: '1-3Y Treasury', category: 'Bonds' },
{ ticker: 'HYG', name: 'High Yield Corp', category: 'Bonds' },
{ ticker: 'LQD', name: 'Investment Grade', category: 'Bonds' },
{ ticker: 'TIP', name: 'TIPS (Inflation)', category: 'Bonds' },
{ ticker: 'BIL', name: 'T-Bills 1-3M', category: 'Bonds' },
{ ticker: 'EMB', name: 'EM Sovereign', category: 'Bonds' },
{ ticker: 'MBB', name: 'Mortgage-Backed', category: 'Bonds' },
// ── Metals ────────────────────────────────────────────────────────────────────
{ ticker: 'GLD', name: 'Gold', category: 'Metals' },
{ ticker: 'SLV', name: 'Silver', category: 'Metals' },
{ ticker: 'PPLT', name: 'Platinum', category: 'Metals' },
{ ticker: 'PALL', name: 'Palladium', category: 'Metals' },
{ ticker: 'COPX', name: 'Copper Miners', category: 'Metals' },
{ ticker: 'DBB', name: 'Base Metals', category: 'Metals' },
// ── Energy ────────────────────────────────────────────────────────────────────
{ ticker: 'USO', name: 'WTI Crude Oil', category: 'Energy' },
{ ticker: 'BNO', name: 'Brent Crude Oil', category: 'Energy' },
{ ticker: 'UNG', name: 'Natural Gas (US)', category: 'Energy' },
{ ticker: 'UGA', name: 'Gasoline RBOB', category: 'Energy' },
{ ticker: 'AMLP', name: 'Pipelines MLP', category: 'Energy' },
// ── Agriculture ───────────────────────────────────────────────────────────────
{ ticker: 'WEAT', name: 'Wheat', category: 'Agriculture' },
{ ticker: 'CORN', name: 'Corn', category: 'Agriculture' },
{ ticker: 'SOYB', name: 'Soybeans', category: 'Agriculture' },
{ ticker: 'BAL', name: 'Cotton', category: 'Agriculture' },
{ ticker: 'CANE', name: 'Sugar', category: 'Agriculture' },
{ ticker: 'NIB', name: 'Cocoa', category: 'Agriculture' },
{ ticker: 'JO', name: 'Coffee', category: 'Agriculture' },
{ ticker: 'DBA', name: 'Agri Diversified', category: 'Agriculture' },
// ── Crypto ────────────────────────────────────────────────────────────────────
{ ticker: 'BTC-USD', name: 'Bitcoin', category: 'Crypto' },
{ ticker: 'ETH-USD', name: 'Ethereum', category: 'Crypto' },
{ ticker: 'SOL-USD', name: 'Solana', category: 'Crypto' },
// ── Volatility ────────────────────────────────────────────────────────────────
{ ticker: '^VIX', name: 'CBOE VIX', category: 'Volatility' },
{ ticker: 'VXX', name: 'VIX Short-Term', category: 'Volatility' },
{ ticker: 'UVXY', name: 'Ultra VIX 1.5x', category: 'Volatility' },
{ ticker: 'SVXY', name: 'Short VIX -0.5x', category: 'Volatility' },
]

View File

@@ -1007,3 +1007,22 @@ export const useDeleteLabRun = () => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }),
})
}
export const useInstrumentScan = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: {
ticker: string; instrument_name: string;
start_date: string; end_date: string; horizon_days: number;
}) => api.post('/pattern-lab/instrument-scan', body).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }),
})
}
export const useEvaluateInstrumentScan = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (run_id: string) => api.post(`/pattern-lab/evaluate-instrument/${run_id}`, {}).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }),
})
}

View File

@@ -11,6 +11,7 @@ import {
usePatternsByInstrument,
useLastScores,
} from '../hooks/useApi'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
// ── Local types ───────────────────────────────────────────────────────────────
@@ -588,94 +589,130 @@ function InstrumentRow({ pattern, searchTerm }: { pattern: Pattern; searchTerm:
}
function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
const [inputValue, setInputValue] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [customInput, setCustomInput] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounce the search term by 300 ms
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => setSearchTerm(inputValue.trim()), 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [inputValue])
// Selected ticker — either clicked from grid or typed
const [selectedTicker, setSelectedTicker] = useState('')
// Also use the dedicated hook when a ticker is typed
const { data: instrumentData } = usePatternsByInstrument(searchTerm)
const { data: instrumentData } = usePatternsByInstrument(selectedTicker)
const instrumentPatterns = instrumentData as Pattern[] | undefined
const results = useMemo(() => {
if (!searchTerm) return []
// Prefer dedicated API data if available, otherwise filter locally
if (!selectedTicker) return []
const source: Pattern[] = (instrumentPatterns && instrumentPatterns.length > 0)
? instrumentPatterns
: patterns.filter(p =>
(p.suggested_trades ?? []).some(t =>
t.underlying.toLowerCase().includes(searchTerm.toLowerCase())
t.underlying.toLowerCase().includes(selectedTicker.toLowerCase())
)
)
return [...source].sort((a, b) => b.probability - a.probability)
}, [searchTerm, instrumentPatterns, patterns])
}, [selectedTicker, instrumentPatterns, patterns])
// Deduplicate instruments list
const uniqueInstruments = useMemo(() => {
const seen = new Set<string>()
return INSTRUMENTS.filter(i => { if (seen.has(i.ticker)) return false; seen.add(i.ticker); return true })
}, [])
const filteredInstruments = useMemo(() => {
const byCategory = activeCategory
? uniqueInstruments.filter(i => i.category === activeCategory)
: uniqueInstruments
if (!searchTerm) return byCategory
const q = searchTerm.toLowerCase()
return byCategory.filter(i =>
i.ticker.toLowerCase().includes(q) || i.name.toLowerCase().includes(q)
)
}, [uniqueInstruments, activeCategory, searchTerm])
const handleCustom = () => {
const t = customInput.trim()
if (t) { setSelectedTicker(t); setCustomInput('') }
}
return (
<div className="flex flex-col gap-4">
{/* Search bar */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Search by underlying (EUR/USD, USO, GLD, CL=F…)"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
className="input text-sm pl-10 py-2 w-full"
/>
{/* ── Category pills ── */}
<div className="flex flex-wrap gap-1.5">
<button onClick={() => setActiveCategory(null)}
className={clsx('text-xs px-2.5 py-1 rounded-full border transition-colors', {
'bg-blue-600 border-blue-500 text-white': activeCategory === null,
'border-slate-700 text-slate-500 hover:text-slate-300': activeCategory !== null,
})}>All</button>
{INSTRUMENT_CATEGORIES.map(cat => (
<button key={cat} onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={clsx('text-xs px-2.5 py-1 rounded-full border transition-colors', {
'bg-blue-600 border-blue-500 text-white': activeCategory === cat,
'border-slate-700 text-slate-500 hover:text-slate-300': activeCategory !== cat,
})}>{cat}</button>
))}
</div>
{/* ── Search + custom ── */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value)}
placeholder="Search instruments…"
className="w-full pl-8 pr-3 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-blue-600" />
</div>
{searchTerm && (
<button
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
onClick={() => { setInputValue(''); setSearchTerm('') }}
>
Clear
</button>
<input type="text" value={customInput} onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCustom()}
placeholder="Custom ticker…"
className="w-32 px-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-blue-600 font-mono" />
<button onClick={handleCustom}
className="px-2.5 py-1.5 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors">Go</button>
{selectedTicker && (
<button onClick={() => setSelectedTicker('')}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">Clear</button>
)}
</div>
{/* Results */}
{!searchTerm ? (
<div className="flex flex-col items-center justify-center py-24 text-slate-600 gap-3">
<Search className="w-8 h-8 opacity-30" />
<p className="text-sm">Type an underlying to find matching patterns</p>
<div className="flex flex-wrap gap-2 mt-1 justify-center">
{['EUR/USD', 'USO', 'GLD', 'CL=F', 'GC=F', 'SPY', 'TLT'].map(example => (
<button
key={example}
className="text-xs px-2.5 py-1 rounded border border-slate-700/50 text-slate-500 hover:border-blue-600/50 hover:text-blue-400 transition-colors font-mono"
onClick={() => setInputValue(example)}
>
{example}
</button>
))}
</div>
{/* ── Instrument grid ── */}
{!selectedTicker && (
<div className="grid grid-cols-4 gap-1.5">
{filteredInstruments.map(inst => (
<button key={inst.ticker} onClick={() => setSelectedTicker(inst.ticker)}
className="text-left px-2.5 py-2 rounded border border-slate-700/40 bg-dark-700/40 hover:border-blue-600/60 hover:bg-blue-900/20 transition-colors group">
<div className="flex items-center gap-1 mb-0.5">
{inst.flag && <span className="text-sm">{inst.flag}</span>}
<span className="text-[10px] font-mono text-blue-400 group-hover:text-blue-300">{inst.ticker}</span>
</div>
<div className="text-[10px] text-slate-400 truncate">{inst.name}</div>
</button>
))}
{filteredInstruments.length === 0 && (
<div className="col-span-4 text-center py-8 text-xs text-slate-600">No instruments match</div>
)}
</div>
) : results.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-600 text-sm gap-2">
<Zap className="w-6 h-6 opacity-30" />
<p>No patterns found for <span className="font-mono text-slate-500">{searchTerm}</span></p>
</div>
) : (
<>
<div className="text-sm text-slate-400">
<span className="font-semibold text-slate-200">{results.length}</span> pattern{results.length !== 1 ? 's' : ''} influence{' '}
<span className="font-mono text-blue-400">{searchTerm}</span>, ranked by probability
)}
{/* ── Results ── */}
{selectedTicker && (
results.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-600 text-sm gap-2">
<Zap className="w-6 h-6 opacity-30" />
<p>No patterns found for <span className="font-mono text-slate-500">{selectedTicker}</span></p>
<button onClick={() => setSelectedTicker('')} className="text-xs text-blue-500 hover:text-blue-400 mt-1"> Back to list</button>
</div>
<div className="flex flex-col gap-3">
{results.map(p => (
<InstrumentRow key={p.id} pattern={p} searchTerm={searchTerm} />
))}
</div>
</>
) : (
<>
<div className="flex items-center gap-3 text-sm text-slate-400">
<button onClick={() => setSelectedTicker('')} className="text-xs text-blue-500 hover:text-blue-400"> Back</button>
<span className="font-semibold text-slate-200">{results.length}</span> pattern{results.length !== 1 ? 's' : ''} influence{' '}
<span className="font-mono text-blue-400">{selectedTicker}</span>, ranked by probability
</div>
<div className="flex flex-col gap-3">
{results.map(p => (
<InstrumentRow key={p.id} pattern={p} searchTerm={selectedTicker} />
))}
</div>
</>
)
)}
</div>
)

View File

@@ -2,13 +2,15 @@ import { useState, useMemo } from 'react'
import {
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
usePatternLabRuns, useDeleteLabRun,
useInstrumentScan, useEvaluateInstrumentScan,
} from '../hooks/useApi'
import {
FlaskConical, Play, ChevronRight, CheckCircle2, XCircle,
Clock, Trash2, Save, RefreshCw, Search, CalendarDays,
TrendingUp, TrendingDown, Zap, BarChart2, AlertTriangle,
FlaskConical, Play, CheckCircle2, XCircle,
Trash2, Save, RefreshCw, Search, CalendarDays,
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine,
} from 'lucide-react'
import clsx from 'clsx'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
// ── Preset catalogue (2015-2025, 34 events) ────────────────────────────────────
@@ -85,28 +87,109 @@ function MoveBadge({ move, dir }: { move: number; dir: string }) {
// ── Main page ──────────────────────────────────────────────────────────────────
// ── Shared instrument picker ───────────────────────────────────────────────────
function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: string) => void }) {
const [search, setSearch] = useState('')
const [cat, setCat] = useState<string | null>(null)
const [custom, setCustom] = useState('')
const seen = new Set<string>()
const unique = INSTRUMENTS.filter(i => { if (seen.has(i.ticker)) return false; seen.add(i.ticker); return true })
const filtered = unique.filter(i => {
if (cat && i.category !== cat) return false
if (search) {
const q = search.toLowerCase()
return i.ticker.toLowerCase().includes(q) || i.name.toLowerCase().includes(q)
}
return true
})
return (
<div className="space-y-2">
{/* Category pills */}
<div className="flex flex-wrap gap-1">
<button onClick={() => setCat(null)}
className={clsx('text-[10px] px-2 py-0.5 rounded-full border transition-colors',
cat === null ? 'bg-violet-600 border-violet-500 text-white' : 'border-slate-700 text-slate-500 hover:text-slate-300')}>
All
</button>
{INSTRUMENT_CATEGORIES.map(c => (
<button key={c} onClick={() => setCat(cat === c ? null : c)}
className={clsx('text-[10px] px-2 py-0.5 rounded-full border transition-colors',
cat === c ? 'bg-violet-600 border-violet-500 text-white' : 'border-slate-700 text-slate-500 hover:text-slate-300')}>
{c}
</button>
))}
</div>
{/* Search + custom */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search…"
className="w-full pl-6 pr-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-violet-500" />
</div>
<input value={custom} onChange={e => setCustom(e.target.value)}
onKeyDown={e => e.key === 'Enter' && custom.trim() && onSelect(custom.trim(), custom.trim())}
placeholder="Custom ticker"
className="w-28 px-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded font-mono text-slate-200 placeholder-slate-600 focus:outline-none focus:border-violet-500" />
</div>
{/* Grid */}
<div className="grid grid-cols-3 gap-1 max-h-60 overflow-y-auto pr-1">
{filtered.map(inst => (
<button key={inst.ticker} onClick={() => onSelect(inst.ticker, inst.name)}
className="text-left px-2 py-1.5 rounded border border-slate-700/40 bg-dark-700/30 hover:border-violet-600/60 hover:bg-violet-900/20 transition-colors">
<div className="text-[10px] font-mono text-violet-400">{inst.ticker}</div>
<div className="text-[9px] text-slate-500 truncate">{inst.name}</div>
</button>
))}
{filtered.length === 0 && (
<div className="col-span-3 text-center py-4 text-[10px] text-slate-600">No match</div>
)}
</div>
</div>
)
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function PatternLab() {
const { data: runsData, isLoading: runsLoading } = usePatternLabRuns()
const { mutateAsync: runBacktest, isPending: running } = useRunPatternLab()
const { mutateAsync: evaluateRun, isPending: evaluating } = useEvaluatePatternLab()
const { mutateAsync: savePattern } = useSaveLabPattern()
const { mutate: deleteRun } = useDeleteLabRun()
const { data: runsData } = usePatternLabRuns()
const { mutateAsync: runBacktest, isPending: running } = useRunPatternLab()
const { mutateAsync: evaluateRun, isPending: evaluating } = useEvaluatePatternLab()
const { mutateAsync: savePattern } = useSaveLabPattern()
const { mutate: deleteRun } = useDeleteLabRun()
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
const runs: any[] = runsData ?? []
// ── Filters
// ── Mode tabs
const [mode, setMode] = useState<'events' | 'instrument'>('events')
// ── Event preset filters
const [search, setSearch] = useState('')
const [yearFilter, setYearFilter] = useState<number | null>(null)
const [catFilter, setCatFilter] = useState<string | null>(null)
// ── Active experiment
// ── Active experiment (events mode)
const [selected, setSelected] = useState<Preset | null>(null)
const [editDate, setEditDate] = useState('')
const [editHorizon, setEditHorizon] = useState(90)
const [editAssets, setEditAssets] = useState('')
const [editHint, setEditHint] = useState('')
// ── Current run state
// ── Instrument scan mode
const [instTicker, setInstTicker] = useState('')
const [instName, setInstName] = useState('')
const [instStart, setInstStart] = useState('2020-01-01')
const [instEnd, setInstEnd] = useState('2023-12-31')
const [instHorizon, setInstHorizon] = useState(90)
const [instRun, setInstRun] = useState<any | null>(null)
const [instSavedIdx, setInstSavedIdx] = useState<Set<number>>(new Set())
// ── Current run state (events mode)
const [activeRun, setActiveRun] = useState<any | null>(null)
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
const [toast, setToast] = useState<string | null>(null)
@@ -181,6 +264,45 @@ export default function PatternLab() {
}
}
// ── Instrument scan handlers
const handleInstScan = async () => {
if (!instTicker) return
try {
const result = await runInstScan({
ticker: instTicker, instrument_name: instName || instTicker,
start_date: instStart, end_date: instEnd, horizon_days: instHorizon,
})
setInstRun(result)
setInstSavedIdx(new Set())
} catch (e: any) {
showToast(`Error: ${e?.response?.data?.detail ?? e?.message ?? 'unknown'}`)
}
}
const handleInstEvaluate = async () => {
if (!instRun?.run_id) return
try {
const result = await evaluateInst(instRun.run_id)
setInstRun((prev: any) => ({ ...prev, outcome: result.outcomes }))
} catch (e: any) {
showToast(`Error: ${e?.response?.data?.detail ?? e?.message ?? 'unknown'}`)
}
}
const handleInstSave = async (idx: number) => {
if (!instRun?.run_id) return
const pat = instRun.ai_result?.patterns?.[idx]
if (!pat) return
try {
await savePattern({ run_id: instRun.run_id, pattern_index: idx, name: pat.name, category: pat.category, signal_direction: pat.signal_direction })
setInstSavedIdx(prev => new Set([...prev, idx]))
showToast(`"${pat.name}" saved to Pattern Library`)
} catch (e: any) {
showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`)
}
}
// ── Event mode helpers
const aiPatterns: any[] = activeRun?.ai_result?.patterns ?? []
const outcomes: any[] = activeRun?.outcome ?? []
const hasOutcomes = outcomes.length > 0
@@ -192,9 +314,18 @@ export default function PatternLab() {
? outcomes.filter((o: any) => o.hit === true).length / outcomes.filter((o: any) => 'hit' in o).length
: null
// ── Instrument mode helpers
const instPatterns: any[] = instRun?.ai_result?.patterns ?? []
const instOutcomes: any[] = instRun?.outcome ?? []
const instHasOut = instOutcomes.length > 0
const instOutcomeFor = (name: string) => instOutcomes.find((o: any) => o.pattern_name === name)
const instHitRate = instHasOut
? instOutcomes.filter((o: any) => o.hit === true).length / instOutcomes.filter((o: any) => 'hit' in o).length
: null
return (
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
{/* ── Left sidebar: preset selector ──────────────────────────────────── */}
{/* ── Left sidebar ────────────────────────────────────────────────────── */}
<div className="w-72 flex-shrink-0 border-r border-slate-700/40 flex flex-col overflow-hidden">
<div className="p-3 border-b border-slate-700/40">
<div className="flex items-center gap-2 mb-2">
@@ -202,6 +333,23 @@ export default function PatternLab() {
<span className="font-semibold text-sm text-slate-100">Pattern Lab</span>
<span className="ml-auto text-[10px] bg-slate-700 rounded px-1.5 py-0.5 text-slate-400">{PRESETS.length} events</span>
</div>
{/* Mode tabs */}
<div className="flex gap-1 mb-2 bg-dark-700 rounded p-0.5">
<button onClick={() => setMode('events')}
className={clsx('flex-1 text-xs py-1 rounded transition-colors flex items-center justify-center gap-1', {
'bg-violet-700 text-white': mode === 'events',
'text-slate-500 hover:text-slate-300': mode !== 'events',
})}>
<CalendarDays className="w-3 h-3" /> Events
</button>
<button onClick={() => setMode('instrument')}
className={clsx('flex-1 text-xs py-1 rounded transition-colors flex items-center justify-center gap-1', {
'bg-teal-700 text-white': mode === 'instrument',
'text-slate-500 hover:text-slate-300': mode !== 'instrument',
})}>
<ScanLine className="w-3 h-3" /> Instrument
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-slate-500" />
@@ -262,7 +410,153 @@ export default function PatternLab() {
{/* ── Main content ────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto">
{!selected ? (
{/* ══ INSTRUMENT SCAN MODE ══════════════════════════════════════════ */}
{mode === 'instrument' && (
<div className="p-5 space-y-5">
<div className="flex items-center gap-2">
<ScanLine className="w-4 h-4 text-teal-400" />
<h1 className="text-lg font-bold text-slate-100">Instrument Historical Scan</h1>
</div>
<p className="text-xs text-slate-400">
Pick an instrument the AI scans a historical period, identifies the key patterns/events that drove it, and validates them against actual price data.
</p>
{/* Instrument picker */}
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3">
{instTicker
? <span className="flex items-center gap-2">Selected: <span className="font-mono text-teal-300">{instTicker}</span> {instName} <button onClick={() => { setInstTicker(''); setInstName(''); setInstRun(null) }} className="text-slate-500 hover:text-slate-300 text-[10px] ml-1">change</button></span>
: 'Select Instrument'}
</div>
{!instTicker && (
<InstrumentPicker onSelect={(t, n) => { setInstTicker(t); setInstName(n) }} />
)}
</div>
{/* Period + horizon */}
{instTicker && (
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3">Scan Parameters</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Period Start</label>
<input type="date" value={instStart} onChange={e => setInstStart(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-teal-500" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Period End</label>
<input type="date" value={instEnd} onChange={e => setInstEnd(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-teal-500" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Horizon per pattern</label>
<div className="flex items-center gap-2">
<input type="range" min={14} max={365} value={instHorizon}
onChange={e => setInstHorizon(Number(e.target.value))}
className="flex-1 accent-teal-600" />
<span className="text-xs font-mono text-teal-300 w-8 text-right">{instHorizon}d</span>
</div>
</div>
</div>
<div className="mt-3">
<button onClick={handleInstScan} disabled={scanning}
className="flex items-center gap-2 bg-teal-700 hover:bg-teal-600 disabled:opacity-50 text-white px-4 py-2 rounded text-xs font-semibold transition-colors">
{scanning
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Fetching data + AI analysis</>
: <><ScanLine className="w-3.5 h-3.5" /> Scan History</>}
</button>
</div>
</div>
)}
{/* Instrument scan results */}
{instPatterns.length > 0 && (
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
{instPatterns.length} Pattern Instances <span className="text-teal-300 font-mono">{instTicker}</span>
<span className="text-slate-600 ml-2">{instStart} {instEnd}</span>
</div>
<div className="flex items-center gap-2">
{instHasOut && instHitRate !== null && (
<span className={clsx('text-sm font-bold', instHitRate >= 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{Math.round(instHitRate * 100)}% hit rate
</span>
)}
{!instHasOut && (
<button onClick={handleInstEvaluate} disabled={evalInst}
className="flex items-center gap-1.5 bg-teal-700 hover:bg-teal-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors">
{evalInst
? <><RefreshCw className="w-3 h-3 animate-spin" /> Evaluating</>
: <><BarChart2 className="w-3 h-3" /> Evaluate outcomes</>}
</button>
)}
</div>
</div>
<div className="space-y-3">
{instPatterns.map((pat: any, idx: number) => {
const out = instOutcomeFor(pat.name)
const isSaved = instSavedIdx.has(idx)
const DirIcon = pat.signal_direction === 'bullish' ? TrendingUp : pat.signal_direction === 'bearish' ? TrendingDown : Zap
const dirCol = pat.signal_direction === 'bullish' ? 'text-emerald-400' : pat.signal_direction === 'bearish' ? 'text-red-400' : 'text-violet-400'
return (
<div key={idx} className={clsx('border rounded-lg p-3',
out?.hit === true ? 'border-emerald-700/60 bg-emerald-900/10' :
out?.hit === false ? 'border-red-700/40 bg-red-900/10' :
'border-slate-700/40 bg-dark-700/30')}>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-0.5">
<DirIcon className={`w-3.5 h-3.5 ${dirCol}`} />
<span className="text-xs font-semibold text-slate-100">{pat.name}</span>
<span className="text-[10px] bg-teal-900/40 border border-teal-700/30 text-teal-300 rounded px-1.5 font-mono">{pat.analysis_date}</span>
{pat.category && <span className="text-[10px] text-slate-500">{pat.category}</span>}
</div>
<p className="text-xs text-slate-400 mb-1.5">{pat.description}</p>
<div className="flex items-center gap-4 text-[10px] text-slate-500">
<span>Strategy: <span className="text-slate-300">{pat.strategy}</span></span>
<span>Expected: <span className={clsx('font-semibold', dirCol)}>
{pat.expected_direction === 'up' ? '+' : pat.expected_direction === 'down' ? '-' : '±'}{pat.expected_move_pct}%
</span></span>
<span>Horizon: <span className="text-slate-300">{pat.horizon_days}d</span></span>
<span>Confidence: <span className={clsx('font-semibold', pat.confidence >= 70 ? 'text-emerald-400' : pat.confidence >= 50 ? 'text-yellow-400' : 'text-slate-400')}>{pat.confidence}</span></span>
</div>
{pat.rationale && <p className="text-[10px] text-slate-500 mt-1 italic">{pat.rationale}</p>}
{out && (
<div className={clsx('mt-2 pt-2 border-t flex items-center gap-3 text-[10px]',
out.hit ? 'border-emerald-700/30 text-emerald-300' : 'border-red-700/30 text-red-300')}>
{out.hit ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> : <XCircle className="w-3.5 h-3.5 text-red-400" />}
<span className="font-semibold">{out.hit ? 'HIT' : 'MISS'}</span>
<span className="text-slate-400">Actual:</span>
<span className={clsx('font-mono font-semibold', out.actual_move_pct > 0 ? 'text-emerald-400' : 'text-red-400')}>
{out.actual_move_pct > 0 ? '+' : ''}{out.actual_move_pct?.toFixed(1)}%
</span>
<span className="text-slate-500">{out.entry_date} {out.exit_date}</span>
</div>
)}
</div>
<div className="flex-shrink-0">
{isSaved
? <span className="flex items-center gap-1 text-emerald-400 text-xs"><CheckCircle2 className="w-3.5 h-3.5" /> Saved</span>
: <button onClick={() => handleInstSave(idx)}
className="flex items-center gap-1.5 border border-slate-600 hover:border-teal-500 hover:text-teal-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
<Save className="w-3 h-3" /> Save
</button>
}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* ══ EVENT PRESET MODE ════════════════════════════════════════════ */}
{mode === 'events' && !selected && (
<div className="flex items-center justify-center h-full text-center">
<div>
<FlaskConical className="w-12 h-12 text-slate-700 mx-auto mb-3" />
@@ -270,7 +564,8 @@ export default function PatternLab() {
<p className="text-slate-600 text-xs mt-1">The AI will analyse what it would have done at that date using real market data</p>
</div>
</div>
) : (
)}
{mode === 'events' && selected && (
<div className="p-5 space-y-5">
{/* ── Header ── */}
<div className="flex items-start gap-3">