feat: Pattern Explorer — taxonomy tree + 23 historical patterns + instrument lens
- PatternExplorer.tsx: new page with two views:
• Tree View — 6-root taxonomy (geopolitical, monetary_policy, economic,
commodity, risk_off, market_structure) with collapsible sub-nodes;
pattern cards appear on node selection
• Instrument Lens — search any ticker (e.g. GLD, FXE) to see every
pattern + scenario that references it, with matching trades highlighted
- geo_analyzer.py: PATTERN_TAXONOMY tree constant + taxonomy_path on all
patterns; 15 new documented patterns P009-P023 (BoJ YCC, SVB crisis,
Taiwan semis, OPEC cuts, Fed pivot, Debt ceiling, Iran nuclear, DPRK,
VIX backwardation, ECB surprise, Extreme Fear contrarian, S. China Sea,
European energy, CPI hot print, Flash crash)
- patterns.py: GET /api/patterns/taxonomy, GET /api/patterns/by-instrument
- database.py: taxonomy_path migration + seed_builtin_patterns updates path
- App.tsx: /patterns → PatternExplorer, /patterns/edit → PatternEditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from services.database import (
|
||||
save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active
|
||||
)
|
||||
from services.geo_analyzer import PATTERN_TAXONOMY
|
||||
|
||||
router = APIRouter(prefix="/api/patterns", tags=["patterns"])
|
||||
|
||||
@@ -71,3 +72,26 @@ def toggle_pattern(pat_id: str):
|
||||
"""Enable or disable a pattern (works for builtin and custom)."""
|
||||
new_state = toggle_pattern_active(pat_id)
|
||||
return {"id": pat_id, "is_active": new_state}
|
||||
|
||||
|
||||
@router.get("/taxonomy")
|
||||
def get_taxonomy():
|
||||
"""Return the taxonomy tree + all patterns (frontend builds node→pattern mapping)."""
|
||||
patterns = get_custom_patterns()
|
||||
return {"tree": PATTERN_TAXONOMY, "patterns": patterns}
|
||||
|
||||
|
||||
@router.get("/by-instrument")
|
||||
def get_by_instrument(ticker: str = Query(..., description="Ticker / underlying to search")):
|
||||
"""Return all patterns that have a suggested_trade matching the given ticker."""
|
||||
ticker_lower = ticker.strip().lower()
|
||||
result = []
|
||||
for p in get_custom_patterns():
|
||||
trades = p.get("suggested_trades") or []
|
||||
matching = [
|
||||
t for t in trades
|
||||
if ticker_lower in (t.get("underlying") or "").lower()
|
||||
]
|
||||
if matching:
|
||||
result.append({**p, "matching_trades": matching})
|
||||
return result
|
||||
|
||||
@@ -81,6 +81,8 @@ def init_db():
|
||||
# Convergence Phase 1 — thematic classification
|
||||
"ALTER TABLE custom_patterns ADD COLUMN category TEXT",
|
||||
"ALTER TABLE custom_patterns ADD COLUMN signal_direction TEXT",
|
||||
# Pattern Explorer — taxonomy tree path
|
||||
"ALTER TABLE custom_patterns ADD COLUMN taxonomy_path TEXT DEFAULT '[]'",
|
||||
]:
|
||||
try:
|
||||
c.execute(_sql)
|
||||
@@ -880,16 +882,17 @@ def update_position_notes(pos_id: str, notes: str):
|
||||
# ── Custom Patterns ────────────────────────────────────────────────────────────
|
||||
|
||||
def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]):
|
||||
"""Seed built-in patterns into DB (idempotent — skips existing IDs)."""
|
||||
"""Seed built-in patterns into DB (idempotent). Always updates taxonomy_path."""
|
||||
conn = get_conn()
|
||||
existing = {r[0] for r in conn.execute("SELECT id FROM custom_patterns").fetchall()}
|
||||
for p in builtin_patterns:
|
||||
taxonomy_json = json.dumps(p.get("taxonomy_path", []))
|
||||
if p["id"] not in existing:
|
||||
conn.execute("""INSERT INTO custom_patterns (
|
||||
id, name, description, triggers, keywords, historical_instances,
|
||||
suggested_trades, asset_class, expected_move_pct, probability,
|
||||
horizon_days, source, is_active, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'builtin',1,datetime('now'))""", (
|
||||
horizon_days, source, is_active, taxonomy_path, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'builtin',1,?,datetime('now'))""", (
|
||||
p["id"],
|
||||
p.get("name", ""),
|
||||
p.get("description", ""),
|
||||
@@ -901,7 +904,13 @@ def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]):
|
||||
p.get("expected_move_pct", 0),
|
||||
p.get("probability", 0.5),
|
||||
p.get("horizon_days", 30),
|
||||
taxonomy_json,
|
||||
))
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE custom_patterns SET taxonomy_path=? WHERE id=? AND source='builtin'",
|
||||
(taxonomy_json, p["id"])
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -955,6 +964,7 @@ def get_custom_patterns() -> List[Dict[str, Any]]:
|
||||
d = dict(r)
|
||||
for f in ["triggers", "keywords", "historical_instances", "suggested_trades", "ai_evaluation"]:
|
||||
d[f] = json.loads(d.get(f) or "[]")
|
||||
d["taxonomy_path"] = json.loads(d.get("taxonomy_path") or "[]")
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
@@ -7,6 +7,128 @@ from typing import List, Dict, Any, Optional
|
||||
import json
|
||||
|
||||
|
||||
# ── Pattern Taxonomy Tree ─────────────────────────────────────────────────────
|
||||
PATTERN_TAXONOMY = {
|
||||
"id": "root",
|
||||
"label": "Pattern Library",
|
||||
"children": [
|
||||
{
|
||||
"id": "geopolitical",
|
||||
"label": "Geopolitical",
|
||||
"children": [
|
||||
{
|
||||
"id": "armed_conflict",
|
||||
"label": "Armed Conflict",
|
||||
"children": [
|
||||
{"id": "armed_conflict.middle_east", "label": "Middle East"},
|
||||
{"id": "armed_conflict.europe", "label": "Europe / Ukraine"},
|
||||
{"id": "armed_conflict.asia_pacific", "label": "Asia-Pacific"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "sanctions",
|
||||
"label": "Sanctions & Trade War",
|
||||
"children": [
|
||||
{"id": "sanctions.us_china", "label": "US–China"},
|
||||
{"id": "sanctions.russia", "label": "Russia"},
|
||||
{"id": "sanctions.iran", "label": "Iran"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "territorial",
|
||||
"label": "Territorial Disputes",
|
||||
"children": [
|
||||
{"id": "territorial.taiwan", "label": "Taiwan Strait"},
|
||||
{"id": "territorial.south_china_sea", "label": "South China Sea"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "elections",
|
||||
"label": "Elections & Political Risk",
|
||||
"children": [
|
||||
{"id": "elections.us", "label": "US Elections"},
|
||||
{"id": "elections.europe", "label": "Europe"},
|
||||
{"id": "elections.emerging", "label": "Emerging Markets"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "monetary_policy",
|
||||
"label": "Monetary Policy",
|
||||
"children": [
|
||||
{"id": "monetary_policy.fed", "label": "Federal Reserve"},
|
||||
{"id": "monetary_policy.ecb", "label": "European Central Bank"},
|
||||
{"id": "monetary_policy.boj", "label": "Bank of Japan"},
|
||||
{"id": "monetary_policy.pivot", "label": "Policy Pivot / Surprise"},
|
||||
{"id": "monetary_policy.divergence", "label": "Central Bank Divergence"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "economic",
|
||||
"label": "Economic Shocks",
|
||||
"children": [
|
||||
{"id": "economic.banking_crisis", "label": "Banking Crisis"},
|
||||
{"id": "economic.recession", "label": "Recession Fears"},
|
||||
{"id": "economic.inflation", "label": "Inflation Surge"},
|
||||
{"id": "economic.china_slowdown", "label": "China Slowdown"},
|
||||
{"id": "economic.debt_ceiling", "label": "US Debt Ceiling"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "commodity",
|
||||
"label": "Commodity Shocks",
|
||||
"children": [
|
||||
{
|
||||
"id": "commodity.energy",
|
||||
"label": "Energy",
|
||||
"children": [
|
||||
{"id": "commodity.energy.crude", "label": "Crude Oil (OPEC / supply)"},
|
||||
{"id": "commodity.energy.gas", "label": "Natural Gas"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "commodity.agriculture",
|
||||
"label": "Agriculture",
|
||||
"children": [
|
||||
{"id": "commodity.agriculture.wheat", "label": "Wheat / Grains"},
|
||||
{"id": "commodity.agriculture.softs", "label": "Softs (Coffee, Sugar)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "commodity.metals",
|
||||
"label": "Metals",
|
||||
"children": [
|
||||
{"id": "commodity.metals.gold", "label": "Gold (Safe Haven)"},
|
||||
{"id": "commodity.metals.copper", "label": "Copper (China / Growth)"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "risk_off",
|
||||
"label": "Risk-Off Events",
|
||||
"children": [
|
||||
{"id": "risk_off.pandemic", "label": "Pandemic / Health Crisis"},
|
||||
{"id": "risk_off.financial_contagion", "label": "Financial Contagion"},
|
||||
{"id": "risk_off.natural_disaster", "label": "Natural Disaster"},
|
||||
{"id": "risk_off.nuclear", "label": "Nuclear Risk"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "market_structure",
|
||||
"label": "Market Structure",
|
||||
"children": [
|
||||
{"id": "market_structure.volatility", "label": "Volatility Regime (VIX)"},
|
||||
{"id": "market_structure.positioning", "label": "Positioning Extremes"},
|
||||
{"id": "market_structure.sentiment", "label": "Sentiment Extremes"},
|
||||
{"id": "market_structure.liquidity", "label": "Liquidity Crisis"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Historical geopolitical pattern library ───────────────────────────────────
|
||||
GEO_PATTERNS = [
|
||||
{
|
||||
@@ -15,6 +137,7 @@ GEO_PATTERNS = [
|
||||
"description": "Armed conflict or threat in Gulf region triggers Brent/WTI crude spike +10-20% within 2-4 weeks",
|
||||
"triggers": ["military", "energy", "sanctions"],
|
||||
"keywords": ["Iran", "Israel", "Saudi", "Gulf", "Strait of Hormuz", "OPEC"],
|
||||
"taxonomy_path": ["geopolitical", "armed_conflict", "armed_conflict.middle_east"],
|
||||
"historical_instances": [
|
||||
{"date": "2019-09-14", "event": "Attack on Saudi Aramco facilities", "brent_move": +14.6, "days": 2},
|
||||
{"date": "2020-01-03", "event": "Soleimani assassination", "brent_move": +4.4, "days": 1},
|
||||
@@ -35,6 +158,7 @@ GEO_PATTERNS = [
|
||||
"description": "Trump/US tariff threats on China cause immediate selloff in soy, corn, wheat (retaliatory risk)",
|
||||
"triggers": ["trade_war", "political_speech"],
|
||||
"keywords": ["tariff", "China", "trade", "soybean", "agriculture", "import duty"],
|
||||
"taxonomy_path": ["geopolitical", "sanctions", "sanctions.us_china"],
|
||||
"historical_instances": [
|
||||
{"date": "2018-07-06", "event": "US-China trade war tariffs", "zs_move": -10.2, "days": 30},
|
||||
{"date": "2019-05-10", "event": "Trump tariff escalation tweet", "zs_move": -5.8, "days": 5},
|
||||
@@ -55,6 +179,7 @@ GEO_PATTERNS = [
|
||||
"description": "Major geopolitical uncertainty drives safe-haven demand for gold +5-15%",
|
||||
"triggers": ["military", "health_crisis", "financial_crisis", "elections"],
|
||||
"keywords": ["nuclear", "war", "crisis", "uncertainty", "safe haven", "debt ceiling"],
|
||||
"taxonomy_path": ["commodity", "commodity.metals", "commodity.metals.gold"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-02-24", "event": "Ukraine invasion", "gc_move": +6.8, "days": 14},
|
||||
{"date": "2023-10-07", "event": "Hamas attack on Israel", "gc_move": +9.2, "days": 30},
|
||||
@@ -75,6 +200,7 @@ GEO_PATTERNS = [
|
||||
"description": "Fed signals higher-for-longer rates → USD Index rallies, EUR/USD drops",
|
||||
"triggers": ["political_speech"],
|
||||
"keywords": ["Fed", "interest rate", "hike", "hawkish", "inflation", "FOMC", "Powell"],
|
||||
"taxonomy_path": ["monetary_policy", "monetary_policy.fed"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-06-15", "event": "Fed 75bps hike", "dxy_move": +3.2, "days": 5},
|
||||
{"date": "2023-03-22", "event": "Fed signals further hikes", "eurusd_move": -1.8, "days": 7},
|
||||
@@ -94,6 +220,7 @@ GEO_PATTERNS = [
|
||||
"description": "Weak Chinese PMI or stimulus disappointment drives copper lower (China = 50%+ of global demand)",
|
||||
"triggers": ["resource_scarcity", "trade_war"],
|
||||
"keywords": ["China", "PMI", "slowdown", "recession", "property", "Evergrande", "copper demand"],
|
||||
"taxonomy_path": ["economic", "economic.china_slowdown"],
|
||||
"historical_instances": [
|
||||
{"date": "2015-08-24", "event": "China Black Monday", "hg_move": -8.4, "days": 5},
|
||||
{"date": "2022-11-01", "event": "China PMI contraction", "hg_move": -5.2, "days": 10},
|
||||
@@ -113,6 +240,7 @@ GEO_PATTERNS = [
|
||||
"description": "New escalation in Russia-Ukraine conflict → wheat/fertilizer spike, defense stocks rally",
|
||||
"triggers": ["military", "resource_scarcity"],
|
||||
"keywords": ["Russia", "Ukraine", "Zelensky", "Kyiv", "grain corridor", "Black Sea", "NATO"],
|
||||
"taxonomy_path": ["geopolitical", "armed_conflict", "armed_conflict.europe"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-02-24", "event": "Full-scale invasion", "zw_move": +50.0, "days": 45},
|
||||
{"date": "2022-07-22", "event": "Grain deal collapse threat", "zw_move": +6.3, "days": 3},
|
||||
@@ -133,6 +261,7 @@ GEO_PATTERNS = [
|
||||
"description": "Pipeline disruption, LNG strike, or extreme weather drives natural gas +20-40%",
|
||||
"triggers": ["energy", "natural_disaster", "military"],
|
||||
"keywords": ["pipeline", "LNG", "natural gas", "Nord Stream", "gas supply", "storage"],
|
||||
"taxonomy_path": ["commodity", "commodity.energy", "commodity.energy.gas"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-09-26", "event": "Nord Stream pipeline explosion", "ng_move": +18.0, "days": 5},
|
||||
{"date": "2021-02-10", "event": "Texas winter storm Uri", "ng_move": +40.0, "days": 3},
|
||||
@@ -152,6 +281,7 @@ GEO_PATTERNS = [
|
||||
"description": "New pandemic scare or major health crisis → VIX spike, equity selloff, gold bid",
|
||||
"triggers": ["health_crisis"],
|
||||
"keywords": ["pandemic", "virus", "outbreak", "WHO", "lockdown", "COVID", "mpox", "H5N1"],
|
||||
"taxonomy_path": ["risk_off", "risk_off.pandemic"],
|
||||
"historical_instances": [
|
||||
{"date": "2020-02-24", "event": "COVID-19 global spread fear", "spx_move": -34.0, "days": 30},
|
||||
{"date": "2022-11-25", "event": "China COVID lockdowns", "spx_move": -3.5, "days": 3},
|
||||
@@ -166,6 +296,330 @@ GEO_PATTERNS = [
|
||||
"probability": 0.45,
|
||||
"horizon_days": 30,
|
||||
},
|
||||
# ── New patterns P009-P023 ──────────────────────────────────────────────────
|
||||
{
|
||||
"id": "P009",
|
||||
"name": "BoJ YCC Break / Ultra-Loose Exit → JPY Surge",
|
||||
"description": "Bank of Japan abandons yield curve control or signals rate normalisation → JPY rally, JGB yields spike, global carry unwind",
|
||||
"triggers": ["political_speech"],
|
||||
"keywords": ["Bank of Japan", "BoJ", "YCC", "yield curve control", "yen", "JPY", "Ueda", "Kuroda", "JGB"],
|
||||
"taxonomy_path": ["monetary_policy", "monetary_policy.boj"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-12-20", "event": "BoJ widens YCC band to ±0.5%", "usdjpy_move": -3.7, "days": 1},
|
||||
{"date": "2023-07-28", "event": "BoJ further loosens YCC to 1%", "usdjpy_move": -2.2, "days": 2},
|
||||
{"date": "2024-03-19", "event": "BoJ raises rates for first time since 2007", "usdjpy_move": -0.8, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Put", "underlying": "FXY", "rationale": "JPY ETF put — gains as USD/JPY falls (yen strengthens)"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "YCS", "rationale": "Ultra-short yen ETF put spread"},
|
||||
{"strategy": "Long Call", "underlying": "FXY", "rationale": "Direct long JPY via ETF call"},
|
||||
],
|
||||
"asset_class": "forex",
|
||||
"expected_move_pct": -4.0,
|
||||
"probability": 0.60,
|
||||
"horizon_days": 7,
|
||||
},
|
||||
{
|
||||
"id": "P010",
|
||||
"name": "Regional Banking Crisis → Financial Contagion",
|
||||
"description": "Bank run or failure of regional lenders triggers systemic fear, credit spreads widen, equities sell off",
|
||||
"triggers": ["financial_crisis"],
|
||||
"keywords": ["bank run", "SVB", "Silicon Valley Bank", "FDIC", "deposit", "contagion", "credit suisse", "regional bank"],
|
||||
"taxonomy_path": ["economic", "economic.banking_crisis"],
|
||||
"historical_instances": [
|
||||
{"date": "2023-03-10", "event": "SVB collapse — largest US bank failure since 2008", "kre_move": -27.0, "days": 5},
|
||||
{"date": "2023-03-19", "event": "Credit Suisse emergency merger with UBS", "cs_move": -60.0, "days": 2},
|
||||
{"date": "2023-05-01", "event": "First Republic Bank seized by FDIC", "kre_move": -8.5, "days": 3},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Put", "underlying": "KRE", "rationale": "Regional banks ETF put — direct contagion play"},
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Gold flight-to-safety bid"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "XLF", "rationale": "Broad financial sector downside hedge"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": -15.0,
|
||||
"probability": 0.55,
|
||||
"horizon_days": 14,
|
||||
},
|
||||
{
|
||||
"id": "P011",
|
||||
"name": "Taiwan Strait Military Tension → Semis Selloff",
|
||||
"description": "PLA military exercises or blockade threat → global semiconductor supply fear, Taiwan semis/TSMC selloff",
|
||||
"triggers": ["military"],
|
||||
"keywords": ["Taiwan", "TSMC", "PLA", "strait", "China invasion", "semiconductor", "chip supply", "Pelosi"],
|
||||
"taxonomy_path": ["geopolitical", "territorial", "territorial.taiwan"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-08-02", "event": "Pelosi visits Taiwan — PLA launches live-fire drills", "tsm_move": -6.5, "days": 3},
|
||||
{"date": "2023-04-08", "event": "PLA encirclement drills after Tsai-McCarthy meeting", "tsm_move": -3.2, "days": 2},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Put", "underlying": "TSM", "rationale": "TSMC put — direct Taiwan semi exposure"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "SOXX", "rationale": "Semis ETF put spread on supply disruption risk"},
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Safe-haven gold bid on regional military tension"},
|
||||
],
|
||||
"asset_class": "equities",
|
||||
"expected_move_pct": -8.0,
|
||||
"probability": 0.50,
|
||||
"horizon_days": 14,
|
||||
},
|
||||
{
|
||||
"id": "P012",
|
||||
"name": "OPEC+ Surprise Production Cut → Crude Oil Spike",
|
||||
"description": "Unexpected OPEC+ output cut announcement drives Brent/WTI +5-15% in days",
|
||||
"triggers": ["energy", "political_speech"],
|
||||
"keywords": ["OPEC", "Saudi Arabia", "production cut", "barrel", "oil output", "supply cut"],
|
||||
"taxonomy_path": ["commodity", "commodity.energy", "commodity.energy.crude"],
|
||||
"historical_instances": [
|
||||
{"date": "2023-04-02", "event": "OPEC+ surprise 1.16Mb/d cut", "brent_move": +6.3, "days": 1},
|
||||
{"date": "2022-10-05", "event": "OPEC+ cuts 2Mb/d despite US pressure", "brent_move": +11.0, "days": 5},
|
||||
{"date": "2020-04-12", "event": "Historic OPEC+ 9.7Mb/d cut deal", "cl_move": +20.0, "days": 2},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Bull Call Spread", "underlying": "USO", "rationale": "Oil ETF bull spread, defined risk"},
|
||||
{"strategy": "Long Call", "underlying": "CL=F", "rationale": "WTI crude futures call for directional upside"},
|
||||
{"strategy": "Long Call", "underlying": "XLE", "rationale": "Energy sector ETF call for broader sector play"},
|
||||
],
|
||||
"asset_class": "energy",
|
||||
"expected_move_pct": 8.0,
|
||||
"probability": 0.65,
|
||||
"horizon_days": 14,
|
||||
},
|
||||
{
|
||||
"id": "P013",
|
||||
"name": "Fed Dovish Pivot → Risk-On Rally",
|
||||
"description": "Fed signals rate cuts ahead of schedule → equities rally, USD weakens, risk assets bid",
|
||||
"triggers": ["political_speech"],
|
||||
"keywords": ["Fed cut", "dovish", "pivot", "rate cut", "easing", "QE", "FOMC pause", "Jackson Hole"],
|
||||
"taxonomy_path": ["monetary_policy", "monetary_policy.pivot"],
|
||||
"historical_instances": [
|
||||
{"date": "2023-11-01", "event": "Fed holds rates, signals peak", "spy_move": +5.9, "days": 5},
|
||||
{"date": "2023-12-13", "event": "Fed dots signal 3 cuts in 2024", "spy_move": +3.5, "days": 2},
|
||||
{"date": "2019-07-31", "event": "First Fed cut since 2008", "spy_move": +2.1, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Bull Call Spread", "underlying": "SPY", "rationale": "S&P 500 bull spread on risk-on rally"},
|
||||
{"strategy": "Long Call", "underlying": "QQQ", "rationale": "Nasdaq call — rate-sensitive growth names"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "UUP", "rationale": "USD index downside on dovish turn"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": 5.0,
|
||||
"probability": 0.70,
|
||||
"horizon_days": 14,
|
||||
},
|
||||
{
|
||||
"id": "P014",
|
||||
"name": "US Debt Ceiling Standoff → T-Bill Stress",
|
||||
"description": "Congress deadlock on debt ceiling → short-term T-bill yields spike, credit risk perception rises, risk-off",
|
||||
"triggers": ["political_speech", "financial_crisis"],
|
||||
"keywords": ["debt ceiling", "default", "Treasury", "X-date", "fiscal cliff", "Congress", "spending"],
|
||||
"taxonomy_path": ["economic", "economic.debt_ceiling"],
|
||||
"historical_instances": [
|
||||
{"date": "2023-05-01", "event": "US 1-month T-bill yield spikes to 5.9%", "vix_move": +22.0, "days": 14},
|
||||
{"date": "2011-08-02", "event": "S&P downgrades US credit", "spy_move": -16.0, "days": 10},
|
||||
{"date": "2013-10-01", "event": "US government shutdown + debt ceiling standoff", "vix_move": +40.0, "days": 16},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Safe-haven gold on fiscal credibility risk"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "SPY", "rationale": "Equity hedge during debt ceiling uncertainty"},
|
||||
{"strategy": "Long Put", "underlying": "TLT", "rationale": "Long-duration Treasury put on fiscal risk premium"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": -5.0,
|
||||
"probability": 0.55,
|
||||
"horizon_days": 21,
|
||||
},
|
||||
{
|
||||
"id": "P015",
|
||||
"name": "Iran Nuclear Escalation → Oil + Gold Spike",
|
||||
"description": "Iran nuclear programme breakthrough, US/Israel strike threat, or IAEA crisis → oil and gold both bid",
|
||||
"triggers": ["military", "sanctions"],
|
||||
"keywords": ["Iran", "nuclear", "enrichment", "IAEA", "sanctions", "Strait of Hormuz", "Israel strike"],
|
||||
"taxonomy_path": ["geopolitical", "sanctions", "sanctions.iran"],
|
||||
"historical_instances": [
|
||||
{"date": "2020-01-08", "event": "Iran missiles hit US bases in Iraq (after Soleimani)", "brent_move": +3.5, "days": 1},
|
||||
{"date": "2024-04-01", "event": "Israel strikes Iran consulate in Damascus", "brent_move": +4.0, "days": 3},
|
||||
{"date": "2024-04-13", "event": "Iran direct drone/missile attack on Israel", "brent_move": +3.0, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Bull Call Spread", "underlying": "USO", "rationale": "Oil supply risk premium via ETF spread"},
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Dual safe-haven gold bid on military escalation"},
|
||||
],
|
||||
"asset_class": "energy",
|
||||
"expected_move_pct": 6.0,
|
||||
"probability": 0.58,
|
||||
"horizon_days": 10,
|
||||
},
|
||||
{
|
||||
"id": "P016",
|
||||
"name": "North Korea Missile / Nuclear Test → Asian Risk-Off",
|
||||
"description": "DPRK ICBM launch or nuclear test → KRW selloff, Nikkei/Kospi dip, gold bid",
|
||||
"triggers": ["military"],
|
||||
"keywords": ["North Korea", "DPRK", "Kim Jong-un", "missile", "ICBM", "nuclear test", "Korea"],
|
||||
"taxonomy_path": ["geopolitical", "armed_conflict", "armed_conflict.asia_pacific"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-11-18", "event": "DPRK fires ICBM over Japan", "krw_move": -0.8, "days": 1},
|
||||
{"date": "2017-09-03", "event": "North Korea 6th nuclear test", "krw_move": -1.5, "days": 2},
|
||||
{"date": "2023-03-16", "event": "DPRK fires ballistic missile", "nky_move": -0.4, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Safe-haven gold on regional military tension"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "EWY", "rationale": "South Korea ETF put on escalation risk"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": -2.0,
|
||||
"probability": 0.45,
|
||||
"horizon_days": 7,
|
||||
},
|
||||
{
|
||||
"id": "P017",
|
||||
"name": "VIX Backwardation (Crisis Regime) → Vol Premium Collapse",
|
||||
"description": "VIX term structure inverts (spot > 3m future) signalling acute stress; historically mean-reverts fast → vol sellers reload",
|
||||
"triggers": ["financial_crisis"],
|
||||
"keywords": ["VIX", "backwardation", "volatility spike", "contango flip", "fear gauge", "VX futures"],
|
||||
"taxonomy_path": ["market_structure", "market_structure.volatility"],
|
||||
"historical_instances": [
|
||||
{"date": "2020-03-16", "event": "COVID crash peak backwardation VIX=82", "vix_move": +82.0, "days": 1},
|
||||
{"date": "2022-01-24", "event": "Fed tightening fear, VIX=38, backwardation", "vix_move": +38.0, "days": 14},
|
||||
{"date": "2018-02-05", "event": "Volmageddon — inverse VIX ETPs implode", "vix_move": +115.0, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Bear Put Spread", "underlying": "SPY", "rationale": "Hedge equity downside during vol spike"},
|
||||
{"strategy": "Bull Call Spread", "underlying": "SPY", "rationale": "Mean-reversion entry once VIX normalises"},
|
||||
{"strategy": "Short Put", "underlying": "VXX", "rationale": "VIX ETF put — backwardation normalises quickly"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": -8.0,
|
||||
"probability": 0.60,
|
||||
"horizon_days": 10,
|
||||
},
|
||||
{
|
||||
"id": "P018",
|
||||
"name": "ECB Surprise Rate Move → EUR Volatility",
|
||||
"description": "ECB delivers surprise hike, cut, or emergency action outside meeting → EUR/USD sharp move",
|
||||
"triggers": ["political_speech"],
|
||||
"keywords": ["ECB", "Lagarde", "euro", "interest rate", "refi rate", "fragmentation", "BTP", "PEPP"],
|
||||
"taxonomy_path": ["monetary_policy", "monetary_policy.ecb"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-07-21", "event": "ECB surprise 50bps hike (vs 25 expected)", "eurusd_move": +1.4, "days": 1},
|
||||
{"date": "2022-09-08", "event": "ECB hikes 75bps (largest ever)", "eurusd_move": +0.8, "days": 1},
|
||||
{"date": "2024-06-06", "event": "ECB first rate cut in 5 years", "eurusd_move": -0.3, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Straddle", "underlying": "FXE", "rationale": "EUR/USD straddle on surprise — benefits from direction"},
|
||||
{"strategy": "Bull Call Spread", "underlying": "FXE", "rationale": "EUR call spread if dovish surprise expected"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "FXE", "rationale": "EUR put spread if hawkish surprise"},
|
||||
],
|
||||
"asset_class": "forex",
|
||||
"expected_move_pct": 1.5,
|
||||
"probability": 0.55,
|
||||
"horizon_days": 5,
|
||||
},
|
||||
{
|
||||
"id": "P019",
|
||||
"name": "Extreme Fear Sentiment → Contrarian Buy Signal",
|
||||
"description": "CNN F&G < 20 + AAII bears > 45% → historically strong mean-reversion buy within 4-8 weeks",
|
||||
"triggers": ["financial_crisis", "political_speech"],
|
||||
"keywords": ["fear & greed", "AAII", "bearish sentiment", "extreme fear", "capitulation", "put/call ratio"],
|
||||
"taxonomy_path": ["market_structure", "market_structure.sentiment"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-10-13", "event": "CNN F&G=15, AAII Bears=60% — S&P bottoms", "spy_move": +19.0, "days": 60},
|
||||
{"date": "2020-03-23", "event": "COVID low — CNN F&G=2 extreme fear", "spy_move": +34.0, "days": 30},
|
||||
{"date": "2023-03-13", "event": "SVB panic — CNN F&G=22, bottom", "spy_move": +9.0, "days": 21},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Bull Call Spread", "underlying": "SPY", "rationale": "Contrarian equity entry on extreme fear reading"},
|
||||
{"strategy": "Long Call", "underlying": "QQQ", "rationale": "Growth recovery play post-capitulation"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": 12.0,
|
||||
"probability": 0.68,
|
||||
"horizon_days": 45,
|
||||
},
|
||||
{
|
||||
"id": "P020",
|
||||
"name": "South China Sea Incident → Shipping / Energy Risk-Off",
|
||||
"description": "Collision, blockade, or military incident in SCS → shipping disruption, energy supply risk, regional selloff",
|
||||
"triggers": ["military"],
|
||||
"keywords": ["South China Sea", "Philippines", "Vietnam", "shipping lane", "Spratly", "Paracel", "Taiwan"],
|
||||
"taxonomy_path": ["geopolitical", "territorial", "territorial.south_china_sea"],
|
||||
"historical_instances": [
|
||||
{"date": "2023-08-05", "event": "China water cannon at Ayungin Shoal", "psei_move": -1.5, "days": 2},
|
||||
{"date": "2024-02-05", "event": "Heightened China-Philippines naval standoff", "eem_move": -0.8, "days": 3},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Safe-haven bid on regional naval tension"},
|
||||
{"strategy": "Bull Call Spread", "underlying": "USO", "rationale": "Oil supply disruption premium via ETF"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "EEM", "rationale": "EM equity downside on Asian risk-off"},
|
||||
],
|
||||
"asset_class": "energy",
|
||||
"expected_move_pct": 3.0,
|
||||
"probability": 0.40,
|
||||
"horizon_days": 10,
|
||||
},
|
||||
{
|
||||
"id": "P021",
|
||||
"name": "European Energy Crisis → EUR Selloff + NG Spike",
|
||||
"description": "Russian gas cutoff, LNG supply shortfall, or extreme winter demand drives European natural gas +30%+, EUR weakens",
|
||||
"triggers": ["energy", "military", "resource_scarcity"],
|
||||
"keywords": ["Europe energy", "gas storage", "TTF", "Gazprom", "LNG", "energy crisis", "winter", "sanctions"],
|
||||
"taxonomy_path": ["geopolitical", "armed_conflict", "armed_conflict.europe"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-06-15", "event": "Gazprom cuts Nord Stream 1 flows 60%", "ttf_move": +60.0, "days": 10},
|
||||
{"date": "2022-08-26", "event": "Nord Stream shut entirely, gas crisis peak", "eurusd_move": -3.5, "days": 7},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Call", "underlying": "UNG", "rationale": "US nat gas proxy for European supply squeeze"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "FXE", "rationale": "EUR/USD put spread on energy terms-of-trade shock"},
|
||||
],
|
||||
"asset_class": "energy",
|
||||
"expected_move_pct": 20.0,
|
||||
"probability": 0.52,
|
||||
"horizon_days": 30,
|
||||
},
|
||||
{
|
||||
"id": "P022",
|
||||
"name": "Inflation Surprise (CPI Hot Print) → Equity Drawdown",
|
||||
"description": "Above-consensus CPI print reignites rate hike fear → equities sell off, USD spikes, growth/duration hit hardest",
|
||||
"triggers": ["political_speech"],
|
||||
"keywords": ["CPI", "inflation", "hot print", "core inflation", "PCE", "surprise", "rate hike expectations"],
|
||||
"taxonomy_path": ["economic", "economic.inflation"],
|
||||
"historical_instances": [
|
||||
{"date": "2022-09-13", "event": "Aug CPI +8.3% vs 8.1% exp — worst drop in 2y", "spy_move": -4.3, "days": 1},
|
||||
{"date": "2023-02-14", "event": "Jan CPI +6.4% vs 6.2% exp", "spy_move": -1.4, "days": 1},
|
||||
{"date": "2022-06-10", "event": "May CPI +8.6% — 40-year high", "spy_move": -5.8, "days": 2},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Bear Put Spread", "underlying": "QQQ", "rationale": "Nasdaq put spread — growth names most rate-sensitive"},
|
||||
{"strategy": "Long Call", "underlying": "UUP", "rationale": "USD call on hawkish repricing"},
|
||||
{"strategy": "Bear Put Spread", "underlying": "TLT", "rationale": "Long-duration bond downside on rate expectations reset"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": -4.0,
|
||||
"probability": 0.65,
|
||||
"horizon_days": 5,
|
||||
},
|
||||
{
|
||||
"id": "P023",
|
||||
"name": "Flash Crash / Liquidity Crisis → Rapid Selloff + Recovery",
|
||||
"description": "Sudden illiquidity event (algo cascade, forced deleveraging) → intraday/multi-day panic, followed by sharp recovery",
|
||||
"triggers": ["financial_crisis"],
|
||||
"keywords": ["flash crash", "liquidity crisis", "deleveraging", "margin call", "carry unwind", "forced selling"],
|
||||
"taxonomy_path": ["market_structure", "market_structure.liquidity"],
|
||||
"historical_instances": [
|
||||
{"date": "2024-08-05", "event": "JPY carry unwind — Nikkei -12% in one day", "nky_move": -12.4, "days": 1},
|
||||
{"date": "2020-03-12", "event": "COVID crash peak — S&P -10% in one session", "spy_move": -10.0, "days": 1},
|
||||
{"date": "2010-05-06", "event": "Flash Crash — Dow -1000pts intraday, recovers same day", "spy_move": -9.2, "days": 1},
|
||||
],
|
||||
"suggested_trades": [
|
||||
{"strategy": "Long Put", "underlying": "SPY", "rationale": "Tail-risk put during systemic deleveraging"},
|
||||
{"strategy": "Bull Call Spread", "underlying": "SPY", "rationale": "Mean-reversion entry post-capitulation"},
|
||||
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Gold flight-to-safety during liquidity panic"},
|
||||
],
|
||||
"asset_class": "indices",
|
||||
"expected_move_pct": -10.0,
|
||||
"probability": 0.50,
|
||||
"horizon_days": 5,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import Backtest from './pages/Backtest'
|
||||
import CalendarPage from './pages/CalendarPage'
|
||||
import Portfolio from './pages/Portfolio'
|
||||
import PatternEditor from './pages/PatternEditor'
|
||||
import PatternExplorer from './pages/PatternExplorer'
|
||||
import JournalDeBord from './pages/JournalDeBord'
|
||||
import RapportIA from './pages/RapportIA'
|
||||
import SuperContexte from './pages/SuperContexte'
|
||||
@@ -40,7 +41,8 @@ export default function App() {
|
||||
<Route path="/markets" element={<Markets />} />
|
||||
<Route path="/macro" element={<MacroRegime />} />
|
||||
<Route path="/options" element={<OptionsLab />} />
|
||||
<Route path="/patterns" element={<PatternEditor />} />
|
||||
<Route path="/patterns" element={<PatternExplorer />} />
|
||||
<Route path="/patterns/edit" element={<PatternEditor />} />
|
||||
<Route path="/portfolio" element={<Portfolio />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
|
||||
@@ -251,6 +251,21 @@ export const useAllPatterns = () =>
|
||||
queryFn: () => api.get('/patterns/all').then(r => r.data),
|
||||
})
|
||||
|
||||
export const usePatternTaxonomy = () =>
|
||||
useQuery({
|
||||
queryKey: ['pattern-taxonomy'],
|
||||
queryFn: () => api.get('/patterns/taxonomy').then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
export const usePatternsByInstrument = (ticker: string) =>
|
||||
useQuery({
|
||||
queryKey: ['patterns-by-instrument', ticker],
|
||||
queryFn: () => api.get('/patterns/by-instrument', { params: { ticker } }).then(r => r.data),
|
||||
enabled: !!ticker,
|
||||
staleTime: 2 * 60_000,
|
||||
})
|
||||
|
||||
export const usePatternSimilarity = () =>
|
||||
useQuery({
|
||||
queryKey: ['pattern-similarity'],
|
||||
|
||||
638
frontend/src/pages/PatternExplorer.tsx
Normal file
638
frontend/src/pages/PatternExplorer.tsx
Normal file
@@ -0,0 +1,638 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
TreePine, Search, ChevronRight, ChevronDown,
|
||||
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useAllPatterns,
|
||||
usePatternTaxonomy,
|
||||
usePatternsByInstrument,
|
||||
useLastScores,
|
||||
} from '../hooks/useApi'
|
||||
|
||||
// ── Local types ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface TaxonomyNode {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
description?: string
|
||||
children?: TaxonomyNode[]
|
||||
count?: number
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface SuggestedTrade {
|
||||
strategy: string
|
||||
underlying: string
|
||||
rationale: string
|
||||
strike_guidance?: string
|
||||
expected_move_pct?: number
|
||||
}
|
||||
|
||||
interface HistoricalInstance {
|
||||
date: string
|
||||
event: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
triggers: string[]
|
||||
keywords: string[]
|
||||
historical_instances: HistoricalInstance[]
|
||||
suggested_trades: SuggestedTrade[]
|
||||
asset_class: string
|
||||
expected_move_pct: number
|
||||
probability: number
|
||||
horizon_days: number
|
||||
source: string
|
||||
category?: string
|
||||
signal_direction?: string
|
||||
taxonomy_path?: string[]
|
||||
ai_quality_score?: number
|
||||
is_active?: number
|
||||
}
|
||||
|
||||
interface ScoreResult {
|
||||
pattern_id: string
|
||||
score?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ASSET_CLASS_LABELS: Record<string, string> = {
|
||||
energy: '⚡ Energy',
|
||||
metals: '🥇 Metals',
|
||||
agriculture: '🌾 Agri',
|
||||
equities: '📈 Equities',
|
||||
indices: '📊 Indices',
|
||||
forex: '💱 Forex',
|
||||
rates: '📉 Rates',
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function probStars(prob: number): string {
|
||||
const n = prob >= 0.8 ? 3 : prob >= 0.6 ? 2 : 1
|
||||
return '★'.repeat(n) + '☆'.repeat(3 - n)
|
||||
}
|
||||
|
||||
function pathStartsWith(full: string[] | undefined, prefix: string[]): boolean {
|
||||
if (!full || prefix.length === 0) return true
|
||||
if (full.length < prefix.length) return false
|
||||
return prefix.every((seg, i) => seg === full[i])
|
||||
}
|
||||
|
||||
function formatTaxPath(path: string[] | undefined): string {
|
||||
if (!path || path.length === 0) return ''
|
||||
return path.join(' › ')
|
||||
}
|
||||
|
||||
// ── Signal Direction Badge ────────────────────────────────────────────────────
|
||||
|
||||
function SignalBadge({ direction }: { direction?: string }) {
|
||||
if (direction === 'bullish') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-900/40 border border-emerald-700/40 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" /> Bullish
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (direction === 'bearish') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">
|
||||
<TrendingDown className="w-3 h-3" /> Bearish
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-900/40 border border-amber-700/40 text-amber-400">
|
||||
<Zap className="w-3 h-3" /> Vol
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pattern Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PatternCard({
|
||||
pattern,
|
||||
highlightTrades,
|
||||
}: {
|
||||
pattern: Pattern
|
||||
highlightTrades?: string[]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const trades = pattern.suggested_trades ?? []
|
||||
const instances = pattern.historical_instances ?? []
|
||||
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
|
||||
|
||||
const moveSign = pattern.expected_move_pct >= 0 ? '+' : ''
|
||||
|
||||
return (
|
||||
<div className="card hover:border-slate-600/50 transition-colors flex flex-col gap-2">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SignalBadge direction={pattern.signal_direction} />
|
||||
<span className="text-[10px] font-mono text-slate-500">{pattern.id}</span>
|
||||
<span className="text-[10px] text-amber-400 tracking-wider">{probStars(pattern.probability)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white leading-snug">{pattern.name}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5 line-clamp-2">{pattern.description}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="border-t border-slate-700/40 pt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
|
||||
<span>
|
||||
Expected:{' '}
|
||||
<span className={clsx('font-mono font-medium', pattern.expected_move_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{moveSign}{pattern.expected_move_pct}%
|
||||
</span>
|
||||
</span>
|
||||
<span>Horizon: <span className="text-slate-300">{pattern.horizon_days}d</span></span>
|
||||
<span>Asset: <span className="text-slate-300">{assetLabel}</span></span>
|
||||
<span>Prob: <span className="text-slate-300">{Math.round(pattern.probability * 100)}%</span></span>
|
||||
</div>
|
||||
|
||||
{/* Trades row */}
|
||||
{trades.length > 0 && (
|
||||
<div className="border-t border-slate-700/40 pt-2">
|
||||
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Trades</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{trades.map((t, i) => {
|
||||
const isHighlighted =
|
||||
highlightTrades && highlightTrades.some(u =>
|
||||
t.underlying.toLowerCase().includes(u.toLowerCase())
|
||||
)
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={clsx(
|
||||
'text-[10px] px-1.5 py-0.5 rounded border font-mono',
|
||||
isHighlighted
|
||||
? 'bg-blue-900/40 border-blue-600/50 text-blue-300'
|
||||
: 'bg-dark-700/60 border-slate-700/30 text-slate-400'
|
||||
)}
|
||||
>
|
||||
{t.strategy} {t.underlying}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical instances (collapsible) */}
|
||||
{instances.length > 0 && (
|
||||
<div className="border-t border-slate-700/40 pt-2">
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors w-full text-left"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
Historical instances ({instances.length})
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{instances.map((inst, i) => (
|
||||
<li key={i} className="text-xs text-slate-400 flex gap-2">
|
||||
<span className="font-mono text-slate-500 shrink-0">{inst.date}</span>
|
||||
<span>{inst.event}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Taxonomy Tree Node ────────────────────────────────────────────────────────
|
||||
|
||||
function TreeNodeRow({
|
||||
node,
|
||||
level,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
defaultExpandedDepth,
|
||||
}: {
|
||||
node: TaxonomyNode
|
||||
level: number
|
||||
selectedPath: string[]
|
||||
onSelect: (path: string[]) => void
|
||||
defaultExpandedDepth: number
|
||||
}) {
|
||||
const hasChildren = (node.children ?? []).length > 0
|
||||
const [expanded, setExpanded] = useState(level < defaultExpandedDepth)
|
||||
|
||||
const isSelected =
|
||||
selectedPath.length === node.path.length &&
|
||||
node.path.every((seg, i) => seg === selectedPath[i])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(node.path)
|
||||
if (hasChildren) setExpanded(v => !v)
|
||||
}, [node.path, onSelect, hasChildren])
|
||||
|
||||
const indent = level * 16
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + indent}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
{node.icon && <span className="text-sm">{node.icon}</span>}
|
||||
<span className="flex-1 truncate">{node.label}</span>
|
||||
{node.count != null && node.count > 0 && (
|
||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full ml-1 shrink-0">
|
||||
{node.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{node.children!.map(child => (
|
||||
<TreeNodeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
defaultExpandedDepth={defaultExpandedDepth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tree View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
const { data: taxonomyData } = usePatternTaxonomy()
|
||||
const taxonomy = taxonomyData as TaxonomyNode | undefined
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
|
||||
const filteredPatterns = useMemo(() => {
|
||||
let list = patterns
|
||||
if (selectedPath.length > 0) {
|
||||
list = list.filter(p => pathStartsWith(p.taxonomy_path, selectedPath))
|
||||
}
|
||||
if (nameFilter.trim()) {
|
||||
const q = nameFilter.trim().toLowerCase()
|
||||
list = list.filter(p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q))
|
||||
}
|
||||
return list
|
||||
}, [patterns, selectedPath, nameFilter])
|
||||
|
||||
const breadcrumb = selectedPath.length > 0 ? selectedPath.join(' › ') : 'All Patterns'
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 min-h-0 flex-1">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-72 shrink-0 card flex flex-col gap-1 overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
<div className="text-[10px] uppercase tracking-widest text-slate-500 font-semibold px-2 pt-1 pb-2">
|
||||
Taxonomy
|
||||
</div>
|
||||
|
||||
{/* Root "All" entry */}
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
||||
selectedPath.length === 0
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setSelectedPath([])}
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
<span className="flex-1">All Patterns</span>
|
||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full">
|
||||
{patterns.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Taxonomy tree */}
|
||||
{taxonomy ? (
|
||||
(taxonomy.children ?? []).map(node => (
|
||||
<TreeNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
defaultExpandedDepth={1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-slate-600 px-2 py-4">Loading taxonomy…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{/* Breadcrumb + filter */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="text-sm text-slate-400 font-mono">
|
||||
<span className="text-slate-600">Path: </span>
|
||||
<span className="text-slate-300">{breadcrumb}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<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"
|
||||
placeholder="Filter by name…"
|
||||
value={nameFilter}
|
||||
onChange={e => setNameFilter(e.target.value)}
|
||||
className="input text-sm pl-8 py-1.5 w-52"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 shrink-0">{filteredPatterns.length} patterns</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pattern grid */}
|
||||
{filteredPatterns.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1 text-slate-600 text-sm py-16">
|
||||
No patterns match the selected filter
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 overflow-y-auto max-h-[calc(100vh-220px)] pr-1">
|
||||
{filteredPatterns.map(p => (
|
||||
<PatternCard key={p.id} pattern={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Instrument Lens ───────────────────────────────────────────────────────────
|
||||
|
||||
function InstrumentRow({ pattern, searchTerm }: { pattern: Pattern; searchTerm: string }) {
|
||||
const matchingTrades = pattern.suggested_trades?.filter(t =>
|
||||
t.underlying.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="card hover:border-slate-600/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
{/* Left: name + path */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-0.5">
|
||||
<SignalBadge direction={pattern.signal_direction} />
|
||||
<span className="text-sm font-semibold text-white">{pattern.name}</span>
|
||||
</div>
|
||||
{pattern.taxonomy_path && pattern.taxonomy_path.length > 0 && (
|
||||
<div className="text-[10px] text-slate-500 font-mono mt-0.5">
|
||||
{formatTaxPath(pattern.taxonomy_path)}
|
||||
</div>
|
||||
)}
|
||||
{/* Matching trades highlighted */}
|
||||
{matchingTrades.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{matchingTrades.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs px-2 py-1 rounded border bg-blue-900/30 border-blue-600/40 text-blue-300"
|
||||
>
|
||||
<span className="font-semibold">{t.strategy}</span>
|
||||
<span className="text-blue-400 mx-1">·</span>
|
||||
<span className="font-mono">{t.underlying}</span>
|
||||
{t.expected_move_pct != null && (
|
||||
<span className="ml-2 text-slate-400">
|
||||
{t.expected_move_pct >= 0 ? '+' : ''}{t.expected_move_pct}%
|
||||
</span>
|
||||
)}
|
||||
{t.rationale && (
|
||||
<span className="ml-2 text-slate-500 italic text-[10px]">{t.rationale}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: stats */}
|
||||
<div className="flex flex-col gap-1 items-end text-xs text-slate-400 shrink-0">
|
||||
<span>
|
||||
Prob:{' '}
|
||||
<span className="font-semibold text-slate-300">{Math.round(pattern.probability * 100)}%</span>
|
||||
</span>
|
||||
<span>
|
||||
Move:{' '}
|
||||
<span className={clsx('font-mono font-semibold', pattern.expected_move_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pattern.expected_move_pct >= 0 ? '+' : ''}{pattern.expected_move_pct}%
|
||||
</span>
|
||||
</span>
|
||||
<span>Horizon: <span className="text-slate-300">{pattern.horizon_days}d</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [searchTerm, setSearchTerm] = 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])
|
||||
|
||||
// Also use the dedicated hook when a ticker is typed
|
||||
const { data: instrumentData } = usePatternsByInstrument(searchTerm)
|
||||
const instrumentPatterns = instrumentData as Pattern[] | undefined
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!searchTerm) return []
|
||||
|
||||
// Prefer dedicated API data if available, otherwise filter locally
|
||||
const source: Pattern[] = (instrumentPatterns && instrumentPatterns.length > 0)
|
||||
? instrumentPatterns
|
||||
: patterns.filter(p =>
|
||||
(p.suggested_trades ?? []).some(t =>
|
||||
t.underlying.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
|
||||
return [...source].sort((a, b) => b.probability - a.probability)
|
||||
}, [searchTerm, instrumentPatterns, patterns])
|
||||
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
onClick={() => { setInputValue(''); setSearchTerm('') }}
|
||||
>
|
||||
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>
|
||||
</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
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{results.map(p => (
|
||||
<InstrumentRow key={p.id} pattern={p} searchTerm={searchTerm} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page root ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'tree' | 'instrument'
|
||||
|
||||
export default function PatternExplorer() {
|
||||
const [view, setView] = useState<ViewMode>('tree')
|
||||
const { data: patternsData, isLoading } = useAllPatterns()
|
||||
const { data: _scores } = useLastScores()
|
||||
|
||||
const patterns = useMemo<Pattern[]>(() => {
|
||||
if (!Array.isArray(patternsData)) return []
|
||||
return patternsData as Pattern[]
|
||||
}, [patternsData])
|
||||
|
||||
return (
|
||||
<div className="p-6 flex flex-col gap-5 min-h-screen">
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<TreePine className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||
<h1 className="text-xl font-bold text-white">Pattern Library</h1>
|
||||
<Link
|
||||
to="/patterns/edit"
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded border border-slate-700/50 text-slate-400 hover:border-blue-600/50 hover:text-blue-400 transition-colors ml-2"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Edit Library
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Browse {patterns.length > 0 ? patterns.length : '…'} geo-financial patterns by theme or by instrument
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-1 bg-dark-700/60 border border-slate-700/40 rounded-lg p-1">
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
|
||||
view === 'tree'
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setView('tree')}
|
||||
>
|
||||
<TreePine className="w-3.5 h-3.5" />
|
||||
Tree
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
|
||||
view === 'instrument'
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setView('instrument')}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
Instrument
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-32 text-slate-600 text-sm gap-2">
|
||||
<div className="w-4 h-4 border-2 border-slate-600 border-t-blue-500 rounded-full animate-spin" />
|
||||
Loading patterns…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{view === 'tree' && <TreeView patterns={patterns} />}
|
||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user