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
|
||||
|
||||
146
frontend/src/constants/instruments.ts
Normal file
146
frontend/src/constants/instruments.ts
Normal 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' },
|
||||
]
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user