<< Sommaire QC | Précédent : QC-Py-05-Universe-Selection << | Suivant : QC-Py-06-Options-Trading >>

QC-Py-05b - F-Score de Piotroski : l’article, le papier, et trois doutes honnêtes

[RECHERCHE / PÉDAGOGIE] Le F-Score de Piotroski existe-t-il « dans la nature » sous une seule forme ? Non : c’est une convention de datation autant qu’un chiffre. Ce notebook implémente les 9 sous-scores sur données réelles, mesure ce que change la convention de timing, et rend mesurables les trois doutes structurels qui pèsent sur les métriques publiées.

Ce notebook est la distillation de l’article QuantConnect Piotroski F-Score Investing (Louis Szeto, 2025-11-04) et de sa discussion (Jared Broad, Derek Melchin — STAFF ; Pavel Fedorov, Fyker1, Michael Hofer). Il complète deux voisins du bouquet :

Ce que l’article annonce… et ce qu’il concède lui-même

Stratégie F-Score ≥ 7 SPY
CAR (07/2020-07/2023) 43,2 % 14,4 %
Sharpe 1,139 0,722
Vol annuelle 0,293 0,151
MaxDD 29,9 % (pire) 26,3 %

L’auteur conclut lui-même « weakly proven » : les intervalles de confiance à 95 % des rendements annuels moyens se chevauchent — (9,2 %, 76,6 %) pour la stratégie contre (−3,1 %, 31,8 %) pour SPY. La section 1 re-dérive ce chevauchement numériquement. Les sections 2-3 implémentent le score et mesurent l’effet de la convention de timing (l’article compare three_months à one_year, le papier de 2000 prescrit annuel + retard fiscal). La section 4 traite la critique de liquidité de Fedorov. La section 5 mesure la variance « small-universe » qui rend le Sharpe 2.09 de la library difficile à distinguer d’un 1,2 honnête.

1. « Weakly proven » : re-dériver le chevauchement des intervalles

Un IC95 à ±1,96 écarts-types autour du rendement annuel moyen. Deux stratégies dont les IC95 se chevauchent ne sont pas statistiquement séparables : observer 43,2 % côté F-Score et 14,4 % côté SPY sur 3 ans ne suffit pas à conclure que le filtre bat le marché. Dérivons-le sur les nombres publiés.

import numpy as np

# Intervalles IC95 des rendements annuels moyens tels que publies dans l'article
# (feuille Statistics du rapport de backtest, 3 ans de donnees).
ic_strategie = (9.2, 76.6)   # F-Score >= 7, en %
ic_spy = (-3.1, 31.8)        # SPY buy-and-hold, en %

for nom, (lo, hi) in [("Strategie F-Score >= 7", ic_strategie), ("SPY", ic_spy)]:
    demi = (hi - lo) / 2.0
    se = demi / 1.96
    centre = (lo + hi) / 2.0
    print(f"{nom:24s} IC95 [{lo:+6.1f}, {hi:+6.1f}]%  centre {centre:+5.1f}%  se ~= {se:4.1f} pts")

lo_ov = max(ic_strategie[0], ic_spy[0])
hi_ov = min(ic_strategie[1], ic_spy[1])
largeur = max(0.0, hi_ov - lo_ov)
print(f"\nZone de chevauchement : [{lo_ov:+.1f}, {hi_ov:+.1f}]% soit {largeur:.1f} points")

# Test de difference des moyennes avec les ecarts-types implicites.
c1 = sum(ic_strategie) / 2; c2 = sum(ic_spy) / 2
se1 = (ic_strategie[1] - ic_strategie[0]) / 2 / 1.96
se2 = (ic_spy[1] - ic_spy[0]) / 2 / 1.96
z = (c1 - c2) / np.sqrt(se1**2 + se2**2)
print(f"Ecart des centres : {c1 - c2:+.1f} pts -> z = {z:.2f} (il faudrait |z| > 1.96 pour separer)")
Strategie F-Score >= 7   IC95 [  +9.2,  +76.6]%  centre +42.9%  se ~= 17.2 pts
SPY                      IC95 [  -3.1,  +31.8]%  centre +14.3%  se ~=  8.9 pts

Zone de chevauchement : [+9.2, +31.8]% soit 22.6 points
Ecart des centres : +28.5 pts -> z = 1.47 (il faudrait |z| > 1.96 pour separer)

Lecture du résultat : l’écart des centres vaut moins de deux écarts-types combinés

