From 303ecc2a3ae9bc88f0fdb1a092e321a84269f24b Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 22 Jun 2026 18:20:22 +0200 Subject: [PATCH] feat: instrument picker + Pattern Lab instrument scan mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/routers/pattern_lab.py | 84 +++++++ backend/services/pattern_lab.py | 189 +++++++++++++++ frontend/src/constants/instruments.ts | 146 +++++++++++ frontend/src/hooks/useApi.ts | 19 ++ frontend/src/pages/PatternExplorer.tsx | 169 ++++++++----- frontend/src/pages/PatternLab.tsx | 323 +++++++++++++++++++++++-- 6 files changed, 850 insertions(+), 80 deletions(-) create mode 100644 frontend/src/constants/instruments.ts diff --git a/backend/routers/pattern_lab.py b/backend/routers/pattern_lab.py index 4ffa028..fc86ff1 100644 --- a/backend/routers/pattern_lab.py +++ b/backend/routers/pattern_lab.py @@ -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.""" diff --git a/backend/services/pattern_lab.py b/backend/services/pattern_lab.py index f3fc48d..6b15c77 100644 --- a/backend/services/pattern_lab.py +++ b/backend/services/pattern_lab.py @@ -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 diff --git a/frontend/src/constants/instruments.ts b/frontend/src/constants/instruments.ts new file mode 100644 index 0000000..6ae73eb --- /dev/null +++ b/frontend/src/constants/instruments.ts @@ -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' }, +] diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 7e00e78..93ec470 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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'] }), + }) +} diff --git a/frontend/src/pages/PatternExplorer.tsx b/frontend/src/pages/PatternExplorer.tsx index 7bcdd09..654cfea 100644 --- a/frontend/src/pages/PatternExplorer.tsx +++ b/frontend/src/pages/PatternExplorer.tsx @@ -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(null) + const [customInput, setCustomInput] = useState('') const debounceRef = useRef | 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() + 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 (
- {/* Search bar */} -
-
- - setInputValue(e.target.value)} - className="input text-sm pl-10 py-2 w-full" - /> + {/* ── Category pills ── */} +
+ + {INSTRUMENT_CATEGORIES.map(cat => ( + + ))} +
+ + {/* ── Search + custom ── */} +
+
+ + 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" />
- {searchTerm && ( - + 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" /> + + {selectedTicker && ( + )}
- {/* Results */} - {!searchTerm ? ( -
- -

Type an underlying to find matching patterns

-
- {['EUR/USD', 'USO', 'GLD', 'CL=F', 'GC=F', 'SPY', 'TLT'].map(example => ( - - ))} -
+ {/* ── Instrument grid ── */} + {!selectedTicker && ( +
+ {filteredInstruments.map(inst => ( + + ))} + {filteredInstruments.length === 0 && ( +
No instruments match
+ )}
- ) : results.length === 0 ? ( -
- -

No patterns found for {searchTerm}

-
- ) : ( - <> -
- {results.length} pattern{results.length !== 1 ? 's' : ''} influence{' '} - {searchTerm}, ranked by probability + )} + + {/* ── Results ── */} + {selectedTicker && ( + results.length === 0 ? ( +
+ +

No patterns found for {selectedTicker}

+
-
- {results.map(p => ( - - ))} -
- + ) : ( + <> +
+ + {results.length} pattern{results.length !== 1 ? 's' : ''} influence{' '} + {selectedTicker}, ranked by probability +
+
+ {results.map(p => ( + + ))} +
+ + ) )}
) diff --git a/frontend/src/pages/PatternLab.tsx b/frontend/src/pages/PatternLab.tsx index 316efc6..ad225e2 100644 --- a/frontend/src/pages/PatternLab.tsx +++ b/frontend/src/pages/PatternLab.tsx @@ -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(null) + const [custom, setCustom] = useState('') + + const seen = new Set() + 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 ( +
+ {/* Category pills */} +
+ + {INSTRUMENT_CATEGORIES.map(c => ( + + ))} +
+ {/* Search + custom */} +
+
+ + 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" /> +
+ 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" /> +
+ {/* Grid */} +
+ {filtered.map(inst => ( + + ))} + {filtered.length === 0 && ( +
No match
+ )} +
+
+ ) +} + +// ── 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(null) const [catFilter, setCatFilter] = useState(null) - // ── Active experiment + // ── Active experiment (events mode) const [selected, setSelected] = useState(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(null) + const [instSavedIdx, setInstSavedIdx] = useState>(new Set()) + + // ── Current run state (events mode) const [activeRun, setActiveRun] = useState(null) const [savedIdx, setSavedIdx] = useState>(new Set()) const [toast, setToast] = useState(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 (
- {/* ── Left sidebar: preset selector ──────────────────────────────────── */} + {/* ── Left sidebar ────────────────────────────────────────────────────── */}
@@ -202,6 +333,23 @@ export default function PatternLab() { Pattern Lab {PRESETS.length} events
+ {/* Mode tabs */} +
+ + +
{/* Search */}
@@ -262,7 +410,153 @@ export default function PatternLab() { {/* ── Main content ────────────────────────────────────────────────────── */}
- {!selected ? ( + + {/* ══ INSTRUMENT SCAN MODE ══════════════════════════════════════════ */} + {mode === 'instrument' && ( +
+
+ +

Instrument Historical Scan

+
+

+ Pick an instrument — the AI scans a historical period, identifies the key patterns/events that drove it, and validates them against actual price data. +

+ + {/* Instrument picker */} +
+
+ {instTicker + ? Selected: {instTicker} — {instName} + : 'Select Instrument'} +
+ {!instTicker && ( + { setInstTicker(t); setInstName(n) }} /> + )} +
+ + {/* Period + horizon */} + {instTicker && ( +
+
Scan Parameters
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+ +
+ setInstHorizon(Number(e.target.value))} + className="flex-1 accent-teal-600" /> + {instHorizon}d +
+
+
+
+ +
+
+ )} + + {/* Instrument scan results */} + {instPatterns.length > 0 && ( +
+
+
+ {instPatterns.length} Pattern Instances — {instTicker} + {instStart} → {instEnd} +
+
+ {instHasOut && instHitRate !== null && ( + = 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}> + {Math.round(instHitRate * 100)}% hit rate + + )} + {!instHasOut && ( + + )} +
+
+
+ {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 ( +
+
+
+
+ + {pat.name} + {pat.analysis_date} + {pat.category && {pat.category}} +
+

{pat.description}

+
+ Strategy: {pat.strategy} + Expected: + {pat.expected_direction === 'up' ? '+' : pat.expected_direction === 'down' ? '-' : '±'}{pat.expected_move_pct}% + + Horizon: {pat.horizon_days}d + Confidence: = 70 ? 'text-emerald-400' : pat.confidence >= 50 ? 'text-yellow-400' : 'text-slate-400')}>{pat.confidence} +
+ {pat.rationale &&

{pat.rationale}

} + {out && ( +
+ {out.hit ? : } + {out.hit ? 'HIT' : 'MISS'} + Actual: + 0 ? 'text-emerald-400' : 'text-red-400')}> + {out.actual_move_pct > 0 ? '+' : ''}{out.actual_move_pct?.toFixed(1)}% + + {out.entry_date} → {out.exit_date} +
+ )} +
+
+ {isSaved + ? Saved + : + } +
+
+
+ ) + })} +
+
+ )} +
+ )} + + {/* ══ EVENT PRESET MODE ════════════════════════════════════════════ */} + {mode === 'events' && !selected && (
@@ -270,7 +564,8 @@ export default function PatternLab() {

The AI will analyse what it would have done at that date using real market data

- ) : ( + )} + {mode === 'events' && selected && (
{/* ── Header ── */}