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
|
category: Optional[str] = None
|
||||||
signal_direction: Optional[str] = None
|
signal_direction: Optional[str] = None
|
||||||
asset_class: Optional[str] = "indices"
|
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):
|
class InstrumentScanRequest(BaseModel):
|
||||||
@@ -260,6 +270,110 @@ def evaluate_instrument_run(run_id: str):
|
|||||||
return {"run_id": run_id, "outcomes": outcomes}
|
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")
|
@router.post("/save-pattern")
|
||||||
def save_pattern_from_run(req: SavePatternRequest):
|
def save_pattern_from_run(req: SavePatternRequest):
|
||||||
"""Promote one AI-suggested pattern (with its outcome) to the pattern library."""
|
"""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
|
hit = bool(outcome.get("hit")) if outcome else None
|
||||||
actual_move = outcome.get("actual_move_pct") 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
|
new_instance = {
|
||||||
conn = get_conn()
|
"date": run["analysis_date"],
|
||||||
existing = conn.execute(
|
"event_name": req.event_name or run["theme"],
|
||||||
"SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'",
|
"horizon": run["horizon_days"],
|
||||||
(pat_name,)
|
"run_id": req.run_id,
|
||||||
).fetchone()
|
"hit": hit,
|
||||||
|
"hit_type": "full" if hit else "miss",
|
||||||
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,
|
|
||||||
"actual_move_pct": actual_move,
|
"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 (
|
conn.execute("""INSERT INTO custom_patterns (
|
||||||
id, name, description, triggers, keywords, historical_instances,
|
id, name, description, triggers, keywords, historical_instances,
|
||||||
suggested_trades, asset_class, expected_move_pct, probability,
|
suggested_trades, asset_class, expected_move_pct, probability,
|
||||||
horizon_days, source, category, signal_direction,
|
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
|
is_active, taxonomy_path, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,1,'[]',datetime('now'))""", (
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,?,?,1,'[]',datetime('now'))""", (
|
||||||
new_id,
|
new_id,
|
||||||
pat_name,
|
pat_name,
|
||||||
pat.get("description", ""),
|
pat.get("description", ""),
|
||||||
json.dumps([]),
|
json.dumps([]),
|
||||||
json.dumps([]),
|
json.dumps([]),
|
||||||
json.dumps([historical_instance]),
|
json.dumps([new_instance]),
|
||||||
json.dumps([{
|
json.dumps([{
|
||||||
"underlying": pat.get("underlying", ""),
|
"underlying": pat.get("underlying", ""),
|
||||||
"strategy": pat.get("strategy", ""),
|
"strategy": pat.get("strategy", ""),
|
||||||
"expected_move_pct": pat.get("expected_move_pct", 0),
|
"expected_move_pct": pat.get("expected_move_pct", 0),
|
||||||
"asset_class": req.asset_class,
|
"asset_class": req.asset_class,
|
||||||
}]),
|
}]),
|
||||||
req.asset_class or "indices",
|
req.asset_class or "indices",
|
||||||
pat.get("expected_move_pct", 0),
|
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", ""),
|
req.signal_direction or pat.get("signal_direction", ""),
|
||||||
1 if hit else 0,
|
1 if hit else 0,
|
||||||
1,
|
1,
|
||||||
|
regime_tag,
|
||||||
|
req.target_id if action == "counter" else None,
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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",
|
"ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0",
|
||||||
# Remove all built-in patterns (no proof of legitimacy)
|
# Remove all built-in patterns (no proof of legitimacy)
|
||||||
"DELETE FROM custom_patterns WHERE source = 'builtin'",
|
"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:
|
try:
|
||||||
c.execute(_sql)
|
c.execute(_sql)
|
||||||
|
|||||||
@@ -996,6 +996,8 @@ export const useSaveLabPattern = () => {
|
|||||||
mutationFn: (body: {
|
mutationFn: (body: {
|
||||||
run_id: string; pattern_index: number; name?: string;
|
run_id: string; pattern_index: number; name?: string;
|
||||||
category?: string; signal_direction?: string; asset_class?: string;
|
category?: string; signal_direction?: string; asset_class?: string;
|
||||||
|
action?: 'new' | 'instance' | 'counter';
|
||||||
|
target_id?: string; regime_tag?: string; event_name?: string;
|
||||||
}) => api.post('/pattern-lab/save-pattern', body).then(r => r.data),
|
}) => api.post('/pattern-lab/save-pattern', body).then(r => r.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['all-patterns'] })
|
qc.invalidateQueries({ queryKey: ['all-patterns'] })
|
||||||
@@ -1004,6 +1006,21 @@ export const useSaveLabPattern = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MatchResult = {
|
||||||
|
recommendation: 'merge_as_instance' | 'counter_scenario' | 'new_pattern'
|
||||||
|
match_id?: string
|
||||||
|
match_name?: string
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
suggested_regime_tag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFindMatchingPattern = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (body: { run_id: string; pattern_index: number }): Promise<MatchResult> =>
|
||||||
|
api.post('/pattern-lab/find-matching', body).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
export const useDeleteLabRun = () => {
|
export const useDeleteLabRun = () => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
import {
|
import {
|
||||||
TreePine, Search, ChevronRight, ChevronDown,
|
TreePine, Search, ChevronRight, ChevronDown,
|
||||||
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
||||||
|
Layers, GitMerge, Tag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useAllPatterns,
|
useAllPatterns,
|
||||||
@@ -58,6 +59,10 @@ interface Pattern {
|
|||||||
taxonomy_path?: string[]
|
taxonomy_path?: string[]
|
||||||
ai_quality_score?: number
|
ai_quality_score?: number
|
||||||
is_active?: number
|
is_active?: number
|
||||||
|
regime_tag?: string
|
||||||
|
counter_of?: string
|
||||||
|
backtest_hits?: number
|
||||||
|
backtest_runs_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScoreResult {
|
interface ScoreResult {
|
||||||
@@ -749,9 +754,136 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Regime View ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RegimeCard({ pattern, allPatterns }: { pattern: Pattern; allPatterns: Pattern[] }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const isCounter = !!pattern.counter_of
|
||||||
|
const counterOf = isCounter ? allPatterns.find(p => p.id === pattern.counter_of) : null
|
||||||
|
const instances = Array.isArray(pattern.historical_instances) ? pattern.historical_instances : []
|
||||||
|
const hitRate = pattern.backtest_runs_count
|
||||||
|
? Math.round(((pattern.backtest_hits ?? 0) / pattern.backtest_runs_count) * 100)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
'border rounded-lg overflow-hidden transition-colors',
|
||||||
|
isCounter ? 'border-orange-700/50 bg-orange-900/5' : 'border-slate-700/50 bg-dark-700/30'
|
||||||
|
)}>
|
||||||
|
<button className="w-full flex items-center gap-3 p-3 text-left hover:bg-slate-800/30 transition-colors"
|
||||||
|
onClick={() => setOpen(v => !v)}>
|
||||||
|
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />}
|
||||||
|
{isCounter
|
||||||
|
? <Layers className="w-4 h-4 text-orange-400 shrink-0" />
|
||||||
|
: <BookOpen className="w-4 h-4 text-violet-400 shrink-0" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-slate-100 truncate">{pattern.name}</span>
|
||||||
|
{isCounter && (
|
||||||
|
<span className="text-[10px] border border-orange-600 text-orange-400 rounded px-1.5 py-0.5 shrink-0">
|
||||||
|
counter of {counterOf?.name ?? pattern.counter_of}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pattern.signal_direction && (
|
||||||
|
<span className={clsx('text-[10px] rounded px-1.5 py-0.5 shrink-0',
|
||||||
|
pattern.signal_direction === 'up' ? 'bg-emerald-900/40 text-emerald-400' :
|
||||||
|
pattern.signal_direction === 'down' ? 'bg-red-900/40 text-red-400' : 'bg-slate-700 text-slate-400')}>
|
||||||
|
{pattern.signal_direction === 'up' ? '↑' : pattern.signal_direction === 'down' ? '↓' : '↕'} {pattern.signal_direction}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hitRate !== null && (
|
||||||
|
<span className={clsx('text-[10px] font-mono shrink-0',
|
||||||
|
hitRate >= 60 ? 'text-emerald-400' : hitRate >= 40 ? 'text-yellow-400' : 'text-red-400')}>
|
||||||
|
{hitRate}% ({pattern.backtest_hits}/{pattern.backtest_runs_count})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 truncate mt-0.5">{pattern.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-600 shrink-0 font-mono">{instances.length} events</span>
|
||||||
|
</button>
|
||||||
|
{open && instances.length > 0 && (
|
||||||
|
<div className="border-t border-slate-700/40 p-3 pt-2">
|
||||||
|
<div className="text-[10px] text-slate-500 mb-2 font-semibold uppercase tracking-wide">Historical instances</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{instances.map((inst: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 text-[11px]">
|
||||||
|
<span className="text-slate-500 font-mono w-24 shrink-0">{inst.date ?? '—'}</span>
|
||||||
|
<span className="text-slate-300 flex-1 truncate">{inst.event_name ?? inst.event ?? inst.theme ?? '—'}</span>
|
||||||
|
{inst.hit !== undefined && (
|
||||||
|
<span className={clsx('shrink-0', inst.hit_type === 'full' || inst.hit === true ? 'text-emerald-400' : inst.hit_type === 'partial' ? 'text-amber-400' : 'text-red-400')}>
|
||||||
|
{inst.hit_type === 'full' || inst.hit === true ? '✓' : inst.hit_type === 'partial' ? '~' : '✗'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegimeView({ patterns }: { patterns: Pattern[] }) {
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const byRegime: Record<string, Pattern[]> = {}
|
||||||
|
const untagged: Pattern[] = []
|
||||||
|
for (const p of patterns) {
|
||||||
|
if (p.source !== 'backtested' && p.source !== 'user') continue
|
||||||
|
if (p.regime_tag) {
|
||||||
|
;(byRegime[p.regime_tag] ??= []).push(p)
|
||||||
|
} else {
|
||||||
|
untagged.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byRegime, untagged }
|
||||||
|
}, [patterns])
|
||||||
|
|
||||||
|
const regimes = Object.entries(groups.byRegime).sort((a, b) => b[1].length - a[1].length)
|
||||||
|
|
||||||
|
if (regimes.length === 0 && groups.untagged.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32 gap-3 text-slate-600">
|
||||||
|
<Tag className="w-8 h-8" />
|
||||||
|
<p className="text-sm">No saved patterns yet.</p>
|
||||||
|
<p className="text-xs">Use "Find Matching" in Pattern Lab to classify and save patterns into regimes.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{regimes.map(([tag, pats]) => (
|
||||||
|
<div key={tag} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-violet-400" />
|
||||||
|
<span className="text-sm font-semibold text-violet-300">#{tag}</span>
|
||||||
|
<span className="text-xs text-slate-500">{pats.length} pattern{pats.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 pl-4 border-l border-violet-900/40">
|
||||||
|
{pats.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{groups.untagged.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
<span className="text-sm font-semibold text-slate-500">Untagged</span>
|
||||||
|
<span className="text-xs text-slate-600">{groups.untagged.length} pattern{groups.untagged.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 pl-4 border-l border-slate-700/40">
|
||||||
|
{groups.untagged.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Page root ─────────────────────────────────────────────────────────────────
|
// ── Page root ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ViewMode = 'tree' | 'instrument'
|
type ViewMode = 'tree' | 'instrument' | 'regime'
|
||||||
|
|
||||||
export default function PatternExplorer() {
|
export default function PatternExplorer() {
|
||||||
const [view, setView] = useState<ViewMode>('tree')
|
const [view, setView] = useState<ViewMode>('tree')
|
||||||
@@ -810,6 +942,18 @@ export default function PatternExplorer() {
|
|||||||
<Search className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
Instrument
|
Instrument
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
|
||||||
|
view === 'regime'
|
||||||
|
? 'bg-violet-600/30 text-violet-300 border border-violet-500/30'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
)}
|
||||||
|
onClick={() => setView('regime')}
|
||||||
|
>
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
By Regime
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -823,6 +967,7 @@ export default function PatternExplorer() {
|
|||||||
<>
|
<>
|
||||||
{view === 'tree' && <TreeView patterns={patterns} />}
|
{view === 'tree' && <TreeView patterns={patterns} />}
|
||||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||||
|
{view === 'regime' && <RegimeView patterns={patterns} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import {
|
|||||||
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
|
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
|
||||||
usePatternLabRuns, useDeleteLabRun,
|
usePatternLabRuns, useDeleteLabRun,
|
||||||
useInstrumentScan, useEvaluateInstrumentScan,
|
useInstrumentScan, useEvaluateInstrumentScan,
|
||||||
|
useFindMatchingPattern, MatchResult,
|
||||||
validateTicker,
|
validateTicker,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import {
|
import {
|
||||||
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
|
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
|
||||||
Trash2, Save, RefreshCw, Search, CalendarDays,
|
Trash2, Save, RefreshCw, Search, CalendarDays,
|
||||||
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign,
|
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign,
|
||||||
|
GitMerge, Layers, Plus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||||
@@ -255,6 +257,7 @@ export default function PatternLab() {
|
|||||||
const { mutate: deleteRun } = useDeleteLabRun()
|
const { mutate: deleteRun } = useDeleteLabRun()
|
||||||
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
|
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
|
||||||
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
|
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
|
||||||
|
const { mutateAsync: findMatching } = useFindMatchingPattern()
|
||||||
|
|
||||||
const runs: any[] = runsData ?? []
|
const runs: any[] = runsData ?? []
|
||||||
|
|
||||||
@@ -283,9 +286,11 @@ export default function PatternLab() {
|
|||||||
const [instSavedIdx, setInstSavedIdx] = useState<Set<number>>(new Set())
|
const [instSavedIdx, setInstSavedIdx] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
// ── Current run state (events mode)
|
// ── Current run state (events mode)
|
||||||
const [activeRun, setActiveRun] = useState<any | null>(null)
|
const [activeRun, setActiveRun] = useState<any | null>(null)
|
||||||
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
|
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
|
||||||
const [toast, setToast] = useState<string | null>(null)
|
const [toast, setToast] = useState<string | null>(null)
|
||||||
|
const [matchResults, setMatchResults] = useState<Record<string, MatchResult>>({})
|
||||||
|
const [matchLoading, setMatchLoading] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const filteredPresets = useMemo(() => PRESETS.filter(p => {
|
const filteredPresets = useMemo(() => PRESETS.filter(p => {
|
||||||
if (yearFilter && p.year !== yearFilter) return false
|
if (yearFilter && p.year !== yearFilter) return false
|
||||||
@@ -338,7 +343,28 @@ export default function PatternLab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (idx: number) => {
|
const matchKey = (idx: number) => `${activeRun?.run_id}-${idx}`
|
||||||
|
|
||||||
|
const handleFindMatching = async (idx: number) => {
|
||||||
|
if (!activeRun?.run_id) return
|
||||||
|
const key = matchKey(idx)
|
||||||
|
setMatchLoading(prev => ({ ...prev, [key]: true }))
|
||||||
|
try {
|
||||||
|
const result = await findMatching({ run_id: activeRun.run_id, pattern_index: idx })
|
||||||
|
setMatchResults(prev => ({ ...prev, [key]: result }))
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(`Find matching failed: ${e?.response?.data?.detail ?? e?.message}`)
|
||||||
|
} finally {
|
||||||
|
setMatchLoading(prev => ({ ...prev, [key]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (
|
||||||
|
idx: number,
|
||||||
|
action: 'new' | 'instance' | 'counter' = 'new',
|
||||||
|
target_id?: string,
|
||||||
|
regime_tag?: string,
|
||||||
|
) => {
|
||||||
if (!activeRun?.run_id) return
|
if (!activeRun?.run_id) return
|
||||||
const pat = activeRun.ai_result?.patterns?.[idx]
|
const pat = activeRun.ai_result?.patterns?.[idx]
|
||||||
if (!pat) return
|
if (!pat) return
|
||||||
@@ -349,9 +375,13 @@ export default function PatternLab() {
|
|||||||
name: pat.name,
|
name: pat.name,
|
||||||
category: pat.category,
|
category: pat.category,
|
||||||
signal_direction: pat.signal_direction,
|
signal_direction: pat.signal_direction,
|
||||||
|
action,
|
||||||
|
target_id,
|
||||||
|
regime_tag,
|
||||||
})
|
})
|
||||||
setSavedIdx(prev => new Set([...prev, idx]))
|
setSavedIdx(prev => new Set([...prev, idx]))
|
||||||
showToast(`"${pat.name}" saved to Pattern Library`)
|
const actionLabel = action === 'instance' ? 'merged as instance' : action === 'counter' ? 'saved as counter-scenario' : 'saved'
|
||||||
|
showToast(`"${pat.name}" ${actionLabel}`)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`)
|
showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`)
|
||||||
}
|
}
|
||||||
@@ -853,18 +883,65 @@ export default function PatternLab() {
|
|||||||
{out && <OutcomeRow out={out} />}
|
{out && <OutcomeRow out={out} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save button */}
|
{/* Save / Find Matching buttons */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 flex flex-col items-end gap-2">
|
||||||
{isSaved ? (
|
{isSaved ? (
|
||||||
<span className="flex items-center gap-1 text-emerald-400 text-xs">
|
<span className="flex items-center gap-1 text-emerald-400 text-xs">
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" /> Saved
|
<CheckCircle2 className="w-3.5 h-3.5" /> Saved
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (() => {
|
||||||
<button onClick={() => handleSave(idx)}
|
const key = matchKey(idx)
|
||||||
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
|
const mr = matchResults[key]
|
||||||
<Save className="w-3 h-3" /> Save pattern
|
const loading = matchLoading[key]
|
||||||
</button>
|
if (mr) {
|
||||||
)}
|
const isInstance = mr.recommendation === 'merge_as_instance'
|
||||||
|
const isCounter = mr.recommendation === 'counter_scenario'
|
||||||
|
const badgeColor = isInstance ? 'text-emerald-400 border-emerald-600' : isCounter ? 'text-orange-400 border-orange-600' : 'text-slate-400 border-slate-600'
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
|
<div className={`text-[10px] border rounded px-1.5 py-0.5 ${badgeColor}`}>
|
||||||
|
{isInstance ? <><GitMerge className="w-3 h-3 inline mr-1" />Match: {mr.match_name} ({mr.confidence}%)</>
|
||||||
|
: isCounter ? <><Layers className="w-3 h-3 inline mr-1" />Counter of: {mr.match_name} ({mr.confidence}%)</>
|
||||||
|
: <><Plus className="w-3 h-3 inline mr-1" />New pattern</>}
|
||||||
|
</div>
|
||||||
|
{mr.suggested_regime_tag && (
|
||||||
|
<div className="text-[10px] text-violet-400 font-mono">#{mr.suggested_regime_tag}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{isInstance && mr.match_id && (
|
||||||
|
<button onClick={() => handleSave(idx, 'instance', mr.match_id, mr.suggested_regime_tag)}
|
||||||
|
className="flex items-center gap-1 border border-emerald-600 hover:border-emerald-400 text-emerald-400 px-2 py-1 rounded text-[10px] transition-colors">
|
||||||
|
<GitMerge className="w-3 h-3" /> Merge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isCounter && mr.match_id && (
|
||||||
|
<button onClick={() => handleSave(idx, 'counter', mr.match_id, mr.suggested_regime_tag)}
|
||||||
|
className="flex items-center gap-1 border border-orange-600 hover:border-orange-400 text-orange-400 px-2 py-1 rounded text-[10px] transition-colors">
|
||||||
|
<Layers className="w-3 h-3" /> Save counter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => handleSave(idx, 'new', undefined, mr.suggested_regime_tag)}
|
||||||
|
className="flex items-center gap-1 border border-slate-600 hover:border-slate-400 text-slate-400 px-2 py-1 rounded text-[10px] transition-colors">
|
||||||
|
<Save className="w-3 h-3" /> Save new
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button onClick={() => handleFindMatching(idx)} disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 border border-violet-700 hover:border-violet-400 text-violet-400 px-2.5 py-1.5 rounded text-xs transition-colors disabled:opacity-50">
|
||||||
|
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <GitMerge className="w-3 h-3" />}
|
||||||
|
Find matching
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleSave(idx)}
|
||||||
|
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user