Le z-score de la différence (1,47) reste sous le seuil de 1,96 : sur 3 ans, la volatilité annuelle du portefeuille concentré (29,3 %) rend un écart de 28,5 points de CAR compatible avec le hasard — la zone de chevauchement couvre 22,6 points. Ce n’est pas un défaut caché de l’article — c’est lui qui le dit (« weakly proven ») ; ce notebook rend la remarque calculable. C’est le même phénomène que dans QC-Py-12b (Validité du backtest) : avec peu d’années, l’erreur-type domine la comparaison.

2. Les neuf sous-scores, implémentés sur données réelles

Le F-Score (Piotroski 2000, Journal of Accounting Research 38, « Value Investing: The Use of Historical Financial Statement Information to Separate Winners from Losers ») additionne 9 indicateurs binaires : rentabilité (ROA > 0, CFO > 0, ΔROA > 0, accruals), endettement / liquidité / structure (Δlevier < 0, Δliquidité courante > 0, pas de dilution), efficacité opérationnelle (Δmarge brute > 0, Δrotation d’actifs > 0).

Nous les calculons from scratch via yfinance (états financiers Morningstar — même famille que le dataset de l’article, mais pas la même source exacte). Limites assumées, dans la discipline du QC-Py-14 :

  • l’univers est borné et choisi ex post (15 grandes capitalisations hétérogènes, retenues pour la fiabilité des données) — le papier cible au contraire le bas 80 % du book-to-market ; notre univers démontre la mécanique, pas l’edge du papier ;
  • yf.Ticker expose les états tels que publiés aujourd’hui : pas de garantie point-in-time (le doute n° 1 du projet voisin) ;
  • les banques (JPM) n’ont pas de « marge brute » au sens industriel : le sous-score 8 ressort NaN et le score se compte sur les sous-scores disponibles — la réalité des données fait partie du cours.
import warnings
import numpy as np
import pandas as pd
import yfinance as yf
warnings.filterwarnings("ignore")

# Univers borne : 15 grandes capitalisations US heterogenes (croissance, valeur,
# cycliques, sante, finance). Choix ex post documente dans la cellule precedente.
UNIVERS = ["AAPL", "MSFT", "INTC", "XOM", "CVX", "JPM",
           "WMT", "KO", "PG", "F", "PFE", "CAT", "BA", "X", "T"]

def get(df, row, col):
    # Lecture sure d'une cellule d'etat financier (NaN si ligne/colonne absente).
    if df is None or row not in df.index or col not in df.columns:
        return np.nan
    v = df.loc[row, col]
    try:
        v = float(v)
    except (TypeError, ValueError):
        return np.nan
    return v if pd.notna(v) else np.nan

def etats(tk, kind):
    if kind == "annuel":
        return tk.balance_sheet, tk.income_stmt, tk.cashflow
    return tk.quarterly_balance_sheet, tk.quarterly_income_stmt, tk.quarterly_cashflow

def snapshot(tk, kind, col_now, col_prev):
    # Valeurs brutes des lignes d'etat, pour [periode precedente, periode courante].
    bs, inc, cf = etats(tk, kind)
    vals = {}
    for cle, (df_, row) in {
        "TA":  (bs, "Total Assets"),
        "NI":  (inc, "Net Income"),
        "CFO": (cf, "Operating Cash Flow"),
        "LTD": (bs, "Long Term Debt"),
        "CA":  (bs, "Current Assets"),
        "CL":  (bs, "Current Liabilities"),
        "SHR": (bs, "Share Issued"),
        "GP":  (inc, "Gross Profit"),
        "REV": (inc, "Total Revenue"),
    }.items():
        vals[cle] = [get(df_, row, c) for c in (col_prev, col_now)]
    # Fallback dette long terme (certaines presentations fusionnent avec les leases).
    if np.isnan(vals["LTD"]).all():
        vals["LTD"] = [get(bs, "Long Term Debt And Capital Lease Obligation", c)
                       for c in (col_prev, col_now)]
    return vals

