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:
OpenSquared
2026-06-22 20:52:11 +02:00
parent 198341b0c2
commit a435c11246
5 changed files with 438 additions and 47 deletions

View File

@@ -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}

View File

@@ -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)