Initial commit — GeoOptions Intelligence Cockpit v2.0
Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
697
backend/services/auto_cycle.py
Normal file
697
backend/services/auto_cycle.py
Normal file
@@ -0,0 +1,697 @@
|
||||
"""
|
||||
Auto-cycle orchestration — runs every N hours (configurable).
|
||||
|
||||
Cycle steps:
|
||||
1. Fetch current context (news, quotes, macro, geo)
|
||||
2. Ask GPT-4o to suggest new patterns
|
||||
3. Filter: keep only those with Jaccard keyword similarity < threshold vs existing
|
||||
4. Save filtered patterns to DB
|
||||
5. Score ALL patterns (existing + new)
|
||||
6. Log: pattern scores, trade entry prices, geo alert, macro snapshot
|
||||
7. Generate GPT-4o commentary: why are top/bottom trades performing this way?
|
||||
8. Update cycle_runs with results + commentary
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Global scheduler state ────────────────────────────────────────────────────
|
||||
|
||||
_stop_event = threading.Event()
|
||||
_cycle_thread: Optional[threading.Thread] = None
|
||||
_cycle_lock = threading.Lock() # prevents concurrent cycles
|
||||
_current_status: Dict[str, Any] = {
|
||||
"running": False,
|
||||
"last_run_id": None,
|
||||
"last_run_at": None,
|
||||
"next_run_at": None,
|
||||
"enabled": False,
|
||||
"interval_hours": 3,
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _jaccard(a: List[str], b: List[str]) -> float:
|
||||
sa = {x.lower() for x in (a or [])}
|
||||
sb = {x.lower() for x in (b or [])}
|
||||
if not sa and not sb:
|
||||
return 0.0
|
||||
union = sa | sb
|
||||
return len(sa & sb) / len(union) if union else 0.0
|
||||
|
||||
|
||||
def _max_similarity_vs_existing(candidate_kws: List[str], existing: List[Dict]) -> float:
|
||||
return max((_jaccard(candidate_kws, p.get("keywords") or []) for p in existing), default=0.0)
|
||||
|
||||
|
||||
# ── Core cycle logic ──────────────────────────────────────────────────────────
|
||||
|
||||
def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
"""
|
||||
Execute one full auto-cycle. Thread-safe (skips if already running).
|
||||
Returns a summary dict.
|
||||
"""
|
||||
if not _cycle_lock.acquire(blocking=False):
|
||||
logger.info("Auto-cycle skipped — another cycle is already running")
|
||||
return {"skipped": True, "reason": "already_running"}
|
||||
|
||||
run_id = datetime.utcnow().isoformat()
|
||||
summary: Dict[str, Any] = {
|
||||
"run_id": run_id,
|
||||
"trigger": trigger,
|
||||
"patterns_suggested": 0,
|
||||
"patterns_added": 0,
|
||||
"patterns_scored": 0,
|
||||
"geo_score": None,
|
||||
"dominant_regime": None,
|
||||
"commentary": None,
|
||||
"status": "error",
|
||||
}
|
||||
|
||||
try:
|
||||
from services.database import (
|
||||
get_config, get_custom_patterns, save_custom_pattern,
|
||||
save_pattern_scores, log_macro_regime, log_geo_alert, log_trade_entries,
|
||||
add_cycle_run, update_cycle_run, save_reasoning_trace,
|
||||
get_latest_portfolio_lessons,
|
||||
)
|
||||
from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios
|
||||
from services.geo_analyzer import compute_geo_risk_score
|
||||
from services.ai_analyzer import (
|
||||
suggest_patterns_from_market_context, score_patterns_with_context,
|
||||
ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE,
|
||||
)
|
||||
|
||||
# Check AI key
|
||||
ai_key = get_config("openai_api_key") or ""
|
||||
if not ai_key:
|
||||
logger.warning("Auto-cycle: no OpenAI key configured, skipping AI steps")
|
||||
return {**summary, "status": "no_ai_key"}
|
||||
|
||||
import os
|
||||
os.environ["OPENAI_API_KEY"] = ai_key
|
||||
|
||||
sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30")
|
||||
|
||||
add_cycle_run(run_id, trigger=trigger)
|
||||
_current_status["running"] = True
|
||||
_current_status["last_run_id"] = run_id
|
||||
|
||||
# ── Step 0: Load portfolio lessons + Super Contexte ──────────────────
|
||||
portfolio_lessons = get_latest_portfolio_lessons()
|
||||
|
||||
# Load Super Contexte (accumulated knowledge base)
|
||||
try:
|
||||
from services.database import get_latest_reasoning_state
|
||||
_reasoning_state = get_latest_reasoning_state()
|
||||
if _reasoning_state:
|
||||
if portfolio_lessons is None:
|
||||
portfolio_lessons = {}
|
||||
portfolio_lessons["super_context"] = (
|
||||
f"[Super Contexte v{_reasoning_state.get('version',1)} "
|
||||
f"du {(_reasoning_state.get('created_at','')[:16])}]\n"
|
||||
+ _reasoning_state.get("narrative", "")[:800]
|
||||
)
|
||||
_synth = _reasoning_state.get("synthesis") or {}
|
||||
portfolio_lessons["strategic_priorities"] = _synth.get("strategic_priorities", [])
|
||||
portfolio_lessons["recurring_mistakes"] = [
|
||||
m.get("mistake", "") for m in _synth.get("recurring_mistakes", [])[:3]
|
||||
]
|
||||
logger.info(
|
||||
f"[Cycle {run_id[:16]}] Super Contexte v{_reasoning_state.get('version')} loaded "
|
||||
f"({_reasoning_state.get('reports_used',0)} rapports, "
|
||||
f"{_reasoning_state.get('trades_analyzed',0)} trades)"
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning(f"[Cycle] Could not load Super Contexte: {_e}")
|
||||
|
||||
if portfolio_lessons:
|
||||
age_hours = 0
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
created = _dt.fromisoformat(portfolio_lessons["created_at"])
|
||||
age_hours = (_dt.utcnow() - created).total_seconds() / 3600
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
f"[Cycle {run_id[:16]}] Portfolio lessons loaded "
|
||||
f"(report from {portfolio_lessons.get('created_at','?')[:10]}, "
|
||||
f"{age_hours:.0f}h ago, avg_pnl={portfolio_lessons['stats'].get('avg_pnl_pct')}%)"
|
||||
)
|
||||
else:
|
||||
logger.info(f"[Cycle {run_id[:16]}] No portfolio report yet — cycle runs without performance feedback")
|
||||
|
||||
# ── Step 1: Fetch context ─────────────────────────────────────────────
|
||||
logger.info(f"[Cycle {run_id[:16]}] Step 1: fetching context")
|
||||
from routers.geopolitical import _news_cache # type: ignore
|
||||
news = _news_cache.get("data") or fetch_geo_news()
|
||||
news = ai_score_news_batch(news)
|
||||
_news_cache["data"] = news
|
||||
|
||||
geo_score_obj = compute_geo_risk_score(news)
|
||||
geo_score_val = int(geo_score_obj.get("score") or 0)
|
||||
summary["geo_score"] = geo_score_val
|
||||
|
||||
quotes = get_all_quotes()
|
||||
|
||||
gauges = get_macro_gauges()
|
||||
scenarios = score_macro_scenarios(gauges)
|
||||
macro_regime = {"gauges": gauges, "scenarios": scenarios}
|
||||
dominant = scenarios.get("dominant", "incertain")
|
||||
summary["dominant_regime"] = dominant
|
||||
|
||||
# ── Step 2: Suggest new patterns ──────────────────────────────────────
|
||||
logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting patterns")
|
||||
try:
|
||||
from services.data_fetcher import get_economic_calendar
|
||||
calendar = get_economic_calendar()
|
||||
suggestions = suggest_patterns_from_market_context(
|
||||
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
|
||||
portfolio_lessons=portfolio_lessons,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Cycle] Suggestion step failed: {e}")
|
||||
suggestions = []
|
||||
|
||||
summary["patterns_suggested"] = len(suggestions)
|
||||
logger.info(f"[Cycle {run_id[:16]}] Suggested {len(suggestions)} patterns from AI")
|
||||
|
||||
# ── Step 3: Filter by similarity ──────────────────────────────────────
|
||||
existing = get_custom_patterns()
|
||||
logger.info(f"[Cycle {run_id[:16]}] Step 3: {len(existing)} existing patterns, threshold={sim_threshold}")
|
||||
added_count = 0
|
||||
for s in suggestions:
|
||||
kws = s.get("keywords") or []
|
||||
sim = _max_similarity_vs_existing(kws, existing)
|
||||
if sim < sim_threshold:
|
||||
# Capture returned ID so the pattern has a valid id for scoring
|
||||
assigned_id = save_custom_pattern(s)
|
||||
s["id"] = assigned_id
|
||||
existing.append(s)
|
||||
added_count += 1
|
||||
logger.info(f"[Cycle] Added pattern '{s.get('name')}' id={assigned_id} (sim={sim:.2f})")
|
||||
# ── Save suggestion reasoning trace ───────────────────────────
|
||||
_top_news_ctx = [
|
||||
{"title": n.get("title", "")[:120], "impact": round(float(n.get("impact_score") or 0), 2), "source": n.get("source", "")}
|
||||
for n in sorted(news, key=lambda x: -(float(x.get("impact_score") or 0)))[:8]
|
||||
]
|
||||
save_reasoning_trace(
|
||||
run_id=run_id,
|
||||
trace_type="suggestion",
|
||||
pattern_id=assigned_id,
|
||||
input_context={
|
||||
"geo_score": geo_score_val,
|
||||
"macro_dominant": dominant,
|
||||
"macro_scores": scenarios.get("scores", {}),
|
||||
"top_news": _top_news_ctx,
|
||||
"cycle_run_id": run_id,
|
||||
},
|
||||
output={
|
||||
"name": s.get("name"),
|
||||
"description": s.get("description"),
|
||||
"macro_fit": s.get("macro_fit"),
|
||||
"expected_move_pct": s.get("expected_move_pct"),
|
||||
"probability": s.get("probability"),
|
||||
"horizon_days": s.get("horizon_days"),
|
||||
"suggested_trades": s.get("suggested_trades", []),
|
||||
"keywords": s.get("keywords", []),
|
||||
"triggers": s.get("triggers", []),
|
||||
},
|
||||
reasoning_summary=(s.get("macro_fit") or s.get("description") or "")[:300],
|
||||
geo_score=geo_score_val,
|
||||
macro_dominant=dominant,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"[Cycle] Filtered '{s.get('name')}' — sim={sim:.2f} >= {sim_threshold}")
|
||||
|
||||
summary["patterns_added"] = added_count
|
||||
|
||||
# ── Step 4: Score ALL patterns ────────────────────────────────────────
|
||||
# Verify all patterns have IDs before scoring (guard against stale data)
|
||||
patterns_with_id = [p for p in existing if p.get("id")]
|
||||
patterns_without_id = [p.get("name", "?") for p in existing if not p.get("id")]
|
||||
if patterns_without_id:
|
||||
logger.warning(f"[Cycle] {len(patterns_without_id)} patterns have no id, skipping: {patterns_without_id}")
|
||||
|
||||
logger.info(f"[Cycle {run_id[:16]}] Step 4: scoring {len(patterns_with_id)} patterns (of {len(existing)} total)")
|
||||
template = get_config("analysis_template") or DEFAULT_ANALYSIS_TEMPLATE
|
||||
try:
|
||||
scored = score_patterns_with_context(
|
||||
patterns=patterns_with_id,
|
||||
recent_news=news,
|
||||
quotes_by_class=quotes,
|
||||
geo_score=geo_score_obj,
|
||||
template=template,
|
||||
top_n=len(patterns_with_id),
|
||||
category_filter=None,
|
||||
macro_regime=macro_regime,
|
||||
portfolio_lessons=portfolio_lessons,
|
||||
)
|
||||
scored_with_id = [s for s in scored if s.get("pattern_id")]
|
||||
scored_without_id = [s for s in scored if not s.get("pattern_id")]
|
||||
if scored_without_id:
|
||||
logger.warning(f"[Cycle] {len(scored_without_id)} scored results have no pattern_id — they will NOT be saved to history")
|
||||
logger.info(f"[Cycle {run_id[:16]}] Scoring returned {len(scored)} results ({len(scored_with_id)} with valid id)")
|
||||
except Exception as e:
|
||||
logger.error(f"[Cycle] Scoring failed: {e}", exc_info=True)
|
||||
scored = []
|
||||
|
||||
summary["patterns_scored"] = len(scored)
|
||||
|
||||
# ── Enrich scored patterns with original data not in GPT-4o response ─
|
||||
# GPT-4o scoring doesn't return expected_move_pct or suggested_trades —
|
||||
# copy from the original pattern so log_trade_entries can use them.
|
||||
_pmap = {p.get("id"): p for p in patterns_with_id}
|
||||
for s in scored:
|
||||
orig = _pmap.get(s.get("pattern_id", ""), {})
|
||||
if orig:
|
||||
if not s.get("expected_move_pct") and orig.get("expected_move_pct"):
|
||||
s["expected_move_pct"] = orig["expected_move_pct"]
|
||||
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", [])
|
||||
|
||||
# ── Step 5: Log everything ────────────────────────────────────────────
|
||||
logger.info(f"[Cycle {run_id[:16]}] Step 5: logging")
|
||||
scoring_run_id = save_pattern_scores(scored, meta={
|
||||
"geo_score": geo_score_val,
|
||||
"total": len(scored),
|
||||
"cycle_run_id": run_id,
|
||||
"trigger": trigger,
|
||||
})
|
||||
|
||||
# ── Save scoring reasoning traces (one per scored pattern) ────────────
|
||||
_macro_scores_ctx = scenarios.get("scores", {})
|
||||
_asset_bias_ctx = scenarios.get("asset_bias", {}).get(dominant, {})
|
||||
for sp in scored:
|
||||
pid = sp.get("pattern_id", "")
|
||||
if not pid:
|
||||
continue
|
||||
orig = _pmap.get(pid, {})
|
||||
save_reasoning_trace(
|
||||
run_id=scoring_run_id,
|
||||
trace_type="scoring",
|
||||
pattern_id=pid,
|
||||
input_context={
|
||||
"geo_score": geo_score_val,
|
||||
"macro_dominant": dominant,
|
||||
"macro_scores": _macro_scores_ctx,
|
||||
"asset_class": sp.get("asset_class") or orig.get("asset_class"),
|
||||
"asset_bias": _asset_bias_ctx.get(sp.get("asset_class") or orig.get("asset_class", ""), "neutral"),
|
||||
"expected_move_pct": sp.get("expected_move_pct") or orig.get("expected_move_pct"),
|
||||
"cycle_run_id": run_id,
|
||||
},
|
||||
output={
|
||||
"score": sp.get("score"),
|
||||
"confidence": sp.get("confidence"),
|
||||
"buckets": sp.get("buckets", []),
|
||||
"key_catalyst": sp.get("key_catalyst"),
|
||||
"summary": sp.get("summary"),
|
||||
"trade_rankings": sp.get("trade_rankings", []),
|
||||
"recommended_trade": sp.get("recommended_trade", {}),
|
||||
"has_strong_contra": sp.get("has_strong_contra", False),
|
||||
"geo_trigger": sp.get("geo_trigger"),
|
||||
},
|
||||
reasoning_summary=((sp.get("key_catalyst") or "") + " | " + (sp.get("summary") or ""))[:400],
|
||||
geo_score=geo_score_val,
|
||||
macro_dominant=dominant,
|
||||
)
|
||||
logger.info(f"[Cycle {run_id[:16]}] Saved {len([s for s in scored if s.get('pattern_id')])} reasoning traces")
|
||||
|
||||
top_patterns_log = sorted(
|
||||
[{"pattern_id": sp.get("pattern_id"), "name": sp.get("geo_trigger"), "score": sp.get("score", 0)}
|
||||
for sp in scored if sp.get("score", 0) > 0],
|
||||
key=lambda x: -x["score"]
|
||||
)[:10]
|
||||
|
||||
log_geo_alert(geo_score=geo_score_val, top_patterns=top_patterns_log,
|
||||
news_count=len(news), run_id=scoring_run_id)
|
||||
|
||||
log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes)
|
||||
|
||||
gauges_summary = {
|
||||
k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")}
|
||||
for k, v in gauges.items()
|
||||
if v.get("value") is not None or v.get("change_pct") is not None
|
||||
}
|
||||
log_macro_regime(dominant=dominant, scores=scenarios.get("scores", {}),
|
||||
reasons=scenarios.get("reasons", {}), gauges_summary=gauges_summary)
|
||||
|
||||
# Update macro cache so the UI sees fresh data immediately
|
||||
from routers.market_data import _macro_cache # type: ignore
|
||||
import datetime as _dt
|
||||
_macro_cache["data"] = {"gauges": gauges, "scenarios": scenarios,
|
||||
"fetched_at": datetime.utcnow().isoformat(), "cached": False}
|
||||
_macro_cache["ts"] = _dt.datetime.utcnow()
|
||||
|
||||
# ── Step 6: GPT-4o cycle commentary ──────────────────────────────────
|
||||
logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary")
|
||||
commentary = _generate_cycle_commentary(
|
||||
scored=scored, dominant=dominant, scenarios=scenarios,
|
||||
geo_score_val=geo_score_val, news=news, gauges=gauges,
|
||||
)
|
||||
# Attach lessons metadata to commentary so UI can display it
|
||||
if commentary and portfolio_lessons:
|
||||
try:
|
||||
import json as _json
|
||||
c = _json.loads(commentary) if isinstance(commentary, str) else commentary
|
||||
c["lessons_from_report"] = portfolio_lessons.get("created_at", "")[:16].replace("T", " ")
|
||||
c["lessons_headline"] = portfolio_lessons.get("headline", "")[:100]
|
||||
commentary = _json.dumps(c, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
summary["commentary"] = commentary
|
||||
|
||||
# ── Finalize ──────────────────────────────────────────────────────────
|
||||
summary["status"] = "completed"
|
||||
update_cycle_run(
|
||||
run_id,
|
||||
completed_at=datetime.utcnow().isoformat(),
|
||||
patterns_suggested=summary["patterns_suggested"],
|
||||
patterns_added=summary["patterns_added"],
|
||||
patterns_scored=summary["patterns_scored"],
|
||||
geo_score=geo_score_val,
|
||||
dominant_regime=dominant,
|
||||
commentary=commentary,
|
||||
status="completed",
|
||||
)
|
||||
_current_status["last_run_at"] = datetime.utcnow().isoformat()
|
||||
logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored")
|
||||
|
||||
# ── Step 7: Auto portfolio snapshot ──────────────────────────────────
|
||||
# Generate (or refresh) the portfolio report so the NEXT cycle has
|
||||
# fresh performance lessons. Runs in background to not block the cycle.
|
||||
import threading
|
||||
threading.Thread(
|
||||
target=_auto_portfolio_snapshot,
|
||||
args=(ai_key,),
|
||||
daemon=True,
|
||||
name=f"portfolio-snapshot-{run_id[:8]}",
|
||||
).start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cycle {run_id[:16]}] Fatal error: {e}", exc_info=True)
|
||||
try:
|
||||
from services.database import update_cycle_run
|
||||
update_cycle_run(run_id, status="error", completed_at=datetime.utcnow().isoformat())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_current_status["running"] = False
|
||||
_cycle_lock.release()
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _generate_cycle_commentary(
|
||||
scored: List[Dict], dominant: str, scenarios: Dict,
|
||||
geo_score_val: int, news: List[Dict], gauges: Dict,
|
||||
) -> Optional[str]:
|
||||
"""Ask GPT-4o to explain current performance of top/bottom patterns."""
|
||||
try:
|
||||
from services.database import get_trade_entry_prices
|
||||
from services.ai_analyzer import _chat
|
||||
|
||||
# Get recent trade P&L for context (last 7 days)
|
||||
entries = get_trade_entry_prices(7)
|
||||
trade_summary = []
|
||||
for e in entries[:15]:
|
||||
trade_summary.append({
|
||||
"pattern": e.get("pattern_name", ""),
|
||||
"underlying": e.get("underlying", ""),
|
||||
"strategy": e.get("strategy", ""),
|
||||
"score_at_entry": e.get("score_at_entry", 0),
|
||||
"entry_date": e.get("entry_date", ""),
|
||||
})
|
||||
|
||||
# Top 5 scored patterns now
|
||||
top_scored = sorted(scored, key=lambda x: -(x.get("score") or 0))[:5]
|
||||
top_scored_summary = [
|
||||
{"name": s.get("geo_trigger"), "score": s.get("score"), "summary": s.get("summary", "")[:120]}
|
||||
for s in top_scored
|
||||
]
|
||||
|
||||
# Top recent news headlines
|
||||
top_news = [{"title": n.get("title", ""), "impact": n.get("impact_score", 0)}
|
||||
for n in sorted(news, key=lambda x: -(x.get("impact_score") or 0))[:5]]
|
||||
|
||||
import json
|
||||
|
||||
def gv(key: str) -> str:
|
||||
v = gauges.get(key, {}).get("value")
|
||||
return str(round(v, 2)) if v is not None else "N/A"
|
||||
|
||||
def gc(key: str) -> str:
|
||||
v = gauges.get(key, {}).get("change_pct")
|
||||
return f"{v:+.2f}%" if v is not None else "N/A"
|
||||
|
||||
prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse la performance de notre système de détection de patterns.
|
||||
|
||||
CONTEXTE DU CYCLE (maintenant):
|
||||
- Régime dominant: {dominant.upper()} (score: {scenarios.get('scores', {}).get(dominant, 0)}%)
|
||||
- Score risque géopolitique: {geo_score_val}/100
|
||||
- VIX: {gv('vix')} | Pente 10Y-3M: {gv('slope_10y3m')}% | DXY: {gc('dxy')} | Brent: {gc('brent')}
|
||||
- Cuivre: {gc('copper')} | Or: {gc('gold')} | S&P vs 200j: {gv('spx_vs_200d')}%
|
||||
|
||||
TOP 5 PATTERNS ACTUELLEMENT LES MIEUX SCORÉS:
|
||||
{json.dumps(top_scored_summary, ensure_ascii=False, indent=2)}
|
||||
|
||||
TRADES LOGUÉS CES 7 DERNIERS JOURS:
|
||||
{json.dumps(trade_summary, ensure_ascii=False, indent=2)}
|
||||
|
||||
NEWS GÉOPOLITIQUES À FORT IMPACT:
|
||||
{json.dumps(top_news, ensure_ascii=False, indent=2)}
|
||||
|
||||
Écris un COMMENTAIRE DE CYCLE concis (4-6 phrases) pour un trader options:
|
||||
1. Le régime macro confirme-t-il les patterns qui scorent le mieux ?
|
||||
2. Y a-t-il des news ou événements qui expliquent un écart avec nos prévisions ?
|
||||
3. Quels patterns/trades méritent attention (confirmation ou invalidation) ?
|
||||
4. Une recommandation tactique pour le prochain cycle (3h)
|
||||
|
||||
Réponds UNIQUEMENT en JSON: {{"commentary": "<ton texte 4-6 phrases>", "key_risk": "<risque principal en 1 phrase>", "top_pattern": "<nom du pattern le plus pertinent maintenant>"}}"""
|
||||
|
||||
result = _chat(
|
||||
"Tu es un stratège macro senior. Analyse concise et actionnable. JSON uniquement.",
|
||||
prompt,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=500,
|
||||
)
|
||||
if result and result.get("commentary"):
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Cycle] Commentary generation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Auto portfolio snapshot ───────────────────────────────────────────────────
|
||||
|
||||
def _auto_portfolio_snapshot(ai_key: str) -> None:
|
||||
"""
|
||||
Called in a background thread at the end of each cycle.
|
||||
Fetches live prices, checks if enough trades are priced (P&L ≠ 0),
|
||||
and if so generates a GPT-4o portfolio report so the NEXT cycle has
|
||||
fresh performance lessons. Skipped silently if not enough data.
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
os.environ["OPENAI_API_KEY"] = ai_key
|
||||
|
||||
from services.database import (
|
||||
get_mtm_trades_with_traces, save_ai_report, get_latest_portfolio_lessons,
|
||||
)
|
||||
|
||||
data = get_mtm_trades_with_traces(days=30, limit_movers=5)
|
||||
winners = data.get("winners", [])
|
||||
losers = data.get("losers", [])
|
||||
priced = data.get("priced_count", 0)
|
||||
|
||||
# Need at least 3 priced trades with actual movement to make analysis meaningful
|
||||
meaningful = [
|
||||
t for t in (winners + losers)
|
||||
if t.get("pnl_pct") is not None and abs(t.get("pnl_pct", 0)) > 0.05
|
||||
]
|
||||
if len(meaningful) < 3:
|
||||
logger.info(
|
||||
f"[AutoSnapshot] Skipping GPT-4o report: only {len(meaningful)} trades "
|
||||
f"with meaningful P&L movement (need ≥ 3)"
|
||||
)
|
||||
return
|
||||
|
||||
avg_pnl = data.get("avg_pnl_pct")
|
||||
stats = {
|
||||
"total_trades": data["total_trades"],
|
||||
"priced_count": priced,
|
||||
"avg_pnl_pct": avg_pnl,
|
||||
}
|
||||
|
||||
# Build prompt (reuse same logic as reasoning.py generate endpoint)
|
||||
from routers.reasoning import _trade_summary_block, _bucket_summary, _rankings_summary
|
||||
from services.ai_analyzer import _chat
|
||||
|
||||
winners_block = _trade_summary_block("TOP GAINS", winners)
|
||||
losers_block = _trade_summary_block("TOP PERTES", losers)
|
||||
avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A"
|
||||
|
||||
prompt = f"""Tu es un stratège macro-géopolitique senior. Rapport synthétique post-cycle automatique.
|
||||
|
||||
═══ STATISTIQUES GLOBALES ═══
|
||||
Période : 30 derniers jours | Trades total: {data['total_trades']} | Pricés: {priced} | P&L moyen: {avg_str}
|
||||
|
||||
═══ {winners_block}
|
||||
|
||||
═══ {losers_block}
|
||||
|
||||
Génère un rapport JSON :
|
||||
{{
|
||||
"headline": "<1 phrase résumant la performance>",
|
||||
"regime_assessment": "<alignement régime macro avec nos thèses ?>",
|
||||
"winners_analysis": "<pourquoi ces trades ont marché — 2-3 phrases>",
|
||||
"losers_analysis": "<pourquoi ces trades ont déçu — 2-3 phrases>",
|
||||
"key_lessons": ["<leçon 1>", "<leçon 2>", "<leçon 3>"],
|
||||
"blind_spots": "<ce que le scoring n'a pas bien capturé>",
|
||||
"next_cycle_priorities": "<3 priorités pour le prochain cycle>",
|
||||
"risk_watch": "<1-2 risques à surveiller>"
|
||||
}}"""
|
||||
|
||||
result = _chat(
|
||||
"Tu es un stratège macro senior. Rapport post-cycle concis. JSON uniquement.",
|
||||
prompt,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=800,
|
||||
)
|
||||
if not result:
|
||||
logger.warning("[AutoSnapshot] GPT-4o returned empty response")
|
||||
return
|
||||
|
||||
report_id = save_ai_report(
|
||||
days=30, stats=stats, winners=winners, losers=losers, report=result,
|
||||
report_type="portfolio",
|
||||
)
|
||||
logger.info(
|
||||
f"[AutoSnapshot] Portfolio report #{report_id} saved automatically "
|
||||
f"({len(meaningful)} meaningful trades, avg P&L {avg_str})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[AutoSnapshot] Failed: {e}", exc_info=True)
|
||||
|
||||
|
||||
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _scheduler_loop(stop_event: threading.Event):
|
||||
"""Background loop that runs the cycle at the configured interval."""
|
||||
import time
|
||||
from services.database import get_config
|
||||
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
interval_hours = float(get_config("auto_cycle_hours") or "3")
|
||||
except Exception:
|
||||
interval_hours = 3.0
|
||||
|
||||
_current_status["interval_hours"] = interval_hours
|
||||
next_run = datetime.utcnow().isoformat()
|
||||
_current_status["next_run_at"] = next_run
|
||||
logger.info(f"[Scheduler] Next cycle in {interval_hours}h")
|
||||
|
||||
# Wait for the interval (or until stop is signalled)
|
||||
stop_event.wait(timeout=interval_hours * 3600)
|
||||
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
# Check if still enabled
|
||||
try:
|
||||
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
|
||||
except Exception:
|
||||
enabled = False
|
||||
|
||||
if enabled:
|
||||
logger.info("[Scheduler] Running scheduled auto-cycle")
|
||||
try:
|
||||
run_cycle_once(trigger="auto")
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] Cycle error: {e}", exc_info=True)
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Start the background scheduler thread if auto_cycle is enabled."""
|
||||
global _cycle_thread, _stop_event
|
||||
from services.database import get_config
|
||||
|
||||
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
|
||||
_current_status["enabled"] = enabled
|
||||
|
||||
if not enabled:
|
||||
logger.info("[Scheduler] Auto-cycle disabled — skipping scheduler start")
|
||||
return
|
||||
|
||||
if _cycle_thread and _cycle_thread.is_alive():
|
||||
logger.info("[Scheduler] Already running")
|
||||
return
|
||||
|
||||
_stop_event = threading.Event()
|
||||
_cycle_thread = threading.Thread(
|
||||
target=_scheduler_loop,
|
||||
args=(_stop_event,),
|
||||
daemon=True,
|
||||
name="auto-cycle-scheduler",
|
||||
)
|
||||
_cycle_thread.start()
|
||||
logger.info("[Scheduler] Auto-cycle scheduler started")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the background scheduler thread."""
|
||||
global _cycle_thread
|
||||
_stop_event.set()
|
||||
if _cycle_thread:
|
||||
_cycle_thread.join(timeout=5)
|
||||
_current_status["enabled"] = False
|
||||
logger.info("[Scheduler] Stopped")
|
||||
|
||||
|
||||
def restart_scheduler():
|
||||
"""Restart the scheduler — call after config changes."""
|
||||
stop_scheduler()
|
||||
_stop_event.clear()
|
||||
start_scheduler()
|
||||
|
||||
|
||||
def trigger_manual():
|
||||
"""Run one cycle immediately in a background thread (non-blocking)."""
|
||||
t = threading.Thread(target=run_cycle_once, args=("manual",), daemon=True, name="auto-cycle-manual")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
from services.database import get_config, get_cycle_runs
|
||||
try:
|
||||
interval_hours = float(get_config("auto_cycle_hours") or "3")
|
||||
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
|
||||
sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30")
|
||||
min_ev = float(get_config("min_ev_threshold") or "0.0")
|
||||
min_score = int(get_config("min_score_threshold") or "0")
|
||||
except Exception:
|
||||
interval_hours, enabled, sim_threshold, min_ev, min_score = 3.0, False, 0.30, 0.0, 0
|
||||
|
||||
recent = get_cycle_runs(limit=1)
|
||||
last = recent[0] if recent else None
|
||||
|
||||
return {
|
||||
**_current_status,
|
||||
"enabled": enabled,
|
||||
"interval_hours": interval_hours,
|
||||
"similarity_threshold": sim_threshold,
|
||||
"min_ev_threshold": min_ev,
|
||||
"min_score_threshold": min_score,
|
||||
"last_cycle": last,
|
||||
"scheduler_alive": bool(_cycle_thread and _cycle_thread.is_alive()),
|
||||
}
|
||||
Reference in New Issue
Block a user