def sous_scores(v):
    # Les 9 sous-scores de Piotroski depuis les valeurs brutes [t-1, t].
    # Retour None quand un input manque : le sous-score est compte hors score.
    def ratio(num, den, i):
        n, d = v[num][i], v[den][i]
        if not (np.isfinite(n) and np.isfinite(d)) or d <= 0:
            return np.nan
        return n / d
    roa   = [ratio("NI", "TA", i) for i in (0, 1)]
    cfo_ta = [ratio("CFO", "TA", i) for i in (0, 1)]
    lev   = [ratio("LTD", "TA", i) for i in (0, 1)]
    liq   = [ratio("CA", "CL", i) for i in (0, 1)]
    marge = [ratio("GP", "REV", i) for i in (0, 1)]
    rot   = [ratio("REV", "TA", i) for i in (0, 1)]

    def delta_pos(arr):    # arr = [t-1, t] : le ratio s'est-il ameliore ?
        a, b = arr
        return None if not (np.isfinite(a) and np.isfinite(b)) else int(b > a)
    def delta_inv(arr):    # le ratio a-t-il diminue (levier) ?
        a, b = arr
        return None if not (np.isfinite(a) and np.isfinite(b)) else int(b < a)

    s1 = None if not np.isfinite(roa[1]) else int(roa[1] > 0)
    s2 = None if not np.isfinite(v["CFO"][1]) else int(v["CFO"][1] > 0)
    s3 = delta_pos(roa)
    s4 = None if not (np.isfinite(cfo_ta[1]) and np.isfinite(roa[1])) else int(cfo_ta[1] > roa[1])
    s5 = delta_inv(lev)
    s6 = delta_pos(liq)
    s7 = None if not all(np.isfinite(x) for x in v["SHR"]) else int(v["SHR"][1] <= v["SHR"][0])
    s8 = delta_pos(marge)
    s9 = delta_pos(rot)
    return [s1, s2, s3, s4, s5, s6, s7, s8, s9]

NOMS_SS = ["ROA>0", "CFO>0", "dROA", "Accr", "dLev", "dLiq", "dShr", "dMarge", "dRot"]

lignes = []
tickers = {}
for t in UNIVERS:
    try:
        tk = yf.Ticker(t)
        cols = sorted(tk.balance_sheet.columns, reverse=True)  # du plus recent au plus ancien
        if len(cols) < 2:
            print(f"{t}: moins de 2 exercices annuels disponibles, exclu")
            continue
        tickers[t] = tk
        ss = sous_scores(snapshot(tk, "annuel", cols[0], cols[1]))
        presents = [x for x in ss if x is not None]
        lignes.append([t, str(cols[0].date()), *ss, sum(presents), len(presents)])
    except Exception as e:
        print(f"{t}: echec de recuperation ({type(e).__name__}), exclu honnetement")

df_annuel = pd.DataFrame(lignes, columns=["Ticker", "FY_t", *NOMS_SS, "F", "n_ss"])
print(f"Convention annuelle — {len(df_annuel)} titres (exercice le plus recent par ticker) :")
print(df_annuel.to_string(index=False))
X: moins de 2 exercices annuels disponibles, exclu
Convention annuelle — 14 titres (exercice le plus recent par ticker) :
Ticker       FY_t  ROA>0  CFO>0  dROA  Accr  dLev  dLiq  dShr  dMarge  dRot  F  n_ss
  AAPL 2025-09-30      1      1     1     0     1   1.0     1     1.0     1  8     9
  MSFT 2026-06-30      1      1     1     1     1   0.0     1     0.0     0  6     9
  INTC 2025-12-31      0      1     1     1     1   1.0     0     1.0     0  6     9
   XOM 2025-12-31      1      1     0     1     1   0.0     1     0.0     0  5     9
   CVX 2025-12-31      1      1     0     1     0   1.0     1     1.0     0  6     9
   JPM 2025-12-31      1      0     0     0     1   NaN     1     NaN     0  3     7
   WMT 2026-01-31      1      1     1     1     1   0.0     1     1.0     0  7     9
    KO 2025-12-31      1      1     1     0     1   1.0     1     1.0     0  7     9
    PG 2026-06-30      1      1     0     1     1   0.0     1     0.0     1  6     9
     F 2025-12-31      0      1     0     1     0   0.0     0     0.0     0  2     9
   PFE 2025-12-31      1      1     0     1     0   0.0     0     1.0     1  5     9
   CAT 2025-12-31      1      1     0     1     1   1.0     1     0.0     0  6     9
    BA 2025-12-31      1      1     1     0     1   0.0     1     1.0     1  7     9
     T 2025-12-31      1      1     1     1     0   1.0     1     0.0     0  6     9

Lecture du résultat : la distribution réelle des scores dans un univers borné

