// guide interactif d'onboarding

Precision Ledger Academy

Chaque jour ouvré à 12:00 UTC, une machine lit la presse, fait débattre des IA, organise un tournoi de 810 duels et passe des ordres chez Interactive Brokers. Personne ne clique.

Ce guide t'apprend à la comprendre de l'intérieur : la langue des marchés, les ordres, le risque, le cerveau multi-agents, la rotation top-N — chaque concept ancré dans le vrai code, les vrais chiffres et les vraies cicatrices du projet.

Tu prédis avant de lire, tu manipules avant la formule, tu es testé après chaque section. La progression est sauvegardée localement, le rappel espacé fait le reste.

// module 00

La machine qui trade à midi

Chaque jour ouvré à 12:00 UTC — environ 1h30 avant l'ouverture de Wall Street — une machine se réveille. Elle lit la presse, fait débattre des IA contradictoires, organise un tournoi de 810 duels, calcule des tailles de position au millième, et passe des ordres chez un vrai courtier. Personne ne clique. Ce guide t'apprend comment — et surtout pourquoi chaque pièce existe (presque toujours : à cause d'un incident réel).

départ du run12:00 UTC
titres scannés~512
book cible7L + 2S
clics humains0

Concrètement : un timer systemd (trading-top-n-rotation.timer) déclenche la rotation quotidienne — le mode principal du système depuis le 2026-06-02 — qui recompose un book cible de 7 longs + 2 shorts sur le compte Interactive Brokers DUQ133771 (devise de base EUR).

La journée type du système (heures UTC)
00:00 06:00 12:00 18:00 24:00 monitor intraday / 15 min points = snapshots du portefeuille (6/jour) 12:00 — LE RUN 13:30 — open US
Le run part à 12:00, les ordres se remplissent à l'open de 13:30 — d'où un job de « rattrapage » (reconcile) que tu verras plus bas.

Paper trading — compte broker simulé : mêmes API, mêmes ordres, mêmes statuts, mêmes rejets qu'en réel — seul l'argent est fictif. Le fonds tourne aujourd'hui en paper sur DUQ133771. « Paper = étape, pas destination. »

Première question — la réponse est juste au-dessus, c'est cadeau (et ça démarre ta progression) :

0.2 — La carte du système

Avant de cliquer, devine : entre « 512 tickers en entrée » et « 9 positions en portefeuille », combien d'étages distincts la machine traverse-t-elle ? Réponse en 4 boîtes + 2 rails. Cette carte te suivra dans tout le guide : chaque module en éclaire une zone.

Le run quotidien en 4 étages — clique chaque boîte
1 · DÉTECTER 512 → ~50 → 25L + 15S 2 · ANALYSER 1 débat d'IA / finaliste 3 · CLASSER 810 duels → top 7 + 2 4 · EXÉCUTER sizing → gate A1 → ordres INTERACTIVE BROKERS paper DUQ133771 — la vérité RAIL DATA n8n (10 workflows) · FinBERT · GLiNER · géopolitique — alimente les étages 1-2 en continu RAIL MÉMOIRE le carnet auto-apprenant — écrit chaque décision, labellise l'outcome à J+7, relu au run suivant

Clique un étage (ou un rail) pour ouvrir le capot : composants internes, fichier réel, module du guide où on l'apprend.

Chaque dossier riche produit à l'étage 2 nourrit les duels de l'étage 3. L'étage 4 ne trade que le diff entre book cible et book courant.

0.3 — Prédis avant de savoir : combien d'appels LLM par run ?

Lire la presse, débattre, juger 810 duels… chacun de ces gestes est un appel à un grand modèle de langage (LLM). Avant de révéler : pour UN run quotidien, combien d'appels LLM la machine fait-elle, à ton avis ? Verrouille ta prédiction — le guide la garde en mémoire, tu la reverras au module 09.

Ta prédiction

Un seul appel LLM accessoire sur un provider payant externe peut tuer le pipeline : un 403 sur Grok (quota épuisé) a un jour cassé l'attribution des agents en plein run (leçon L42). Règle maison : aucun sous-appel sur un provider facturé au token — tout vit sur l'abonnement.

0.4 — Les 3 machines et la règle n°1

Où vit physiquement tout ça ? Pas dans le cloud : sur un serveur Proxmox self-hosted, découpé en LXC — des conteneurs isolés, comme les appartements d'un même immeuble. Survole (ou clique) chaque bloc.

La topologie — qui parle à qui
SERVEUR PROXMOX — machine physique, self-hosted LXC 124 — modèles FinBERT · Chronos · GLiNER + Ollama · Langfuse LXC 108 — Supabase 192.168.1.126:8000 ~59 tables — le journal LXC trading 192.168.15.8 api · worker · bridge · gateway Qdrant — mémoire 192.168.15.37:6333 n8n — les sens 192.168.15.7 ib-gateway :4004 INTERACTIVE BROKERS cloud — paper DUQ133771 source de vérité absolue

Survole ou clique un bloc pour voir son rôle et ses ports.

IB = source de vérité absolue — la règle n°1 de la culture du fonds. La DB Supabase n'est qu'un journal, écrit après coup et réconcilié contre IB : un ordre peut être rejeté en silence, un fill peut tomber après la fenêtre de poll, un cache peut être périmé. Positions, ordres ouverts, fills : on les vérifie chez IB, jamais en DB. Toute divergence DB↔IB déclenche une alerte (ledger_coherence). Tu reverras cette règle dans CHAQUE module.

Rappel actif — réponds de tête, puis compare

Écrire ta réponse avant de voir le modèle, c'est ce qui ancre. Cette petite difficulté est volontaire : elle renforce ta mémoire.

Quiz de fin — déverrouille le module 01

Module suivant : 01 · Le marché et sa langue — la langue que parlent les 512 titres que la machine scanne chaque midi.

// module 01

Le marché et sa langue

Chaque jour ouvré, le run de 12:00 UTC se termine par une rafale d'ordres envoyés à Interactive Brokers, exécutés à l'open de Wall Street (13:30 UTC). Pour comprendre ce que la machine fait à cet instant — et surtout ce qui peut mal tourner — il faut parler sa langue : bougie, long, short, bid, ask, NAV, ticker. Ce module n'est pas un lexique abstrait : chacun de ces mots correspond à un vrai morceau du système, et l'un d'eux (la devise du NAV) a coûté -13% de déploiement en silence pendant des mois. On y arrive.

1.1 · Qu'est-ce qu'un marché ?

Question d'ouverture : quand le système achète NVDA à l'open, qui est en face ? Personne ne « fournit » l'action par magie : un marché est une salle d'enchères permanente où des acheteurs affichent leurs prix et des vendeurs les leurs. Quand les deux se touchent, transaction. Le « prix » que tu vois n'est pas décidé par quelqu'un — c'est juste la dernière transaction conclue. En face du système, il y a un autre fonds, un market-maker, un particulier : quelqu'un qui, au même instant, veut faire l'inverse.

ACHETEURS (bid) veut 50 @ 99.99 veut 30 @ 99.98 VENDEURS (ask) vend 40 @ 100.01 vend 25 @ 100.03 dernier prix 100.00 Le prix = le dernier point d'accord entre un acheteur et un vendeur. Rien d'autre.

Action / volume / prix — une action est une part de propriété d'une entreprise. Le volume est la quantité échangée sur une période. Le périmètre de chasse du fonds : la vue tradeable_universe_current, ~512 titres US (S&P 500 ∪ NASDAQ-100, capitalisation ≥ $5B) — que des titres très liquides, où il y a toujours quelqu'un en face.

1.2 · La bougie OHLC

Prédiction : un titre ouvre à 50, touche 52, descend à 49, finit à 49.5. Avant de lire la suite — la bougie de cette journée est-elle verte ou rouge ? Garde ta réponse en tête.

Le prix bouge des milliers de fois par jour. Pour le résumer, on découpe le temps en périodes fixes (1 min, 1 h, 1 jour) et chaque période devient une bougie OHLC : 4 chiffres seulement. C'est la matière première de l'analyste market et du service de prévision Chronos-2 (modèle amazon/chronos-bolt-base, LXC 124 port 8083) qui dessine les overlays de forecast sur les charts du cockpit — et fournit l'un des 5 axes de notation du ranker (module 05).

OHLCOpen : premier prix de la période. High : plus haut touché. Low : plus bas touché. Close : dernier prix. Corps = entre O et C ; mèches = jusqu'à H et L. Verte si C > O, rouge si C < O — la couleur dit la direction, pas « gagné/perdu ».

Construis ta bougie
Glisse les 4 poignées O / H / L / C — la bougie se redessine en live.
Une doji (O ≈ C) = indécision ; une capitulation = gros corps rouge qui clôture tout en bas. Le défi 3 te montre que le composant refuse une bougie où H < L — par définition, impossible.
60 jours de NVDA (données simulées) — le format que lit l'analyste market
Chart Lightweight Charts v5, le même moteur que le cockpit. Chaque bougie = 1 jour. Repère les capitulations et les dojis que tu viens de construire.

1.3 · Long vs Short

Le book cible de la rotation, c'est 7 longs + 2 shorts. Être long, tout le monde comprend : acheter, attendre que ça monte, revendre. Être short est contre-intuitif : vendre quelque chose qu'on ne possède pas.

Métaphore : tu empruntes le vélo de ton voisin (valeur 200 €) et tu le vends immédiatement 200 €. Si le modèle tombe à 150 €, tu en rachètes un pour 150 €, tu le rends, tu gardes 50 €. Mais s'il devient collector à 600 €… tu dois quand même le rendre, donc racheter à 600 €. Et la location n'est pas gratuite : le broker facture un coût d'emprunt — chez nous, ces taux arrivent chaque nuit par le workflow n8n WF07 (FTP IBKR) dans la table borrow_rates.

Le P&L dans les deux sens — entrée à 100 $, 10 actions
P&L+200 $
perte max possible-1 000 $
Long : la perte s'arrête quand le titre vaut 0. Bascule en SHORT et pousse le prix à droite…

La perte d'un short n'est pas bornée. Un long à 100 $ perd au pire 100 $/action (le titre s'arrête à 0). Un short perd sans limite : le prix peut monter à 300, 500… C'est pourquoi les shorts ont leurs propres gates dédiés (A6 : Reg SHO, hard-to-borrow, earnings proches — module 03) et un budget de risque plus serré : 18% de volatilité cible pour la poche longue, 8% seulement pour la poche short.

1.4 · Le compte : NAV, equity, P&L

Quatre mots qui se ressemblent, un piège réel derrière. La NAV est la valeur totale du compte en temps réel ; le P&L existe en deux états : latent (position encore ouverte, il respire avec le prix) et réalisé (position fermée, il est figé).

TermeDéfinitionBouge quand…
Capitalce qui a été déposéjamais (sauf apport)
P&L latentgain/perte des positions ouvertesà chaque tick de prix
P&L réaliségain/perte figé à la clôturequand un trade est fermé
NAV / equityvaleur réelle = cash + positions au prix couranten continu

Maintenant le twist Precision Ledger : le compte paper DUQ133771 est en euros, mais les 9 positions du book sont des actions US en dollars. IB gère ça en « segments » de devise — et c'est là que les ennuis commencent.

Mini-enquête : bug ou pas bug ?

Le bridge lit le tag UnrealizedPnL sur le segment EUR du compte. Il affiche 0. Pourtant la position NVDA est à +800 $ de latent. Prédis avant de cliquer :

Segment BASE — sur un compte IB multi-devise, les tags P&L (UnrealizedPnL, RealizedPnL) doivent se lire sur le segment BASE (tout converti en devise de base). Le segment EUR ne voit que les actifs en EUR : des positions 100% USD y affichent… 0. Ce n'est pas un bug, c'est la grammaire d'IB.

Le portefeuille est photographié 6 fois par jour ouvré (cron 0 7,9,12,15,18,21 * * 1-5, api/snapshot_cron.py) dans la table portfolio_snapshotsen EUR. C'est sur ces snapshots que le circuit de drawdown (module 03) mesure ses paliers.

Teaser L49 — le NAV EUR ÷ prix USD. Pendant des mois, le calcul de taille des positions a divisé un NAV en euros par des prix en dollars. Aucun crash, aucun log rouge : juste -13% de déploiement, du cash dormant invisible. Un bug de devise ne crie pas — il sous-performe en silence. Le fix (fetch_nav_usd()) et la leçon complète t'attendent au module 03.

1.5 · Bid / Ask / Spread

Question : si tu achètes une action et la revends une seconde plus tard, sans que le marché ait bougé… perds-tu de l'argent ? Oui. Voici pourquoi.

À tout instant, deux prix coexistent : le bid (meilleur prix d'achat affiché — celui auquel TU peux vendre immédiatement) et l'ask (meilleur prix de vente affiché — celui auquel TU peux acheter immédiatement). L'écart entre les deux est le spread : le péage invisible du marché.

Ordre market : quel prix paies-tu vraiment ?
BID 99.99 tu VENDS ici ASK 100.01 tu ACHÈTES ici spread 0.02 Clique un bouton pour passer un ordre market.
Un ordre market ne négocie pas : il traverse le spread. Aller-retour immédiat = −0.02/action, marché immobile. La rotation entre en ordres market à l'open US : elle paie ce péage 9 fois par book — le slippage non mesuré est une dette connue de la ROADMAP (module 02).

1.6 · Le ticker et ses dialectes

Dernier piège de langue : le même titre ne porte pas le même nom selon qui parle. Vallourec, coté à Paris : IB dit VK + primaryExchange=SBF (Euronext Paris). Yahoo Finance dit VK.PA — le suffixe encode la place de cotation. Supabase stocke le format Yahoo. Le ticker est un dialecte, pas un identifiant universel.

La traduction vit dans le bridge (ib-bridge/main.py, dict _IB_EXCHANGE_TO_YAHOO) et dans l'autre sens, _ib_symbol() (tradingagents/utils/symbols.py) retire le suffixe pour parler à IB :

_IB_EXCHANGE_TO_YAHOO = {
    "SBF":  ".PA",   # Euronext Paris
    "IBIS": ".DE",   # Frankfurt / Xetra
    "AEB":  ".AS",   # Euronext Amsterdam
    "LSE":  ".L",    # London Stock Exchange
    ...
}                    # bourse US → pas de suffixe
Jeu d'appariement — traduis IB ↔ Yahoo
DIALECTE IB (symbole · primaryExchange)
DIALECTE YAHOO (= format Supabase)
Clique une carte IB, puis sa traduction Yahoo. 0/6.

Le même titre a ≥ 4 identités : ticker IB, yahoo_symbol, ISIN, conId IB, FIGI. Croiser des tables sur le seul ticker = collisions garanties. C'est exactement le problème que résout la table instruments (source de vérité de l'identité d'un titre depuis 2026-05-19, ADR 0007) — on la dissèque au module 07.

Ce qu'il faut retenir

Tu sais maintenant lire une bougie, calculer un P&L dans les deux sens, expliquer pourquoi le segment EUR affiche 0, et traduire VK·SBF en VK.PA. Trois rappels à voix haute avant le gate — réponds avant de révéler :

Gate de fin de module — 80% pour déverrouiller le module 02

// module 02

Ordres & exécution IB

Tout ce que le système pense — 5 analystes, le débat bull/bear, les 810 duels du ranking — finit compressé dans une seule chose : un POST /order de quelques lignes. C'est le dernier mètre, et c'est là que l'argent se gagne ou se perd vraiment : un bon signal mal exécuté est une perte, point. Le compte est en paper (DUQ133771), mais les flux d'ordres envoyés à Interactive Brokers sont réels.

Question d'ouverture, tu auras la réponse en 2.3 : comment deux appels de code presque innocents ont-ils créé un short à nu de 3,22 M$ à 374 % de levier, sans stop-loss et sans déclencher la moindre exception ?

2.1 Le chemin d'un ordre

Avant de cliquer, prédis : combien de machines un ordre traverse-t-il entre la décision du worker et le marché ? La réponse n'est pas « une ». Le worker parle HTTP à l'IB Bridge (façade maison, ib-bridge/main.py, ~2695 lignes, lib ib_async 2.1.0), qui parle protocole natif à l'IB Gateway (client officiel du broker, port API 4004), qui parle aux serveurs IB. Clique chaque étape pour voir le payload réel simplifié.

Suis l'ordre — 6 étapes cliquables

Le bridge subit des déconnexions récurrentes non rootées. Réflexe maison : curl http://ib-gateway:8081/health AVANT d'accuser IB Gateway. Si le health dit « déconnecté », le coupable est souvent le maillon du milieu, pas le broker.

2.2 Types d'ordres : mange le carnet toi-même

Quatre types à connaître. Market : « exécute maintenant, peu importe le prix » — rempli garanti, prix non garanti. Limit : « exécute à X ou mieux » — prix garanti, exécution non garantie. Stop : dort, puis DEVIENT un market quand le prix touche le seuil. Stop-limit : dort, puis devient un limit — tu peux ne jamais sortir si le marché gap au travers.

TypePrix garantiExécution garantieChez Precision Ledger
Marketnonouientrées/sorties de la rotation, à l'open US
Limitouinonnon utilisé (dette assumée, cf. plus bas)
Stopnonoui si touchélegs de bracket (2.4)
Trailing stopnonoui si touchéfilet serveur TRAIL GTC (2.5)
Le carnet d'ordres animé — slippage chiffré

Un market buy de 500 actions va frapper ce carnet (meilleur ask 100.02, mid 100.00). Il va « manger » les vendeurs niveau par niveau, de moins en moins bon marché. Le slippage total vs le mid, en dollars : combien ? Prédis AVANT de tirer.

Carnet au repos.

Slippage — l'écart entre le prix espéré et le prix moyen réellement obtenu, parce qu'un ordre market consomme la profondeur du carnet. Coût invisible : aucune ligne de frais, juste un fill moins bon.

Ancrage projet : la rotation quotidienne passe des ordres market à l'open US — pas de limit, pas d'algo d'exécution. Le slippage n'est pas encore mesuré : c'est une dette connue, inscrite dans la ROADMAP. Et une garde absolue : aucun exit market hors RTH (13:30–20:00 UTC) — un market posé sur un marché fermé meurt en Inactive… ou pire (tu verras les « 119 lignes fantômes » au module 05).

2.3 La machine à états IB — PendingSubmit ne prouve RIEN

Quand le bridge envoie un ordre, IB répond par une série de statuts. Le piège mortel : les premiers statuts sont émis par TON côté de la connexion, pas par IB. Clique chaque état pour son histoire — les trois verts sont les seuls qui prouvent quelque chose.

Statuts d'ordre — qui parle vraiment ?
CÔTÉ CLIENT API (ib_async) CÔTÉ IB — les seules preuves PendingSubmit ApiPending PreSubmitted Submitted Filled Cancelled Inactive rejet SILENCIEUX possible (marge…)
Clique un statut pour son histoire.
Le jeu des 5 scénarios — confirmé ou pas ?

Le short à nu de 3,22 M$. Un bracket (entrée + stop + target) posé en 2 placeOrder chaînés. IB a rejeté le parent en silence (statut Cancelled, zéro exception) — mais le child stop SELL, lui, est parti, orphelin. Résultat : 3 shorts à nu, 3,22 M$ d'exposition, 374 % de levier, sans stop-loss. Le code, lui, croyait que tout allait bien : il avait vu PendingSubmit et l'avait pris pour une confirmation. Leçon L26, et naissance de place_bracket_and_confirm — section suivante.

2.4 Le bracket atomique (A3) — vis l'incident

Un bracket doit être atomique : soit les trois legs (entrée, stop, target) existent ensemble chez IB, soit AUCUN. Le helper ib-bridge/order_safety.pyplace_bracket_and_confirm impose deux garanties : (1) chaque leg doit atteindre un statut vert {PreSubmitted, Submitted, Filled} avant de continuer ; (2) si un leg est rejeté, rollback automatique des autres. Compare les deux chemins ci-dessous — le chemin naïf est une roulette : rejoue jusqu'à voir l'orphelin.

Simulateur fail-safe : 2 placeOrder vs A3
Choisis un chemin. L'échec est une information, pas un game over — rejoue autant que nécessaire.
Sur le chemin naïf, le rejet du parent est aléatoire (~1 fois sur 2) : exactement comme en prod, où il dépend de la marge du moment.

2.5 Trailing stops — le filet qui survit au crash

Prédis : si le serveur Python du fonds crashe à 15h, qu'est-ce qui protège encore les 9 positions ? Réponse : rien côté Python — mais les trailing stops SELL TRAIL GTC (longs) / BUY TRAIL GTC (shorts) sont maintenus côté serveur par IBKR : ils suivent le prix et ne reculent jamais, même si toute l'infra maison est éteinte. Détails qui coûtent cher : la distance est auxPrice (montant absolu, k×ATR) XOR trailingPercent — jamais les deux ; jamais adjustedOrderType (bug ib_insync #216) ; et une dédup défensive HTTP 409 sur /order/long-trail empêche de poser deux fois le même filet.

Le trail en action — règle la distance, rejoue des chemins
Entrée à 100. La ligne ambre est le stop : elle monte avec le prix, jamais l'inverse.

Les 109 TRAIL empilés. Un cron rejouait la pose de trailing stops en se fiant au cache DB (position_state.stop_order_id)… périmé. Résultat : 109 ordres TRAIL accumulés chez IB → déclenchements en cascade → short involontaire de -64 904 actions, 374 % de levier, zéro trace en DB. Leçon L20 : la vérité des ordres ouverts vit chez IB — GET /orders/open — jamais dans un cache. C'est l'application n°1 de la règle de culture « IB = source de vérité absolue ».

2.6 Preflight & valeurs piégées

Avant un ordre risqué, le système fait un preflight : whatIfOrder via /order/whatif — IB simule l'ordre et répond « voilà l'impact marge » et « ce titre est hard-to-borrow », sans rien exécuter. Mais l'API IB a un humour particulier : le whatIf peut retourner ±Infinity (bug #380), et CommissionReport.realizedPNL « pas encore calculé » vaut ~1.8e308 (DBL_MAX) — un nombre FINI, donc invisible pour isnan. Gardes du bridge : math.isfinite + rejet de abs(v) >= 1e300 (L46). Autre piège : reqPnL/reqAccountSummary sont des souscriptions qui s'empilent jusqu'à l'Error 322 — le bridge n'en garde qu'UNE, tracée et annulée avant re-souscription (L21).

Quiz : quelles valeurs sont du poison ?

Quatre valeurs lues dans un champ commission / realizedPNL. Toutes passent is not None. Classe-les.

Rappel actif — verrouille le module

Quatre questions + un chiffre. 80 % pour marquer le module done — les cartes partent ensuite en rappel espacé.

// module 03

Risque, sizing & frais

Au module 02 tu as vu les mains de la machine — les ordres, les brackets, les statuts IB. Ici, son bouclier. Deux chiffres pour comprendre pourquoi ce module existe : 374 % de levier atteint un jour de mai par accident, et -13 % de capital jamais déployé pendant des mois — en silence, sans un seul log. La leçon centrale de l'industrie comme de Precision Ledger : ce n'est presque jamais le mauvais signal qui tue un compte, c'est la mauvaise taille. Et un bug de risque ne crashe pas — il sous-performe en silence.

3.1 — Le sizing tue avant les mauvais signaux

Avant toute formule, un duel. Prédis le gagnant — c'est la règle ici : on parie d'abord, on regarde ensuite.

Prédiction. Deux systèmes prennent 200 trades en risquant 1 % du compte par trade. Qui finit le plus haut ?

R — l'unité de risque : le montant que tu acceptes de perdre si le trade part contre toi (ici 1 % du compte). Un gain de « +2R » = deux fois la perte acceptée. Penser en R rend tous les trades comparables, quel que soit le titre ou le prix.

Courbe d'equity — 200 trades simulés
Glisse les curseurs : la courbe se recalcule en direct (même graine aléatoire — seuls tes paramètres changent). Reproduis le duel : 60 % × 0.8 puis 45 % × 2.5.

$E = p \cdot G - (1-p) \cdot P$ — l'expectancy : gain moyen par trade. Avec un gain de $b$ R et une perte de 1 R : $E = p\,b - (1-p)$. Système A : $0.60 \times 0.8 - 0.40 = +0.08$ R. Système B : $0.45 \times 2.5 - 0.55 = +0.575$ R — 7 fois mieux en gagnant moins souvent.

Le win rate seul ne dit rien : ce qui compte est le produit fréquence × taille relative des gains. C'est exactement pourquoi le ranker de Precision Ledger note l'asymétrie RR comme premier des 5 axes du scoring pointwise (module 05) — un dossier n'est bon que si ce qu'on peut gagner dépasse nettement ce qu'on risque. Le gate A1 du système refuse d'ailleurs tout trade avec un ratio RR < 2.

3.2 — Monte-Carlo : voir la ruine avant de la vivre

Question piège : un système gagnant peut-il te ruiner ? Oui — si la taille de position est trop grosse. Une seule courbe d'equity est une anecdote ; pour voir le risque, il faut rejouer le même système des centaines de fois. C'est le principe du Monte-Carlo : 500 vies parallèles du même trader.

500 trajectoires du même système
Ligne épaisse claire = trajectoire médiane ; ligne rouge = pire 5 %. Monte le risque/trade à 8-10 % : la zone de mort s'élargit même si l'expectancy reste positive.

Risk of ruin — probabilité de toucher un seuil de perte dont on ne se relève pas (ici -50 % du capital : un chemin qui le touche s'arrête). Il dépend plus de la taille que de l'edge. Le critère de Kelly donne la fraction optimale à risquer — mais Kelly pur est brutal : le chemin worker legacy de Precision Ledger le borne à [1 %-10 %] du NAV (calculate_position_size(), execution.py:1612). La rotation, elle, dimensionne autrement — c'est la section suivante.

3.3 — Le sizing vol-target (A2) : la volatilité comme budget

Question d'ouverture : le book contient NVDA (volatilité ~45 %/an) et KO (~12 %/an). Si on met le même montant sur chacun, NVDA portera presque 4 fois plus de risque. Comment les dimensionner pour qu'ils portent le même poids de risque ? Réponse du système : penser le risque comme un budget de courses. NVDA coûte cher au kilo de risque — on en met moins dans le panier. C'est le sizing vol-target (agents/risk_engine/sizing.py, pur NumPy, testable sans I/O).

$$\sigma_t^2 = \lambda\,\sigma_{t-1}^2 + (1-\lambda)\,r_t^2 \qquad \lambda = 0.94$$ L'EWMA estime la volatilité en donnant plus de poids aux jours récents (décroissance exponentielle — λ = 0.94 est le standard RiskMetrics). Comme un rétroviseur qui montre surtout les derniers mètres : si le régime change, l'estimation réagit en quelques jours, pas en quelques mois.

Calculateur vol-target — book de 5 longs (σ mockées)
TitreConvictionσ EWMAσ effectivePoids final
Le poids s'inverse avec la volatilité (KO > NVDA). En régime CALME, regarde la jauge : le « scale brut » voudrait dépasser 1.0 — le cap anti-levier le bloque.

Les vrais paramètres de size_book_long_short : budgets de vol séparés long 18 % / short 8 % annualisés (asymétrie volontaire : la perte d'un short n'est pas bornée et il paie un loyer d'emprunt), σ floor 10 % (aucun titre n'est traité comme « sans risque » — ici KO à 9 % est remonté à 10 %), cap par titre ×1.6 l'equal-weight (≈ 22.8 % pour N=7 : un gagnant style NVDA ne peut pas absorber le book), floor ×0.5, et surtout scale ≤ 1.0. Pondération par conviction : score_i/100 issu du pointwise. Un drift relatif ≥ 15 % d'un poids déclenche un rebalance (flag ROTATION_REBALANCE_ENABLED, en shadow).

Pourquoi le cap scale ≤ 1.0 ? Sans lui, le vol-targeting peut exiger du levier : avec le σ floor à 10 % sur 7 titres, l'estimateur de vol du portefeuille tombe à ≈ 3.8 % → scale ≈ 4.8 → 476 % d'exposition demandée par les maths. Le commentaire est dans le code de sizing.py. Autre piège réel : l'estimateur suppose les titres non corrélés — faux pour un book 100 % long (tout monte et baisse ensemble). C'est pourquoi le budget long a été recalibré 12 % → 18 % le 2026-06-10 : à 12 %, ~35 % du cash dormait.

3.4 — Le circuit de drawdown (A5) : le disjoncteur

Qui a le droit d'arrêter la machine ? Pas un humain qui regarde un écran — un disjoncteur, comme dans une maison : il ne demande pas pourquoi le courant grimpe, il coupe. Le drawdown (la baisse depuis le plus haut) est mesuré en continu par drawdown_circuit.py (cron toutes les 10 min, calculé en EUR sur portfolio_snapshots) et traduit en régime qui multiplie la taille de tout nouveau trade.

RégimeSeuilEffet (qty_mult)
green×1.0
warnDD intraday ≤ -2 %×0.5
pauseDD trailing 90 j ≤ -7 %×0.5
haltDD trailing ≤ -8 %×0 — aucun nouveau trade
halt absolulevier brut ≥ 200 %×0 (anti-runaway)

Note d'histoire : la pause était à -5 % jusqu'au 2026-06-04. Sur un book corrélé à l'indice (β ≈ 1), un -5 % est une correction de marché ordinaire, pas un signal que les agents se trompent — à ce seuil, le circuit affamait la rotation à ~30 % investi. Desserré à -7 %, le kill reste à -8 %. Le seuil 200 % vient directement de l'incident du 2026-05-06 : 374 % de levier en paper (module 02).

Scrubber de régimes — 90 jours rejoués
Scrubbe la timeline : la bande du bas colore le régime jour par jour. Cherche les 3 événements : le choc intraday (warn), la glissade vers -8 % (pause → halt), et le pic de levier à ~220 % qui déclenche un halt alors que le drawdown est modéré.

3.5 — Le gate A1 et le bug de devise

Dernier rempart avant l'ordre : le gate A1, core.evaluate_trade(req, ctx) → RiskDecision, appelé en tout premier dans worker.py avant tout le reste. Il compose le régime A5, les gates shorts A6, le sizing A2, et vérifie RR ≥ 2, la liquidité, la corrélation, le plafond de borrow. Chaque évaluation — acceptée ou rejetée — écrit une ligne d'audit dans la table risk_audit (payload JSONB, 6 indexes).

Never-raise (L27). Que fait un garde du corps quand sa radio tombe en panne ? Il n'enferme pas tout l'immeuble. Si un fetcher de données du gate crashe, le gate dégrade en APPROVE avec un warning tracé — jamais d'exception qui tue le pipeline. C'est un choix assumé pour un helper accessoire ; à l'inverse, le universe gate (module 07) est fail-CLOSED : cache injoignable = on bloque. Deux philosophies, choisies selon le coût de chaque type d'erreur. Et la règle absolue : on ne stubbe JAMAIS un gate en approved=True, même « temporairement ».

Maintenant, le bug le plus instructif du projet. Le compte IB DUQ133771 est en euros ; les titres cotent en dollars. Pendant des mois, le sizing a divisé un NAV en EUR par des prix en USD. Prédis l'impact sur le déploiement du capital :

Prédiction — l'erreur de devise

n°3 — Le NAV EUR ÷ prix USD (L49). Avec un EUR/USD autour de 1.15, traiter 100 000 € comme 100 000 $ revient à se croire ~13 % plus pauvre : -13 % de déploiement, silencieux, depuis l'origine du gate. Aucun crash, aucun log — juste du cash dormant invisible. Fix 2026-06-10 : fetch_nav_usd() (risk_engine_wiring.py) divise le NAV EUR par le tag IB ExchangeRate currency=USD — pour le sizing SEULEMENT. Le régime drawdown/levier reste en EUR (les portfolio_snapshots sont en EUR, convertir casserait l'historique). Garde-fou : fx absent ou aberrant (hors 0.3-3.0) → fallback NAV EUR + warning wiring_fx_missing. Moralité : un bug de risque se chasse avec des chiffres falsifiables, pas avec des logs d'erreur — il n'y en a pas.

3.6 — Les frais : l'ennemi silencieux

Une rotation quotidienne, ce sont des ordres tous les jours — et chaque ordre paie un péage : commission broker, taxes réglementaires (SEC, FINRA) sur les ventes, et pour les shorts un loyer d'emprunt quotidien. Individuellement dérisoires ; cumulés, c'est de la limaille dans le moteur. Combien sur un an ? Manipule :

Compteur de frottement — 1 an de rotation (NAV 100 k$, book 9 positions, hypothèses simplifiées)
Formules réelles du ledger : SEC Section 31 = 20.60 $ par million de $ vendus (depuis le 04/04/2026), FINRA TAF = 0.000195 $/action (cap 9.79 $), borrow = valeur × taux/360 par jour. L'edge brut de 12 %/an est hypothétique. À 5-6 rotations/jour, les frais mangent une part visible de l'edge — c'est ce qui justifie min-hold et hystérésis (module 05).

Dans le système, tout cela vit dans fees_ledger (Task 018) : un journal append-only de tous les frais — commission ib_real, taxes reg_sec/reg_finra, borrow estimated (accrual quotidien, cron 02:30 UTC, sur watchlist ∪ shorts ouverts). Point crucial : c'est du tracking pur. Le ledger ne modifie NI le NAV NI le realized_pnl — IB déduit déjà la commission du P&L ; la re-soustraire serait du double comptage.

n°9 — La commission à 0 sur ~100 % des fills (L52). Le bridge GET /order/{id} n'a PAS de clé commission au premier niveau : elle vit dans fills[].commission, et le CommissionReport d'IB arrive 50-500 ms APRÈS le fill. Un .get("commission", 0) sur une clé inexistante a donc cristallisé des zéros en DB sur la quasi-totalité des fills. Fix en double couche : (1) sommer fills[].commission au poll, (2) une passe de réparation toutes les 15 min (rotation_commission_repair.py) qui relit GET /executions chez IB. Les fills ≤ 10/06 resteront à 0 pour toujours — session IB close. Leçon : un défaut silencieux (« 0 par défaut ») est pire qu'un crash, parce que personne ne le cherche.

3.7 — Les gates shorts (A6) : le permis spécial

Pourquoi shorter exige-t-il un contrôle technique à part ? Parce que la perte d'un short n'est pas bornée (module 01) et qu'un short squeeze — quand tous les vendeurs à découvert doivent racheter en même temps — peut tripler un titre en quelques jours (GameStop 2021). short_gates.py filtre en deux étages : Tier 1, binaire (un seul critère suffit à bloquer) : liste Reg SHO ≥ 5 jours consécutifs, earnings dans ≤ 10 jours, achats nets d'initiés ≥ 5 M$ sur 30 j (on ne shorte pas contre ceux qui savent), flag hard-to-borrow d'IB. Tier 2, composite squeeze (il faut ≥ 2 signaux sur 4 pour bloquer) : short interest ≥ 7 % du flottant, days-to-cover ≥ 3 (volume 10 j), borrow ≥ max(1 %, 2× la médiane de l'univers), utilization ≥ 90 %. La leçon des post-mortems GameStop est dans le code : le signal tueur était la convergence des quatre, pas un seul — un OR plat aurait produit des milliers de faux positifs.

Détail qui en dit long : l'ancien gate « flottant < 20 M d'actions » a été supprimé au pivot large-cap (2026-05-26) — sur un univers ≥ 5 G$ il ne se déclenchait jamais. Un gate qui ne peut jamais tirer n'est pas un garde-fou, c'est du code mort qui rassure à tort.

Joue le gate A6 — 5 candidats short (données mockées)

Ce qu'il faut retenir

budgets vol long / short18 % / 8 %
EWMA λ · σ floor · scale0.94 · 10 % · ≤ 1.0
circuit drawdown-2 / -7 / -8 %
halt absolu levier≥ 200 %

Le fil rouge : l'edge décide quoi trader, le risque décide combien — et c'est le combien qui fait survivre. Quiz de validation (≥ 80 % pour déverrouiller le module 04) :

// module 04

Le cerveau multi-agents

Au module M0, on a vu QUAND la machine décide (12:00 UTC). Ici, on ouvre la boîte crânienne : qui écrit l'avis sur chaque titre finaliste, et pourquoi ce « qui » est une équipe d'IA qui se contredit exprès plutôt qu'un humain — ou qu'une IA unique. La réponse tient en une phrase dérangeante : le problème n'a jamais été l'information, c'est le cerveau qui la lit. Un humain qui regarde sa position perdante ne voit pas un prix : il voit son ego. Precision Ledger traite ça comme n'importe quel bug — en codant des contre-mesures.

Et parce qu'une IA unique a ses propres biais (complaisance, ancrage, sur-confiance), le système en fait débattre plusieurs, à l'aveugle, puis fait juger le résultat par des mécanismes externes. À la fin de ce module, tu sauras lire le graphe d'agents, dire quel modèle tourne où, et comprendre ce que « la machine se souvient de ses trades » veut dire concrètement.

4.1 · Pourquoi pas un humain ? Les biais comme bug

Avant toute théorie, joue le jeu : voici 4 écrans de décision d'un trader. Pour chacun, diagnostique le biais cognitif à l'œuvre — ou certifie que le raisonnement est sain. Feedback immédiat.

Diagnostic : 4 écrans de décision

PLTR -12% sous le prix d'entrée. « J'ai déjà perdu 1 200 $, couper maintenant ce serait acter la perte. Je garde. »

SMCI +4% en 20 minutes, tout X/Twitter en parle. « Si je n'entre pas MAINTENANT, je rate le mouvement. » Achat au sommet, sans setup prévu au plan.

NVDA à +50 $. « Et si ça redescend ? Je sécurise. » Tu coupes — alors que ton plan écrit visait +400 $ et qu'aucun signal de sortie n'a tiré.

Le 8-K du matin invalide la thèse d'origine. Tu sors à -2% sans regarder ton prix d'entrée, et tu notes la leçon dans le journal.

Score : 0/4 — le prix d'achat n'a aucune pertinence pour la décision de vente ; seul compte « ce trade a-t-il une espérance positive À PARTIR DE MAINTENANT ? »

Biais cognitif — déviation systématique (donc prévisible) du jugement. Kahneman & Tversky (1979) : perdre 100 $ fait ~2× plus mal que gagner 100 $ ne fait plaisir. Cette asymétrie fait couper les gagnants trop tôt et garder les perdants trop longtemps.

L'idée-pivot du fonds : un biais prévisible est un bug spécifiable, et un bug spécifiable se corrige par construction, pas par volonté. Chaque garde-fou du système est un anti-biais codé :

Biais humainAnti-biais codéOù dans le code
Tilt / revenge trading Cooldown 7 jours après un forced exit (ré-entrée interdite sauf conviction pointwise ≥ 0.50) rotation_orchestrator.py
Ancrage (le 1er avis contamine) Débat bull/bear aveugle — aucun camp ne lit l'autre evidence scoping asymétrique (§4.2)
Sunk cost (prix d'entrée sacré) Re-classement quotidien from scratch : la sortie dépend du RANG du titre, jamais du prix payé ranker/ + hystérésis en rangs
Aversion à la perte (espoir) Stops trailing maintenus côté serveur IBKR — survivent même à un crash du système ib-bridge, ordres TRAIL GTC
Sur-confiance auto-déclarée La « confidence » de l'agent n'est plus qu'une métadonnée d'audit ; le classement vient d'un scoring externe ADR 0006 (dossier riche, §4.2)

4.2 · Le graphe d'agents : une salle d'audience par titre

Ce graphe représente le chemin worker / graphe complet — instancié pour les analyses à la demande d'un titre. En rotation quotidienne, seuls bull+bear+research_manager (~3 appels LLM) tournent, via evidence_debate.py — les 5 analystes et le trader ne font pas partie du chemin rotation. Clique chaque nœud pour voir son rôle, son fichier et son modèle.

Le graphe complet — chemin worker / analyse à la demande
market news social fundamentals geopolitical 5 analystes parallèles BULL avocat de l'achat mur — aucun ne lit l'autre BEAR avocat de la vente research_manager le juge — Opus 4.8 trader le greffier — Opus 4.8 risk debators ×3 stress-test dossier riche 15-20 lignes + footer
Clique un nœud du graphe pour voir son rôle, son fichier et son modèle.

Le point contre-intuitif : bull et bear sont aveugles l'un à l'autre, et l'« evidence scoping asymétrique » force chacun à ne recevoir que les éléments de SON camp. Sans ce mur, les deux plaidoiries convergeraient vers le consensus de la veille — l'ancrage, version LLM. Chaque camp voit aussi SES leçons passées issues de la mémoire RAG (§4.4).

Le juge tampon-encreur. Un jour d'audit, le research_manager avait approuvé 26 BUY sur 26 dossiers. Un arbitre qui dit toujours oui n'est pas un arbitre — c'est un rubber-stamp. C'est CET incident qui a fait basculer le système vers le scoring externe (pointwise/pairwise, module M5) plutôt que la confiance auto-déclarée des agents. Depuis l'ADR 0006, le trader ne produit plus un {action, confidence} binaire mais un dossier riche (15-20 lignes + footer chiffré) ; la « confidence » n'est plus qu'une métadonnée d'audit.

Vis le débat — l'explorable E05

Maintenant, vis-le de l'intérieur sur un cas maquette (arguments fictifs au format réel des plaidoiries). Fixe d'abord ta conviction sur NVDA AVANT tout argument, puis ré-ajuste après chaque plaidoirie. Ta trajectoire est tracée.

Débat bull/bear — curseur de conviction
Mise à jour bayésienne vécue : chaque argument déplace (un peu) une conviction — il ne la remplace pas.

4.3 · Quel modèle pour quel rôle

Tous ces agents ne tournent pas sur le même cerveau. La règle économique : le modèle cher et profond (Opus 4.8) là où UNE décision unique engage de l'argent ; le modèle rapide (Sonnet 4.6) là où le volume et l'agrégation statistique absorbent le bruit — les 810 duels du tournoi M5. La source de vérité unique de ce routage : ROLE_MODEL_MAP dans agents/tradingagents/llm_clients/role_routing.py. Explore la matrice (clique une ligne, bascule de phase) :

Matrice de routing par rôle
RôleModèleEffortVolume / run
research_manager · trader · risk_manager · deep_exit_review Opus 4.8 medium~25-40 dossiers
rankers pointwise & pairwise (les duels) Sonnet 4.6 medium~810 duels + k=5
researchers bull + bear (rotation) · 5 analystes = worker only Sonnet 4.6 / Grok medium~3 appels rotation · ~9 = worker
news_threat_judge Opus 4.8 highà la demande
debrief / tldr du carnet Haiku 4.5 1 / trade
Clique une ligne pour la justification du choix. Note : en rotation, seuls bull/bear (Sonnet 4.6)+RM (Opus 4.8) tournent (~3 appels) ; analystes+trader = chemin worker uniquement, analystes sur Grok quick_thinking.

Trois garde-fous d'infrastructure autour de ce routage : tout passe par le wrapper claude_subscription.py (abonnement Claude Max = coût marginal nul), qui se limite lui-même à 3 appels concurrents (_MAX_CONCURRENT=3) et protège la sentinelle d'erreur LLM_EXCEPTION:. Et l'effort n'accepte QUE low/medium/high — jamais max (le modèle sur-réfléchit le JSON), jamais xhigh (invalide).

Le caractère qui a éteint le cerveau. Un effort xhigh — invalide pour le CLI — faisait échouer CHAQUE appel avec un stdout vide : zéro dossier, zéro duel, 0 picks produits. Aucun crash, aucun ordre aberrant : juste le silence. Attrapé par le shadow dry-run du 29/05, avant tout ordre réel. Moralité : un seul token de config peut éteindre le cerveau entier — d'où la règle culture « shadow → flip » (module M8).

Provider payant en accessoire (L42). Un sous-appel LLM « accessoire » sur un provider externe facturé (Grok) a renvoyé un 403… et cassé l'attribution des agents. Règle : aucun appel metered dans le chemin critique — tout tourne sur l'abonnement.

4.4 · La mémoire : RAG sur Qdrant

Dernière pièce : un trader sans mémoire refait les mêmes erreurs. Le système stocke chaque décision dans Qdrant (base vectorielle, 192.168.15.37:6333, v1.17.1), collection trading_decisions : ~1400 points de 384 dimensions (distance cosinus, quantization int8, 6 index de payload). Chaque texte est transformé en embedding par BAAI/bge-small-en-v1.5 (FastEmbed, ~50 MB, CPU pur) et le contexte retrouvé est injecté via 5 champs per-rôle filtrés par angle (lessons_bull, lessons_bear, lessons_rm, lessons_trader, lessons_risk_mgr) depuis Phase 27 (MEM-04) — l'ancien state["rag_context"] unique est gardé vide pour rétro-compatibilité (rag_memory.py).

Embedding / RAG — un embedding encode le SENS d'un texte en un vecteur de 384 nombres ; deux textes proches en sens sont proches en géométrie. Le RAG (Retrieval-Augmented Generation) retrouve les souvenirs les plus proches d'une situation et les glisse dans le prompt. C'est une mémoire associative, pas un grep.

$\mathrm{sim}(a,b) = \dfrac{a \cdot b}{\lVert a\rVert\,\lVert b\rVert}$ — similarité cosinus : 1 = même direction (même sens), 0 = orthogonal (sans rapport).

Prédis avant de tester : la requête « puces IA » retrouvera-t-elle un souvenir sur NVDA qui ne contient NI le mot « puces » NI le mot « IA » ?

Recherche sémantique — démo (souvenirs maquettes au format réel du payload)
· Seuil haut = précision mais amnésie ; seuil bas = tout remonte, y compris le hors-sujet. Le compromis bruit/rappel se règle, il ne se supprime pas.

La boucle se ferme au carnet (M5) : à J+7, le point Qdrant de chaque décision est ré-écrit avec son outcome réel (win/loss/timeout). La machine d'aujourd'hui lit les cicatrices d'hier — c'est le sens littéral de « carnet auto-apprenant ».

set_payload sur un point Qdrant inexistant répond 404 en silence : l'outcome n'était jamais écrit et la mémoire restait naïve. Bug racine fixé : le point est créé À L'ENTRÉE (au fill), le J+7 ne fait que le mettre à jour.

Ce qu'il faut retenir

Le cerveau de Precision Ledger n'est pas « une IA qui trade ». C'est une architecture anti-biais : des humains biaisés remplacés par des agents… eux-mêmes considérés comme biaisés, donc mis en débat aveugle, arbitrés, stress-testés, routés sur le bon modèle au bon prix, et adossés à une mémoire qui apprend de ses outcomes réels. Le module M5 montre ce qu'on FAIT de ces dossiers riches : le tournoi.

// module 05

La rotation top-N

Chaque jour ouvré à 12:00 UTC, 512 titres se présentent à la porte. Le soir, il en reste 9 dans le portefeuille. Ce module démonte la machine qui fait ce tri — le cœur de l'ADR 0006 V2 : un entonnoir quantitatif, un jury de 5, un tournoi de 810 duels, et des garde-fous nés chacun d'une perte réelle. C'est ici que vit l'edge du fonds : non pas « trouver l'info avant les autres », mais front-loader le raisonnement LLM sur des dossiers riches, puis classer ces dossiers avec une rigueur statistique qu'un humain n'a pas.

5.1 · L'entonnoir : 512 → 50 → 25/15 → 7+2

Avant de voir le schéma, prédis : sur les 512 titres de l'univers, combien finissent réellement dans le book chaque jour ?

Prédiction — combien de survivants ?

Rotation top-N — au lieu de décider trade par trade (« j'achète ? »), le système re-classe TOUT l'univers chaque jour et fait converger le portefeuille vers le top du classement : 7 longs + 2 shorts. La décision n'est plus « acheter X » mais « X mérite-t-il encore sa place ? ».

5.2 · Stage 1 — le jury de 5 (pointwise)

Question d'ouverture : si tu demandes à un même LLM de noter un dossier sur 100, deux fois de suite, obtiens-tu la même note ? Non — et le système ne nie pas ce bruit, il le traite statistiquement. Le scoring pointwise (ranker/pointwise_scorer.py) pose k=5 fois la même question (self-consistency) et garde la médiane des 5 notes, sur 5 axes : asymétrie RR, force du catalyst, forecast Chronos, technique, sentiment. L'ordre de présentation des éléments est permuté à chaque sample pour neutraliser le biais de position (un LLM favorise ce qu'il lit en premier). Les notes sont ensuite normalisées en rank-z par book.

Pourquoi la médiane ? Joue avec le bruit du juge
moyenne médiane
La vraie qualité du dossier vaut 62. Injecte l'outlier : la moyenne dévisse, la médiane tient. C'est exactement le sample fou que k=5 + médiane absorbe.

Self-consistency — poser k fois la même question au LLM (ici k=5) et agréger les réponses. La variance du juge est un bruit qu'on traite avec des stats (médiane), pas un défaut qu'on nie. C'est aussi ce qui permet de faire tourner les rankers sur Sonnet 4.6 plutôt qu'Opus : l'agrégation absorbe le bruit du juge.

5.3 · Stage 2 — le tournoi de duels + Bradley-Terry

Noter 25 dossiers de 0 à 100, c'est dur et bruité. Dire lequel de DEUX dossiers est meilleur, c'est facile. Le Stage 2 (ranker/pairwise_duels.py) ne demande au LLM que des duels : un round-robin complet double passe (A vs B, puis B vs A — encore l'anti-biais de position). Reste à transformer des centaines de réponses « A bat B » en un classement : c'est le modèle Bradley-Terry (ranker/bradley_terry.py, MLE NumPy maison, algorithme MM de Zermelo-Ford). Avant la formule, joue le tournoi toi-même — tu ES le juge LLM.

Tournoi — 10 duels, tu votes, Bradley-Terry classe (données fictives)
duel 1 / 10 — clique la fiche que tu préfères
Classement Bradley-Terry en direct (bt_strength, somme nulle)
vote ton premier duel pour voir le classement se construire…
Le même algorithme tourne en prod : tes votes remplissent une matrice de victoires, l'itération MM en déduit une force π par titre. Le LLM fait pareil, 810 fois.

$P(i \succ j) = \dfrac{\pi_i}{\pi_i + \pi_j}$ — Bradley-Terry cherche les forces $\pi$ qui expliquent le mieux les duels observés. Un prior MAP BT_ALPHA=0.05 (une « victoire virtuelle » de chacun contre un adversaire moyen) garantit la convergence sur tout graphe — même un titre qui perd tous ses duels garde une force finie. Sortie : bt_strength (somme nulle) + intervalle de confiance bootstrap ; fallback win-rate si le fit échoue.

Le compte exact : 25 longs × 24 adversaires = 600 duels, 15 shorts × 14 = 210, soit 810 duels/run en mode full. Chaque duel = 1 appel LLM (Sonnet 4.6, abonnement — coût marginal nul). Le mode seeded (ranker/pairwise_schedule.py, flag PAIRWISE_SCHEDULE_MODE) tombe à ~144 duels (÷5.6) : une épine dorsale connexe + une clique concentrée à la frontière rang-K/(K+1) — là où se joue la sélection — avec des ancres déterministes sha256(run_id:book_side).

Le graphe de duels — full vs seeded, et pourquoi la connexité est sacrée
6 nœuds pour illustrer (la prod en a 25). En prod, _resolve_pair ne supprime JAMAIS une arête : un graphe coupé en deux îlots rend les forces incomparables entre îlots.

5.4 · La sélection et ses garde-fous

Le classement est fait. Pourquoi ne pas simplement prendre le top-7 chaque jour ? Parce qu'un titre qui oscille entre rang 7 et rang 8 ferait un aller-retour par jour — et chaque aller-retour coûte (commissions, spread, slippage — module 03). portfolio_builder.py assemble le book (F4 tri bt_score, F5 cap secteur 2/secteur, F6 sizing vol-target, F7 cash mode : < 4 longs valides → cash partiel, jamais un 7/7 forcé), puis rotation_orchestrator.py applique quatre amortisseurs :

Garde-fouRègleAnti-quoi
Hystérésis en rangsentrée si rang ≤ 7 · sortie si rang > 11 (longs) / > 5 (shorts)whipsaw
Min-hold2 j long · 2 j shortsur-rotation, tilt
Cooldown forced-exit7 j (override si pointwise ≥ 0.50)revenge-trading
Floor pointwise≥ 0.20 sur les entrées longs« gagnant par défaut »

Détail qui a coûté cher : l'hystérésis travaille en rangs absolus, jamais en pourcentage d'un score — un buffer multiplicatif est mathématiquement mal défini quand le score est signé ou proche de zéro (L34). Seul un forced exit (stop touché, choc news) passe outre min-hold et hystérésis — et déclenche alors le cooldown.

Simulateur d'hystérésis — un titre dont le rang oscille sur 30 jours
ordres passés sorties (allers-retours) frottement (hyp. 0,08 %/ordre)
Zone verte = période en portefeuille. Sans buffer, le titre fait le yo-yo autour du rang 7 ; la zone tampon (rangs 8-11) absorbe l'oscillation. Le min-hold amortit encore par-dessus.

SMCI ré-entré à J+4 (−18 k$) et ADM le « gagnant par défaut » (L50). Le cooldown était bien comparé sur 7 jours… mais la requête fetch_recently_closed ne remontait que 3 jours d'historique : SMCI (pointwise 0.04) est ré-entré 1 jour trop tôt, sur une thèse inversée. Le même audit a montré ADM entré avec un pointwise de −0.30 : dans un tournoi relatif, quelqu'un gagne toujours — même quand tous les dossiers sont mauvais. Deux règles en sont nées : un score relatif doit croiser un floor absolu (d'où le floor 0.20), et la fenêtre d'une requête doit couvrir le seuil qu'elle teste, sinon le gate est mort.

5.5 · L'exécution : du classement aux ordres

À toi de jouer l'orchestrateur. rotation_executor.py ne reçoit pas « un classement » mais un diff : book cible vs book courant → ENTER / EXIT / HOLD. Chaque ordre passe le gate A1 (module 03) puis part au bridge en direct — il n'écrit pas trade_proposals (L37). Trois kill-switches étagés : ROTATION_ENABLED (maître), ROTATION_EXEC_ENABLED (ordres réels), ROTATION_DRY_RUN (shadow audit-only). Et un garde-fou de bon sens : un book cible vide signifie « LIQUIDE tout » — le chemin exit-only construit donc son RotationPlan à la main, sans repasser par la décision (L39).

Quiz de diff — décide chaque ligne (scénario fictif, règles réelles)
score
Rappels : entrée si rang ≤ 7 · sortie si rang > 11 · min-hold 2 j (long et short) · cooldown 7 j (override pointwise ≥ 0.50) · floor 0.20.

Depuis la fusion Task 016, des étages optionnels s'intercalent (flags, validés en shadow avant flip) : ESCALIER_PRECUT_ENABLED (pré-coupe recall@N≈0.9 avant les duels — moins d'appels, même top), DEEP_EXIT_REVIEW_ENABLED (re-check Opus avant d'éjecter une position gagnante), ROTATION_REBALANCE_ENABLED (top-up/trim si le poids dérive de ≥ 15 %).

5.6 · Reconcile : rattraper la réalité

Piège temporel : les ordres partent à 12:00 UTC… mais l'open US est à 13:30. Les fills arrivent APRÈS la fenêtre de poll du run. La réponse : des crons de réconciliation idempotents qui relisent IB — rotation_fill_reconcile (rattrape les fills lents et écrit aussi la ligne rotation_log d'entrée — sinon la date d'entrée serait « maintenant » à chaque run et le min-hold deviendrait éternel), rotation_exit_reconcile (sorties décidées côté IB, ex. stop serveur), ledger_coherence (toute divergence DB↔IB = alerte). Règle d'écriture : persist-after-fillrotation_log n'enregistre que des fills réels, et aucun ordre market ne part hors RTH (13:30-20:00 UTC).

INCY/NFLX jamais journalisés (L45) — remplis à l'open, après le poll : aucun fill en DB, min-hold faussé. Les 119 lignes fantômes (L41) — des market orders pré-marché mouraient Inactive, le code voyait « quantité encore due » et re-tirait à chaque tick : 119 lignes rotation_log en 2 jours sur META/GS/COST/LLY. Les deux incidents ont la même morale : la DB raconte, IB tranche — d'où persist-after-fill + garde RTH + reconcile.

5.7 · La surveillance intraday

Entre deux runs, le book n'est pas aveugle : intraday_exit_monitor.py tourne toutes les 15 minutes en RTH. Si une news liée à une position sort avec un sentiment FinBERT ≤ −0.7 (module 06), c'est un forced exit : il override tout — min-hold compris — et arme le cooldown 7 jours. C'est le seul chemin qui court-circuite les amortisseurs de 5.4, et c'est voulu : les règles anti-whipsaw protègent contre le bruit, pas contre un incendie.

5.8 · Le carnet auto-apprenant

Prédis avant de lire : pour savoir si le ranker bat le hasard, faut-il journaliser les 9 trades pris… ou les ~40 candidats scorés ?

Prédiction — que faut-il logger pour apprendre ?

Le carnet repose sur trois pièces. 1) Un fil traversant : le decision_id = UUIDv5 déterministe uuid5(NS, run_id|TICKER|side) (ranker/decision_id.py) — recalculable à l'identique au scoring ET à l'exécution, il relie ranker_run, trades et rotation_log sans jamais se passer d'identifiant. 2) Le contrefactuel : candidate_forward_returns stocke 1 ligne par candidat scoré (pas seulement tradé) avec ses retours forward 1/3/5/7 jours — l'antidote au biais du survivant. 3) Le label honnête : à J+7, forward_labeler.py applique la triple-barrier et ré-écrit l'outcome dans le point Qdrant du trade (la mémoire RAG du module 04 apprend de ses résultats).

Triple-barrier — pourquoi un label naïf « plus haut à J+7 ? » ment
Chemin de prix fictif, ATR = 2.0, entrée 100 (long). En prod : k = 2.5 pour les longs, 3.5 pour les shorts (perte non bornée + risque de squeeze ⇒ barrières plus larges). Fallback EWMA-σ quand iv_history.atr_14 manque (il ne couvre que ~17 tickers).

Glisse k : trop serré (k ≤ 1.4 ici), le bruit normal du titre touche la barrière basse → LOSS alors que la thèse était bonne. Médian, la barrière haute est atteinte → WIN. Trop large, plus rien n'est touché → TIMEOUT. Le label dépend du chemin, pas seulement du point d'arrivée — un titre qui finit à +3 % après avoir traversé −6 % aurait stoppé n'importe quel trader réel. C'est ce que le label naïf « prix plus haut à J+7 ? » efface.

Look-ahead bias — tout label du carnet est point-in-time : il n'utilise que des informations connues au moment de la décision. Une feature qui « voit » t+1 (le classique shift(-1) pandas) fabrique un backtest à 95 % de win rate, totalement faux. Le carnet étant la matière d'entraînement future du ranker, un seul label contaminé empoisonne la boucle d'apprentissage.

Dernière pièce : à D+0 un debrief Haiku résume chaque décision ; à J+7 le point Qdrant est mis à jour par set_payload (merge). Subtilité réelle : set_payload sur un point inexistant répond 404 silencieux — le point doit donc être créé À L'ENTRÉE (au fill), keyé sur le decision_id traversant. La boucle est fermée : ce que le système trade aujourd'hui, il s'en souviendra demain — avec l'outcome.

Ce qu'il faut retenir

La rotation n'est pas un « stock picker » : c'est un tournoi quotidien où l'entonnoir économise le raisonnement, où la médiane et les duels neutralisent le bruit du juge, où chaque garde-fou est la cicatrice d'une perte réelle, et où le carnet transforme chaque journée en donnée d'apprentissage — y compris pour les titres NON tradés.

Valide le module

// module 06

Data, news & sentiment

À 15:00 UTC, une dépêche tombe sur un titre du book. Quinze minutes plus tard au plus, la position est liquidée — sans qu'aucun humain n'ait cliqué. Entre la dépêche et l'ordre de vente, il y a tout ce module : une ferme de workflows qui aspire le web, un modèle de sentiment local, un extracteur d'entités, et un seuil gravé dans le code : -0.7. M4 vous a montré le cerveau, M5 le tournoi. Voici les sens du système — et pourquoi un système qui lit mal une news peut vendre Eli Lilly à cause du mot « rally ».

workflows n8n prod10
articles scorés FinBERT2255+
seuil exit forcé≤ -0.7
F1 GLiNER vs LLM87% / 62%

6.1 · Les sens du système — la ferme n8n

Question d'abord : quand l'analyste news (M4) appelle son outil get_n8n_news(ticker), d'où viennent les articles qu'il lit ? Personne ne les colle à la main. Une instance n8n (outil d'automatisation no-code, LXC 192.168.15.7, v2.14.2) fait tourner 10 workflows qui aspirent presse RSS, Finnhub, Reddit, discours Fed/ECB, dépôts SEC et taux d'emprunt IBKR, puis versent tout dans Supabase. Le module agents/tradingagents/utils/n8n_news.py requête ensuite news_items + news_sentiment + news_tickers. Clique chaque workflow sur la carte : cadence, ruse anti-bot, table de destination, consommateurs.

Carte des flux : 10 sources → n8n → Supabase → consommateurs
Clique un workflow (colonne n8n) pour ouvrir sa fiche.
+ 3 workflows shadow « v3 GLiNER » en validation. Les erreurs des 10 WFs prod partent toutes vers un workflow Telegram dédié — jamais en silence.

La ruse la plus instructive : WF03 Reddit. L'API .json de Reddit bloquait les requêtes automatisées (anti-bot). Plutôt que de se battre, le workflow est passé au flux RSS Atom public avec un User-Agent Firefox réaliste et un parser Atom maison. Leçon de plomberie data : la voie officielle n'est pas toujours la voie ouverte.

Un workflow n8n se valide en l'EXÉCUTANT, cas vides inclus (L22) : un node HTTP qui reçoit [] émet 0 item, et toute la chaîne aval meurt en silence — pas d'erreur, pas de Telegram, juste des tables qui cessent de se remplir.

6.2 · FinBERT — le thermomètre à -0.7

Chaque article ingéré passe par FinBERT (LXC 124 :8082) : un modèle BERT spécialisé finance, local et gratuit, qui classe un texte en 3 classes — positive / negative / neutral — agrégées en un score signé de -1 à +1. Plus de 2255 articles sont déjà scorés. Son rôle le plus critique : le détonateur d'exit forcé. Si une news liée à un titre du book sort à ≤ -0.7, le moniteur intraday (intraday_exit_monitor.py, toutes les 15 min en séance 13:30-20:00 UTC) liquide la position et arme un cooldown de 7 jours — car face à une vraie mauvaise nouvelle, le prix gap : un stop technique arrive après la bataille.

L'échelle FinBERT et la zone de tir
-0.7 : exit forcé -1 0 (neutre) +1 positif
Sous -0.7 sur un titre détenu : sortie immédiate, qui override même le min-hold (M5).

À toi de jouer le modèle. Avant de voir les scores, place chacune des 6 headlines ci-dessous sur l'axe -1 → +1. Deux d'entre elles sont des pièges connus des modèles de sentiment : l'ironie et la négation.

Jeu : « joue FinBERT » sur 6 headlines
Scores représentatifs du comportement de FinBERT (3 classes agrégées en score signé), pas une vérité absolue.

FinBERT — modèle de langue (famille BERT) ré-entraîné sur du texte financier pour classer le sentiment : positive / negative / neutral. C'est un thermomètre, pas un analyste : il mesure la tonalité, il ne comprend ni l'ironie ni le contexte. C'est pourquoi un juge LLM (news_threat_judge, Opus effort high — M4) est posé PAR-DESSUS pour les décisions destructives.

6.3 · « rally » n'est pas LLY — le problème name→instrument

Prédis avant de lire : une dépêche parle de « la firme de Cupertino ». Comment la machine sait-elle qu'il s'agit du même instrument que « Apple » et « AAPL » ? C'est le problème name→instrument : relier du texte libre à une ligne de la table instruments (M7). La solution naïve — chercher le ticker comme sous-chaîne dans le texte (« substring ») — est une bombe : le mot « rally » contient « LLY », le ticker d'Eli Lilly. Un exit forcé branché là-dessus pourrait liquider une position sur un mot de vocabulaire. D'où deux parades réelles : linked_only=True obligatoire dans get_n8n_news pour le chemin forced-exit (on ne lit que les news déjà liées à l'instrument), et GLiNER, un modèle de NER dédié.

Duel : substring naïf vs NER GLiNER — compte les dégâts
Rouge = faux positif. Pointillé ambre = société manquée ou match par accident. Vert = entité correctement résolue.

GLiNER (gliner_multi-v2.1, LXC 124 :8084, endpoint POST /extract, code dans precision-ledger-backend/gliner-service/) extrait les noms de sociétés avec 87% de F1 en multilingue, contre 62% pour un LLM généraliste en zero-shot — et en 130 ms sur CPU, local, gratuit. Il est le Layer 3 de la pipeline name→instrument des workflows news, et doit remplacer l'extraction de tickers actuellement confiée à Ollama (qwen3:14b, LXC 124 :11434).

Le fallback substring de n8n_news.py matchait LLY dans « rally » et ALL dans « small ». Sur un chemin destructif (forced-exit), linked_only=True n'est pas une option de confort : c'est ce qui sépare « vendre sur une vraie news » de « vendre sur une coïncidence orthographique ».

6.4 · Catalysts SEC & coût d'emprunt — la data qui bloque

Toutes les données n'alimentent pas des opinions : certaines bloquent des trades. Trois workflows scrutent les dépôts SEC : Form 4 (transactions d'initiés → table insider_transactions — un dirigeant qui vend $5M, c'est un signal), Reg SHO (liste seuil des titres en échec de livraison → regsho_threshold_list) et 8-K (événements corporate matériels → corporate_events_8k). Le quatrième, WF07, récupère par FTP chez IBKR les taux d'emprunt de titres → borrow_rates / borrow_rate_history. Ces tables nourrissent les gates shorts A6 (M3) et la porte A du détecteur (M5). À toi de relier chaque table à son effet aval — glisse (ou clique) chaque table vers l'effet qu'elle déclenche.

Drag & drop : cette table bloque quoi ?
Gate short Tier 1 : vente insider ≥ $5M = veto binaire sur le short
Porte A du détecteur : un événement corporate frais qualifie le titre comme candidat du jour
Hard reject short : 5+ jours consécutifs sur la liste = interdiction de shorter
Coût de portage du short + signal Tier 2 (borrow ≥ 1%/an compte pour le composite 2/4)
4 tables, 4 effets. Aucun n'est décoratif.

Reg SHO threshold list — liste publiée par la SEC des titres avec des échecs de livraison persistants (les vendeurs à découvert n'arrivent plus à livrer les actions). 5+ jours dessus = squeeze possible, emprunt fragile : le système refuse le short, point (Tier 1, short_gates.py).

6.5 · Géopolitique & boule de cristal — geo_ingest et Chronos-2

Dernier sens : le monde au-delà des tickers. La pipeline agents/geo_ingest/ ingère GDELT (base mondiale d'événements géopolitiques), Polymarket (marchés de prédiction), FinLight, PortWatch (trafic portuaire) et l'EIA (énergie), fabrique un geo_context injecté à l'analyste géopolitique (geopolitical_analyst), et propage les chocs par secteur via un graphe de connaissances inspiré des matrices entrées-sorties de Leontief — une guerre du fret frappe d'abord les transporteurs, puis leurs clients. Avant de lire la suite, prédis ceci :

Prédiction : la doc GDELT promet 58 colonnes. Le CSV réel en a combien ?
graph LR
  G[GDELT] --> I[agents/geo_ingest/]
  P[Polymarket] --> I
  F[FinLight] --> I
  W[PortWatch] --> I
  E[EIA] --> I
  I --> T[geo_context]
  T --> A[geopolitical_analyst]

Et la boule de cristal : Chronos-2 (amazon/chronos-bolt-base, LXC 124 :8083, POST /forecast) est un modèle de forecasting de séries temporelles qui projette l'OHLCV en avant. Il dessine les overlays de prévision sur les charts du cockpit (via api/routers/markets.py) et fournit l'un des 5 axes de la note pointwise du ranker (M5). Pas une promesse de voyance : un axe parmi cinq, pondéré comme les autres.

Valide la structure RÉELLE d'un fichier externe avant de parser : la doc ment. GDELT livrait 61 colonnes au lieu des 58 documentées (L12), et les IDs PortWatch ne matchaient qu'après .lower() (L13). Deux pipelines morts en silence, deux fois la même leçon.

Ce qu'il faut retenir

Le système ne « lit pas les news » : il industrialise leur ingestion (10 workflows n8n), les thermomètre localement (FinBERT, exit forcé ≤ -0.7 vérifié toutes les 15 min), résout les noms en instruments sans coïncidences orthographiques (GLiNER 87% F1, linked_only=True), et transforme les dépôts SEC et les taux d'emprunt en vetos mécaniques. La règle culturelle commune à tout le module : une donnée externe est coupable jusqu'à preuve du contraire — on valide en exécutant, cas vides et colonnes surnuméraires inclus.

Quiz de fin de module

// module 07 · identité & squelette

Identité des titres & infra

Ton dollar vient d'être alloué à NVDA par le ranker — mais sous quel nom cette action est-elle connue chez IB, chez Yahoo, à l'ISIN ? Si la machine confond deux de ces noms, elle achète le mauvais instrument.

Le même titre s'appelle VK chez Interactive Brokers, VK.PA chez Yahoo, FR00… à l'ISIN, conId 12345 dans le registre interne d'IB et BBG000… au FIGI. Si la machine confond deux de ces noms — ou pire, croit voir deux titres là où il n'y en a qu'un — elle achète le mauvais instrument, double une position, ou laisse un short sans surveillance. Ce module raconte l'état civil des titres et le squelette qui porte tout le reste : Supabase, Qdrant, Redis, l'API, et le geste de deploy qui met tout ça en prod.

tables Supabase prod~59
tables en dual-write instrument_id17

7.1 — La table instruments : l'état civil des titres

Question d'ouverture : combien d'identités différentes porte une seule action dans ce système ? Compte sur le schéma — chaque branche est un dialecte qu'une source de données parle (IB, Yahoo, OpenFIGI, les dépêches…) :

Un titre, six noms — la PK synthétique au centre
instrument_id = 42 (PK) ticker IB : VK + SBF yahoo_symbol : VK.PA isin : FR00… figi_composite : BBG000… ib_con_id : 12345 (immuable à vie) name : « Vallourec » (dépêches — le maillon fragile)
Aucun de ces noms n'est fiable seul : le ticker se recycle, l'ISIN ne porte pas la place de cotation, le nom est ambigu. D'où une clé synthétique qui les fédère.

Depuis le 2026-05-19 (ADR 0007), la table instruments est la source de vérité de l'identité d'un titre : PK synthétique + colonnes de mapping figi_composite, isin, ib_con_id, yahoo_symbol, peuplée par agents/backfill_instruments.py et le resolver d'entrée tradingagents/utils/ingress_resolver.py. Règle de schéma : toute table qui porte un ticker ajoute instrument_id INTEGER REFERENCES instruments en dual-write~25 tables aujourd'hui, avec un trigger SQL en filet pour remplir la colonne si un écrivain l'oublie. Pour retirer un ticker bogus : UPDATE instruments SET active=false, jamais de liste codée en dur.

Dual-write — pendant une migration d'identifiant, on écrit les DEUX colonnes (l'ancienne ticker et la nouvelle instrument_id) dans chaque table. Le jour où tout le monde lit la nouvelle, l'ancienne peut mourir — sans big-bang.

Deux INSERT concurrents du même titre (le run + un workflow n8n, à la même seconde) = doublon silencieux — deux instrument_id pour une seule action, et tout l'aval se divise en deux. C'est pourquoi tout UPSERT passe par la RPC instruments_upsert (stored procedure race-safe + auto-merge). Jamais de SELECT-then-INSERT applicatif.

Détail savoureux : l'ISIN est une donnée payante chez la plupart des fournisseurs… mais IB le donne gratuitement via reqContractDetailsAsync() (champ secIdList de ContractDetails). À l'inverse, OpenFIGI /v3/mapping ne renvoie pas l'ISIN, et son /v3/search est inexploitable pour passer d'un nom à un ticker — c'est la recherche Yahoo qui fait ce travail.

Puzzle — 8 cartes, 2 instruments

Ces 8 identités (valeurs d'exemple) traînent dans les flux d'une journée. Clique chaque carte pour la classer : 1 clic = INSTRUMENT A, 2 clics = INSTRUMENT B, 3 clics = retour à zéro. Combien de titres réels se cachent là-dessous ?

Le feedback nomme, pour chaque carte, la colonne d'instruments qui permet le rapprochement.

7.2 — L'univers tradeable et son gate

Le système ne peut acheter que ce qui appartient à son univers : la vue matérialisée tradeable_universe_current (~512 lignes : S&P 500 = 499, complément NASDAQ-100 = 13). Le gate « Layer 0 » universe_gate.py bloque tout BUY/SHORT hors de cette liste, avec un cache Redis SET (universe:tradable, TTL 5 min, test d'appartenance SISMEMBER en O(1)). Maintenant, le cas qui fâche :

Prédiction — la panne du cache

Redis tombe en plein run, et la DB ne répond pas non plus. Un ordre BUY arrive au gate. Que fait-il ? Engage-toi avant de voir la réponse :

Fail-CLOSED — un garde-fou qui, en cas de panne de ses propres dépendances, bloque au lieu de laisser passer. Un garde-fou qui s'ouvre quand il tombe en panne n'est pas un garde-fou : c'est une décoration. Même cache cold + DB vide → BUYs bloqués avec reason='universe_empty' et signal opérateur via claude-ops.

7.3 — Supabase : le grand livre

Supabase (Postgres self-hosted, LXC 108, 192.168.1.126:8000) tient le journal de toute la machine : ~59 tables en prod. On lui parle par deux portes qu'il ne faut JAMAIS confondre :

EndpointUsageExemple
POST /pg/querySQL brut — réservé au DDL (CREATE / ALTER / DROP)créer une table, étendre une contrainte CHECK
/rest/v1/{table}CRUD via PostgRESTinsérer un trade, lire des snapshots

Un DELETE … WHERE x NOT IN (…) lancé via /pg/query sur un champ texte libre a wipé une table entière : la liste d'exclusion ne matchait pas le format réel des valeurs, donc TOUT était « NOT IN ». Règle née ce jour-là : écriture idempotente SELECT-then-INSERT, jamais de DELETE NOT IN sur du texte libre.

Le client Python supabase-py : .update().or_() retourne data=[] même quand l'UPDATE a matché. Un code qui prend data=[] pour « rien modifié » casse ses compare-and-set. Vérification obligatoire par re-lecture. Et côté schéma : les migrations versionnées vivent dans un foyer unique, precision-ledger-backend/docs/migration_*.sql — on introspecte information_schema AVANT d'appliquer (L38), car la prod a parfois dérivé du fichier.

Sur ces 59 tables, 7 forment le cœur rotation + carnet. Explore-les — pour chacune : qui écrit, qui lit, et où tu l'as déjà croisée dans ce guide.

Explorateur — les 7 tables cœur
Clique une table pour ouvrir sa fiche.

7.4 — Qdrant, Redis, FastAPI : le reste du squelette

Prédiction — avant de lire

Redis, Qdrant, FastAPI — lequel des trois fait planter la boucle worker si sa config de timeout est mauvaise ?

Qdrant (192.168.15.37:6333, v1.17.1) — la mémoire vectorielle du RAG : collection trading_decisions, vecteurs 384 dimensions en distance Cosine, quantization int8, ~1400 points. C'est ici que le carnet ré-écrit l'outcome de chaque décision à J+7.

Redis — la file d'attente entre crons et worker : le cron pousse un job, le worker pioche en BLPOP (attente bloquante). Il sert aussi de cache au universe gate (7.2).

FastAPI (api/main.py — 131 lignes seulement) — 23 routers, démarrage par lifespan (jamais le vieux @app.on_event), chaque réponse validée par un response_model Pydantic en extra="forbid" (un champ inattendu = erreur, pas une surprise en silence), auth JWT Supabase sans fallback.

Trois services, trois rôles : se souvenir (Qdrant), faire patienter (Redis), exposer (FastAPI).

Deux pièges de version vécus : Qdrant n'accepte comme PointStruct.id que des entiers ou des UUID — une string arbitraire est rejetée, d'où uuid.uuid5 pour dériver un UUID déterministe (L18). redis-py 8 a cassé la boucle worker : un socket timeout silencieux toutes les 5 s tuait le BLPOP — fix : socket_timeout=None, PAS un downgrade de protocole.

7.5 — La journée du système en crons

Personne ne clique, mais quelque chose tourne presque à chaque heure. Voici les 24 heures UTC d'un jour ouvré — clique un marqueur pour voir ce qui s'exécute et ce qui s'écrit, ou rejoue la journée entière :

Timeline 24h UTC — un jour ouvré de la machine
Bande ambre = RTH (heures de cotation US, 13:30-20:00 UTC). Ligne pointillée du bas = scan du circuit de drawdown, toutes les 10 min, Mon-Fri 06:00-22:00 UTC.

7.6 — Le cycle de deploy : 2 repos, 3 gestes

Le code vit dans 2 repos git séparésprecision-ledger-backend/ et precision-ledger-frontend/. Le dossier racine du projet n'est PAS un repo : jamais de git init à la racine (L1, gravée après l'avoir frôlé). Le chemin vers la prod est toujours le même :

graph LR
  A[commit dans backend/] --> B[push GitHub via gh CLI]
  B --> C[ssh 192.168.15.8 : git pull]
  C --> D{quel dossier a change ?}
  D -->|api/| E[docker compose up -d --build api]
  D -->|agents/| F[systemctl restart trading-worker]
  D -->|ib-bridge/| G[rebuild du container ib-bridge]

Pourquoi deux gestes différents côté serveur ? Parce que api/ et agents/ sont deux mondes Python distincts : 2 pyproject.toml séparés (L2 — vérifier le bon avant d'ajouter une dépendance), gérés par uv (jamais pip), Python 3.12+. L'API tourne dans un container Docker ; le worker des agents tourne en service systemd. Et le push lui-même a son piège : depuis le sandbox, export PATH="/c/Program Files/GitHub CLI:$PATH" d'abord, sinon le credential helper gh est introuvable et le push meurt en 403.

Le container API n'embarque pas le dossier agents/ : il y accède par un bind-mount en lecture seule (./agents:/agents:ro, L9). Conséquence contre-intuitive : modifier agents/ ne demande PAS de rebuild de l'API… mais exige bien le restart du worker, qui charge ce code en mémoire.

À toi de router — trois fichiers modifiés, trois gestes à choisir :

Ce qu'il faut retenir

L'infra de Precision Ledger tient en quatre réflexes : une identité = une PK synthétique (jamais le ticker en clé), un garde-fou en panne = un garde-fou fermé, la DB est un journal (deux portes, chacune ses pièges — la vérité reste chez IB), et un fichier modifié = un geste de deploy précis. Vérifie que ça tient :

// module 08

Leçons de guerre & culture ops

Ton dollar est parti en ordre à 12:45 UTC. À 13:30 il est rempli. À J+7, le carnet notera si la thèse était bonne. Mais entre les deux, il a failli ne jamais partir — ou partir à $3.22M de levier non voulu. Ce module raconte comment chaque cicatrice est devenue une règle.

À 12:00 UTC, personne ne regarde. La machine lit la presse, lance 810 duels, passe des ordres — seule. Quand un humain trade mal, il perd de l'argent. Quand une machine trade mal, elle perd de l'argent en boucle, sans s'arrêter, sans honte. Ce module raconte comment Precision Ledger a appris ça — au prix fort — et la culture née de chaque cicatrice.

le short à nu (incident n°1)$3.22M
règles L## accumulées52
dossiers à élucider ici8
humains dans la boucle à 12:000

Le système a déjà shorté $3.22M à nu, empilé 109 stops sur un même titre, fabriqué 119 lignes fantômes… et il tourne toujours. Pourquoi ? Parce que chaque incident a un seul droit : devenir une règle. Et chaque règle, un seul devoir : être relue avant la prochaine ligne de code.

8.1 — Le notebook des règles

Que fait un fonds sérieux après une grosse perte ? Une réunion de crise ? Un rapport de trente pages que personne ne relira ? Non. Une ligne.

LESSONS.md, à la racine du projet, est le journal d'erreurs du système : des règles causales durables, une par ligne, numérotées L## et groupées par thème (Git/deploy, Supabase, IB, LLM, Python). Il y en a ~62 aujourd'hui — la prochaine s'appellera L63. Le détail complet de chaque incident (code, vérifications) est archivé dans docs/archive/LESSONS-full-2026-06-09.md ; le fichier de tête ne garde que la règle, courte et tranchante.

Post-mortemanalyse à froid d'un incident : symptômes → cause racine → règle durable. Chez Precision Ledger, le livrable n'est pas un rapport : c'est une ligne L##.

L'ancien guide disait du journal de trade : « les pros le tiennent par nécessité analytique, pas par discipline morale ». LESSONS.md est exactement ça, version ingénieur. Sans lui, on répète ; avec lui, chaque bug n'a le droit de frapper qu'une fois. Le rituel est écrit en toutes lettres dans le CLAUDE.md racine : lire avant de coder (OBLIGATOIRE), écrire après chaque incident.

Le cycle LESSONS
INCIDENTça saigne en prod POST-MORTEMcause racine, pas symptôme RÈGLE L##une ligne dans LESSONS.md RELIREavant de coder la boucle qui rend chaque bug unique l'incident suivant frappe ailleurs — jamais deux fois au même endroit

Attention à la frontière : LESSONS.md ne stocke QUE le causal durable. Les faits d'état (« tel flag est OFF », « telle table couvre 17 tickers ») vivent dans la mémoire auto, avec un cycle de vie — ils périment. Une règle causale, elle, ne périme pas.

Prédiction — combien de règles ?

Le projet vit depuis quelques mois. Combien de règles L## son notebook a-t-il déjà accumulées ? Prédis avant de révéler.

8.2 — La salle des incidents

Bienvenue dans la salle des incidents : 8 classeurs, 8 vraies pannes qui ont marqué le fonds. Tu en as déjà croisé 6 dans les modules précédents — les résoudre de mémoire est exactement le but : l'effort de récupération renforce la trace bien plus qu'une relecture. Deux dossiers sont inédits. Protocole d'enquête : lis les symptômes, choisis la cause racine parmi 4 hypothèses, puis confronte-toi au verdict — et à la règle L## née ce jour-là.

Ne retourne pas relire les modules. Te tromper ici vaut mieux que réussir en trichant : une erreur corrigée maintenant, c'est un souvenir consolidé pour de bon.

8 dossiers d'enquête
Clique un dossier. Symptômes → hypothèses → verdict. Score d'enquêteur = causes trouvées du premier coup.

8.3 — Les 5 piliers de la culture ops

Si tu ne retiens que cinq phrases de tout ce guide, que ce soient celles-ci. Chacune est née d'au moins un dossier de la salle des incidents — aucune n'est théorique.

Ce qui porte le book en prod
LE BOOK EN PROD — 7 longs / 2 shorts IB =VÉRITÉ NEVER-RAISE fêlure : 23514 SHADOWPUIS FLIP ZÉRO STUBDE GATE VERIFY =REBUILD RÉEL retire un pilier et regarde la salle des incidents se remplir
  1. IB = source de vérité absolue. Tests, logs, décisions, reconcile : tout se compare à IB. La DB n'est qu'un journal réconcilié ; toute divergence DB↔IB est une alerte (ledger_coherence), et c'est la DB qu'on corrige — jamais l'inverse. Dossiers 02 et 08.
  2. Never-raise / degrade-to-APPROVE (L27). Tout helper appelé depuis worker.py est exception-safe : un fetcher qui crashe dégrade en APPROVE avec un warning tracé (wiring_degraded), car une exception remontée tue le pipeline entier. Son ombre : le dossier 08 — un never-raise peut avaler une vraie erreur. La discipline complète, c'est tracer le warning ET le lire.
  3. Shadow → flip. Un composant neuf est câblé en prod flag OFF : il calcule et logge sans aucun effet de bord. Après N runs propres, on le flippe d'un coup — pas de rollout progressif : un book de 9 lignes n'a pas de « 10% d'utilisateurs ». Réel : l'escalier et le deep exit review ont été flippés le 10/06 après dry-run propre ; ROTATION_REBALANCE_ENABLED logge encore en shadow (rotation_rebalance_shadow) en attendant ses runs propres.
  4. Jamais stubber un gate. Jamais de approved=True « temporaire » : le temporaire survit toujours. On crée le remplaçant d'abord, on retire l'ancien après. Un garde-fou contourné une fois est un garde-fou mort.
  5. Verify = rebuild réel. « Les tests passent » ne prouve rien sur la prod. Vérifier = rebuild docker + health check + un signal prod falsifiable (un chiffre qui aurait pu être faux). Le self-report d'un agent n'est pas une preuve.

Dilemmes de culture — que fais-tu ?

Quatre situations réelles. À chaque fois, un pilier tranche.

8.4 — Où regarder quand ça casse

Il est 12:41 UTC. Le run de 12:00 n'a produit aucun book, et l'open US est dans 49 minutes. Où cliques-tu en premier ? L'observabilité, c'est pouvoir répondre « que s'est-il passé ? » sans redéployer ni deviner. Le fonds a quatre yeux :

Les 4 yeux du système
traces LangGraph logs structlog JSON alertes & rapports métriques Langfuse — LXC 124 :3000 Loki (par x-trace-id) claude-ops → Telegram Prometheus /metrics

Langfuse (LXC 124 :3000) trace chaque run LangGraph agent par agent, prompt par prompt, et reçoit le scoring D+7. Piège L36 : un score Langfuse est un upsert silencieux par (trace_id, name) — ne jamais réutiliser un nom de score ; la vérité numérique reste la colonne DB. claude-ops est un agent Claude sur son propre LXC qui pousse rapports et alertes vers Telegram — jamais Discord (ADR 0004) ; les erreurs n8n y arrivent aussi. structlog → Loki : logs JSON en keyword args (jamais print() ni f-string), corrélés par le x-trace-id. Prometheus scrape /metrics.

Arbre de premier réflexe

Choisis une panne, puis ton PREMIER geste. Le bon ordre de diagnostic se révèle ensuite.

Ce que tu emportes

Un fonds algorithmique ne se juge pas à l'absence d'incidents — il se juge à la vitesse à laquelle un incident devient une règle, et une règle, un réflexe. ~62 règles, 5 piliers, 4 yeux. Et un rituel d'une ligne : avant de coder, lis LESSONS.md.

// module 09 — finale

Un jour dans la vie d'un run

Neuf modules. Des bougies, des brackets, des budgets de volatilité, des duels, des cicatrices. Tout cela n'existe que pour UNE chose : la journée qui commence chaque jour ouvré à 12:00 UTC, quand le timer trading-top-n-rotation.timer tire — sans qu'aucun humain ne clique. Ce module rejoue cette journée en 11 scènes, de la dernière minute de silence jusqu'au souvenir écrit à J+7. À chaque scène, une question d'un module passé t'attend : c'est ton échauffement avant l'examen final.

Prédiction — le premier fill

Le run part à 12:00 UTC et les ordres market sont envoyés vers 12:45. À quelle heure le PREMIER fill tombe-t-il réellement chez IB ?

Ta prédiction est enregistrée — tu la retrouveras dans l'écran de synthèse.

9.1 — Le film du run, scène par scène

Fais défiler : l'horloge à gauche suit la scène active. Réponds dans ta tête à chaque question AVANT de révéler — score d'échauffement : 0/11.

--:--
11:55 UTC

Le silence

Rien ne bouge. La vue matérialisée tradeable_universe_current (~512 lignes : S&P 500 ∪ NASDAQ-100, market cap ≥ $5B, volume quotidien ≥ $50M) attend. Dans 5 minutes, 512 titres entrent en lice pour 9 places.

worker: idle — next timer 12:00:00 UTC 
// rappel — module 00
Cite les 4 étages du flux quotidien, dans l'ordre.
revoir → 00 · La machine qui trade à midi
12:00 UTC

Le détecteur 3-portes

Le timer systemd tire. Le détecteur (ranker/detector) ouvre ses trois portes : A catalyst passé, B quant pré-catalyst, C calendrier (earnings, FOMC, OPEX). Pas de cap fixe ; les positions déjà détenues sont auto-ajoutées pour être re-jugées chaque jour. Le quant filter retient ensuite 40 longs + 20 shorts (≤ 60).

L'entonnoir s'amorce
512 lignes d'univers → candidats du détecteur → 40+20 survivants quant → 25+15 finalistes (pointwise) → book 7+2.
// rappel — module 05
Les 3 portes du détecteur ?
revoir → 05 · La rotation top-N
12:02 UTC

Le quant filter

ranker/quant_filter.py note les candidats du détecteur sans aucun LLM : RSI(2), rank-z-sum, momentum skip-1. Sortie : ~60 survivants qui méritent un dossier bull/bear+RM.

survivants longs40
survivants shorts20
// rappel — module 05
Pourquoi filtrer en quant AVANT les LLM ?
revoir → 05 · La rotation top-N
12:05 UTC

Les dossiers s'empilent

Un débat bull/bear+RM par survivant (evidence_debate.py) : ~3 appels LLM (bull Sonnet 4.6, bear Sonnet 4.6, research_manager Opus 4.8). Chaque débat produit un dossier riche (15-20 lignes + footer chiffré) — plus aucune décision binaire. Les 5 analystes et le trader font partie du graphe complet (worker), pas du chemin rotation. Routing (role_routing.py) : rankers sur Sonnet 4.6, décisions sur Opus 4.8 medium.

dossier NVDAthese + footer chiffre x ~60 debatsbull + bear + RM
// rappel — module 04
Pourquoi bull et bear sont-ils aveugles l'un à l'autre ?
revoir → 04 · Le cerveau multi-agents
12:25 UTC

Pointwise : le jury de 5

ranker/pointwise_scorer.py note chaque dossier 5 fois (k=5 self-consistency, 5 axes : asymétrie RR, catalyst, forecast Chronos, technique, sentiment) et garde la médiane, avec permutation anti-position-bias, puis normalise en rank-z. Retient les top 25 longs / 15 shorts pour les duels.

medianeoutlier ignore
// rappel — module 05
Pourquoi la médiane de 5 plutôt qu'une note unique ?
revoir → 05 · La rotation top-N
12:30 UTC

810 duels, et Bradley-Terry converge

Round-robin complet double-passe (A/B puis B/A) : 810 duels en mode full (~144 en seeded). ranker/bradley_terry.py (MLE NumPy maison, prior BT_ALPHA=0.05) transforme les duels en forces bt_strength. Glisse le curseur : plus on joue de duels, plus le classement se stabilise.

Convergence Bradley-Terry (mock)
À 810 duels, les barres ne bougent presque plus : le bruit de juge est moyenné par le tournoi. Les moustaches ambre = incertitude (CI bootstrap).
// rappel — module 05
Pourquoi des duels plutôt que des notes 0-100 ?
revoir → 05 · La rotation top-N
12:40 UTC

Le book cible : 7+2

ranker/portfolio_builder.py (F4-F7) : tri par bt_score, cap secteur (2 max), cash mode si moins de 4 longs valides. Et le garde-fou né d'une perte réelle : le floor pointwise 0.20 écarte tout candidat qui « gagne par défaut » au tournoi avec une conviction absolue négative.

long x7 short x2 candidat n°8 — pointwise -0.30 souviens-toi d'ADM
// rappel — module 05
ADM : pointwise -0.30 mais gagnant au pairwise. Quel garde-fou existe, et quelle leçon ?
revoir → 05 · La rotation top-N
12:45 UTC

Diff, gate, sizing — les ordres partent

rotation_orchestrator.py compare book cible et book courant : ENTER / EXIT / HOLD. Un min-hold (2 j long / 2 j short) bloque une sortie prévue. Chaque ordre passe le gate A1 (evaluate_trade), puis le sizing vol-target (risk_engine/sizing.py, EWMA λ=0.94, budgets 18%/8%, NAV converti EUR→USD pour le sizing seulement). Les ordres market partent vers le bridge.

PendingSubmitPreSubmittedSubmittedFilled ?
// rappel — module 02
L'ordre est en PendingSubmit depuis 40 secondes. Que sait-on ?
revoir → 02 · Ordres & exécution IB
13:30 UTC

L'open : fills et reconcile

Wall Street ouvre. Les fills tombent — APRÈS la fenêtre de poll du cron de 12:00. C'est rotation_fill_reconcile (idempotent) qui relit IB et écrit la réalité dans rotation_log. La commission de chaque fill est sommée depuis fills[].commission (le CommissionReport arrive 50-500 ms après le fill) et versée au ledger fees_ledger — tracking pur, jamais re-soustraite du P&L.

INCY et NFLX, remplis à l'open, ne furent JAMAIS journalisés : les fills arrivaient après la fenêtre de poll (L45). Sans reconcile, le min-hold devenait éternel — la date d'entrée était réécrite à « maintenant » à chaque run.

// rappel — module 08
La DB dit 7 positions, IB en montre 8. Qui a raison, et que se passe-t-il ?
revoir → 08 · Leçons de guerre
15:00 UTC

Le choc news : forced exit

intraday_exit_monitor.py tourne toutes les 15 minutes en RTH. Une news frappe un titre du book : FinBERT (LXC 124 :8082) score -0.82 — sous le seuil -0.7. Exit forcé immédiat : il override tout, min-hold compris, et arme le cooldown 7 jours (ré-entrée possible seulement si pointwise ≥ 0.50). Le matching news→titre est linked_only=True : le mot « rally » ne doit plus jamais matcher LLY.

SMCI, sorti en forced exit, fut ré-entré à J+4 sur thèse inversée : le cooldown comparait 7 jours mais la requête ne remontait que 3 jours de données (-18 k$, L50). Une fenêtre de requête plus courte que le seuil testé = gate mort.

// rappel — module 06
Quel seuil FinBERT déclenche l'exit forcé, et qui l'exécute ?
revoir → 06 · Data, news & sentiment
J+7

La boucle se ferme

Sept jours plus tard, forward_labeler.py labellise CHAQUE candidat scoré (pas seulement les 9 tradés) dans candidate_forward_returns : triple-barrier win / loss / timeout à ±k·ATR (k=2.5 long, 3.5 short), point-in-time. Puis le point Qdrant (collection trading_decisions, ~1400 points) est ré-écrit avec l'outcome — keyé par le decision_id UUIDv5 déterministe (ranker/decision_id.py), le même du scoring à l'exécution. Le système d'aujourd'hui se souviendra demain.

J+7+k.ATR = win / -k.ATR = loss / temps = timeout
// rappel — module 05
Pourquoi labelliser les ~40 candidats scorés et pas seulement les 9 tradés ?
revoir → 05 · La rotation top-N

9.2 — L'examen final

16 questions, seuil 80% (13/16). Elles sont tirées de TON deck de rappel espacé, pondérées vers les cartes que tu as le plus ratées — ton examen n'est pas celui d'un autre. Si ton deck est encore maigre (modules sautés), des questions de secours complètent le tirage.

Réviser juste avant ne remplace pas les gates des modules : chaque quiz de fin réussi ajoute ses cartes au deck — c'est LUI qui rend cet examen riche et personnel.

Examen — Precision Ledger Academy

9.3 — Et maintenant ?

Le guide s'arrête ici ; le vrai système continue chaque jour à 12:00 UTC. Pour passer de l'académie au terrain :

Ordre de lecturePourquoi
NORTH_STAR.mdla vision long-terme, la boussole
ROADMAP.mdles priorités actives, source de vérité unique
PRODUCT.mdce que le système FAIT aujourd'hui
LESSONS.mdles règles L## nées des incidents — à lire AVANT de coder
tasks/ puis adr/les chantiers en cours, les décisions structurantes

Et les trois premiers morceaux de code à ouvrir : ranker/ (le tournoi), agents/risk_engine/sizing.py (le budget de risque), ib-bridge/order_safety.py (le bracket atomique). Le rituel de la maison tient en une phrase : avant de coder, lis LESSONS.md ; après un incident, écris-y.

// référence

Glossaire

Tous les termes glossés du guide, alimentés par les modules via PLA.gloss.add.