feat: time-aware Super Contexte synthesis

- knowledge.py: classify trades by maturity before building synthesis
  prompt; only mature trades (≥35% elapsed) contribute to P&L stats
  and conclusions; immature trades listed for transparency only
- Add 6h staleness gate on POST /synthesize (force=true to override)
- System prompt now includes hard timing rule: GPT-4o must not revise
  existing conclusions because of newly-added immature trades
- useApi.ts: useSynthesizeKnowledge accepts force boolean param
- SuperContexte.tsx: shows amber notice with age when skipped + offers
  "Force quand même" button; success banner uses new response shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-16 23:57:46 +02:00
parent 9075762dd5
commit 22687dfd03
3 changed files with 118 additions and 33 deletions

View File

@@ -7,7 +7,7 @@ import os
from services.database import ( from services.database import (
get_kb_entries, get_all_kb_entries, save_kb_entry, update_kb_entry_status, get_kb_entries, get_all_kb_entries, save_kb_entry, update_kb_entry_status,
get_latest_reasoning_state, get_reasoning_history, get_reasoning_state_by_id, get_latest_reasoning_state, get_reasoning_history, get_reasoning_state_by_id,
save_reasoning_state, list_ai_reports, get_mtm_trades_with_traces, save_reasoning_state, list_ai_reports, get_mtm_trades_with_traces, _trade_maturity,
) )
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries: List[Dict]): def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries: List[Dict]):
"""Build the GPT-4o synthesis prompt from all accumulated data.""" """Build the GPT-4o synthesis prompt from all accumulated data."""
from datetime import date as _date
now_str = __import__("datetime").datetime.utcnow().strftime("%Y-%m-%d %H:%M") now_str = __import__("datetime").datetime.utcnow().strftime("%Y-%m-%d %H:%M")
# Portfolio reports summary # Portfolio reports summary
@@ -22,39 +23,61 @@ def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries:
for r in reports[:10]: for r in reports[:10]:
rpt = r.get("report") or {} rpt = r.get("report") or {}
stats = r.get("stats") or {} stats = r.get("stats") or {}
date = r.get("created_at", "")[:16] date_str = r.get("created_at", "")[:16]
headline = rpt.get("headline", "") headline = rpt.get("headline", "")
winners = rpt.get("winners_analysis", "") winners_a = rpt.get("winners_analysis", "")
losers = rpt.get("losers_analysis", "") losers_a = rpt.get("losers_analysis", "")
lessons = rpt.get("key_lessons", []) lessons = rpt.get("key_lessons", [])
blind = rpt.get("blind_spots", "") blind = rpt.get("blind_spots", "")
next_p = rpt.get("next_cycle_priorities", "") next_p = rpt.get("next_cycle_priorities", "")
timing = rpt.get("timing_note", "")
lessons_str = " | ".join(lessons) if isinstance(lessons, list) else str(lessons) lessons_str = " | ".join(lessons) if isinstance(lessons, list) else str(lessons)
reports_block += f""" reports_block += f"""
--- Rapport du {date} --- --- Rapport du {date_str} ---
Headline: {headline} Headline: {headline}
Stats: {stats} Stats: {stats}
Gagnants: {winners[:300]} Gagnants (matures): {winners_a[:300]}
Perdants: {losers[:300]} Perdants (matures): {losers_a[:300]}
Leçons clés: {lessons_str[:400]} Leçons clés: {lessons_str[:400]}
Angles morts: {blind[:200]} Angles morts: {blind[:200]}
Priorités cycle suivant: {next_p[:200]} Priorités cycle suivant: {next_p[:200]}
Note timing: {timing[:150]}
""" """
# Trade history # Classify trades by maturity — only draw P&L conclusions from matures
winners = [t for t in trades if (t.get("pnl_pct") or 0) > 0.5] def _enrich(t: dict) -> dict:
losers = [t for t in trades if (t.get("pnl_pct") or 0) < -0.5] try:
neutral = [t for t in trades if t not in winners and t not in losers] dh = (_date.today() - _date.fromisoformat(t["entry_date"])).days
except Exception:
dh = 0
mat = _trade_maturity(dh, t.get("horizon_days") or 90)
return {**t, "days_held": dh, "maturity": mat}
enriched = [_enrich(t) for t in trades]
matures = [t for t in enriched if t["maturity"]["status"] in ("mature", "fin_horizon") and t.get("pnl_pct") is not None]
immatures = [t for t in enriched if t["maturity"]["status"] in ("trop_tot", "debut")]
mature_winners = [t for t in matures if (t.get("pnl_pct") or 0) > 0.5]
mature_losers = [t for t in matures if (t.get("pnl_pct") or 0) < -0.5]
mature_neutral = [t for t in matures if t not in mature_winners and t not in mature_losers]
def trade_line(t): def trade_line(t):
mat = t.get("maturity", {})
return (f"{t.get('underlying','?')} {t.get('strategy','?')} " return (f"{t.get('underlying','?')} {t.get('strategy','?')} "
f"P&L={t.get('pnl_pct',0):.2f}% score={t.get('latest_score','?')} " f"P&L={t.get('pnl_pct',0):+.1f}% score={t.get('latest_score','?')} "
f"regime={t.get('macro_regime','?')}") f"regime={t.get('macro_regime','?')} [{mat.get('readable','')}]")
trades_block = f""" trades_block = f"""
Gagnants ({len(winners)}): {' | '.join(trade_line(t) for t in winners[:8])} ⚠️ ATTENTION TIMING : nos options durent 30-90 jours. Seuls les trades MATURES (≥35% horizon écoulé) ont un signal P&L fiable.
Perdants ({len(losers)}): {' | '.join(trade_line(t) for t in losers[:8])}
Neutres ({len(neutral)}): {len(neutral)} trades sans signal fort MATURES — signal fiable ({len(matures)} trades) :
Gagnants ({len(mature_winners)}): {' | '.join(trade_line(t) for t in mature_winners[:8]) or 'aucun'}
Perdants ({len(mature_losers)}): {' | '.join(trade_line(t) for t in mature_losers[:8]) or 'aucun'}
Neutres ({len(mature_neutral)}): {len(mature_neutral)} trades sans signal fort
IMMATURES — trop tôt pour conclure ({len(immatures)} trades) :
{' | '.join(trade_line(t) for t in immatures[:6]) or 'aucun'}
→ Ces trades sont listés pour transparence uniquement. Ne pas en tirer de conclusions de performance.
""" """
# Existing KB # Existing KB
@@ -72,6 +95,13 @@ Neutres ({len(neutral)}): {len(neutral)} trades sans signal fort
system = """Tu es l'intelligence analytique centrale d'un système de trading d'options géopolitiques. system = """Tu es l'intelligence analytique centrale d'un système de trading d'options géopolitiques.
Tu dois synthétiser TOUT l'historique disponible pour produire un document de raisonnement évolutif. Tu dois synthétiser TOUT l'historique disponible pour produire un document de raisonnement évolutif.
Ce document sera utilisé comme contexte enrichi pour tous les prochains cycles d'analyse. Ce document sera utilisé comme contexte enrichi pour tous les prochains cycles d'analyse.
⚠️ RÈGLE FONDAMENTALE DE TIMING :
Nos trades sont des options de 30 à 90 jours. Un trade ajouté il y a 2 heures ne dit RIEN sur sa performance finale.
Tu NE DOIS PAS réviser les conclusions existantes à cause de trades immatures (< 35% de l'horizon écoulé).
Tire des leçons UNIQUEMENT des trades MATURES. Les trades immatures sont listés pour transparence, pas pour analyse.
Si la synthèse précédente était basée sur des trades matures solides, ne la remets pas en cause à cause de nouveaux trades immatures.
Réponds UNIQUEMENT en JSON valide selon le schéma spécifié.""" Réponds UNIQUEMENT en JSON valide selon le schéma spécifié."""
user = f"""Date: {now_str} user = f"""Date: {now_str}
@@ -79,14 +109,15 @@ Réponds UNIQUEMENT en JSON valide selon le schéma spécifié."""
=== HISTORIQUE DES RAPPORTS DE PERFORMANCE ({len(reports)} rapports) === === HISTORIQUE DES RAPPORTS DE PERFORMANCE ({len(reports)} rapports) ===
{reports_block} {reports_block}
=== HISTORIQUE DES TRADES ({len(trades)} trades) === === HISTORIQUE DES TRADES ({len(trades)} trades total — {len(matures)} matures, {len(immatures)} immatures) ===
{trades_block} {trades_block}
=== BASE DE CONNAISSANCES EXISTANTE === === BASE DE CONNAISSANCES EXISTANTE ===
{kb_block if kb_block else "Aucune entrée existante — première synthèse."} {kb_block if kb_block else "Aucune entrée existante — première synthèse."}
=== MISSION === === MISSION ===
Produis un JSON avec ces champs: Produis un JSON avec ces champs.
IMPORTANT : si peu de trades matures sont disponibles, conserve les insights existants de la KB avec une confiance stable plutôt que de tout remettre à zéro.
{{ {{
"narrative": "Un texte narratif riche (500-800 mots) qui décrit l'état actuel du raisonnement du système, les patterns qui fonctionnent, les erreurs récurrentes, les corrélations géopolitiques/macro identifiées, les régimes qui favorisent nos stratégies, et les priorités d'amélioration. C'est le 'cerveau' du système.", "narrative": "Un texte narratif riche (500-800 mots) qui décrit l'état actuel du raisonnement du système, les patterns qui fonctionnent, les erreurs récurrentes, les corrélations géopolitiques/macro identifiées, les régimes qui favorisent nos stratégies, et les priorités d'amélioration. C'est le 'cerveau' du système.",
@@ -187,12 +218,36 @@ def patch_entry_status(entry_id: int, body: Dict[str, str]):
@router.post("/synthesize") @router.post("/synthesize")
async def synthesize(): async def synthesize(force: bool = False):
"""Run GPT-4o synthesis over all historical data and save new reasoning state.""" """Run GPT-4o synthesis over all historical data and save new reasoning state.
By default, skips if last synthesis was < 6h ago (use ?force=true to override).
"""
ai_key = os.environ.get("OPENAI_API_KEY", "") ai_key = os.environ.get("OPENAI_API_KEY", "")
if not ai_key: if not ai_key:
raise HTTPException(400, "OpenAI API key not configured") raise HTTPException(400, "OpenAI API key not configured")
# 6h staleness gate (unless force=true)
if not force:
last = get_latest_reasoning_state()
if last and last.get("created_at"):
import datetime as _dt
try:
last_ts = _dt.datetime.fromisoformat(last["created_at"].replace("Z", "+00:00"))
if last_ts.tzinfo is None:
last_ts = last_ts.replace(tzinfo=_dt.timezone.utc)
age_h = (_dt.datetime.now(_dt.timezone.utc) - last_ts).total_seconds() / 3600
if age_h < 6:
return {
"skipped": True,
"reason": f"Dernière synthèse il y a {age_h:.1f}h (< 6h). Utilise ?force=true pour forcer.",
"last_version": last.get("version"),
"last_at": last.get("created_at"),
"age_hours": round(age_h, 1),
}
except Exception:
pass
import openai import openai
client = openai.OpenAI(api_key=ai_key) client = openai.OpenAI(api_key=ai_key)

View File

@@ -526,11 +526,14 @@ export const useKnowledgeEntries = () =>
export const useSynthesizeKnowledge = () => { export const useSynthesizeKnowledge = () => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: () => api.post('/knowledge/synthesize').then(r => r.data), mutationFn: (force: boolean = false) =>
onSuccess: () => { api.post(`/knowledge/synthesize${force ? '?force=true' : ''}`).then(r => r.data),
qc.invalidateQueries({ queryKey: ['knowledge-state'] }) onSuccess: (_data) => {
qc.invalidateQueries({ queryKey: ['knowledge-history'] }) if (!_data?.skipped) {
qc.invalidateQueries({ queryKey: ['knowledge-entries'] }) qc.invalidateQueries({ queryKey: ['knowledge-state'] })
qc.invalidateQueries({ queryKey: ['knowledge-history'] })
qc.invalidateQueries({ queryKey: ['knowledge-entries'] })
}
}, },
}) })
} }

View File

@@ -187,9 +187,14 @@ export default function SuperContexte() {
const byCategory = entriesData?.by_category || {} const byCategory = entriesData?.by_category || {}
const totalEntries = entriesData?.total || 0 const totalEntries = entriesData?.total || 0
const handleSynthesize = () => { const synthResult = synthesize.data as any
synthesize.mutate(undefined, { const isSkipped = synthResult?.skipped === true
onSuccess: () => setSelectedHistoryId(null),
const handleSynthesize = (force = false) => {
synthesize.mutate(force, {
onSuccess: (data: any) => {
if (!data?.skipped) setSelectedHistoryId(null)
},
}) })
} }
@@ -223,7 +228,7 @@ export default function SuperContexte() {
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
</button> </button>
<button <button
onClick={handleSynthesize} onClick={() => handleSynthesize()}
disabled={synthesize.isPending} disabled={synthesize.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
> >
@@ -290,15 +295,37 @@ export default function SuperContexte() {
</div> </div>
)} )}
{synthesize.isSuccess && ( {synthesize.isSuccess && isSkipped && (
<div className="border border-amber-500/30 bg-amber-500/5 rounded-xl p-4 flex items-start gap-3">
<Clock className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 text-sm">
<span className="text-amber-300 font-medium">Synthèse non relancée. </span>
<span className="text-slate-400">
Dernière synthèse il y a {synthResult?.age_hours}h (moins de 6h).
Les trades immatures n'apportent pas encore d'information fiable inutile de recalculer.
</span>
<div className="mt-2">
<button
onClick={() => handleSynthesize(true)}
disabled={synthesize.isPending}
className="text-xs px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-400 hover:bg-amber-500/10 transition-colors"
>
Forcer quand même la synthèse
</button>
</div>
</div>
</div>
)}
{synthesize.isSuccess && !isSkipped && (
<div className="border border-emerald-500/30 bg-emerald-500/5 rounded-xl p-4 flex items-center gap-3"> <div className="border border-emerald-500/30 bg-emerald-500/5 rounded-xl p-4 flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-emerald-400 flex-shrink-0" /> <CheckCircle className="w-5 h-5 text-emerald-400 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="text-emerald-300 font-medium">Synthèse complète. </span> <span className="text-emerald-300 font-medium">Synthèse complète. </span>
<span className="text-slate-400"> <span className="text-slate-400">
{(synthesize.data as any)?.kb_entries_added ?? 0} entrées KB ajoutées ·{' '} {synthResult?.kb_entries_added ?? 0} entrées KB ajoutées ·{' '}
{(synthesize.data as any)?.sources?.reports ?? 0} rapports ·{' '} {synthResult?.sources?.reports ?? 0} rapports ·{' '}
{(synthesize.data as any)?.sources?.trades ?? 0} trades analysés. {synthResult?.sources?.trades ?? 0} trades analysés.
</span> </span>
</div> </div>
</div> </div>