Skip to content

feat(backtest): banc de référence phase 1 (moteur, coûts, impôt, buy & hold / MA200) - #99

Merged
laurentvv merged 2 commits into
mainfrom
feat/backtest-baselines
Sep 29, 2026
Merged

laurentvv merged 2 commits into
mainfrom
feat/backtest-baselines

Conversation

@laurentvv

Copy link
Copy Markdown
Owner

Contexte

Phase 1 du plan de passage en réel (#92) : le banc de vérité. Cette première tranche pose le moteur et les références que tout modèle devra battre ; le rejeu des modèles de l'ensemble en walk-forward et l'ablation viennent dans une tranche suivante.

Contenu

  • src/backtest/engine.py : exposition cible 0..1 → équité. Décision à la clôture t, exécution à l'ouverture t+1 (aucun regard vers le futur), coût par côté, impôt annuel sur plus-values réalisées, option de solde en fin de période.
  • src/backtest/metrics.py : CAGR, Sharpe, Sortino, drawdown, Calmar, temps investi, turnover, gain/perte moyens ; bootstrap par blocs (ΔSharpe avec IC) ; Sharpe déflaté.
  • src/backtest/strategies.py : buy & hold, 50 % fixe, tendance MA (avec hystérésis), momentum 12 mois, et les règles de sortie actuelles de t212_executor rejouées sur un signal d'entrée.
  • src/backtest/data.py : chargement du cache et détection des séries gelées.
  • src/backtest/report.py + docs/BACKTEST_BASELINES_2026-09-29.md : rapport généré, avec une lecture rédigée à la main.
  • tests/test_backtest.py : 26 tests (pas de regard vers le futur, coûts, impôt, règles de sortie, métriques, séries gelées).

Résultats à retenir (détail dans le document)

  • CRUDP.PA n'est pas backtestable : flux gelé à 82 %, vivant depuis 2026-01-08 seulement.
  • SXRV.DE (2022-07 → 2026-09) : aucune règle simple ne bat le buy & hold sur le Sharpe (1,12) ; la MA200 avec hystérésis offre un drawdown de −15 % contre −26,7 % pour 6 points de CAGR en moins.
  • Les règles de sortie actuelles, même avec une entrée toujours haussière : CAGR 8,6 % contre 21,8 %. Sensibilité aux coûts : 20,0 % à 0 pb, 15,3 % à 10 pb. Le mal vient surtout du churn (83 trades, rotation ×40/an).

Limites

4 ans, un seul cycle, hypothèse de coût (25 pb/côté) et d'impôt (30 %) à confirmer, aucun modèle de l'ensemble encore testé.

Vérification

pytest tests/ : 346 passés, 3 ignorés. ruff check (règles de la CI) propre. Aucune modification du code de production.

🤖 Generated with Claude Code

… futur, coûts, impôt, buy & hold / MA200)

Moteur quotidien exposition cible -> équité (décision à la clôture t, exécution à
l'ouverture t+1, coûts par côté, impôt annuel sur plus-values réalisées), métriques
(CAGR, Sharpe, Sortino, drawdown, Calmar, turnover), bootstrap par blocs, Sharpe déflaté,
détection des séries gelées (CRUDP.PA : 82 % gelé) et rapport généré.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread src/backtest/metrics.py

import numpy as np
import pandas as pd
from scipy import stats

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: scipy is imported here but is not declared in pyproject.toml

scipy is only pulled in transitively (via scikit-learn/statsmodels), so this is an undeclared direct dependency. Any resolver change that drops or renames the transitive edge breaks import src.backtest.metrics at module load, which also breaks tests/test_backtest.py and python -m src.backtest.report. Add "scipy>=1.11" to [project].dependencies.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/backtest/metrics.py
"time_invested": float((result.exposure > 0.01).mean()),
"avg_exposure": float(result.exposure.mean()),
"turnover_per_year": float(tr["notional"].sum() / eq.mean() / years),
"n_trades": int(len(sells)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: n_trades counts only SELL rows, but turnover_per_year above sums both BUY and SELL notionals

The report renders this as the column "Trades" (and the published doc quotes "83 trades" for the exit-rules strategy), so the same table mixes two different bases: 83 round-trip exits but ~166 fills. Readers comparing the trade count with the 40x/year turnover will understate the churn by half. Either rename the key/column (n_exits) or count all fills.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/backtest/report.py Outdated
for y in years:
seg = eq[eq.index.year == y]
prev = eq[eq.index.year < y]
base = prev.iloc[-1] if len(prev) else seg.iloc[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: First calendar year is computed from seg.iloc[0], silently dropping the return of its first bar

For the earliest year there is no prior equity, so the base becomes the equity after the first session of that year, and seg.iloc[-1] / seg.iloc[0] - 1 skips the first day's move. For the 2022 column of the published report that first bar is a large down day for buy & hold, so the annual figure is biased. Use the initial capital (or prepend initial_capital) as the base for the first year.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/backtest/data.py

def frozen_mask(df: pd.DataFrame) -> pd.Series:
"""Ligne gelée : aucun volume ET clôture identique à la veille (placeholder, pas une vraie séance)."""
return (df["Volume"] == 0) & (df["Close"] == df["Close"].shift(1))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: frozen_mask hard-requires a Volume column that load_prices never validates

load_prices only checks Open/Close, but every downstream call (coverage, live_segment, and the report's quality table) goes through frozen_mask, which raises KeyError: 'Volume' on a parquet written without volume. Guard the column once in load_prices (raise a clear message, or treat a missing column as "not frozen") so a cache-format change fails at load time instead of mid-report.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

All four findings from the previous review are addressed in 2140d1d: scipy>=1.11.0 is declared in pyproject.toml, frozen_mask tolerates a missing Volume column (with a regression test), the first calendar year is now based on the initial capital, and the report column is relabeled Ventes in both the renderer and the published baselines document. No new defects were found on the changed lines.

Files Reviewed (6 files)
  • docs/BACKTEST_BASELINES_2026-09-29.md
  • pyproject.toml
  • src/backtest/data.py
  • src/backtest/report.py
  • tests/test_backtest.py
  • uv.lock
Previous Review Summary (commit 922efd7)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 922efd7)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
src/backtest/metrics.py 12 scipy is a new direct import but is not declared in pyproject.toml (only transitive)

SUGGESTION

File Line Issue
src/backtest/metrics.py 68 n_trades counts only SELL fills while turnover_per_year counts both legs — inconsistent bases in the same table
src/backtest/report.py 110 First calendar year is measured from seg.iloc[0], dropping the return of its first bar
src/backtest/data.py 34 frozen_mask requires a Volume column that load_prices never validates (KeyError risk)
Files Reviewed (8 files)
  • docs/BACKTEST_BASELINES_2026-09-29.md - 0 issues
  • src/backtest/__init__.py - 0 issues
  • src/backtest/data.py - 1 issue
  • src/backtest/engine.py - 0 issues
  • src/backtest/metrics.py - 2 issues
  • src/backtest/report.py - 1 issue
  • src/backtest/strategies.py - 0 issues
  • tests/test_backtest.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by free · Input: 0 · Output: 0 · Cached: 0

… libellé Ventes

Review Kilo #99.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@laurentvv
laurentvv merged commit 64c37f7 into main Sep 29, 2026
2 checks passed
@laurentvv
laurentvv deleted the feat/backtest-baselines branch September 29, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant