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

@@ -531,6 +531,26 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
except Exception as _snap_e:
logger.warning(f"[Cycle] Context snapshot save failed (non-blocking): {_snap_e}")
# ── Build convergence block from previous cycle's scored patterns ──
_convergence_block_for_suggest = ""
try:
from services.database import get_patterns_with_last_score
from services.ai_analyzer import _compute_convergence
_prev_scored = get_patterns_with_last_score()
_prev_with_scores = [
{"pattern_id": p.get("id"), "score": p.get("last_score") or 0,
"recommended_trade": {"underlying": (p.get("suggested_trades") or [{}])[0].get("underlying", "")} if p.get("suggested_trades") else {},
"trade_rankings": [{"underlying": t.get("underlying", "")} for t in (p.get("suggested_trades") or [])],
"geo_trigger": p.get("name", "")}
for p in _prev_scored if p.get("last_score") is not None
]
if _prev_with_scores:
_, _convergence_block_for_suggest = _compute_convergence(_prev_with_scores, _prev_scored)
if _convergence_block_for_suggest:
logger.info(f"[Cycle {run_id[:16]}] Convergence block built from {len(_prev_with_scores)} previously scored patterns")
except Exception as _cbe:
logger.warning(f"[Cycle] Convergence block build failed (non-blocking): {_cbe}")
suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons,
@@ -541,6 +561,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
fred_block=_fred_block,
price_discovery_block=_price_discovery_block,
portfolio_context_block=_portfolio_block,
convergence_block=_convergence_block_for_suggest,
run_id=run_id,
)
except Exception as e:
@@ -599,6 +620,21 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
summary["patterns_added"] = added_count
# ── Step 3.1: Classify unclassified patterns (category + signal_direction) ─
try:
from services.database import get_unclassified_patterns, update_pattern_classification
from services.ai_analyzer import classify_patterns_batch
_unclassified = get_unclassified_patterns()
if _unclassified:
logger.info(f"[Cycle {run_id[:16]}] Classifying {len(_unclassified)} unclassified patterns")
_classifications = classify_patterns_batch(_unclassified)
for _cl in _classifications:
if _cl.get("id") and _cl.get("category"):
update_pattern_classification(_cl["id"], _cl["category"], _cl.get("signal_direction", "neutral"))
logger.info(f"[Cycle {run_id[:16]}] Classified {len(_classifications)} patterns")
except Exception as _cle:
logger.warning(f"[Cycle] Pattern classification failed (non-blocking): {_cle}")
# ── Step 3.5: Collect risk cluster context ───────────────────────────
risk_cluster_context = ""
try:
@@ -694,6 +730,22 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
logger.debug(f"[Cycle] Enriched '{orig.get('name')}' expected_move_pct={orig['expected_move_pct']}")
if not s.get("trade_rankings") and not s.get("suggested_trades"):
s["suggested_trades"] = orig.get("suggested_trades", [])
# Propagate category + signal_direction to scored result for display
if orig.get("category") and not s.get("category"):
s["category"] = orig["category"]
if orig.get("signal_direction") and not s.get("signal_direction"):
s["signal_direction"] = orig["signal_direction"]
# ── Log convergence summary ───────────────────────────────────────────
try:
_top_conv = [s for s in scored if s.get("convergence_count", 0) > 0]
if _top_conv:
logger.info(
f"[Cycle {run_id[:16]}] Convergence: {len(_top_conv)} patterns with cross-pattern boost "
f"— top: {_top_conv[0].get('convergence_underlying','?')} ×{_top_conv[0].get('convergence_count',0)+1}"
)
except Exception:
pass
# ── Step 5: Log everything ────────────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 5: logging")