release: v0.18.0

This commit is contained in:
github-actions[bot]
2026-08-21 01:48:35 +00:00
parent 6c2f4eda9e
commit b47b07e8e6
9 changed files with 544 additions and 81 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ def test_sweep_rejects_undeclared_param():
def test_walk_forward_rejects_undeclared_param():
wf = {
"method": "Rolling", "n_splits": 2, "train_ratio": 0.7,
"geometry": "blocked", "n_splits": 2, "train_ratio": 0.7,
"optimize_metric": "sharpe", "param_grid": {"fast": [10, 20]},
}
with pytest.raises((StrategyError, bt.LicenseError)) as exc:
+87
View File
@@ -0,0 +1,87 @@
"""Le walk-forward doit accepter les series EXOGENES, comme tous les autres
chemins du moteur.
Il chargeait ses colonnes exo pour les deux runs qui tracent les courbes
d'equite, mais appelait le moteur avec des tables VIDES pour la selection du
reglage. Une branche sur trois etait oubliee, et c'etait celle qui decide :
toute strategie lisant `exo.<nom>.<colonne>` echouait sur "unknown input
column" alors que la meme strategie tourne dans `run`, `run_sweep_lite` et
`run_batch_lite`.
Le second test verifie le point qui rend la correction sure : la selection lit
desormais des colonnes DECOUPEES une fois pour toutes, la ou les courbes les
rechargent par fenetre. Les deux chemins doivent rendre la meme metrique
d'in-sample pour le reglage retenu, sinon le decoupage est faux.
"""
import os
import pytest
import manifoldbt as bt
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
def _monte(tmp_path):
"""Une serie horaire regulière, plus une moyenne posee en serie exogene."""
n = 2400
idx = pd.date_range("2021-01-01", periods=n, freq="1h", tz="UTC", name="timestamp")
pas = np.sin(np.arange(n) / 37.0) * 2.0 + np.cos(np.arange(n) / 11.0)
px = 100.0 + np.cumsum(pas) * 0.05
df = pd.DataFrame({"open": px, "high": px * 1.004, "low": px * 0.996,
"close": px, "volume": np.full(n, 1000.0)}, index=idx)
racine = str(tmp_path / "data")
meta = str(tmp_path / "meta.sqlite")
os.makedirs(racine, exist_ok=True)
store = bt.import_dataframe(df.reset_index(), symbol="ZEXO", symbol_id=1,
interval="1h", asset_class="equity",
exchange="TEST", data_root=racine, metadata_db=meta)
moy = pd.Series(px).rolling(24).mean().to_numpy()
bt.register_exo("moyenne", pd.DataFrame({"timestamp": idx, "sma": moy}),
store=store, data_root=racine, timeframe="1h")
# la plage doit coller aux donnees : 2400 heures = 100 jours
tr0, tr1 = bt.time_range("2021-01-01", "2021-04-11")
cfg = bt.BacktestConfig(universe=[1], time_range_start=tr0, time_range_end=tr1,
initial_capital=1000.0, provider="TEST",
bar_interval=bt.Interval.hours(1), symbol_names={"ZEXO": 1})
cfg.warmup_bars = 0
cfg.exo_data = ["moyenne"]
return store, cfg
def _strategie():
from manifoldbt.indicators import close, col
m = col("exo.moyenne.sma")
bande = m * (bt.lit(1.0) - bt.param("dev"))
return (bt.Strategy.create("s")
.signal("aux", bande)
.size(bt.when(close < bande, 1.0, bt.when(close > m, 0.0, bt.hold()))))
WF = {"method": "Anchored", "n_splits": 3, "train_ratio": 0.5,
"optimize_metric": "sharpe",
"param_grid": {"dev": [0.002, 0.005, 0.01]}}
def test_walk_forward_accepte_une_serie_exogene(tmp_path):
store, cfg = _monte(tmp_path)
r = bt.run_walk_forward(_strategie(), WF, cfg, store)
assert len(r["folds"]) == 3
# chaque pli doit avoir EVALUE la grille, pas l'avoir sautee
for f in r["folds"]:
assert len(f["all_is_results"]) == 3, "la grille n'a pas ete evaluee"
def test_selection_et_courbes_voient_les_memes_colonnes(tmp_path):
"""La selection tranche les colonnes une fois, les courbes les rechargent
par fenetre : le meme reglage doit donner le meme in-sample des deux cotes."""
store, cfg = _monte(tmp_path)
r = bt.run_walk_forward(_strategie(), WF, cfg, store)
for f in r["folds"]:
meilleur = max(f["all_is_results"],
key=lambda x: x["metrics"].get("sharpe", float("-inf")))
a = meilleur["metrics"]["sharpe"]
b = f["is_metrics"]["sharpe"]
assert a == b, "selection {} contre courbe {} au pli {}".format(
a, b, f["fold_index"])
+128
View File
@@ -0,0 +1,128 @@
"""Geometrie du walk-forward et chauffe hors echantillon.
Trois contrats poses par la refonte :
1. Le run OOS est CHAUFFE : il simule depuis le debut de l'apprentissage du
pli et ne trade qu'a partir du test. Le test le prouve avec un SMA plus
long que la fenetre de test : a froid l'indicateur resterait nul sur toute
la fenetre et l'equity serait PLATE ; chauffe, il est disponible des la
premiere barre tradable.
2. Les geometries `pardo` et `custom` derivent le nombre de plis des
longueurs de fenetres, et `custom` sait exprimer des tests recouvrants --
signales par `folds_overlap` et repondus par `effective_folds`.
3. `method="Rolling"` est refuse avec un message qui nomme le remplacant :
ce mode faisait des blocs disjoints, pas le rolling de Pardo.
"""
import os
import pytest
import manifoldbt as bt
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
JOUR_NS = 86_400 * 1_000_000_000
def _monte(tmp_path):
"""100 jours de barres horaires, prix cyclique pour garantir des trades."""
n = 2400
idx = pd.date_range("2021-01-01", periods=n, freq="1h", tz="UTC", name="timestamp")
pas = np.sin(np.arange(n) / 37.0) * 2.0 + np.cos(np.arange(n) / 11.0)
px = 100.0 + np.cumsum(pas) * 0.05
df = pd.DataFrame({"open": px, "high": px * 1.004, "low": px * 0.996,
"close": px, "volume": np.full(n, 1000.0)}, index=idx)
racine = str(tmp_path / "data")
meta = str(tmp_path / "meta.sqlite")
os.makedirs(racine, exist_ok=True)
store = bt.import_dataframe(df.reset_index(), symbol="ZWFG", symbol_id=1,
interval="1h", asset_class="equity",
exchange="TEST", data_root=racine, metadata_db=meta)
tr0, tr1 = bt.time_range("2021-01-01", "2021-04-11")
cfg = bt.BacktestConfig(universe=[1], time_range_start=tr0, time_range_end=tr1,
initial_capital=1000.0, provider="TEST",
bar_interval=bt.Interval.hours(1), symbol_names={"ZWFG": 1})
cfg.warmup_bars = 0
return store, cfg
def _strategie_sma_long():
"""SMA plus long (400 barres) que toute fenetre de test des tests ci-dessous."""
from manifoldbt.indicators import close, sma
m = sma(close, 400) * (bt.lit(1.0) + bt.param("dev") * 0.0)
return (bt.Strategy.create("s")
.signal("m", m)
.size(bt.when(close > m, 1.0, 0.0)))
def test_oos_est_chauffe_l_indicateur_est_disponible(tmp_path):
store, cfg = _monte(tmp_path)
wf = {"geometry": "anchored", "n_splits": 2, "train_ratio": 0.8,
"optimize_metric": "sharpe", "param_grid": {"dev": [0.0, 1.0]}}
r = bt.run_walk_forward(_strategie_sma_long(), wf, cfg, store)
assert r["n_folds"] == 2
for f in r["folds"]:
eq = f["oos_equity"]
ts = f["oos_timestamps"]
# la courbe rendue couvre les seules barres du test, chauffe exclue
assert len(eq) == len(ts) > 0
assert ts[0] >= f["test_range"]["start"]
assert ts[-1] < f["test_range"]["end"]
# fenetre de test = 10 jours = 240 barres < SMA(400) : a froid,
# l'indicateur serait nul sur TOUTE la fenetre et l'equity plate.
assert len(eq) <= 400, "le test doit etre plus court que le SMA"
assert max(eq) != min(eq), (
"equity OOS plate : l'indicateur n'a pas ete chauffe")
def test_pardo_derive_le_nombre_de_plis(tmp_path):
store, cfg = _monte(tmp_path)
wf = {"geometry": "pardo",
"train": {"length": {"Days": 50}},
"test": {"length": {"Days": 10}},
"optimize_metric": "sharpe", "param_grid": {"dev": [0.0]}}
r = bt.run_walk_forward(_strategie_sma_long(), wf, cfg, store)
# 100 jours : premier test a j50, puis 5 fenetres de 10 jours
assert r["n_folds"] == 5
assert r["folds_overlap"] is False
assert r["effective_folds"] == 5.0
for f in r["folds"]:
tr, te = f["train_range"], f["test_range"]
assert te["start"] - tr["start"] == 50 * JOUR_NS
assert te["end"] - te["start"] == 10 * JOUR_NS
def test_custom_recouvrant_expose_les_plis_effectifs(tmp_path):
store, cfg = _monte(tmp_path)
wf = {"geometry": "custom",
"train": {"mode": "anchored", "min_length": {"Days": 60}},
"test": {"length": {"Days": 10}, "step": {"Days": 5}},
"optimize_metric": "sharpe", "param_grid": {"dev": [0.0]}}
r = bt.run_walk_forward(_strategie_sma_long(), wf, cfg, store)
# tests possibles de j60 a j90 par pas de 5 -> 7 plis, union 40 jours
assert r["n_folds"] == 7
assert r["folds_overlap"] is True
assert r["effective_folds"] == pytest.approx(4.0)
def test_rolling_est_refuse_avec_le_remplacant_nomme(tmp_path):
store, cfg = _monte(tmp_path)
wf = {"method": "Rolling", "n_splits": 2, "train_ratio": 0.7,
"optimize_metric": "sharpe", "param_grid": {"dev": [0.0]}}
with pytest.raises(Exception) as exc:
bt.run_walk_forward(_strategie_sma_long(), wf, cfg, store)
msg = str(exc.value)
assert "blocked" in msg and "pardo" in msg
def test_wfe_est_rendu(tmp_path):
store, cfg = _monte(tmp_path)
wf = {"geometry": "anchored", "n_splits": 2, "train_ratio": 0.8,
"optimize_metric": "sharpe", "param_grid": {"dev": [0.0]}}
r = bt.run_walk_forward(_strategie_sma_long(), wf, cfg, store)
assert "walk_forward_efficiency" in r
for f in r["folds"]:
assert "wfe" in f