Deux réalités de données apparaissent déjà : X (US Steel) sort du panel — radié de la cotation après l’OPA de 2025, yfinance n’expose plus ses exercices annuels — et la banque du panel JPM perd deux sous-scores (liquidité courante et marge brute, hors périmètre bancaire) : son F se compte sur 7 sous-scores disponibles, documenté plutôt que masqué. Sur les 14 titres restants, la distribution s’étale de 2 (F) à 8 (AAPL), médiane 6 : le seuil de l’article (≥ 7) ne retient que 4 noms — c’est exactement le filtre d’univers de QC-Py-05, appliqué cette fois sur des fondamentaux plutôt que sur des prix. Notez enfin la colonne FY_t : AAPL clôture en septembre, MSFT en juin, WMT en janvier — il n’existe pas de « même date » pour tous les émetteurs, ce qui prépare la section 3.

# Diagnostics quantitatifs sur la distribution obtenue.
scores = df_annuel["F"].astype(float)
print(f"F-Score annuel : moyenne {scores.mean():.2f}, mediane {scores.median():.0f}, "
      f"min {scores.min():.0f} ({df_annuel.loc[scores.idxmin(), 'Ticker']}), "
      f"max {scores.max():.0f} ({df_annuel.loc[scores.idxmax(), 'Ticker']})")
for seuil in (7, 8, 5):
    sel = df_annuel[df_annuel["F"] >= seuil]
    print(f"Seuil >= {seuil} : {len(sel)}/{len(df_annuel)} titres -> {', '.join(sel['Ticker']) or 'aucun'}")

print("\nSous-scores les plus souvent satisfaits (sur les titres ou ils sont mesurables) :")
taux = {n: df_annuel[n].dropna().mean() for n in NOMS_SS}
for n, r in sorted(taux.items(), key=lambda kv: -kv[1]):
    n_mes = int(df_annuel[n].notna().sum())
    print(f"  {n:7s} satisfait pour {100*r:5.1f}% des {n_mes} titres mesurables")
F-Score annuel : moyenne 5.71, mediane 6, min 2 (F), max 8 (AAPL)
Seuil >= 7 : 4/14 titres -> AAPL, WMT, KO, BA
Seuil >= 8 : 1/14 titres -> AAPL
Seuil >= 5 : 12/14 titres -> AAPL, MSFT, INTC, XOM, CVX, WMT, KO, PG, PFE, CAT, BA, T

Sous-scores les plus souvent satisfaits (sur les titres ou ils sont mesurables) :
  CFO>0   satisfait pour  92.9% des 14 titres mesurables
  ROA>0   satisfait pour  85.7% des 14 titres mesurables
  dShr    satisfait pour  78.6% des 14 titres mesurables
  Accr    satisfait pour  71.4% des 14 titres mesurables
  dLev    satisfait pour  71.4% des 14 titres mesurables
  dMarge  satisfait pour  53.8% des 13 titres mesurables
  dROA    satisfait pour  50.0% des 14 titres mesurables
  dLiq    satisfait pour  46.2% des 13 titres mesurables
  dRot    satisfait pour  28.6% des 14 titres mesurables

Lecture du résultat : les sous-scores ne discriminent pas tous autant

Dans un univers de grandes capitalisations, « CFO > 0 » et « ROA > 0 » sont presque toujours vérifiés — un plafond de 2 points quasi gratuit. La discrimination vient des deltas (Δmarge, Δrotation, Δlevier). C’est pédagogiquement décisif : la qualité du F-Score comme séparateur dépend du régime et de l’univers — dans des valeurs cycliques en sortie de récession, les deltas s’améliorent mécaniquement ensemble (corrélation des sous-scores), limite que le papier original documente lui-même.

3. La convention de datation : three_months vs annuel + retard fiscal

C’est la divergence identifiée par Fyker1 en discussion, corrigée par la version STAFF (Derek Melchin). Deux lectures du « même » F-Score :

  • l’article compare les fondamentaux three_months à one_year (snapshot trimestriel le plus récent vs même trimestre de l’année précédente) : chaque rebalance mensuelle utilise des ratios trimestriels — bruités, mais frais ;
  • le papier de 2000 prescrit des données annuelles, disponibles avec un retard de l’ordre de 5 mois après la clôture fiscale (éviter le look-ahead sur les états publiés), avec un pré-filtre book-to-market et un seuil 8-9.

Mesurons ce que change la convention sur les mêmes titres : combien de noms changent de score, et combien basculent au travers du seuil ≥ 7 ?

