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