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:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user