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

@@ -880,9 +880,172 @@ TEMPLATE DE NOTATION:
p["score"] = min(total, 100)
all_scored.sort(key=lambda x: x.get("score", 0), reverse=True)
# ── Phase convergence: compute cross-pattern conviction bonuses ────────────
all_scored, _conv_block = _compute_convergence(all_scored, patterns)
all_scored.sort(key=lambda x: x.get("conviction_score", x.get("score", 0)), reverse=True)
return all_scored[:top_n]
# ── Pattern convergence ───────────────────────────────────────────────────────
PATTERN_CATEGORIES = {
"géopolitique": "Conflits armés, sanctions, élections, alliances militaires, tensions régionales",
"macro_monétaire": "Inflation, taux directeurs Fed/BCE, dollar index, yield curve, QE/QT",
"technique": "RSI, Bollinger, momentum, cassures de support/résistance, patterns chartistes",
"commodités_supply":"OPEC, stocks pétrole/gaz, récoltes, disruptions mines, logistique supply chain",
"risk_off": "Catastrophes naturelles, crises bancaires, pandémies, chocs de volatilité",
"flux_saisonnier": "COT flows, saisonnalité, rééquilibrages fin trimestre, options expiry",
"géo_économique": "Chaînes d'approvisionnement, protectionnisme, dédollarisation, reshoring",
"crédit_stress": "Spreads HY, CDS souverains, stress bancaire, risque de défaut",
}
def classify_patterns_batch(patterns: List[Dict]) -> List[Dict]:
"""Classify unclassified patterns via GPT-4o-mini → [{id, category, signal_direction}]."""
if not get_client() or not patterns:
return []
compact = [
{
"id": p.get("id"),
"name": p.get("name", ""),
"description": (p.get("description") or "")[:200],
"triggers": (p.get("triggers") or [])[:3],
"asset_class": p.get("asset_class", ""),
}
for p in patterns
]
cats_desc = "\n".join(f"- {c}: {d}" for c, d in PATTERN_CATEGORIES.items())
system = "Tu es un classificateur de patterns financiers géopolitiques. Réponds UNIQUEMENT en JSON valide."
user = f"""Classifie chaque pattern selon UNE de ces catégories:
{cats_desc}
signal_direction:
- "bullish": anticipe une hausse du sous-jacent
- "bearish": anticipe une baisse du sous-jacent
- "volatility": anticipe un mouvement sans direction claire (straddle, vol play)
- "neutral": direction incertaine / stratégie de range
Patterns à classifier:
{json.dumps(compact, ensure_ascii=False)}
Retourne UNIQUEMENT ce JSON:
{{"classifications": [{{"id": "<id>", "category": "<catégorie>", "signal_direction": "<direction>"}}]}}"""
try:
res = _chat(system, user, model="gpt-4o-mini", json_mode=True, max_tokens=2000)
return res.get("classifications", []) if res else []
except Exception as _e:
import logging as _log2
_log2.getLogger(__name__).warning(f"[Classify] Batch classification failed: {_e}")
return []
def _compute_convergence(
scored_patterns: List[Dict],
original_patterns: List[Dict],
) -> tuple:
"""
Group scored patterns by (underlying, signal_direction).
Apply conviction_bonus = min(20, +5 per additional agreeing pattern).
Returns (enriched_list, convergence_summary_block_str).
"""
from collections import defaultdict
pat_meta = {str(p.get("id", "")): p for p in original_patterns}
# Collect underlyings + direction per pattern
def _pat_underlyings(sp: Dict):
underlyings = set()
rec = sp.get("recommended_trade") or {}
if rec.get("underlying"):
underlyings.add(rec["underlying"])
for tr in (sp.get("trade_rankings") or []):
if tr.get("underlying"):
underlyings.add(tr["underlying"])
return underlyings
# Build (underlying, direction) → list of pattern entries
groups: Dict = defaultdict(list)
for sp in scored_patterns:
pid = str(sp.get("pattern_id", ""))
orig = pat_meta.get(pid, {})
direction = (orig.get("signal_direction") or "neutral").lower()
if direction not in ("bullish", "bearish", "volatility", "neutral"):
direction = "neutral"
category = orig.get("category") or ""
name = (orig.get("name") or sp.get("geo_trigger") or "")[:50]
score = sp.get("score", 0)
for und in _pat_underlyings(sp):
groups[(und, direction)].append({
"pid": pid, "name": name, "category": category, "score": score,
})
# Enrich each scored pattern
enriched = []
for sp in scored_patterns:
pid = str(sp.get("pattern_id", ""))
orig = pat_meta.get(pid, {})
direction = (orig.get("signal_direction") or "neutral").lower()
if direction not in ("bullish", "bearish", "volatility", "neutral"):
direction = "neutral"
best_count, best_und, best_partners = 0, "", []
for und in _pat_underlyings(sp):
group = groups.get((und, direction), [])
others = [x for x in group if x["pid"] != pid]
if len(others) > best_count:
best_count, best_und, best_partners = len(others), und, others
conviction_bonus = min(20, 5 * best_count)
conviction_score = min(100, sp.get("score", 0) + conviction_bonus)
enriched.append({
**sp,
"conviction_score": conviction_score,
"conviction_bonus": conviction_bonus,
"convergence_count": best_count,
"convergence_underlying": best_und,
"convergence_partners": [
{"name": p["name"], "category": p["category"], "score": p["score"]}
for p in best_partners[:5]
],
})
# Build convergence block for injection into NEXT suggestion prompt
conv_lines = []
for (und, direction), pats in groups.items():
if len(pats) >= 2:
avg_sc = sum(p["score"] for p in pats) / len(pats)
cats = ", ".join(sorted({p["category"] for p in pats if p["category"]}))
conv_lines.append((len(pats), avg_sc, und, direction, pats, cats))
conv_lines.sort(key=lambda x: (-x[0], -x[1]))
if not conv_lines:
conv_block = ""
else:
_dir_sym = {"bullish": "", "bearish": "", "volatility": "", "neutral": ""}
lines = [
"\n## 🎯 CONVERGENCE SIGNAUX — Underlyings à forte conviction inter-patterns",
"Ces sous-jacents sont ciblés par plusieurs patterns actifs dans la MÊME direction :",
]
for count, avg_sc, und, direction, pats, cats in conv_lines[:8]:
sym = _dir_sym.get(direction, "")
pat_names = " | ".join(p["name"][:30] for p in pats[:4])
lines.append(f" {sym} {und} {direction.upper()}: {count} patterns convergents (score moy. {avg_sc:.0f}/100)")
lines.append(f" Catégories: {cats or 'N/A'} | Patterns: {pat_names}")
lines += [
"",
"⚠️ Ces convergences signalent une FORTE conviction collective — privilégie des patterns",
"complémentaires sur ces underlyings ou signale un contra-signal si le contexte l'inverse.",
]
conv_block = "\n".join(lines)
return enriched, conv_block
# ── Suggest new patterns from live market context ─────────────────────────────
# ── Temporal news utilities ───────────────────────────────────────────────────
@@ -1045,6 +1208,7 @@ def suggest_patterns_from_market_context(
fred_block: str = "",
price_discovery_block: str = "",
portfolio_context_block: str = "",
convergence_block: str = "",
run_id: str = "",
) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
@@ -1195,6 +1359,7 @@ Règles supplémentaires:
fred_section = f"\n{fred_block}\n" if fred_block else ""
pd_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
portfolio_section = f"\n{portfolio_context_block}\n" if portfolio_context_block else ""
convergence_section = f"\n{convergence_block}\n" if convergence_block else ""
user = f"""Tu es un stratège géopolitique et financier senior, expert en options.
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
@@ -1205,6 +1370,7 @@ Règles supplémentaires:
{pd_section}
{tech_block_section}
{portfolio_section}
{convergence_section}
## Calendrier économique à venir
{cal_block}
@@ -1233,6 +1399,8 @@ Retourne UNIQUEMENT ce JSON:
"triggers": ["<trigger1>", "<trigger2>"],
"keywords": ["<kw1>", "<kw2>", "<kw3>"],
"asset_class": "<energy|metals|agriculture|indices|equities|forex>",
"category": "<géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress>",
"signal_direction": "<bullish|bearish|volatility|neutral>",
"expected_move_pct": <float, RENDEMENT OPTION MOYEN en % pour ce pattern, levier inclus. Typiquement 50-300%.>,
"probability": <float 0-1>,
"horizon_days": <int>,