release: v0.27.1

This commit is contained in:
github-actions[bot]
2026-10-23 23:06:12 +02:00
committed by Jimmy7892
parent 6de6e3ae2e
commit 8ebd171f71
16 changed files with 610 additions and 42 deletions
+76
View File
@@ -0,0 +1,76 @@
"""Les diagnostics doivent accepter les series EXOGENES, comme `run`.
`detect_lookahead` relancait la strategie avec des tables exo vides, et
`AlignedData` (sur lequel `check_exposure_stability` relance ses runs) n'en
portait aucune : une strategie lisant `exo.<nom>.<colonne>` levait
"unknown input column" dans les deux, alors que `mbt.run` la faisait tourner.
"""
import os
import pytest
import manifoldbt as bt
# Safety diagnostics are a Pro feature.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
reason="requires Pro: skipped on a Community wheel",
)
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
def _monte(tmp_path):
"""Serie horaire reguliere, 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="ZDEX", symbol_id=1,
interval="1h", asset_class="equity",
exchange="TEST", data_root=racine, metadata_db=meta)
# Moyenne CAUSALE (fenetre glissante sur le passe) : aucune fuite.
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")
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={"ZDEX": 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")
return (bt.Strategy.create("s")
.signal("m", m)
.size(bt.when(close > m, 1.0, 0.0)))
def test_la_strategie_a_exo_trade_dans_run(tmp_path):
store, cfg = _monte(tmp_path)
r = bt.run(_strategie(), cfg, store)
assert r.trade_count > 0
def test_detect_lookahead_accepte_une_serie_exogene(tmp_path):
store, cfg = _monte(tmp_path)
rapport = bt.diagnostics.detect_lookahead(_strategie(), cfg, store, mode="all")
# Une moyenne glissante sur le passe ne fuit pas : les re-runs tronques
# doivent retrouver les trades du run complet.
assert rapport.passed, rapport
def test_check_exposure_stability_accepte_une_serie_exogene(tmp_path):
store, cfg = _monte(tmp_path)
rapport = bt.diagnostics.check_exposure_stability(_strategie(), cfg, store)
assert rapport.passed, rapport
@@ -149,6 +149,37 @@ def test_le_message_nomme_le_prix_et_la_sortie(store):
assert "AtClose" in message
@pytest.mark.parametrize("moyenne", ["TypicalPrice", "OhlcAverage"])
def test_une_moyenne_intrabarre_sans_delai_est_refusee(store, moyenne):
"""`fill_model.intra_bar_price` averages the execution bar whatever the
price says: at delay 0, with the default close, it filled at (H+L+C)/3 or
(O+H+L+C)/4 of the signal's own bar (Sharpe 37 and 65 on a driftless
walk, `detect_lookahead` PASS). Refused like `AtVwap`."""
with pytest.raises(ConfigError, match="needs signal_delay >= 1") as exc:
bt.run(_reads_its_own_bar(),
_config(signal_delay=0, fill_model={"intra_bar_price": moyenne}),
store)
assert moyenne in str(exc.value)
assert "signal_delay=1" in str(exc.value)
@pytest.mark.parametrize("moyenne", ["TypicalPrice", "OhlcAverage"])
def test_une_moyenne_intrabarre_avec_delai_reste_permise(store, moyenne):
"""With a delay the averaged bar follows the signal: nothing to refuse."""
result = bt.run(_reads_its_own_bar(),
_config(signal_delay=1, fill_model={"intra_bar_price": moyenne}),
store)
assert abs(result.metrics["total_return"]) < 0.5
def test_single_point_sans_delai_reste_permis(store):
"""`SinglePoint` is the default: the price named by `execution_price`."""
result = bt.run(_reads_its_own_bar(),
_config(signal_delay=0, fill_model={"intra_bar_price": "SinglePoint"}),
store)
assert abs(result.metrics["total_return"]) < 0.5
@pytest.mark.parametrize("price", ["AtClose", "NextBarClose"])
def test_la_cloture_reste_permise_sans_delai(store, price):
"""These two fill at the close that produced the signal: nothing to refuse.
+79
View File
@@ -0,0 +1,79 @@
"""Un sous-test de re-run qui n'a rien compare ne rend pas PASS.
detect_lookahead rendait PASS sur une strategie qui ne trade jamais (un
param() sans valeur suffit), ou quand le run tronque echouait : un verdict
vide lu comme un feu vert. Il rend maintenant INCONCLUSIVE, `.passed` vaut
False et `assert_clean()` leve, en disant pourquoi.
"""
import os
import pytest
import manifoldbt as bt
# Safety diagnostics are a Pro feature.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
reason="requires Pro: skipped on a Community wheel",
)
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
def _monte(tmp_path):
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")
os.makedirs(racine, exist_ok=True)
store = bt.import_dataframe(df.reset_index(), symbol="ZLSV", symbol_id=1,
interval="1h", asset_class="equity",
exchange="TEST", data_root=racine,
metadata_db=str(tmp_path / "meta.sqlite"))
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={"ZLSV": 1})
cfg.warmup_bars = 0
return store, cfg
def _jamais():
from manifoldbt.indicators import close
return (bt.Strategy.create("jamais")
.signal("c", close)
.size(bt.lit(0.0)))
def _croise():
from manifoldbt.indicators import close, sma
m = sma(close, 24)
return bt.Strategy.create("croise").signal("m", m).size(bt.when(close > m, 1.0, 0.0))
def test_une_strategie_qui_ne_trade_pas_est_inconclusive(tmp_path):
store, cfg = _monte(tmp_path)
rapport = bt.diagnostics.detect_lookahead(_jamais(), cfg, store, mode="all")
assert not rapport.passed
re_runs = [r for r in rapport.reports if r.method != "static"]
assert {r.method for r in re_runs} == {"extension", "truncation"}
for r in re_runs:
assert r.inconclusive and "made no trade" in r.inconclusive
assert "INCONCLUSIVE" in str(r)
statique = [r for r in rapport.reports if r.method == "static"]
assert statique and statique[0].passed
assert str(rapport).startswith("Lookahead diagnostics: INCONCLUSIVE")
with pytest.raises(AssertionError, match="inconclusive"):
rapport.assert_clean()
def test_une_strategie_causale_qui_trade_reste_pass(tmp_path):
store, cfg = _monte(tmp_path)
rapport = bt.diagnostics.detect_lookahead(_croise(), cfg, store, mode="all")
assert rapport.passed, rapport
assert all(r.inconclusive is None for r in rapport.reports)
assert str(rapport).startswith("Lookahead diagnostics: PASS")
+81
View File
@@ -0,0 +1,81 @@
"""run() refuse un param() lu sans valeur, au lieu de ne rien trader.
Un param() sans defaut n'a de valeur que dans un balayage. Un run seul le
lisait comme une serie vide : EMA sans periode, aucun signal, 0 trade, aucune
erreur (la strategie de l'exemple 08 lancee seule). Les balayages, eux,
fixent chaque nom balaye et ne passent pas par cette garde.
"""
import os
import pytest
import manifoldbt as bt
from manifoldbt.exceptions import StrategyError
from manifoldbt.indicators import close, ema
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
@pytest.fixture(scope="module")
def monte(tmp_path_factory):
tmp = tmp_path_factory.mktemp("param")
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 / "data")
os.makedirs(racine, exist_ok=True)
store = bt.import_dataframe(df.reset_index(), symbol="ZPSV", symbol_id=1,
interval="1h", asset_class="equity",
exchange="TEST", data_root=racine,
metadata_db=str(tmp / "meta.sqlite"))
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={"ZPSV": 1})
return store, cfg
def _croisement(fast, slow):
f, s = ema(close, fast), ema(close, slow)
return (bt.Strategy.create("croisement").signal("f", f).signal("s", s)
.size(bt.when(f > s, 0.25, bt.when(f < s, -0.25, 0.0))))
def test_un_param_lu_sans_valeur_est_refuse_et_nomme(monte):
store, cfg = monte
with pytest.raises(StrategyError, match="has no value in a plain run") as exc:
bt.run(_croisement(bt.param("fast"), bt.param("slow")), cfg, store)
assert "'fast'" in str(exc.value) and "'slow'" in str(exc.value)
def test_un_param_avec_defaut_tourne(monte):
store, cfg = monte
r = bt.run(_croisement(bt.param("fast", default=12), bt.param("slow", default=26)),
cfg, store)
assert r.trade_count > 0
def test_une_declaration_strategy_param_donne_la_valeur(monte):
store, cfg = monte
s = (_croisement(bt.param("fast"), bt.param("slow"))
.param("fast", default=12).param("slow", default=26))
assert bt.run(s, cfg, store).trade_count > 0
def test_un_param_declare_mais_lu_par_rien_ne_bloque_pas(monte):
store, cfg = monte
s = _croisement(12, 26).param("seuil")
assert bt.run(s, cfg, store).trade_count > 0
def test_un_balayage_fixe_les_valeurs_et_passe(monte):
store, cfg = monte
if bt.license_info()[0] != "Pro":
pytest.skip("requires Pro beyond the Community combination limit")
b = bt.run_sweep_lite(_croisement(bt.param("fast"), bt.param("slow")),
{"fast": [8, 12], "slow": [26]}, cfg, store)
assert len(b) == 2
@@ -0,0 +1,79 @@
"""Le walk-forward doit accepter les sources exogenes PAR SYMBOLE (`exo_sources`).
Une strategie lisant `exo.<colonne>` d'une source par symbole tournait dans
`run`, mais le walk-forward passait des sources vides a ses trois runs
(selection, run d'apprentissage, run hors echantillon) et levait
"unknown input column".
"""
import os
import pytest
import manifoldbt as bt
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
reason="requires Pro: skipped on a Community wheel",
)
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
def _monte(tmp_path):
"""Le titre trade, une seconde source porte une moyenne causale du titre."""
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
aux = pd.Series(px).rolling(24, min_periods=1).mean().to_numpy()
def barres(p):
return pd.DataFrame({"open": p, "high": p * 1.004, "low": p * 0.996,
"close": p, "volume": np.full(n, 1000.0)},
index=idx).reset_index()
racine = str(tmp_path / "data")
meta = str(tmp_path / "meta.sqlite")
os.makedirs(racine, exist_ok=True)
store = bt.import_dataframe(barres(px), symbol="ZESM", symbol_id=1, interval="1h",
asset_class="equity", exchange="TEST",
data_root=racine, metadata_db=meta)
bt.import_dataframe(barres(aux), symbol="ZESA", symbol_id=2, interval="1h",
asset_class="equity", exchange="AUX",
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={"ZESM": 1})
cfg.warmup_bars = 0
cfg.exo_sources = {1: ("AUX", "ZESA")}
return store, cfg
def _strategie():
from manifoldbt.indicators import close, col
m = col("exo.close")
bande = m * (bt.lit(1.0) - bt.param("dev", default=0.002))
return (bt.Strategy.create("s")
.signal("b", 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_la_strategie_trade_dans_run(tmp_path):
store, cfg = _monte(tmp_path)
assert bt.run(_strategie(), cfg, store).trade_count > 0
def test_walk_forward_accepte_une_source_par_symbole(tmp_path):
store, cfg = _monte(tmp_path)
r = bt.run_walk_forward(_strategie(), WF, cfg, store)
assert r["n_folds"] == 3
# La source est lue dans les trois runs : la selection (metriques IS) et
# les deux courbes. Sans elle, aucun pli ne trade.
assert any(f["oos_metrics"]["total_return"] != 0.0 for f in r["folds"])
assert any(len(set(f["is_equity"])) > 1 for f in r["folds"])
@@ -0,0 +1,106 @@
"""Walk-forward sur un signal plus grossier que le store.
Un signal 12h sur des barres 1h (mode hybride) rendait, en sortie
automatique, une courbe OOS sous-echantillonnee au pas du signal. Sa longueur
ne valait plus celle des barres natives, et le walk-forward retombait sur les
metriques du run ENTIER, chauffe plate comprise : Sharpe hors echantillon
dilue, WFE faux, sans un avertissement. Avec `output_resolution` a 1h, le meme
walk-forward donnait le bon chiffre.
Le contrat : les metriques hors echantillon ne dependent pas de la resolution
de sortie, et la courbe rendue suit celle-ci, avec autant d'horodatages que de
points.
"""
import os
import pytest
import manifoldbt as bt
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
reason="requires Pro: skipped on a Community wheel",
)
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
HEURE_NS = 3_600 * 1_000_000_000
def _magasin_horaire(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="ZWFR", symbol_id=1,
interval="1h", asset_class="equity",
exchange="TEST", data_root=racine, metadata_db=meta)
return store
def _config(signal):
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=signal, symbol_names={"ZWFR": 1})
cfg.warmup_bars = 0
return cfg
def _strategie():
from manifoldbt.indicators import close, sma
m = sma(close, bt.param("n"))
return (bt.Strategy.create("s")
.signal("m", m)
.size(bt.when(close > m, 1.0, 0.0)))
WF = {"geometry": "anchored", "n_splits": 2, "train_ratio": 0.6,
"optimize_metric": "total_return", "param_grid": {"n": [4, 8]}}
def test_les_metriques_oos_ne_dependent_pas_de_la_sortie(tmp_path):
store = _magasin_horaire(tmp_path)
auto = bt.run_walk_forward(_strategie(), WF, _config(bt.Interval.hours(12)), store)
cfg_native = _config(bt.Interval.hours(12))
cfg_native.output_resolution = bt.Interval.hours(1)
native = bt.run_walk_forward(_strategie(), WF, cfg_native, store)
assert auto["n_folds"] == native["n_folds"] == 2
for fa, fn in zip(auto["folds"], native["folds"]):
assert fa["best_params"] == fn["best_params"]
for k in ("sharpe", "cagr", "total_return", "volatility", "max_drawdown"):
assert fa["oos_metrics"][k] == pytest.approx(fn["oos_metrics"][k], rel=1e-12), k
assert fa["oos_metrics"]["sharpe"] != 0.0, "le pli doit trader"
if fn["wfe"] is None:
assert fa["wfe"] is None
else:
assert fa["wfe"] == pytest.approx(fn["wfe"], rel=1e-12)
def test_la_courbe_oos_suit_la_resolution_de_sortie(tmp_path):
store = _magasin_horaire(tmp_path)
auto = bt.run_walk_forward(_strategie(), WF, _config(bt.Interval.hours(12)), store)
cfg_native = _config(bt.Interval.hours(12))
cfg_native.output_resolution = bt.Interval.hours(1)
native = bt.run_walk_forward(_strategie(), WF, cfg_native, store)
for fa, fn in zip(auto["folds"], native["folds"]):
eq, ts = fa["oos_equity"], fa["oos_timestamps"]
assert len(eq) == len(ts) > 0
assert ts[0] >= fa["test_range"]["start"]
assert ts[-1] < fa["test_range"]["end"]
# Un point par seau de sortie, au plus 12h d'ecart...
pas = np.diff(np.asarray(ts))
assert pas.max() == 12 * HEURE_NS and pas.min() > 0
# ... et chaque point est celui de la courbe native a cet instant.
natif = dict(zip(fn["oos_timestamps"], fn["oos_equity"]))
assert len(natif) == len(fn["oos_timestamps"]) > len(ts)
for t, e in zip(ts, eq):
assert natif[t] == e