# Convention trimestrielle a la article : trimestre le plus recent vs meme trimestre
# un an plus tot (les etats trimestriels yfinance exposent ~7 quarters).
def conv_trimestrielle(tk):
    cols = sorted(tk.quarterly_balance_sheet.columns, reverse=True)
    now = cols[0]
    cibles = [c for c in cols[1:] if (c.year, c.month) == (now.year - 1, now.month)]
    if not cibles:
        return None, now
    return cibles[0], now

lignes_q = []
for t in df_annuel["Ticker"]:
    tk = tickers[t]
    prev, now = conv_trimestrielle(tk)
    if prev is None:
        lignes_q.append([t, "n/a"] + [None] * 11)
        continue
    ss = sous_scores(snapshot(tk, "trimestriel", now, prev))
    presents = [x for x in ss if x is not None]
    lignes_q.append([t, f"{prev.date()}->{now.date()}", *ss, sum(presents), len(presents)])

df_trim = pd.DataFrame(lignes_q, columns=["Ticker", "Fenetre_q", *NOMS_SS, "F", "n_ss"])

comp = pd.DataFrame({
    "Ticker": df_annuel["Ticker"],
    "F_annuel": df_annuel["F"].astype(float),
    "F_trim": df_trim["F"].astype(float),
})
comp["Delta"] = comp["F_trim"] - comp["F_annuel"]
comp["sel7_annuel"] = comp["F_annuel"] >= 7
comp["sel7_trim"] = comp["F_trim"] >= 7
print(comp.to_string(index=False))
ecarts = int((comp["Delta"].dropna() != 0).sum())
bascules = int((comp["sel7_annuel"] != comp["sel7_trim"]).sum())
print(f"\nScores differents (quelconque amplitude) : {ecarts}/{len(comp)} titres")
print(f"Basculent le seuil >= 7 selon la convention : {bascules}/{len(comp)} titres")
Ticker  F_annuel  F_trim  Delta  sel7_annuel  sel7_trim
  AAPL       8.0     9.0    1.0         True       True
  MSFT       6.0     6.0    0.0        False      False
  INTC       6.0     5.0   -1.0        False      False
   XOM       5.0     7.0    2.0        False       True
   CVX       6.0     8.0    2.0        False       True
   JPM       3.0     4.0    1.0        False      False
   WMT       7.0     6.0   -1.0         True      False
    KO       7.0     9.0    2.0         True       True
    PG       6.0     6.0    0.0        False      False
     F       2.0     3.0    1.0        False      False
   PFE       5.0     5.0    0.0        False      False
   CAT       6.0     8.0    2.0        False       True
    BA       7.0     4.0   -3.0         True      False
     T       6.0     6.0    0.0        False      False

Scores differents (quelconque amplitude) : 10/14 titres
Basculent le seuil >= 7 selon la convention : 5/14 titres

Lecture du résultat : le F-Score n’est pas un nombre, c’est une convention de datation

10 titres sur 14 changent de score entre les deux conventions, et 5 basculent au travers du seuil ≥ 7 : XOM et CVX passent de 5-6 à 7-8 (le raffinement trimestriel les fait entrer au portefeuille de l’article), quand BA s’effondre de 7 à 4 et WMT de 7 à 6 (ils en sortent). Plus d’un tiers du portefeuille change de composition selon que l’on suive l’article ou le papier. Deux causes structurelles :

  1. Bruit du trimestre : un ROA trimestriel (résultat de 3 mois sur actif total annuel) est mécaniquement petit et volatil ; les comparaisons yoy trimestre-à-trimestre reclassent les deltas sur des variations saisonnières ;
  2. Synchronisation fiscale : la colonne FY_t de la section 2 le montrait — AAPL clôture en septembre, MSFT en juin, WMT en janvier ; « même mois, un an plus tôt » ne coïncide avec l’exercice fiscal d’aucun des trois.

La version corrigée STAFF suit le papier (annuel, retard ~5 mois, seuil 8-9) : quand une bibliothèque publie un « F-Score », toujours demander quelle convention il implémente avant de le comparer à un papier académique.

4. La critique de liquidité : Fedorov contre Szeto

La discussion la plus serrée de l’article vient de Pavel Fedorov : « tout le retour vient d’actions totalement illiquides ; avec un critère de dollar-volume décent, les résultats deviennent horribles ». La réponse de Szeto est une hypothèse, pas un fait : l’illiquidité serait constitutive de la prime de valeur (le désaccord qui crée la sous-évaluation disparaît quand le volume monte — l’arbitrage est déjà réalisé).

