mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
release: v0.18.0
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user