Audit de cohérence financière — bout en bout
Date : 2026-05-14 · Auditeur : agent quant senior · Périmètre : signals.ts, winning-strategy.ts, backtest-compute.ts, recommendation-engine.ts, performance-data.ts, insider-pattern.ts, portfolio-advisor.ts
Audit en lecture seule. Aucune modification de code. Numéros mesurés (et non estimés) sur la base de production via scripts/pattern-backtest-delta.mjs et un script ad-hoc supprimé après usage.
1. Flux de données — bout en bout
AMF (PDF déclaration)
└─ scripts/fast-parse-v2.mjs → Declaration row (pdfParsed=false)
└─ pipeline.ts / sync-latest.ts → enrichissement Yahoo (Company)
└─ scripts/classify-insiders.mjs → Insider.tradingPattern (CMP 2012)
└─ signals.scoreDeclarations() → Declaration.signalScore (0-100)
└─ backtestResult worker → return30/60/90/160/365/730d
├─ winning-strategy.ts → /api/v1/strategy/winning (filtre + applyRiskCaps)
├─ recommendation-engine → /api/v1/recommendations
├─ performance-data.ts → /performance (6 stratégies + Sigma★)
└─ backtest-compute.ts → /backtest (analytics)
Couches de cache identifiées
| Lieu | Clé | TTL | Notes |
|---|---|---|---|
signals.ts |
aucun (re-score à la demande) | – | scoredAt IS NULL guard évite re-score involontaire |
winning-strategy.ts:465 |
winning-strategy-signals-v3-cmp |
30 min | unstable_cache + tag winning-strategy |
recommendation-engine.ts:196 |
reco-bucket-stats-v4 |
30 min | bucket stats (k=20 shrinkage) |
recommendation-engine.ts:520 |
reco-dominance-v2 |
30 min | cross-trade + dominance BUY/SELL |
backtest-compute.ts:591 |
backtest-base-stats |
1 h | analytics complet, ~22k rows |
insider-pattern.ts:152 |
insider-pattern-v1 |
24 h | classification par-insider |
performance-data.ts |
wrap dans la page (performance-data-v2) |
1 h | revalidate: 3600 |
Risque cache : aucun de ces caches n'invalide automatiquement quand un cron backtest ou scoreDeclarations se termine. Pour voir des résultats frais après un re-score, il faut soit attendre le TTL, soit invalider manuellement via revalidateTag("winning-strategy") (jamais wiré). C'est acceptable pour une freshness ≤ 1 h, mais entraîne un risque de divergence visible : /performance (1 h) et /methodologie#strategie (signaux 30 min + STRATEGY_PROOF hard-codé) peuvent montrer des numéros incohérents pendant 30-60 min après un rebuild.
2. Bugs et incohérences concrètes (FILE:LINE)
2.1 — STRATEGY_PROOF est obsolète et contredit les données actuelles (CRITIQUE)
winning-strategy.ts:508-524 : le bloc est hard-codé et daté du 2026-04-24. La base a 3 semaines de mouvements supplémentaires depuis, et la méthodologie a aussi changé (minScore 30 → 40 en v3).
Mesures actuelles (script joint, 2026-05-14) sur l'univers exact défini par WINNING_STRATEGY :
| Année (pubDate) | n | avg T+90 | Win-rate | Sharpe | STRATEGY_PROOF dit |
|---|---|---|---|---|---|
| 2021 | 4 | +4.78 % | 100 % | 3.84 | (absent) |
| 2022 | 7 | -7.89 % | 14.3 % | -0.53 | +7.9 % · "battu CAC" |
| 2023 | 51 | +15.99 % | 80.4 % | 0.89 | +18.4 % |
| 2024 | 21 | -3.70 % | 38.1 % | -0.28 | +2.0 % |
| 2025 | 90 | +48.30 % | 88.9 % | 1.73 | +25.5 % |
L'écart 2025 est de +22.8 pts ; 2022 a basculé de +15.8 pts côté négatif. Les sampleSize aussi divergent (35 → 7 en 2022, 118 → 90 en 2025). Le champ totalSamples: 380 est faux (réel : 173 post-filtre). La claim "Beats CAC every year 2022-2025" est fausse pour 2022 et 2024 dans les données actuelles. Si la page /methodologie ou un export marketing s'appuie dessus, c'est une affirmation publique non sourcée.
Verdict : STRATEGY_PROOF doit être (a) recalculé immédiatement, (b) banni des claims publiques tant qu'il n'est pas re-dérivé après chaque scoring, ou (c) remplacé par un appel runtime cached.
2.2 — Concentration sectorielle massive non maîtrisée (CRITIQUE)
Sur les 173 signaux de l'univers Winning Strategy actuel :
| Secteur | n | avg T+90 | Win |
|---|---|---|---|
| Luxe & Mode | 72 (41.6 %) | +58.32 % | 93.1 % |
| Tourisme & Hôtellerie | 29 (16.8 %) | +10.00 % | 100 % |
| Industrie | 24 (13.9 %) | -6.61 % | 12.5 % |
| Défense & Aérospatial | 16 (9.2 %) | +39.04 % | 100 % |
| Technologie | 14 (8.1 %) | +9.38 % | 64.3 % |
| reste | 18 (10.4 %) | mélangé | – |
41.6 % de l'univers vient d'un seul secteur. Le +48 % T+90 2025 est presque entièrement porté par "Luxe & Mode" (probablement Hermès / Kering / LVMH / Christian Dior cluster). Cela n'est pas une stratégie diversifiée ; c'est un long-only luxe avec un filtre insider.
Le cap sectoriel défini dans WINNING_STRATEGY.maxSectorPct = 0.30 n'est PAS appliqué à l'analyse historique (cf. §2.5 ci-dessous). Donc les performances déclarées sont sans contrainte, mais la "stratégie" déclarée est censée capper à 30 %. C'est incohérent.
2.3 — applyRiskCaps() est défini mais ne touche pas l'UI utilisateur (HAUT)
applyRiskCaps() (winning-strategy.ts:104-236) est appelée uniquement depuis _fetchWinningStrategySignals (winning-strategy.ts:412). Cette fonction n'est consommée que par :
src/app/api/v1/strategy/winning/route.ts(API publique)src/lib/mcp/execute.ts(tool MCP)
Aucune page UI (/performance, /methodologie, /recommandations, /strategie, /fonctionnement) n'utilise getWinningStrategySignals. Les weight calculés par applyRiskCaps n'apparaissent donc nulle part visible pour l'utilisateur final.
De plus, vol60d est forcé à null à winning-strategy.ts:410. Conclusion : useVolTargeting: true est un no-op — le code branche dès winning-strategy.ts:122 sur la branche equal-weight. Le flag dans la doc et la marketing copy ("inverse-vol sizing") est mensonger : il n'y a pas de pondération par volatilité, juste equal-weight.
2.4 — Le filtre mid-cap 200M-1B du backtest live diffère de l'« univers Winning » de /performance (HAUT)
performance-data.ts:402-408 "★ Stratégie Sigma recommandée" filtre :
- role ∈ {ceo, cfo} (PAS de "director")
- isCluster
- pubDelay ≤ 5 jours
- AUCUN filtre mid-cap
- AUCUN filtre signalScore ≥ 40
winning-strategy.ts:34-74 "Winning Strategy" filtre :
- role ∈ {ceo, cfo, director}, exclut board
- isCluster
- pubDelay ≤ 7 jours
- mid-cap 200M-1B
- signalScore ≥ 40
→ Ce sont deux stratégies différentes présentées au visiteur comme étant la même chose ("★ Stratégie Sigma"). Les Sharpe / CAGR affichés sur /performance (qui est la page la plus consultée) ne correspondent pas au STRATEGY_PROOF (lui-même obsolète). Trois univers différents racontent trois histoires distinctes.
2.5 — recommendation-engine.ts et winning-strategy.ts ont des classifieurs de rôle subtilement différents (MOYEN)
winning-strategy.ts:280-285:directeur|director→ "director" (et donc gardé viaexcludeBoardRole)recommendation-engine.tsne classifie pas par "category", il utilise directementnormalizeRole()(role-utils.ts) qui retourne"PDG/DG"|"CFO/DAF"|"Directeur"|"CA/Board"|"Autre"performance-data.ts:115-119a sa propreroleCategory()qui dépend deroleFunctionScore()et de regex, plus permissive sur "director"
Le même insider peut donc se voir classer différemment selon le pipeline. Un "Directeur de la communication" passera le filtre dans winning-strategy et performance-data mais sera scoré ~9 pts dans signals.ts:functionScore. Aucune erreur fatale, mais 3 sources de vérité pour la même information.
2.6 — Le pattern bonus n'est appliqué que côté BUY mais le sign est mal-calibré (HAUT)
signals.ts:334-336 : patternBonusScore n'est ajouté qu'en BUY. Cohérent avec l'article CMP 2012 (qui parle d'acquisitions seulement). Mais les mesures actuelles montrent que sur AMF/FR le signe est inversé :
| Cohort | n | avg T+90 | Win | Sharpe |
|---|---|---|---|---|
| opportunistic | 73 | +13.71 % | 80.8 % | 0.76 |
| routine | 85 | +48.62 % | 83.5 % | 1.66 |
| new | 15 | -5.62 % | 26.7 % | -0.35 |
→ Donner +12 pts à opportunistic et -8 pts à routine pénalise activement le meilleur sous-cohort de la base. Le delta de score est +20 pts entre routine et opportunistic, ce qui dépasse même le cluster directionnel (18 pts). C'est probablement le bug le plus contre-productif aujourd'hui en termes d'impact sur le ranking.
excludeRoutineInsiders=false est sage en l'état, mais le scoring lui-même est contaminé et la doc en interne (commentaire winning-strategy.ts:54-60) reconnaît le risque sans le corriger. Le delta mesuré est sans appel : exclure les routine fait passer l'univers de 173→88 et écraser le rendement de +29.2 % → +10.4 %.
Pourquoi routine outperforme sur FR
- Petit-N : 173 trades sur 5 ans = quelques clusters dominants. Hermès, Christian Dior, Eurazeo, Vinci… leurs PDG achètent chaque année au même mois (vesting / RSU) → étiquetés "routine" mais sociétés porteuses indépendamment.
- Concentration sectorielle : Luxe & Mode = 41 % de l'univers. Les insiders luxe font de l'accumulation régulière → routine. Le sous-jacent porte le rendement, pas la classification.
- AMF 3-day deadline : impose une cadence régulière de publication même pour des trades opportunistes, brouillant la détection de "consecutive month" qui marche bien sur SEC (10-Q timing).
- Survivorship bias : on n'a la classification routine que pour les insiders avec ≥ 3 trades historiques, donc des sociétés qui ont survécu et publié assez longtemps.
Diagnostic avant flip : recommandation = ne PAS activer excludeRoutineInsiders, et baisser le patternBonusScore à neutre (0/0/0) en attendant un grid-search post-PIT. La feature reste utile (classification visible UI) mais le scoring ne doit pas se reposer dessus.
2.7 — displayStalenessPenalty peut rendre recencyPts négatif puis tronqué à 0 (BAS)
recommendation-engine.ts:307 : Math.max(0, ...). Pour une déclaration vieille de >30 j la pénalité (-5) peut dépasser le rawRecencyPts (qui décroît exponentiellement avec halflife=45j → ~30 j ~ 9.5 pts). OK numériquement, mais aucune trace : deux trades vieux de 90 j vs 200 j auront tous deux recencyPts=0, alors qu'on voudrait les départager par autre chose (l'ordre signalScore desc les sort déjà, donc impact pratique faible).
2.8 — mergeByCompany peut faire monter recoScore sans recalculer (BAS)
recommendation-engine.ts:372-405 : quand on merge deux cards pour la même société, on garde le recoScore de la première seulement. La deuxième déclaration contribue à totalAmount et declarationCount mais pas au score. Conséquence : pour une société avec 5 dirigeants ayant acheté, le score affiché est celui du seul "best" décl, pas celui d'un signal-agrégé. C'est plutôt conservateur, mais incohérent avec la mention "cluster" qui implique une agrégation.
2.9 — analyzePosition se base sur le pubDate au lieu du transactionDate (BAS)
portfolio-advisor.ts:101-118 : la détection d'achat / cluster utilise pubDate. Pour un trade publié 7 j après la transaction, la fenêtre 30 j sur pubDate est plus stricte que 30 j sur transactionDate. C'est cohérent avec le mode "retail-view" (l'utilisateur ne voit l'info qu'à la pub), mais le commentaire de la fonction ne le dit pas. À documenter.
2.10 — clusterStrengthScore saut brutal à 12 pts dès 2 insiders (MOYEN)
signals.ts:144-149 : passer de 1 à 2 insiders donne +12 pts d'un coup, soit ~50 % du budget cluster (18 pts). Sensible aux faux clusters (cas père/fils, mêmes initiales, holding personnel). Le cross-trade filter de recommendation-engine.ts:487-496 neutralise les cas évidents mais signals.scoreDeclarations ne fait pas ce filtre — donc le score brut peut être inflated par des transmissions familiales avant que la dominance les masque côté reco. À titre d'illustration : un "Maxime Séché / Joël Séché" gonflera le signalScore à 60+ avant d'être éjecté du tab BUY.
2.11 — targetMean filtre dans winning-strategy.ts (champ select inutile) (TRIVIAL)
winning-strategy.ts:351 sélectionne analystReco, targetMean mais ne s'en sert pas pour le filtre. Pas un bug, juste du payload mort.
2.12 — Recap des risques PT-leakage non encore mitigés (cf. 06-pit-audit.md)
| TODO | État | Critique pour |
|---|---|---|
| TODO-1 (PIT marketCap) | non livré | filtre 200M-1B, sizeLabel bucket, pctOfMarketCap (16 pts) |
TODO-2 (computeCompositeHistorical) |
non livré | jusqu'à 10 pts de composite faussés sur décls > 90 j |
| TODO-3 (PIT analyst consensus) | non livré | fundamentalsScore (4 pts) + analystContrarianScore (6 pts) |
Les commentaires // LEAKAGE sont en place dans signals.ts mais aucune des trois infrastructures n'est livrée. Toute interprétation de STRATEGY_PROOF, byYear ou byRole doit encore être prise avec une marge de biais ~ 5-15 pts.
3. Validation des changements récents
3.1 — Pattern bonus (CMP 2012)
| Aspect | Verdict |
|---|---|
| Classification correcte (mécaniquement) | OUI — classifyFromAcquisitionDates est limpide et testable |
| Calibration sur FR | NON — les signes (+12 / -8) sont inversés vs réalité mesurée |
| Diagnostic du biais | confirmé (sectoriel + petit-N + AMF deadline) |
| Recommandation | désactiver patternBonusScore (retourner 0) en attendant un re-tuning ; garder la classification visible UI |
3.2 — Risk overlays
| Overlay | Défini | Wiré dans _fetchWinningStrategySignals |
Affiché UI | Effet réel |
|---|---|---|---|---|
maxPositionPct: 0.08 |
oui | oui (applyRiskCaps) |
NON | bloque les surpondérations dans le JSON API mais l'UI ne consomme pas l'API |
maxSectorPct: 0.30 |
oui | oui | NON | idem |
useVolTargeting: true |
oui | court-circuité (vol60d=null) |
NON | no-op — fallback equal-weight |
Conclusion : les overlays sont du vaporware partiel. Le code est honnête sur les TODO, mais la marketing copy de la page /methodologie et WINNING_STRATEGY impliquent que ces caps sont actifs. Tant qu'on n'a pas (a) une UI qui consomme weight, (b) une table PriceHistory pour vol60d, ces flags devraient être marqués // NOT YET WIRED ou retirés.
4. Backtest live — mesures fraîches (2026-05-14)
Univers WINNING_STRATEGY strict (BUY · cluster≥2 · mid-cap 200M-1B · role∈{PDG,CFO,Dir} · pubDelay≤7d · signalScore≥40) :
| Métrique | Valeur |
|---|---|
| Univers post-filtre (n) | 173 |
| avg T+90 brut | +29.18 % |
| Median T+90 | +25.54 % |
| Std T+90 | 31.20 % |
| Sharpe (quarterly) | 0.94 |
| Sharpe annualisé (×2) | 1.87 |
| Win-rate brut | 77.5 % |
| Win-rate 95 % CI Wilson | [70.7 %, 83.0 %] |
| Max DD (1 % weight/signal, séquentiel) | −1.30 % |
Avertissement : ces numéros sont bruts, sans coût de transaction (1 %), sans cap sectoriel, sans cap par nom, sans rebalancement explicite. Ils n'intègrent pas les biais PIT (TODO-1/2/3). La même mesure passée à travers runStrategy() de performance-data.ts produit des numéros sensiblement différents (mensualisé, cost-adjusted) — d'où l'inconsistance section 2.4. À chiffres comparables (T+90 / 3 mois après coûts 1 %) on attendrait Sharpe annualisé ~1.0–1.2 mensuel.
4.1 — Hit-ratio par secteur (univers 173, cf. §2.2)
Domination critique de Luxe & Mode (41.6 % de l'univers, 58 % T+90). Si l'on cappait à 30 % par secteur conformément à maxSectorPct, on perdrait ~12 signaux luxe → impact estimé ≈ −5 pts sur l'avg T+90 (12 trades × 58 % / 173 ≈ 4 pts attribuables). Cap non appliqué = surperformance non reproductible dans un portefeuille réel.
4.2 — Sans le filtre routine
withoutFilter: n=173, avg=+29.18 %, win=77.5 %, Sharpe=0.94
withFilter: n= 88, avg=+10.41 %, win=71.6 %, Sharpe=0.54
delta: -85, -18.77 pts, -5.9 pts, -0.39
L'exclusion des routine ampute l'univers de moitié et détruit ~63 % du rendement. À conserver tel quel (false).
4.3 — byYear détaillé
Voir tableau §2.1. Points marquants :
- 2022 perdant (-7.89 %, win 14 %) — invalide la claim "battu CAC 40 chaque année"
- 2024 décevant (-3.70 %, win 38 %) — idem
- 2025 spectaculaire (+48.30 %, win 89 %) — porté par luxe / défense (cycle post-COVID + crise géopolitique)
- échantillon 2021 (n=4) statistiquement inutilisable
5. Sanity-check STRATEGY_PROOF
| Question | Réponse |
|---|---|
| Hard-codé ou dérivé ? | Hard-codé (winning-strategy.ts:508-524) |
| Date du calcul | 2026-04-24 (lastUpdatedAt) |
| Source revendiquée | scripts/grid-search-v2.mjs |
| Match avec les données actuelles ? | NON. Écarts 2025 = +22.8 pts, 2022 = +15.8 pts, totalSamples 380 vs réel 173 |
| Claim "Beats CAC every year" | FAUX sur 2022 et 2024 |
| Sharpe 1.0 | sous-évalué (mesure 1.87 annualisé brut), mais après coûts probable 0.8-1.0 |
| MaxDD -12 % | invérifiable sans courbe NAV ; mesure approximative à -1.3 % avec 1 % weight/signal mais non comparable |
Verdict : STRATEGY_PROOF est trompeur en l'état. Soit le remettre à jour mécaniquement et le derive runtime, soit l'enlever de la prod.
6. Cinq actions recommandées (impact × effort)
| Rang | Action | Impact | Effort | Fichiers |
|---|---|---|---|---|
| 1 | Désactiver patternBonusScore (retourner 0 jusqu'au re-tuning) |
très haut · annule ±20 pts de score mal-calibrés sur tout BUY | < 1 h | insider-pattern.ts:166-170 |
| 2 | Re-dériver STRATEGY_PROOF runtime (cached 24 h) au lieu du hard-code, ou retirer la section "battu chaque année" |
très haut · risque légal / confiance | < 1 j | winning-strategy.ts:508-524 + page /methodologie |
| 3 | Aligner performance-data.ts "★ Stratégie Sigma" sur les filtres exacts de WINNING_STRATEGY (delay 7j, mid-cap, score≥40) ou renommer pour expliciter qu'il s'agit de stratégies différentes |
haut · cohérence narrative | < 1 j | performance-data.ts:397-409 |
| 4 | Soit livrer TODO-1 (CompanyMarketCapSnapshot PIT) — c'est la dette technique la plus coûteuse côté crédibilité, soit retirer le filtre mid-cap et basculer sur des buckets pctOfMarketCap (déjà PIT-safe) |
haut · ferme le plus gros risque PIT | 1-2 sem. | signals.ts:546-554, winning-strategy.ts:313-318 |
| 5 | Marquer useVolTargeting, maxPositionPct, maxSectorPct comme STATUS: NOT_WIRED_TO_UI ou implémenter la consommation dans une UI portefeuille pédagogique (/performance#concentration). Au minimum, appliquer maxSectorPct=0.30 aux backtests historiques pour des numéros publiés cohérents avec la stratégie revendiquée |
moyen-haut · couvre le risque "Luxe = 41 %" | 2-3 j | winning-strategy.ts:65-74, performance-data.ts |
Résumé exécutif (< 300 mots)
Top 3 findings critiques
STRATEGY_PROOFest obsolète et mensonger : hard-codé au 2026-04-24, claim "+25.5 % en 2025 / battu CAC 40 chaque année" alors que les données actuelles donnent +48 % en 2025 mais −7.9 % en 2022 et −3.7 % en 2024. La phrase "le seul filtre sur 583k testés qui bat le CAC chaque année" est aujourd'hui fausse sur 2 années sur 4. Risque de confiance immédiat.Pattern bonus mal-calibré pour FR :
+12 ptsopportunistic /−8 ptsroutine, alors que sur AMF la cohort routine surperforme l'opportunistic de +35 pts T+90 (+48.6 % vs +13.7 %, n=85 vs 73). Le scoring est activement contaminé par ~20 pts mal-signés sur les BUY.Risk overlays non câblés :
maxPositionPct,maxSectorPct,useVolTargetingsont définis dansWINNING_STRATEGYmais (a) seul l'API/MCP consommeapplyRiskCaps, aucune UI ; (b)useVolTargetingest un no-op (vol60d=nulltoujours). 41.6 % de l'univers vient d'un seul secteur (Luxe & Mode) — la stratégie revendiquée à 30 %/secteur n'est pas reproductible sans rétro-cap.
Top 3 quick wins (< 1 jour)
- Forcer
patternBonusScoreà 0 en attendant le re-tuning (commit 1 ligne, re-score nocturne, gain immédiat de cohérence). - Aligner les filtres "★ Stratégie Sigma" entre
performance-data.tsetwinning-strategy.ts(5-day vs 7-day delay, mid-cap manquant, minScore manquant) — fin de la divergence visible. - Auto-régénérer
STRATEGY_PROOFviaunstable_cache(_, 86400)qui re-rungrid-search-v2ou un strip-down — éliminer le hard-code daté.
Verdicts
- Opportunistic vs Routine : RE-TUNE. Le classifier est correct mécaniquement, l'effet existe (Sharpe routine 1.66 vs new -0.35 vs opp. 0.76), mais les signes du bonus sont inversés vs littérature US. Garder la classification visible UI mais neutraliser le bonus (retourner 0) jusqu'à un grid-search sur retail-returns AMF.
STRATEGY_PROOF: NON-RELIABLE. Numéros datés, claim "battu chaque année" fausse, totalSamples inexact (380 vs 173). À retirer ou regénérer immédiatement.