Nous ne pouvons pas trancher sans répliquer le backtest complet (coût de calcul — réserve n° 3 de l’article lui-même). Mais nous pouvons mesurer le prémissable : le filtre F-Score ≥ 7, appliqué à notre univers, sélectionne-t-il des noms moins liquides que la moyenne ? On réutilise le proxy dollar-volume du QC-Py-14.

from scipy import stats

# Proxy de liquidite (pattern QC-Py-14) : dollar-volume journalien median sur 1 an.
px = yf.download(UNIVERS, period="1y", progress=False, auto_adjust=True)
dvol = (px["Close"] * px["Volume"]).median().rename("DollarVol").dropna()

liq = comp.merge(dvol, left_on="Ticker", right_index=True)
liq["DollarVol_M$"] = (liq["DollarVol"] / 1e6).round(1)
ok = liq["DollarVol"].notna()
rho, pval = stats.spearmanr(liq.loc[ok, "F_annuel"], np.log10(liq.loc[ok, "DollarVol"]))
print(liq[["Ticker", "F_annuel", "F_trim", "DollarVol_M$"]].sort_values("DollarVol_M$").to_string(index=False))
print(f"\nCorrelation de Spearman F-Score (annuel) vs log10 dollar-volume : rho = {rho:+.2f} (p = {pval:.2f})")

g_haut = liq.loc[liq["F_annuel"] >= 7, "DollarVol"]
g_bas = liq.loc[liq["F_annuel"] < 7, "DollarVol"]
if len(g_haut) >= 2 and len(g_bas) >= 2:
    u, p_mw = stats.mannwhitneyu(g_haut, g_bas, alternative="less")
    print(f"Medianes $-volume : F>=7 {g_haut.median()/1e6:.0f} M$ vs F<7 {g_bas.median()/1e6:.0f} M$ "
          f"(Mann-Whitney unilateral p = {p_mw:.2f})")
else:
    print(f"Trop peu de titres F>=7 ({len(g_haut)}) pour un test de rang sur cet univers")
Ticker  F_annuel  F_trim  DollarVol_M$
     F       2.0     3.0         718.3
     T       6.0     6.0        1020.7
   PFE       5.0     5.0        1045.1
    KO       7.0     9.0        1173.5
    PG       6.0     6.0        1300.9
    BA       7.0     4.0        1419.8
   CVX       6.0     8.0        1598.9
   CAT       6.0     8.0        1884.6
   XOM       5.0     7.0        2188.9
   WMT       7.0     6.0        2326.9
   JPM       3.0     4.0        2584.7
  INTC       6.0     5.0        6684.4
  MSFT       6.0     6.0       12385.0
  AAPL       8.0     9.0       12574.9

Correlation de Spearman F-Score (annuel) vs log10 dollar-volume : rho = +0.30 (p = 0.29)
Medianes $-volume : F>=7 1873 M$ vs F<7 1742 M$ (Mann-Whitney unilateral p = 0.73)

Lecture du résultat : sur large caps le filtre n’est pas un filtre d’illiquidité — la question de Fedorov reste ouverte là où elle se pose

Sur les 14 titres retenus (X, radié, n’a plus de cotation), la corrélation F-Score / dollar-volume est faible et non significative (rho = +0,30, p = 0,29) : dans un univers liquide, un filtre sur les fondamentaux ne sélectionne pas les noms illiquides — les médianes de dollar-volume des groupes F ≥ 7 et F < 7 sont voisines (1,9 vs 1,7 Md$/jour). Cela ne réfute pas Fedorov : sa critique porte sur le backtest complet, dont l’univers éligible descend jusqu’aux small caps — c’est précisément là que pêche le pré-filtre book-to-market du papier (bas 80 % B/M). Ce que la mesure établit : l’hypothèse de Szeto (la valeur viendrait de l’illiquidité) n’est pas nécessaire pour expliquer un F-Score qui fonctionne sur large caps ; elle devient critique en descendant en capitalisation. La charge honnête : avant de croire un backtest F-Score small-cap, exiger le coût d’exécution par nominal (cf. QC-Py-14b).

5. Le doute « small universe » du projet voisin, rendu mesurable

Le README de HighBookToMarketFScore-QC porte un drapeau SUSPECT surfit à trois causes, dont la n° 3 : small-universe variance — un Sharpe library de 2.09 obtenu sur un OOS 1Y d’un univers top 20 % B/M × F-Score ≥ 8 serait indistinguable d’un Sharpe vrai de 1,2-1,6. C’est une affirmation dans le README ; mesurons-la.

