feat: regime system — Find Matching + By Regime view
- Pattern Lab: "Find Matching" button per pattern uses GPT-4o-mini to classify against library (merge_as_instance / counter_scenario / new_pattern); shows match badge + confidence + suggested #regime_tag; conditional action buttons (Merge / Save counter / Save new) - save_pattern_from_run handles action='instance' (appends to historical_instances + updates stats), action='counter' (new pattern with counter_of link, tags parent regime_tag), action='new' (unchanged + regime_tag support) - useApi.ts: extended useSaveLabPattern type + new useFindMatchingPattern mutation + MatchResult export type - DB migrations: regime_tag + counter_of columns on custom_patterns - PatternExplorer: new "By Regime" view groups saved patterns by regime_tag; RegimeCard shows historical instances, hit rate, counter-of link (orange); untagged group at bottom Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,16 @@ class SavePatternRequest(BaseModel):
|
||||
category: Optional[str] = None
|
||||
signal_direction: Optional[str] = None
|
||||
asset_class: Optional[str] = "indices"
|
||||
# Regime architecture
|
||||
action: Optional[str] = "new" # "new" | "instance" | "counter"
|
||||
target_id: Optional[str] = None # existing pattern id for instance/counter
|
||||
regime_tag: Optional[str] = None # short generic label (e.g. "Health Crisis")
|
||||
event_name: Optional[str] = None # human-readable event for historical_instance
|
||||
|
||||
|
||||
class FindMatchingRequest(BaseModel):
|
||||
run_id: str
|
||||
pattern_index: int
|
||||
|
||||
|
||||
class InstrumentScanRequest(BaseModel):
|
||||
@@ -260,6 +270,110 @@ def evaluate_instrument_run(run_id: str):
|
||||
return {"run_id": run_id, "outcomes": outcomes}
|
||||
|
||||
|
||||
@router.post("/find-matching")
|
||||
async def find_matching_pattern(req: FindMatchingRequest):
|
||||
"""Ask GPT-4o-mini if this lab pattern matches / counter-scenario an existing library pattern."""
|
||||
from services.database import get_custom_patterns
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
openai_key = get_config("openai_api_key") or ""
|
||||
if not openai_key:
|
||||
raise HTTPException(400, "OpenAI API key not configured")
|
||||
|
||||
run = _get_run(req.run_id)
|
||||
ai_result = json.loads(run.get("ai_result") or "{}")
|
||||
patterns = ai_result.get("patterns", [])
|
||||
if req.pattern_index >= len(patterns):
|
||||
raise HTTPException(400, "pattern_index out of range")
|
||||
|
||||
pat = patterns[req.pattern_index]
|
||||
|
||||
# Outcome for this pattern if evaluated
|
||||
outcome_list: list = []
|
||||
if run.get("outcome"):
|
||||
try:
|
||||
outcome_list = json.loads(run["outcome"]) if isinstance(run["outcome"], str) else run["outcome"]
|
||||
except Exception:
|
||||
pass
|
||||
outcome = next((o for o in outcome_list if o.get("pattern_name") == pat.get("name")), None)
|
||||
|
||||
# Build compact library (id, name, category, underlying, direction, description, regime_tag)
|
||||
library = get_custom_patterns()
|
||||
if not library:
|
||||
return {
|
||||
"recommendation": "new_pattern", "match_id": None, "match_name": None,
|
||||
"confidence": 100, "reasoning": "Library is empty — save as new pattern.",
|
||||
"suggested_regime_tag": pat.get("category", ""),
|
||||
}
|
||||
|
||||
lib_compact = []
|
||||
for p in library:
|
||||
trades = p.get("suggested_trades") or []
|
||||
underlying = trades[0].get("underlying", "") if trades else ""
|
||||
lib_compact.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"regime_tag": p.get("regime_tag") or "",
|
||||
"category": p.get("category") or "",
|
||||
"underlying": underlying,
|
||||
"signal_direction": p.get("signal_direction") or "",
|
||||
"description": (p.get("description") or "")[:150],
|
||||
})
|
||||
|
||||
outcome_str = "not evaluated"
|
||||
if outcome:
|
||||
ht = outcome.get("hit_type") or ("FULL HIT" if outcome.get("hit") else "MISS")
|
||||
outcome_str = f"{ht.upper()} — actual move {outcome.get('actual_move_pct',0):+.1f}%"
|
||||
|
||||
prompt = f"""You are a quantitative macro pattern analyst. Determine if a new backtest pattern matches an existing library pattern.
|
||||
|
||||
## NEW PATTERN (from Pattern Lab backtest)
|
||||
Name: {pat.get('name')}
|
||||
Category: {pat.get('category')}
|
||||
Description: {pat.get('description')}
|
||||
Underlying: {pat.get('underlying')}
|
||||
Strategy: {pat.get('strategy')}
|
||||
Signal direction: {pat.get('signal_direction')} ({pat.get('expected_direction')})
|
||||
Expected move: {pat.get('expected_direction','?')}{pat.get('expected_move_pct',0)}%
|
||||
Backtest outcome: {outcome_str}
|
||||
|
||||
## EXISTING LIBRARY PATTERNS
|
||||
{json.dumps(lib_compact, ensure_ascii=False)}
|
||||
|
||||
## DECISION RULES
|
||||
**merge_as_instance**: Same macro REGIME + same INSTRUMENT + same DIRECTION (both bullish, or both bearish, or both volatility/neutral). The new event is just another historical proof of the same thesis.
|
||||
|
||||
**counter_scenario**: Same macro REGIME + same INSTRUMENT + OPPOSITE DIRECTION. Both are valid — they capture different scenarios within the same regime (e.g. pandemic bullish USD vs pandemic bearish USD).
|
||||
|
||||
**new_pattern**: Different regime, different instrument, or no meaningful structural match.
|
||||
|
||||
CRITICAL: Never merge opposite directions as a single pattern — use counter_scenario instead.
|
||||
Suggest a short generic regime_tag (2-5 words) capturing the macro theme, not the specific event (e.g. "Health Crisis", "Fed Dovish Pivot", "EM Currency Stress", "Geopolitical Energy Shock").
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{{
|
||||
"recommendation": "merge_as_instance" | "counter_scenario" | "new_pattern",
|
||||
"match_id": "existing pattern id or null",
|
||||
"match_name": "existing pattern name or null",
|
||||
"confidence": 0-100,
|
||||
"reasoning": "2-3 sentences explaining the decision",
|
||||
"suggested_regime_tag": "short generic label"
|
||||
}}"""
|
||||
|
||||
client = AsyncOpenAI(api_key=openai_key)
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.1,
|
||||
response_format={"type": "json_object"},
|
||||
timeout=30,
|
||||
)
|
||||
return json.loads(resp.choices[0].message.content or "{}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Matching failed: {e}")
|
||||
|
||||
|
||||
@router.post("/save-pattern")
|
||||
def save_pattern_from_run(req: SavePatternRequest):
|
||||
"""Promote one AI-suggested pattern (with its outcome) to the pattern library."""
|
||||
@@ -283,53 +397,85 @@ def save_pattern_from_run(req: SavePatternRequest):
|
||||
hit = bool(outcome.get("hit")) if outcome else None
|
||||
actual_move = outcome.get("actual_move_pct") if outcome else None
|
||||
|
||||
pat_name = req.name or pat.get("name", "Unnamed Pattern")
|
||||
pat_name = req.name or pat.get("name", "Unnamed Pattern")
|
||||
action = req.action or "new"
|
||||
regime_tag = req.regime_tag or pat.get("category", "")
|
||||
|
||||
# Check if same name already exists → update reliability counters
|
||||
conn = get_conn()
|
||||
existing = conn.execute(
|
||||
"SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'",
|
||||
(pat_name,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0)
|
||||
new_runs = (existing["backtest_runs_count"] or 0) + 1
|
||||
conn.execute("""UPDATE custom_patterns
|
||||
SET backtest_hits=?, backtest_runs_count=?, updated_at=datetime('now')
|
||||
WHERE id=?""", (new_hits, new_runs, existing["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"saved": existing["id"], "action": "updated", "backtest_hits": new_hits, "backtest_runs_count": new_runs}
|
||||
|
||||
# New pattern
|
||||
new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}"
|
||||
historical_instance = {
|
||||
"date": run["analysis_date"],
|
||||
"horizon": run["horizon_days"],
|
||||
"theme": run["theme"],
|
||||
"hit": hit,
|
||||
new_instance = {
|
||||
"date": run["analysis_date"],
|
||||
"event_name": req.event_name or run["theme"],
|
||||
"horizon": run["horizon_days"],
|
||||
"run_id": req.run_id,
|
||||
"hit": hit,
|
||||
"hit_type": "full" if hit else "miss",
|
||||
"actual_move_pct": actual_move,
|
||||
}
|
||||
|
||||
conn = get_conn()
|
||||
|
||||
# ── ACTION: merge as historical instance of an existing pattern ────────────
|
||||
if action == "instance" and req.target_id:
|
||||
existing = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.target_id,)).fetchone()
|
||||
if not existing:
|
||||
conn.close()
|
||||
raise HTTPException(404, "Target pattern not found")
|
||||
instances = json.loads(existing["historical_instances"] or "[]")
|
||||
instances.append(new_instance)
|
||||
new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0)
|
||||
new_runs = (existing["backtest_runs_count"] or 0) + 1
|
||||
conn.execute("""UPDATE custom_patterns SET
|
||||
historical_instances=?, backtest_hits=?, backtest_runs_count=?,
|
||||
regime_tag=COALESCE(regime_tag, ?), updated_at=datetime('now')
|
||||
WHERE id=?""", (json.dumps(instances), new_hits, new_runs, regime_tag, req.target_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"saved": req.target_id, "action": "merged_instance",
|
||||
"backtest_hits": new_hits, "backtest_runs_count": new_runs}
|
||||
|
||||
# ── ACTION: counter-scenario (new pattern linked to existing) ─────────────
|
||||
# Also tag the parent with regime_tag if not already set
|
||||
if action == "counter" and req.target_id:
|
||||
conn.execute("""UPDATE custom_patterns SET
|
||||
regime_tag=COALESCE(NULLIF(regime_tag,''), ?)
|
||||
WHERE id=?""", (regime_tag, req.target_id))
|
||||
|
||||
# ── ACTION: new or counter — check for exact name duplicate first ─────────
|
||||
name_match = conn.execute(
|
||||
"SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'",
|
||||
(pat_name,)
|
||||
).fetchone()
|
||||
if name_match and action == "new":
|
||||
new_hits = (name_match["backtest_hits"] or 0) + (1 if hit else 0)
|
||||
new_runs = (name_match["backtest_runs_count"] or 0) + 1
|
||||
conn.execute("""UPDATE custom_patterns SET
|
||||
backtest_hits=?, backtest_runs_count=?,
|
||||
regime_tag=COALESCE(NULLIF(regime_tag,''), ?), updated_at=datetime('now')
|
||||
WHERE id=?""", (new_hits, new_runs, regime_tag, name_match["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"saved": name_match["id"], "action": "updated",
|
||||
"backtest_hits": new_hits, "backtest_runs_count": new_runs}
|
||||
|
||||
# ── Create new pattern ────────────────────────────────────────────────────
|
||||
new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}"
|
||||
conn.execute("""INSERT INTO custom_patterns (
|
||||
id, name, description, triggers, keywords, historical_instances,
|
||||
suggested_trades, asset_class, expected_move_pct, probability,
|
||||
horizon_days, source, category, signal_direction,
|
||||
backtest_hits, backtest_runs_count,
|
||||
backtest_hits, backtest_runs_count, regime_tag, counter_of,
|
||||
is_active, taxonomy_path, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,1,'[]',datetime('now'))""", (
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,?,?,1,'[]',datetime('now'))""", (
|
||||
new_id,
|
||||
pat_name,
|
||||
pat.get("description", ""),
|
||||
json.dumps([]),
|
||||
json.dumps([]),
|
||||
json.dumps([historical_instance]),
|
||||
json.dumps([new_instance]),
|
||||
json.dumps([{
|
||||
"underlying": pat.get("underlying", ""),
|
||||
"strategy": pat.get("strategy", ""),
|
||||
"expected_move_pct": pat.get("expected_move_pct", 0),
|
||||
"asset_class": req.asset_class,
|
||||
"underlying": pat.get("underlying", ""),
|
||||
"strategy": pat.get("strategy", ""),
|
||||
"expected_move_pct": pat.get("expected_move_pct", 0),
|
||||
"asset_class": req.asset_class,
|
||||
}]),
|
||||
req.asset_class or "indices",
|
||||
pat.get("expected_move_pct", 0),
|
||||
@@ -339,8 +485,11 @@ def save_pattern_from_run(req: SavePatternRequest):
|
||||
req.signal_direction or pat.get("signal_direction", ""),
|
||||
1 if hit else 0,
|
||||
1,
|
||||
regime_tag,
|
||||
req.target_id if action == "counter" else None,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"saved": new_id, "action": "created", "backtest_hits": 1 if hit else 0, "backtest_runs_count": 1}
|
||||
return {"saved": new_id, "action": action,
|
||||
"backtest_hits": 1 if hit else 0, "backtest_runs_count": 1}
|
||||
|
||||
@@ -88,6 +88,9 @@ def init_db():
|
||||
"ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0",
|
||||
# Remove all built-in patterns (no proof of legitimacy)
|
||||
"DELETE FROM custom_patterns WHERE source = 'builtin'",
|
||||
# Regime / counter-scenario architecture
|
||||
"ALTER TABLE custom_patterns ADD COLUMN regime_tag TEXT",
|
||||
"ALTER TABLE custom_patterns ADD COLUMN counter_of TEXT",
|
||||
]:
|
||||
try:
|
||||
c.execute(_sql)
|
||||
|
||||
Reference in New Issue
Block a user