23, Multi-market scoring v4 (proposal, not implemented)
Auteur : quant review post-multi-market ingestion
Date : 2026-05-16
Status : DESIGN ONLY, src/lib/winning-strategy.ts not modified, no DB writes.
Prereqs : audits 11 (scoring backtest), 13 (feature eng.), 19 (SEC Form 4 r3), 20 (multi-market feasibility).
TL;DR (one-line composite)
recoScore_v4(d, m) = α_m · signalPts(d) + β_m · winRatePts(d, m) + γ_m · returnPts(d, m) + δ_m · recencyPts(d) + liquidityAdj(d, m)
where (α_m, β_m, γ_m, δ_m) are per-market weight vectors that sum to 100,
liquidityAdj ∈ [-5, +5] is a market-median trade-size z-score, and the
country risk premium is OFF by default (placeholder).
FR weights are the validated A1 baseline (35, 25, 20, 20). Other markets
redistribute when a component cannot be computed.
1. Why v4 (not a re-grid-search)
Today's WINNING_STRATEGY + recommendation-engine v6 are tuned ONLY on FR
data:
| Market | n decl. | named insider | cluster computed | tx date | total amount | signalScore populated |
BacktestResult rows |
|---|---|---|---|---|---|---|---|
| FR (AMF) | 25 801 | 25 760 | 9 267 | 25 394 | 22 354 | 22 255 | 15 171 |
| US (SEC) | 6 206 | 6 206 | 0 | 6 206 | 3 456 | 0 | 0 |
| DE (BaFin) | 295 | 295 | 0 | 295 | 0 | 0 | 0 |
| CH (SIX) | 226 | 226 | 0 | 226 | 226 | 0 | 0 |
| UK (RNS) | 176 | 176 | 0 | 176 | 99 | 0 | 0 |
Two hard facts:
- No
signalScoreoutside FR, the scoring cron is FR-only. A v4 composite that calls into v3signalScorereturns null for ~6 900 non-FR decls. Either compute v3 per-market (recommended) OR fall back to a role-only proxy when null. - No
BacktestResultoutside FR, winRatePts / returnPts cannot be computed for US/DE/CH/UK until the T+90 backfill job runs on those markets. For now their bucket priors fall back to__overall(which is the FR universe, leakage of FR-specific structure). Honest: until the BR backfill runs, non-FR signals are essentially signalPts + recencyPts only.
A blind per-market grid search on n=176 (UK) or n=226 (CH) is statistically indefensible, the FR A1 search used n=15 171 BUY rows and the deflated Sharpe is already negative. v4 therefore inherits FR weights and only redistributes mass when a component is structurally unavailable.
2. Per-market component matrix (4 × 6)
✓ = component computes natively · →X = redistributed to component X
· prior = uses overall side prior only (no track-record signal) ·
penalty = applied as a flat point deduction.
| Component (max pts → FR baseline) | FR | US | DE | CH | UK | (JP/IT/ES TBD) |
|---|---|---|---|---|---|---|
| signalPts (35), track-record + role + cluster | 35 | 35 | 30 | 15 | 30 | n/a |
| winRatePts (25), Bayes-shrunk bucket | 25 | 25* | 25* | 25* | 25* | n/a |
| returnPts (20), z-score vs side mean | 20 | 20* | 20* | 20* | 20* | n/a |
| recencyPts (20), exp decay 21d HL | 20 | 20 | 25 | 30 | 20 | n/a |
| liquidityAdj (±5, off composite) | ±5 | ±5 | ±5 | ±5 | ±5 | n/a |
| Connected-person penalty | , | , | , | , | −5 | , |
| Effective weight sum | 100 | 100 | 100 | 100 | 100 | , |
* = computed only once the per-market BacktestResult backfill lands; until
then winRatePts = 8 cold-start and returnPts = 10 (neutral), consistent
with buildRecoItem's existing cold-start branch.
CH adjustment (key): anonymised filings = no per-insider track record. The
20-point signalPts component that came from CMP-2012 pattern + insider
cumulative net is structurally unavailable. Redistribute:
- 20 → 15 on signalPts (role weight + cluster proxy via same-day same-issuer filings can still fire if we synthesize a CH-specific cluster window).
- +10 to recencyPts (CH SER filings have a 4-day publication SLA, freshness carries more information than elsewhere).
- net redistribution: −20 from signalPts, +10 to recencyPts, +10 absorbed by role-weight inside signalPts itself (keep at 15 ceiling).
UK adjustment: PMR connected-persons (spouse, controlled entity) file
under the PDMR identity, polluting track-record. Apply a flat −5 penalty on
signalPts when RNS.connectedPerson === true (parsed from RNS body regex).
DE adjustment: BaFin has no totalAmount natively (audit 20 shows 0/295
have amount populated, derived qty×price still missing). Skew toward
recency until the price-per-share backfill lands.
3. Liquidity / market-size normalization
Cross-market amounts are already EUR-normalized (FX history landed in audit 19). But absolute EUR amounts are biased: a 1 M€ US insider buy is ~5× more common than a 1 M€ FR buy. Without normalization, the cross-market top-N is dominated by US tickets.
Proposed liquidityAdj (signed, ±5 pts, OUTSIDE the 100-point composite):
mtm = log10(totalAmountEur / marketMedianTradeSizeEur[m])
liquidityAdj = clamp(mtm * 2, -5, +5)
Market medians (to compute once at startup from BacktestResult or
Declaration):
| Market | Median EUR ticket (estim.) | Notes |
|---|---|---|
| FR | ~25 k€ | AMF small-cap skew |
| US | ~80 k€ | SEC universe is mostly large-cap |
| DE | ~120 k€ | DAX/MDAX large-cap |
| CH | ~30 k€ | SMI mid-cap heavy |
| UK | ~40 k€ | RNS mid-cap heavy |
Estimates only, to be replaced by SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY totalAmount) FROM "Declaration" GROUP BY market once amounts
populate everywhere.
4. Country risk premium (placeholder, OFF)
crp = {fr: 0, us: 0, de: 0, ch: -2 (CHF havenness premium), uk: +1 (post-Brexit liquidity discount)}
Flagged here for completeness, would need its own walk-forward on ≥3 years of US/DE/CH/UK backtest data before being switched on. Not in v4 ship.
5. Validation, what's measurable today vs what isn't
5.1 Measurable now (FR only)
The FR A1 baseline already cleared the deflated-Sharpe gate (audits 11 + 15);
v4 with α_fr,β_fr,γ_fr,δ_fr = (35,25,20,20) is identical to A1 on FR
rows by construction. No regression risk on the validated market.
5.2 Not measurable today (US/DE/CH/UK)
Per the table in §1: 0 BacktestResult rows on non-FR markets. A T+90
backtest at composite ≥ 60 cannot be computed for US/DE/CH/UK until the
scripts/backfill-backtest-results.mjs (or equivalent) runs on the imported
declarations.
This is the honesty line of the proposal. v4 should ship behind a feature flag with composite ≥ 60 surfaced ONLY on FR until per-market T+90 stats are in. Non-FR markets surface a "preview, unvalidated" badge.
5.3 Sample-size limits per market
| Market | n | A grid-search would need… | Verdict |
|---|---|---|---|
| FR | 25 801 | n ≥ 5 000 per bucket × 6 trials | ✓ done |
| US | 6 206 | n ≥ 5 000, borderline if not stratified by sector | acceptable for the same A1 weights, NOT for re-tuning |
| DE | 295 | needs 5 000+ | too small, inherit FR weights, no per-market tune |
| CH | 226 | same | too small, adjustment from §2 is structural, not statistical |
| UK | 176 | same | too small, same |
v4 explicitly does NOT re-tune weights on small markets. The §2 adjustments are structural (which component is computable), not empirical. This is the only intellectually defensible move at n < 1 000.
6. Implementation plan, minimal diff
Three concrete code changes (file:line numbers from current HEAD):
Change 1, accept market: Market argument
File: src/lib/winning-strategy.ts:489
// BEFORE
export async function getWinningStrategySignals(opts: {
limit?: number;
lookbackDays?: number;
} = {}): Promise<WinningSignal[]> { ... }
// AFTER
export async function getWinningStrategySignals(opts: {
limit?: number;
lookbackDays?: number;
market?: MarketFilter; // default "fr" to preserve current behavior
} = {}): Promise<WinningSignal[]> { ... }
Wire marketPrismaWhere(opts.market ?? "fr", "amfId") into the where block
at line 316. No change to thresholds or filters.
Change 2, per-market weight selector for recommendation-engine.ts
File: src/lib/recommendation-engine.ts:466-498 (in buildRecoItem)
Introduce a marketWeights table keyed by Market:
const MARKET_WEIGHTS: Record<Market, { sig: number; wr: number; ret: number; rec: number }> = {
fr: { sig: 35, wr: 25, ret: 20, rec: 20 },
us: { sig: 35, wr: 25, ret: 20, rec: 20 }, // inherit until BR backfill validates
de: { sig: 30, wr: 25, ret: 20, rec: 25 },
ch: { sig: 15, wr: 25, ret: 20, rec: 30 + 10 /* role+ceiling */ },
uk: { sig: 30, wr: 25, ret: 20, rec: 20 }, // +UK connected-person penalty applied below
};
Replace the four (decl.signalScore ?? 0) / 100) * 35 style hard-coded
weights with MARKET_WEIGHTS[market].sig etc. Add a −5 penalty branch
when market === "uk" && decl.isConnectedPerson === true
(field to be added by RNS parser; until then, no-op).
Change 3, liquidity adjust as a post-hoc bump
File: src/lib/recommendation-engine.ts:496 (after recoScoreRaw line)
const liqAdj = computeLiquidityAdj(decl.totalAmount, MARKET_MEDIANS[market]);
const recoScore = Math.round(Math.max(0, Math.min(100, recoScoreRaw + liqAdj)));
MARKET_MEDIANS is a hard-coded constants table refreshed monthly from the
SQL percentile query in §3. Bounded ±5 → cannot push a 95-score above 100
nor a 5-score below 0.
7. Edge cases
- CH role-only: no insider FK history →
signalPtsuses role weight only. ThetradingPatternslot (CMP-2012) cannot be filled, returnpattern: "new"for all CH insiders (same as< 3 historybranch ininsider-pattern.ts:55). - Connected-person UK: needs an
isConnectedPersonboolean onDeclaration(parser change inscripts/ingest-rns.mjs). Until shipped, apply nothing, graceful no-op. - Missing
totalAmount(DE):liquidityAdj = 0. Don't subtract pts when the data is missing, the user would see DE consistently penalised for an ingestion gap, not a real liquidity signal. - Missing
signalScore(all non-FR today): cold-start path inbuildRecoItem:472already handleswinRate == null. We also needsignalPts = (decl.role-weight-fallback)whensignalScoreis null rather than0. Suggestion:signalPts = roleBasePts(role) * α_m / 100whereroleBasePts(CEO) = 20,(CFO) = 18,(director) = 12. Honest ceiling: ~20/35, non-FR signals top out at composite ~60 until the scoring cron runs cross-market.
8. Risk register (markets too small for independent calibration)
| Market | n | Risk if we re-tune | v4 mitigation |
|---|---|---|---|
| DE | 295 | overfitting to one BaFin sub-cohort (Allianz dominates) | inherit FR weights |
| CH | 226 | overfit to a handful of SIX issuers | inherit + §2 structural adj. |
| UK | 176 | RNS regex parser quality > weight choice | inherit + connected-person flag |
| US | 6 206 | acceptable for validation; tempting to re-tune | inherit until cross-market BacktestResult available; THEN run ONE grid search across pooled US+FR (n ≈ 21 k) |
Hard rule: do not run a per-market grid search at n < 5 000. The Bailey–
López de Prado penalty √(2·ln N_trials / T) is already negative-driving on
the FR universe with T=4 yearly buckets; on T=1-2 for small markets it would
nuke any "tuned" Sharpe.
9. Ship checklist (when implementing)
- Add
Marketarg togetWinningStrategySignals+ plumbmarketPrismaWhere. - Add
MARKET_WEIGHTS+MARKET_MEDIANSconstants tables. -
roleBasePtsfallback for nullsignalScore. - Feature flag
MULTI_MARKET_RECOS_ENABLED(default OFF). Surface non-FR recos with a "preview, unvalidated T+90" badge until BR backfill lands. - Per-market
BacktestResultbackfill job. Once T+90 is populated for US (n ≈ 6 k), re-run the audit-11 harness pooled FR+US and confirm A1 weights still win. - CH
isConnectedPerson+ UK penalty parser only after the RNS body regex is validated against ≥ 50 hand-labeled samples. - Re-run audit 12 walk-forward with the pooled FR+US universe, if Sharpe ann. ≥ 0.4 on US-only fold, ratify A1 weights for US. Else, revert to FR-only surfacing.
10. What this proposal does NOT do
- Does not modify
src/lib/winning-strategy.ts(per task constraint). - Does not re-run a grid search on small markets, n is too low.
- Does not turn on the country risk premium, placeholder only.
- Does not claim per-market validation, §5.2 explicitly states US/DE/CH/ UK T+90 win rate cannot be computed until the BR backfill ships.
The single defensible claim: v4 is identical to A1 on FR (the validated
slice) and degrades gracefully on markets where components are structurally
missing. Anything stronger requires the cross-market BacktestResult
backfill called out in §9.