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)
|
||||
|
||||
@@ -996,6 +996,8 @@ export const useSaveLabPattern = () => {
|
||||
mutationFn: (body: {
|
||||
run_id: string; pattern_index: number; name?: 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),
|
||||
onSuccess: () => {
|
||||
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 = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
||||
import {
|
||||
TreePine, Search, ChevronRight, ChevronDown,
|
||||
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
||||
Layers, GitMerge, Tag,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useAllPatterns,
|
||||
@@ -58,6 +59,10 @@ interface Pattern {
|
||||
taxonomy_path?: string[]
|
||||
ai_quality_score?: number
|
||||
is_active?: number
|
||||
regime_tag?: string
|
||||
counter_of?: string
|
||||
backtest_hits?: number
|
||||
backtest_runs_count?: number
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'tree' | 'instrument'
|
||||
type ViewMode = 'tree' | 'instrument' | 'regime'
|
||||
|
||||
export default function PatternExplorer() {
|
||||
const [view, setView] = useState<ViewMode>('tree')
|
||||
@@ -810,6 +942,18 @@ export default function PatternExplorer() {
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
Instrument
|
||||
</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>
|
||||
|
||||
@@ -823,6 +967,7 @@ export default function PatternExplorer() {
|
||||
<>
|
||||
{view === 'tree' && <TreeView patterns={patterns} />}
|
||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||
{view === 'regime' && <RegimeView patterns={patterns} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
|
||||
usePatternLabRuns, useDeleteLabRun,
|
||||
useInstrumentScan, useEvaluateInstrumentScan,
|
||||
useFindMatchingPattern, MatchResult,
|
||||
validateTicker,
|
||||
} from '../hooks/useApi'
|
||||
import {
|
||||
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
|
||||
Trash2, Save, RefreshCw, Search, CalendarDays,
|
||||
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign,
|
||||
GitMerge, Layers, Plus,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||
@@ -255,6 +257,7 @@ export default function PatternLab() {
|
||||
const { mutate: deleteRun } = useDeleteLabRun()
|
||||
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
|
||||
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
|
||||
const { mutateAsync: findMatching } = useFindMatchingPattern()
|
||||
|
||||
const runs: any[] = runsData ?? []
|
||||
|
||||
@@ -283,9 +286,11 @@ export default function PatternLab() {
|
||||
const [instSavedIdx, setInstSavedIdx] = useState<Set<number>>(new Set())
|
||||
|
||||
// ── Current run state (events mode)
|
||||
const [activeRun, setActiveRun] = useState<any | null>(null)
|
||||
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const [activeRun, setActiveRun] = useState<any | null>(null)
|
||||
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
|
||||
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 => {
|
||||
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
|
||||
const pat = activeRun.ai_result?.patterns?.[idx]
|
||||
if (!pat) return
|
||||
@@ -349,9 +375,13 @@ export default function PatternLab() {
|
||||
name: pat.name,
|
||||
category: pat.category,
|
||||
signal_direction: pat.signal_direction,
|
||||
action,
|
||||
target_id,
|
||||
regime_tag,
|
||||
})
|
||||
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) {
|
||||
showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`)
|
||||
}
|
||||
@@ -853,18 +883,65 @@ export default function PatternLab() {
|
||||
{out && <OutcomeRow out={out} />}
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex-shrink-0">
|
||||
{/* Save / Find Matching buttons */}
|
||||
<div className="flex-shrink-0 flex flex-col items-end gap-2">
|
||||
{isSaved ? (
|
||||
<span className="flex items-center gap-1 text-emerald-400 text-xs">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Saved
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => 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 pattern
|
||||
</button>
|
||||
)}
|
||||
) : (() => {
|
||||
const key = matchKey(idx)
|
||||
const mr = matchResults[key]
|
||||
const loading = matchLoading[key]
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user