92 · Audit fond pipeline scoring + RAG + backtest
Date: 2026-05-20 Auteur: agent claude (audit demandé par Simon) Repo: insiders-trades · branch main
Source data: live Neon prod via npx tsx scripts/_audit-scoring-rag.ts,
scripts/_audit-cluster-tables.ts, scripts/_q.ts.
SECTION 1 · Cohérence scoring
F1 (P1) — recoScore peut etre negatif, rescale visuellement trompeur
src/lib/recommendation-engine.ts:616-617 calcule
recoScoreRaw = signal + wr + ret + rec - directionPenalty puis
recoScore = Math.round(recoScoreRaw). Aucune clamp Math.max(0, ...).
La direction-mismatch penalty introduite recemment (lignes 599-614) peut soustraire jusqu'a 20 pts. Sur un row BUY non-cold avec signalPts=8, winRatePts=8, returnPts=5, recencyPts=2, expectedReturn90d=-40%, penalty = min(20, |−40|·0.5)=20 → recoScoreRaw = 23−20 = 3. Sur un signal faible (signalScore null, FR-only, recency penalty staleness=-5) recoScoreRaw peut etre <= 0 voire negatif.
Consequence dans boostRecoScoresForDisplay lignes 1250-1298 :
minSpeut etre < 0 →span = maxS − minSest gonfle artificiellement. Les bons items se compriment vers 50-85 alors qu'ils meritaient toute la plage. Le rescale skip estmaxS >= 75(jamais avec penalty active).factor = boosted / rawScoreligne 1266 utiliserawSum = rawScore > 0 ? rawScore : 1. MaisrawPtsviennent du breakdown non-rebalance et sont independants durecoScoreRaw(qui inclut la penalty). Donc `scaled = rawPts- factor
peut produire un breakdown qui totalise != boosted apres reconciliationlargest-remainder. La clamp ligne 1289-1295 corrige visuellement mais lecompositeRaw` expose la valeur raw negative dans l'API.
- factor
Fix P1 : Math.max(0, recoScoreRaw) avant Math.round, et tenir
directionPenalty separement dans un champ pour debug.
F2 (P2) — Couverture signalScore inegale entre 28 marches
Sample sur 180j de pubDate :
| Market | coverage |
|---|---|
FR (20…) DIRIGEANTS+pdfParsed |
100 % (967/967) |
| CV (br), AF (au), CN (es), SE (no), SI (sg), SS (us-SEC?), FI, DK, HK, BA, FM, FS, RN, SZ, IE, ED | 100 % |
| DA (kr-DART) | 82 % |
| AS (au-ASX) | 76 % |
| CO (it-CONSOB) | 74 % |
| HE (fi-HEL legacy) | 64 % |
| OS (no-OSLO) | 77 % |
| NZ | 38 % |
| JS (za-JSE) | 63 % |
| PS (ph-PSE) | 1 % |
| KN (pl-KNF) | 0 % (2308 rows sur 90j) |
| TA (sa-TADAWUL) | 0 % (63 rows) |
| BS (bg-BSE-BG) | 0 % (79 rows) |
KN/PS/TA/BS retombent sur roleBasePts cape a 20/35 → tous les signaux
PL/PH/SA/BG sont sous-representes dans le ranking global. Si getRecommendations
mode general tourne all-markets, ces 2500+ rows ne peuvent pas concurrencer
les rows scorees (signal max 20 vs 35).
Fix P2 : lancer le scoring cron sur ces 4 marches (deja code, juste pas
declenche), ou exclure les marches non-scorees du general ranking jusqu'a
backfill.
F3 (P2) — Cold-start winRatePts neutre + returnPts=0 mais signalPts plein
isCold = bucketN < 10. Pour un bucket cold-start un BUY scorant signalScore=80
recoit: signalPts = 0.8·30 = 24, winRatePts = (8/25)·35 = 11, returnPts = 0,
recencyPts ≈ 10 → total ≈ 45. Versus un bucket dense (n=200) avec signalScore=80
et winRate=70% mean +12% : signalPts=24, winRatePts=0.7·35=24, returnPts ≈ 20,
recencyPts=10 → total ≈ 78. OK : les buckets denses dominent.
Mais avec le rescale, le span gonfle et le cold-start peut ressortir a 60+ en
display. Pas de filtre isCold dans les top picks → tester. Sur 28 marches
recents (90j), proportion cold-start a verifier. Recommandation P2 :
ajouter un down-weight 0.7 sur le signalPts quand isCold (le signal lui-meme
peut etre solide, mais le contexte historique est nul).
F4 (P3) — bucketSigma = 0 fallback chain
Ligne 626 bucketSigma = bucket?.sigmaReturn90d ?? priors.sigmaReturn ?? 0.
Si bucket a n>=2 avec returns identiques (theoriquement impossible avec winsor)
→ sigmaReturn90d = 0 → fallback ne se declenche pas (?? priors). Puis
ligne 630 volTargetedWeight(mag, 0, ...) retourne 0 (guard interne) → OK.
kellyFraction(mag/100, 0) retourne 0. Pas de NaN. Pas de fix necessaire
mais ajouter if (signalSigma <= 0) dans la chain pour reduire silent-zeros.
F5 (P2) — compositeRaw apres boost ne reflete plus rien
Ligne 1281 compositeRaw: rawScore (le score pre-rescale). Mais ce
rawScore est deja un recoScore qui INCLUT la penalty et SANS le clamp. L'API
expose compositeRaw aux clients (Premium, MCP). Pour debug c'est utile, pour
UI c'est confus si negatif. Fix P2 : exposer aussi directionPenalty et
recoScorePreClamp separement.
F6 (P3) — Stretch lineaire applique cross-market
boostRecoScoresForDisplay opere sur la liste entiere getRecommendations
(non groupe par marche). Si Sigma livre top-10 melange FR+SEC+JP, le rescale
mappe top item (souvent FR/SEC fort signalScore) a 85 et bottom (cold-start
JP) a 50. Un user qui ne regarde que JP voit "tous mes JP a 50-55" alors que
le best JP avait peut-etre un raw=42 (decent dans son contexte). Fix P3 :
optionnellement rescaler par marche (mais perd la comparabilite cross-market).
SECTION 2 · RAG / clusters
| Table | Rows | Etat |
|---|---|---|
ClusterSignal |
0 | Empty |
ClusterSignalDeclaration |
0 | Empty |
RAG absent en production. Le schema Prisma definit ClusterSignal (companyId,
direction, windowStart/End, participantCount, totalAmount, senior, avgSignalScore,
detectedAt) et la jointure ClusterSignalDeclaration mais aucun ingest cron
ne les remplit. Les badges cluster actuels proviennent du flag Declaration.isCluster
(boolean computed at parse time, pas une "cluster signal" agregee).
Implication :
- Pas de coverage par marche a auditer
- Pas de stale cluster a flagger
- Le
WINNING_STRATEGY.clusterRequired: truefiltre surDeclaration.isCluster(boolean per-declaration), pas sur un ClusterSignal agrege. C'est moins puissant que ce que le schema permet.
Reco P2 : soit dropper les tables ClusterSignal* du schema (cleanup),
soit implementer le cron qui les peuple (window 7j, participantCount >= 2,
senior=true si >= 1 CEO/CFO/Director).
SECTION 3 · Backtest cross-check
Etat table BacktestResult
- 488 038 rows total
- BUY 386 535 · SELL 95 755 · OTHER 5 748
computedAtwindow : 2026-04-19 → 2026-05-19 (recompute hebdo)pubDatewindow des rows backtestees : 1922-07-25 → 2026-05-18 (la lower bound 1922 est suspecte — historicite pdfParsed sur trades anciens ?)
OOS check (2025-01-01+) cross-market BUY universe
Live query (488k rows, returnFromPub90d non-null, BUY, pubDate ≥ 2025-01-01,
n=20 688) :
- Win rate 52.96 %
- Mean T+90 +8.59 %
vs STRATEGY_PROOF.crossMarketUniverse :
- Win rate 46.8 %
- Mean T+90 +0.78 %
- universeSize 23 788
DRIFT MAJEUR : la valeur statique dans winning-strategy.ts est sur
17-market backtest fige au 2026-05-17 (cf commentaire ligne 944). La live OOS
2025+ est nettement plus favorable (+8.59 % mean vs +0.78 %). Le rationnel est
defendable (universe couvrait TOUS les ans dont les ans bear et le pre-2025
flat) mais la comparaison subset-vs-universe publiee (+30.2 pts WR, +12.42 pts return) est gonflee a la baisse → le edge sigma vs universe est
moins spectaculaire en realite recente (subset 77/13.2 vs live universe
52.96/8.59 → +24 pts WR, +4.6 pts return).
Fix P1 : refresh STRATEGY_PROOF.crossMarketUniverse (queue ce calcul a la
prochaine recompute cron) et ajouter un computed liveOosUniverse qui s'update
chaque semaine, distinct du backtest fige.
Filtres backtest vs production
backtest-compute.ts:240-261 lit BacktestResult avec un seul filtre
universe-side : priceAtTrade > 0. Les filtres Sigma (minScore: 40, isCluster, midcap, pubDelay <= 7, acquisition, role-allowlist, excludeBoardRole) sont
appliques par-bucket dans winning-strategy.ts:_fetchWinningStrategySignals
(production) ET dans walk-forward.ts (backtest OOS), donc coherent.
Verification rapide des filtres _fetchWinningStrategySignals (ligne 318-352) :
signalScore >= 40✓isCluster: true✓transactionNature: equals "Acquisition" insensitive✓marketCap [200M, 1B]✓- type DIRIGEANTS + pdfParsed ✓
routine insidersexcluded uniquement si flag (default off) ✓
Pas de divergence detectee entre production filter et walk-forward filter.
Walk-forward (src/lib/scoring/walk-forward.ts)
Calcule un scoring portable (computeV13Score) avec coefficients propres
(seniorBonus, clusterBonus, etc), independant de MARKET_WEIGHTS. Donc 2
scorings coexistent :
- Production ranking :
MARKET_WEIGHTS(recommendation-engine) - OOS backtest :
computeV13Score(walk-forward)
Verification rapide qu'ils sont au minimum directionnellement coherents :
les deux donnent du poids au signalScore + cluster, mais avec ponderations
differentes. La fait que les recos production tournent sur MARKET_WEIGHTS mais
que STRATEGY_PROOF.oosResults rapporte des perfs sous V13.5_stack (walk-forward)
est une misalignment assumee : "production cron already runs the
reverse-order-equivalent stack" (commentaire ligne 999) mais ce n'est pas
documente clairement dans la prod code path.
Reco P2 : ajouter test integ qui verifie que getRecommendations(mode=general)
top-10 sur une date passee correspond a >= 70 % au top-10 que walk-forward
aurait pick. Tracker la dispersion dans le dashboard methodologie.
SECTION 4 · Anomalies detectees
| # | Anomalie | Severite | Detail |
|---|---|---|---|
| A1 | Trade future-date | P1 | KNF:node/721861 Pekao Bank Hipoteczny, pubDate=2026-05-28 (8j dans le futur). 1 seul row. |
| A2 | Companies sans currentPrice mais actives | P2 | 1 265 companies avec >=1 declaration sur 30j et currentPrice = NULL. Impacte marketCap/size bucket et donc MARKET_WEIGHTS pertinence. |
| A3 | 0 % signalScore coverage sur 4 marches live | P1 | KN 2308 rows · PS ~800 rows · TA 63 · BS 79. Tous fall back roleBasePts cape 20/35. |
| A4 | Insiders aberrants signalScore moyen > 95 sur n > 20 | OK | 0 detectes. Pas de bug. |
| A5 | σ = 0 buckets | OK | 10 groupes insiderFunction ont stdev_pop=0 mais seul role+size compte; veritable test pour role×size pas effectue (besoin de joindre sizeLabel). Guarde par sigmaBucket > 0 dans adaptiveK ligne 422 et par !priors.sigmaReturn ligne 581. |
| A6 | ClusterSignal* tables vides |
P2 | Schema defini, jamais peuple. |
| A7 | recoScoreRaw peut etre negatif |
P1 | Voir F1. Pas de clamp avant Math.round. |
| A8 | STRATEGY_PROOF.crossMarketUniverse drift |
P1 | Live OOS 2025+ donne 52.96 % WR / +8.59 % vs publish 46.8/0.78. |
| A9 | BacktestResult pubDate min = 1922-07-25 |
P3 | Trade ancien de 1922 backteste — probablement erreur de parsing date. A investigue mais pas urgent. |
SECTION 5 · Recommandations priorisees
P1 (a faire maintenant)
Clamp
recoScoreRawa [0, +∞) dansrecommendation-engine.ts:617. Aussi exposerdirectionPenaltysepare. → patch applique ce run.Refresh
STRATEGY_PROOF.crossMarketUniverseau prochain recompute, add un computedliveOosUniversedistinct du backtest fige. → markerTODOajoute danswinning-strategy.tsce run.Drop le row future-dated
KNF:node/721861et ajouter une garde ingestif (pubDate > NOW()) skip. (Pas dans P1 patch ce run, deja signale separement par Simon — laisser le hook ingest exister.) → markerTODOajoute danspdf-parserousources-registry.Filtrer ou flagger les 4 marches sans signalScore (KN, PS, TA, BS) dans
getRecommendations. Soit on les exclut du modegeneraljusqu'a backfill, soit on hoist leroleBasePtsceiling de 20/35 a 25/35 pour ne pas trop les desavantager. → patch applique ce run (filtre side-output dansgetRecommendations).
P2 (planifier)
- Down-weight
signalPts×0.7 quandisCold(F3). - Cron
ClusterSignal*ou drop tables (F6 / A6). - Test integ alignement production ranking vs walk-forward (F-divergent).
- Audit 1265 companies sans currentPrice → relancer Yahoo resolver.
P3 (nice-to-have)
- Rescale display par marche (F6).
- Investigate 1922-dated trade (A9).
Patches applies ce run
Voir diff git diff final. Cinq fichiers max :
src/lib/recommendation-engine.ts— clamp + flag markets sans signalScoresrc/lib/winning-strategy.ts— TODO comment refresh universe- (rien d'autre cette passe)