L’erreur-type asymptotique d’un Sharpe annualisé estimé sur T années vaut se ≈ sqrt((1 + SR²/2) / T). Un « OOS 1Y » signifie T = 1 : l’incertitude est énorme. Vérifions analytiquement puis par Monte-Carlo.

rng = np.random.default_rng(42)

def sharpe_annualise(r):
    # Sharpe annualise d'une serie de rendements journaliers.
    r = np.asarray(r)
    return np.sqrt(252) * r.mean() / r.std(ddof=1)

def simuler_sharpes(T, sr_vrai, m=20000, chunk=2500):
    # m trajectoires de T*252 rendements journaliers iid (vol 1%/jour),
    # renvoie les m Sharpes annualises estimes.
    mu, sigma = sr_vrai * 0.01 / np.sqrt(252), 0.01
    out = np.empty(m)
    fait = 0
    while fait < m:
        n = min(chunk, m - fait)
        r = rng.normal(mu, sigma, size=(n, T * 252))
        out[fait:fait + n] = np.sqrt(252) * r.mean(axis=1) / r.std(axis=1, ddof=1)
        fait += n
    return out

SR_VRAI = 1.0
print(f"Sharpe vrai = {SR_VRAI:.1f} — ou tombe le Sharpe ESTIME selon l'horizon d'observation ?")
print(f"{'T (ans)':>8} {'se analytique':>14} {'IC95 Monte-Carlo':>22}")
for T in (1, 3, 5, 12):
    se_ana = np.sqrt((1 + 0.5 * SR_VRAI**2) / T)
    estims = simuler_sharpes(T, SR_VRAI)
    lo, hi = np.percentile(estims, [2.5, 97.5])
    print(f"{T:>8} {se_ana:>14.2f} [{lo:+6.2f}, {hi:+6.2f}]")

print("\nRevendication library #343 : Sharpe 2.09 sur OOS 1Y. Avec T = 1, un Sharpe")
print("vrai de 1.0 produit deja des estimations > 2 dans la queue droite ; un 2.09")
print("observe sur 1 an contraint tres faiblement le Sharpe vrai.")
Sharpe vrai = 1.0 — ou tombe le Sharpe ESTIME selon l'horizon d'observation ?
 T (ans)  se analytique       IC95 Monte-Carlo
       1           1.22 [ -0.94,  +3.01]
       3           0.71 [ -0.13,  +2.13]
       5           0.55 [ +0.12,  +1.88]
      12           0.35 [ +0.45,  +1.57]

Revendication library #343 : Sharpe 2.09 sur OOS 1Y. Avec T = 1, un Sharpe
vrai de 1.0 produit deja des estimations > 2 dans la queue droite ; un 2.09
observe sur 1 an contraint tres faiblement le Sharpe vrai.

Lecture du résultat : un Sharpe 2.09 « OOS 1Y » est compatible avec presque tout

Sur 1 an d’observation, l’IC95 Monte-Carlo du Sharpe estimé d’une stratégie dont le Sharpe vrai serait 1,0 va de −0,94 à +3,01 : le chiffre-phare de la library (2.09, OOS 1Y) tombe à l’intérieur — un fonds médiocre chanceux produit pareil chiffre une année sur vingt, et l’observation ne borne pas serré un vrai 1,2-1,6. Sur 12 ans l’IC se resserre à [+0,45, +1,57] — mais la library roule justement une fenêtre 12 ans choisie a posteriori (doute n° 2, data mining). Les trois doutes du README ne sont pas de la prudence rhétorique : ce sont trois mécanismes qui poussent le chiffre publié vers le haut. La défense opérationnelle : exiger PointInTimeFundamentals + fenêtre fixée a priori + IC publié — et croiser avec le PSR de QC-Py-12b.

6. Synthèse — ce que l’article établit, et ce qu’il n’établit pas

Établi (et ce notebook le rend recalculable) :

  1. le F-Score est implémentable proprement à partir d’états financiers publics — les 9 sous-scores tiennent en une trentaine de lignes, et leur discrimination réelle dépend du régime (les deltas portent l’information, pas les niveaux) ;
  2. la convention de datation change le portefeuille : 10 titres sur 14 changent de score et 5 basculent le seuil ≥ 7 entre annuel + retard fiscal (papier) et trimestriel frais (article) — exiger la convention avant toute comparaison ;
  3. les propres aveux de l’article (« weakly proven », IC95 chevauchants) sont re-dérivables numériquement : z = 1,47 < 1,96.

