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:
@@ -7,7 +7,7 @@ import os
|
||||
from services.database import (
|
||||
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,
|
||||
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"])
|
||||
@@ -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]):
|
||||
"""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")
|
||||
|
||||
# Portfolio reports summary
|
||||
@@ -22,39 +23,61 @@ def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries:
|
||||
for r in reports[:10]:
|
||||
rpt = r.get("report") or {}
|
||||
stats = r.get("stats") or {}
|
||||
date = r.get("created_at", "")[:16]
|
||||
date_str = r.get("created_at", "")[:16]
|
||||
headline = rpt.get("headline", "")
|
||||
winners = rpt.get("winners_analysis", "")
|
||||
losers = rpt.get("losers_analysis", "")
|
||||
winners_a = rpt.get("winners_analysis", "")
|
||||
losers_a = rpt.get("losers_analysis", "")
|
||||
lessons = rpt.get("key_lessons", [])
|
||||
blind = rpt.get("blind_spots", "")
|
||||
next_p = rpt.get("next_cycle_priorities", "")
|
||||
timing = rpt.get("timing_note", "")
|
||||
lessons_str = " | ".join(lessons) if isinstance(lessons, list) else str(lessons)
|
||||
reports_block += f"""
|
||||
--- Rapport du {date} ---
|
||||
--- Rapport du {date_str} ---
|
||||
Headline: {headline}
|
||||
Stats: {stats}
|
||||
Gagnants: {winners[:300]}
|
||||
Perdants: {losers[:300]}
|
||||
Gagnants (matures): {winners_a[:300]}
|
||||
Perdants (matures): {losers_a[:300]}
|
||||
Leçons clés: {lessons_str[:400]}
|
||||
Angles morts: {blind[:200]}
|
||||
Priorités cycle suivant: {next_p[:200]}
|
||||
Note timing: {timing[:150]}
|
||||
"""
|
||||
|
||||
# Trade history
|
||||
winners = [t for t in trades if (t.get("pnl_pct") or 0) > 0.5]
|
||||
losers = [t for t in trades if (t.get("pnl_pct") or 0) < -0.5]
|
||||
neutral = [t for t in trades if t not in winners and t not in losers]
|
||||
# Classify trades by maturity — only draw P&L conclusions from matures
|
||||
def _enrich(t: dict) -> dict:
|
||||
try:
|
||||
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):
|
||||
mat = t.get("maturity", {})
|
||||
return (f"{t.get('underlying','?')} {t.get('strategy','?')} "
|
||||
f"P&L={t.get('pnl_pct',0):.2f}% score={t.get('latest_score','?')} "
|
||||
f"regime={t.get('macro_regime','?')}")
|
||||
f"P&L={t.get('pnl_pct',0):+.1f}% score={t.get('latest_score','?')} "
|
||||
f"regime={t.get('macro_regime','?')} [{mat.get('readable','')}]")
|
||||
|
||||
trades_block = f"""
|
||||
Gagnants ({len(winners)}): {' | '.join(trade_line(t) for t in winners[:8])}
|
||||
Perdants ({len(losers)}): {' | '.join(trade_line(t) for t in losers[:8])}
|
||||
Neutres ({len(neutral)}): {len(neutral)} trades sans signal fort
|
||||
⚠️ ATTENTION TIMING : nos options durent 30-90 jours. Seuls les trades MATURES (≥35% horizon écoulé) ont un signal P&L fiable.
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
⚠️ 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é."""
|
||||
|
||||
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) ===
|
||||
{reports_block}
|
||||
|
||||
=== HISTORIQUE DES TRADES ({len(trades)} trades) ===
|
||||
=== HISTORIQUE DES TRADES ({len(trades)} trades total — {len(matures)} matures, {len(immatures)} immatures) ===
|
||||
{trades_block}
|
||||
|
||||
=== BASE DE CONNAISSANCES EXISTANTE ===
|
||||
{kb_block if kb_block else "Aucune entrée existante — première synthèse."}
|
||||
|
||||
=== 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.",
|
||||
@@ -187,12 +218,36 @@ def patch_entry_status(entry_id: int, body: Dict[str, str]):
|
||||
|
||||
|
||||
@router.post("/synthesize")
|
||||
async def synthesize():
|
||||
"""Run GPT-4o synthesis over all historical data and save new reasoning state."""
|
||||
async def synthesize(force: bool = False):
|
||||
"""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", "")
|
||||
if not ai_key:
|
||||
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
|
||||
client = openai.OpenAI(api_key=ai_key)
|
||||
|
||||
|
||||
@@ -526,11 +526,14 @@ export const useKnowledgeEntries = () =>
|
||||
export const useSynthesizeKnowledge = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => api.post('/knowledge/synthesize').then(r => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['knowledge-state'] })
|
||||
qc.invalidateQueries({ queryKey: ['knowledge-history'] })
|
||||
qc.invalidateQueries({ queryKey: ['knowledge-entries'] })
|
||||
mutationFn: (force: boolean = false) =>
|
||||
api.post(`/knowledge/synthesize${force ? '?force=true' : ''}`).then(r => r.data),
|
||||
onSuccess: (_data) => {
|
||||
if (!_data?.skipped) {
|
||||
qc.invalidateQueries({ queryKey: ['knowledge-state'] })
|
||||
qc.invalidateQueries({ queryKey: ['knowledge-history'] })
|
||||
qc.invalidateQueries({ queryKey: ['knowledge-entries'] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,9 +187,14 @@ export default function SuperContexte() {
|
||||
const byCategory = entriesData?.by_category || {}
|
||||
const totalEntries = entriesData?.total || 0
|
||||
|
||||
const handleSynthesize = () => {
|
||||
synthesize.mutate(undefined, {
|
||||
onSuccess: () => setSelectedHistoryId(null),
|
||||
const synthResult = synthesize.data as any
|
||||
const isSkipped = synthResult?.skipped === true
|
||||
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSynthesize}
|
||||
onClick={() => handleSynthesize()}
|
||||
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"
|
||||
>
|
||||
@@ -290,15 +295,37 @@ export default function SuperContexte() {
|
||||
</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">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="text-emerald-300 font-medium">Synthèse complète. </span>
|
||||
<span className="text-slate-400">
|
||||
{(synthesize.data as any)?.kb_entries_added ?? 0} entrées KB ajoutées ·{' '}
|
||||
{(synthesize.data as any)?.sources?.reports ?? 0} rapports ·{' '}
|
||||
{(synthesize.data as any)?.sources?.trades ?? 0} trades analysés.
|
||||
{synthResult?.kb_entries_added ?? 0} entrées KB ajoutées ·{' '}
|
||||
{synthResult?.sources?.reports ?? 0} rapports ·{' '}
|
||||
{synthResult?.sources?.trades ?? 0} trades analysés.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user