feat: pattern convergence engine — categories, signal_direction, conviction scores

Phase 1 — Catégorisation:
- database.py: ADD COLUMN category + signal_direction on custom_patterns (migration);
  save_custom_pattern persists category/signal_direction; new helpers:
  get_unclassified_patterns(), update_pattern_classification(),
  get_patterns_with_last_score()
- ai_analyzer.py: PATTERN_CATEGORIES dict (8 categories: géopolitique, macro_monétaire,
  technique, commodités_supply, risk_off, flux_saisonnier, géo_économique, crédit_stress);
  classify_patterns_batch() → GPT-4o-mini batch classification
- suggest schema: added category + signal_direction fields so new patterns are
  classified from birth
- auto_cycle.py: Step 3.1 classifies all unclassified patterns after each suggestion

Phase 2 — Convergence layer (post-scoring, no extra AI call):
- ai_analyzer.py: _compute_convergence() groups scored patterns by (underlying, signal_direction);
  conviction_bonus = min(20, +5 per additional agreeing pattern); adds conviction_score,
  conviction_bonus, convergence_count, convergence_underlying, convergence_partners to each result;
  called at end of score_patterns_with_context(), re-sorts by conviction_score
- auto_cycle.py: logs convergence summary after scoring; propagates category/signal_direction
  to scored results for display

Phase optionnelle — Convergence in suggestion prompt:
- ai_analyzer.py: suggest_patterns_from_market_context() accepts convergence_block param;
  injected into prompt so AI knows which underlyings have multi-pattern agreement
- auto_cycle.py: before suggestion, loads last-cycle scores via get_patterns_with_last_score(),
  calls _compute_convergence() to build convergence block, passes to suggester

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-21 20:22:08 +02:00
parent 319ac35a26
commit 952e326590
3 changed files with 278 additions and 1 deletions

View File

@@ -78,6 +78,9 @@ def init_db():
"ALTER TABLE custom_patterns ADD COLUMN bayesian_win_rate REAL",
"ALTER TABLE custom_patterns ADD COLUMN bayesian_updated_at TEXT",
"ALTER TABLE custom_patterns ADD COLUMN bayesian_sample_size INTEGER DEFAULT 0",
# Convergence Phase 1 — thematic classification
"ALTER TABLE custom_patterns ADD COLUMN category TEXT",
"ALTER TABLE custom_patterns ADD COLUMN signal_direction TEXT",
]:
try:
c.execute(_sql)
@@ -863,8 +866,9 @@ def save_custom_pattern(pattern: Dict[str, Any]) -> str:
suggested_trades, asset_class, expected_move_pct, probability,
horizon_days, ai_quality_score, ai_evaluation, source,
counter_thesis, invalidation_trigger, invalidation_probability,
category, signal_direction,
is_active, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", (
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", (
pat_id,
pattern.get("name", ""),
pattern.get("description", ""),
@@ -882,6 +886,8 @@ def save_custom_pattern(pattern: Dict[str, Any]) -> str:
pattern.get("counter_thesis"),
pattern.get("invalidation_trigger"),
pattern.get("invalidation_probability"),
pattern.get("category"),
pattern.get("signal_direction"),
))
conn.commit()
conn.close()
@@ -903,6 +909,57 @@ def get_custom_patterns() -> List[Dict[str, Any]]:
return result
def get_unclassified_patterns() -> List[Dict[str, Any]]:
"""Return active patterns where category IS NULL (need AI classification)."""
conn = get_conn()
rows = conn.execute(
"SELECT * FROM custom_patterns WHERE is_active=1 AND (category IS NULL OR category='')"
).fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
for f in ["triggers", "keywords", "historical_instances", "suggested_trades", "ai_evaluation"]:
d[f] = json.loads(d.get(f) or "[]")
result.append(d)
return result
def update_pattern_classification(pat_id: str, category: str, signal_direction: str) -> None:
"""Persist category + signal_direction for a pattern."""
conn = get_conn()
conn.execute(
"UPDATE custom_patterns SET category=?, signal_direction=?, updated_at=datetime('now') WHERE id=?",
(category, signal_direction, pat_id),
)
conn.commit()
conn.close()
def get_patterns_with_last_score() -> List[Dict[str, Any]]:
"""Each active pattern + its most recent score from pattern_score_history."""
conn = get_conn()
rows = conn.execute("""
SELECT cp.*, psh.score AS last_score, psh.summary AS last_summary, psh.scored_at AS last_scored_at
FROM custom_patterns cp
LEFT JOIN (
SELECT pattern_id, score, summary, scored_at,
ROW_NUMBER() OVER (PARTITION BY pattern_id ORDER BY scored_at DESC) AS rn
FROM pattern_score_history
) psh ON psh.pattern_id = cp.id AND psh.rn = 1
WHERE cp.is_active=1
ORDER BY cp.created_at DESC
""").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
for f in ["triggers", "keywords", "historical_instances", "suggested_trades", "ai_evaluation"]:
d[f] = json.loads(d.get(f) or "[]")
result.append(d)
return result
def toggle_pattern_active(pat_id: str) -> bool:
"""Toggle is_active for a pattern. Returns the new state."""
conn = get_conn()