mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
166 lines
7.1 KiB
Python
166 lines
7.1 KiB
Python
"""`ffill()` compose avec un filtre de regime, `hold()` non.
|
|
|
|
`hold()` retient la POSITION que porte le simulateur, pas la valeur de
|
|
l'expression qui le contient. Sous un filtre qui ecrit 0.0 pendant qu'il est
|
|
ferme, c'est ce 0.0 que `hold()` retient quand le filtre rouvre : l'exposition
|
|
ne revient qu'a la prochaine traversee de seuil.
|
|
|
|
Ce fichier epingle les deux comportements. Celui de `hold()` n'est PAS un bug a
|
|
corriger -- "sortir quand le regime est hostile, ne rentrer que sur un NOUVEAU
|
|
signal" est une strategie legitime -- mais il doit rester un choix, pas une
|
|
surprise, donc il est mesure ici pour que personne ne le change par accident.
|
|
"""
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
import manifoldbt as bt
|
|
from manifoldbt import indicators as ind
|
|
from manifoldbt.expr import col, hold, lit, when
|
|
from manifoldbt.helpers import Interval, Slippage
|
|
|
|
N_BARS = 3000
|
|
FENETRE, SEUIL = 200, 2.5
|
|
|
|
|
|
def _bars(seed: int = 7) -> pd.DataFrame:
|
|
rng = np.random.default_rng(seed)
|
|
close = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 0.004, N_BARS)))
|
|
ts = pd.date_range("2023-01-01", periods=N_BARS, freq="1h", tz="UTC")
|
|
return pd.DataFrame({
|
|
"timestamp": ts, "open": close, "high": close * 1.002,
|
|
"low": close * 0.998, "close": close, "volume": 1000.0,
|
|
})
|
|
|
|
|
|
_DF = _bars()
|
|
_END_NS = int(_DF["timestamp"].iloc[-1].value) + 86_400_000_000_000
|
|
# Le filtre est le JOUR de la semaine (ouvert du lundi au mercredi), pas l'heure.
|
|
# La raison est le palier Community : sa sortie est journaliere, `res.positions`
|
|
# y porte une ligne par jour, la derniere barre. Un filtre `hour() < 12` y est
|
|
# toujours ferme au moment de ce cliche, et l'exposition mesuree vaut 0 quoi que
|
|
# fasse la strategie : c'est ce que la CI publique, sans licence, a vu. Un filtre
|
|
# constant sur la journee rend le meme cliche que les positions horaires. Le
|
|
# taux est lu dans les donnees plutot que pose a 3/7 : l'echantillon ne fait pas
|
|
# un nombre entier de semaines.
|
|
TAUX_OUVERTURE = float(np.mean(_DF["timestamp"].dt.dayofweek < 3))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def store(tmp_path_factory):
|
|
root = tmp_path_factory.mktemp("ffill_etat")
|
|
return bt.import_dataframe(
|
|
_DF, symbol="TEST", symbol_id=1, interval="1h",
|
|
data_root=str(root / "data"), metadata_db=str(root / "meta.sqlite"),
|
|
)
|
|
|
|
|
|
@pytest.fixture(params=[None, Interval.days(1)], ids=["sortie_horaire", "sortie_journaliere"])
|
|
def sortie(request):
|
|
"""La resolution de sortie, les deux que le produit sert.
|
|
|
|
Pro rend les positions a la barre ; Community les ramene au jour, une ligne
|
|
par jour, la derniere barre. En Pro on force ici la sortie journaliere pour
|
|
rejouer ce que voit Community ; en Community la demande horaire est
|
|
plafonnee au jour de toute facon, les deux cas y sont donc identiques.
|
|
"""
|
|
return request.param
|
|
|
|
|
|
def _config(sortie=None) -> bt.BacktestConfig:
|
|
return bt.BacktestConfig(
|
|
output_resolution=sortie,
|
|
universe=[1], time_range_start=0, time_range_end=_END_NS,
|
|
bar_interval=Interval.hours(1), initial_capital=10_000.0,
|
|
execution=bt.ExecutionConfig(
|
|
signal_delay=1, execution_price="AtOpen", max_position_pct=1.0,
|
|
allow_short=True, position_sizing_mode="FractionOfInitialCapital",
|
|
),
|
|
fees=bt.FeeConfig.zero(), slippage=Slippage.none(),
|
|
warmup_bars=FENETRE + 10,
|
|
)
|
|
|
|
|
|
_Z = col("close").zscore(FENETRE)
|
|
_HAUT, _BAS = _Z >= lit(SEUIL), _Z <= lit(-SEUIL)
|
|
_FILTRE = ind.day_of_week() < lit(3.0)
|
|
|
|
# La branche fausse omise vaut NaN, donc ffill retient le dernier etat arme.
|
|
_ETAT_FFILL = when(_HAUT, lit(1.0), when(_BAS, lit(-1.0))).ffill()
|
|
_ETAT_HOLD = when(_HAUT, lit(1.0), when(_BAS, lit(-1.0), hold()))
|
|
|
|
|
|
def _positions(store, taille, nom: str, sortie=None) -> np.ndarray:
|
|
strat = bt.Strategy.create(nom).signal("d", col("close")).size(taille)
|
|
res = bt.run(strat, _config(sortie), store)
|
|
return res.positions.column("position").to_numpy()
|
|
|
|
|
|
def _exposition(positions: np.ndarray) -> float:
|
|
return float(np.mean(positions != 0.0))
|
|
|
|
|
|
def test_sans_filtre_ffill_reproduit_hold_barre_par_barre(store, sortie):
|
|
"""Sans filtre, les deux ecritures sont la meme strategie.
|
|
|
|
C'est ce qui autorise a presenter `ffill()` comme la version qui compose :
|
|
si les deux divergeaient deja sans filtre, ce serait un autre operateur.
|
|
"""
|
|
par_ffill = _positions(store, _ETAT_FFILL, "ffill_nu", sortie)
|
|
par_hold = _positions(store, _ETAT_HOLD, "hold_nu", sortie)
|
|
np.testing.assert_array_equal(par_ffill, par_hold)
|
|
# Et la strategie trade vraiment, sinon l'egalite ne prouverait rien.
|
|
assert _exposition(par_ffill) > 0.5
|
|
|
|
|
|
def test_sous_filtre_ffill_tient_l_exposition(store, sortie):
|
|
"""L'exposition suit le taux d'ouverture du filtre, aux barres non armees pres."""
|
|
nu = _exposition(_positions(store, _ETAT_FFILL, "ffill_nu2", sortie))
|
|
sous_filtre = _exposition(
|
|
_positions(store, when(_FILTRE, _ETAT_FFILL, lit(0.0)), "ffill_filtre", sortie)
|
|
)
|
|
# Le filtre est ouvert TAUX_OUVERTURE du temps et l'etat est arme `nu` du
|
|
# temps ; les deux sont independants ici (le jour de la semaine ne dit rien
|
|
# du z-score d'une marche aleatoire). Mesure : 0,360 contre 0,369 attendu
|
|
# en positions horaires, et la meme valeur en cliches de fin de journee.
|
|
attendu = TAUX_OUVERTURE * nu
|
|
assert abs(sous_filtre - attendu) < 0.03, (sous_filtre, attendu)
|
|
|
|
|
|
def test_sous_filtre_hold_s_effondre(store, sortie):
|
|
"""Le comportement de `hold()` sous filtre, epingle tel qu'il est.
|
|
|
|
A ne PAS "corriger" : c'est la lecture "ne rentrer que sur un nouveau
|
|
signal". Ce test existe pour que le jour ou quelqu'un change la semantique
|
|
de `hold()`, il le fasse en connaissance de cause.
|
|
"""
|
|
par_ffill = _exposition(
|
|
_positions(store, when(_FILTRE, _ETAT_FFILL, lit(0.0)), "ffill_filtre2", sortie)
|
|
)
|
|
par_hold = _exposition(
|
|
_positions(store, when(_FILTRE, _ETAT_HOLD, lit(0.0)), "hold_filtre", sortie)
|
|
)
|
|
assert par_ffill > 0.30, par_ffill
|
|
# Mesure sur ces donnees : ~36 % contre ~7 % en positions horaires (Pro), et
|
|
# ~36 % contre ~8 % en cliches de fin de journee (Community). Le seuil est
|
|
# large expres : ce qui est epingle est l'ordre de grandeur de l'ecart, pas
|
|
# un chiffre. Avec le filtre horaire d'origine l'ecart etait plus fort
|
|
# encore (43 % contre 5 %), mais invisible en sortie journaliere.
|
|
assert par_hold < par_ffill / 3.0, (par_hold, par_ffill)
|
|
|
|
|
|
def test_passer_par_un_signal_nomme_ne_change_rien_a_hold(store):
|
|
"""`.signal("etat", ...)` puis `col("etat")` donne exactement le meme resultat.
|
|
|
|
Le detour par un signal nomme est le premier reflexe quand l'exposition
|
|
s'effondre. Il ne change rien : le NaN veut dire "retiens la position"
|
|
partout ou il est lu, pas seulement dans l'expression ou il est ecrit.
|
|
"""
|
|
direct = _positions(store, when(_FILTRE, _ETAT_HOLD, lit(0.0)), "hold_direct")
|
|
strat = (bt.Strategy.create("hold_nomme")
|
|
.signal("d", col("close"))
|
|
.signal("etat", _ETAT_HOLD)
|
|
.size(when(_FILTRE, col("etat"), lit(0.0))))
|
|
via_signal = bt.run(strat, _config(), store).positions.column("position").to_numpy()
|
|
np.testing.assert_array_equal(direct, via_signal)
|