Non établi (les trois doutes, désormais mesurables) :

  1. look-ahead : les états exposés aujourd’hui ne garantissent pas le point-in-time — le remède existe (PointInTimeFundamentals côté QuantConnect) et n’est pas activé par défaut ;
  2. liquidité : la critique de Fedorov (le retour vient des illiquides) n’est ni confirmée ni réfutée par un univers large-cap — elle se joue sur le segment small-cap que le papier cible ;
  3. variance d’échantillon : un Sharpe 2.09 sur OOS 1Y est statistiquement compatible avec un Sharpe vrai de l’ordre de 1,2 — le README du projet voisin le disait, le Monte-Carlo le montre.

Le F-Score reste un cadre pédagogiquement remarquable : neuf questions binaires, chacune traçable à une ligne d’état financier, qui obligent à lire un bilan. C’est en tant que langage de lecture des fondamentaux qu’il enseigne le mieux — en tant que machine à Sharpe 2, il demande exactement les trois vérifications ci-dessus.

Exercices

Les trois exercices suivants vous font manipuler les concepts de ce notebook. Comme partout dans cette série, complétez les cellules : elles s’exécutent sans erreur même non complétées (résultat None à interpréter) — la solution est votre travail.

Exercice 1 — Seuil 5 vs 8 : deux portefeuilles pour un même score

L’article filtre à F ≥ 7, la library du projet voisin à F ≥ 8, le papier original garde 8-9. Recomputez les ensembles sélectionnés aux seuils 5, 7 et 8 à partir de df_annuel, puis quantifiez leur chevauchement (indice de Jaccard : |A ∩ B| / |A ∪ B|) pour chaque paire. Un seuil plus strict achète-t-il de la qualité, ou seulement de la rareté ?

# Indice : sel_s = set(df_annuel.loc[df_annuel["F"] >= s, "Ticker"]) pour s in (5, 7, 8),
# puis jaccard = len(A & B) / len(A | B) pour chaque paire.
# TODO etudiant
resultats_jaccard = None  # TODO etudiant : dict {(s1, s2): jaccard}
print("Exercice 1 a completer : ensembles par seuil + Jaccard des paires")
Exercice 1 a completer : ensembles par seuil + Jaccard des paires

Exercice 2 — Le retard fiscal du papier : F(FY t−1) contre F(FY t)

Le papier de 2000 n’investit sur l’exercice t qu’avec ~5 mois de retard après publication. Approchez l’effet en recalculant les 9 sous-scores sur la paire d’exercices précédente (t−1 vs t−2) : pour chaque ticker, cols = sorted(tk.balance_sheet.columns, reverse=True) donne [t, t−1, t−2, …], donc appelez sous_scores(snapshot(tk, "annuel", cols[1], cols[2])). Combien de titres gardent leur côté du seuil ≥ 7 entre F(t) et F(t−1) ? C’est une borne basse de la sensibilité temporelle du filtre.

# Indice : construire un DataFrame [Ticker, F_t, F_t_moins_1, stable_au_seuil_7] en reutilisant
# sous_scores() et snapshot() de la section 2, avec cols[1] et cols[2] comme paire.
# TODO etudiant
comparaison_retard = None  # TODO etudiant : DataFrame Ticker, F_t, F_t_moins_1, stable_au_seuil
print("Exercice 2 a completer : F(FY t-1) vs F(FY t), stabilite au seuil 7")
Exercice 2 a completer : F(FY t-1) vs F(FY t), stabilite au seuil 7

Exercice 3 — Combien d’années pour distinguer un Sharpe 2 d’un Sharpe 1,2 ?

Reprenez le Monte-Carlo de la section 5 avec SR_VRAI = 1.2 et balayez T ∈ {1, 2, 3, 5, 8, 12, 20}. Pour chaque T, estimez la puissance trompeuse : probabilité qu’un Sharpe vrai 1,2 produise une estimation ≥ 2,09 (le chiffre library). À partir de combien d’années cette probabilité passe-t-elle sous 5 % ?

# Indice : estims = simuler_sharpes(T, 1.2) ; puissance = (estims >= 2.09).mean().
# La fonction simuler_sharpes(T, sr_vrai) est definie dans la section 5.
# TODO etudiant
puissance_par_T = None  # TODO etudiant : dict {T: fraction des estimations >= 2.09}
print("Exercice 3 a completer : puissance du test 'Sharpe vrai 1.2 vs seuil 2.09' par horizon")
Exercice 3 a completer : puissance du test 'Sharpe vrai 1.2 vs seuil 2.09' par horizon
Retour au sommet