mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""``manifest["strategy_code_hash"]``: the hash a run records of its strategy.
|
|
|
|
The same strategy hashes the same from one run to the next and from one
|
|
process to the next. Its signals and parameters are maps, read by name: the
|
|
order they were declared in does not count. Its quotes are a sequence, which a
|
|
wake-up acts on in order: their order counts. Two strategies that run
|
|
differently hash differently, and a combination of a sweep hashes as the same
|
|
strategy run alone with those values as its defaults.
|
|
|
|
The strategies are those of the guide and the examples, on a synthetic store.
|
|
The engine's own tests hold the same proofs on the definitions themselves.
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
pd = pytest.importorskip("pandas")
|
|
np = pytest.importorskip("numpy")
|
|
|
|
import manifoldbt as bt # noqa: E402
|
|
from manifoldbt import _native # noqa: E402
|
|
from manifoldbt.helpers import Interval # noqa: E402
|
|
from manifoldbt.indicators import atr, close, ema, rsi, sma, volume # noqa: E402
|
|
|
|
from test_quote_dsl import _layer_open, walk_market # noqa: E402
|
|
from test_quote_sweep import config as quote_config, maker # noqa: E402
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The strategies: a fresh object at every call
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def trend_following():
|
|
"""Example 01."""
|
|
fast, slow = ema(close, 12), ema(close, 26)
|
|
trend = fast - slow
|
|
vol_ma = volume.rolling_mean(20)
|
|
return (
|
|
bt.Strategy.create("trend_following")
|
|
.signal("fast", fast)
|
|
.signal("slow", slow)
|
|
.signal("trend", trend)
|
|
.signal("vol_filter", volume > vol_ma)
|
|
.size(bt.when((trend > 0.0) & (volume > vol_ma), 0.5, 0.0))
|
|
.stop_loss(pct=3.0)
|
|
.describe("EMA(12/26) crossover, volume filter, 3% stop-loss")
|
|
)
|
|
|
|
|
|
def ema_crossover():
|
|
"""Example 02."""
|
|
fast, slow = ema(close, 12), ema(close, 26)
|
|
return (
|
|
bt.Strategy.create("ema_crossover")
|
|
.signal("fast", fast)
|
|
.signal("slow", slow)
|
|
.size(bt.when(fast > slow, 1.0, -1.0) * 0.25)
|
|
)
|
|
|
|
|
|
def multi_timeframe():
|
|
"""Example 14: two signals on a coarser grid."""
|
|
h12 = bt.tf("12h")
|
|
entry_rsi = rsi(close, 14)
|
|
return (
|
|
bt.Strategy.create("multi_tf_trend_dip")
|
|
.signal("bullish", h12.apply(ema(close, 20)) > h12.apply(ema(close, 50)))
|
|
.signal("entry_rsi", entry_rsi)
|
|
.signal("dip", entry_rsi < 35.0)
|
|
.size(bt.when(bt.col("bullish") & bt.col("dip"), 0.5, 0.0))
|
|
.stop_loss(pct=3.0)
|
|
)
|
|
|
|
|
|
def entry_orders(stop=3.0):
|
|
"""Example 20, the entry resting on a level the DSL computes."""
|
|
fast, slow = ema(close, 12), ema(close, 50)
|
|
return (
|
|
bt.Strategy.create("entry_orders")
|
|
.signal("fast", fast)
|
|
.signal("slow", slow)
|
|
.signal("pullback", close - atr(14))
|
|
.signal("stop_dist", bt.lit(2.0) * atr(14) / close * bt.lit(100.0))
|
|
.size(bt.when(fast > slow, 1.0, 0.0))
|
|
.stop_loss(pct=stop)
|
|
.limit_entry(signal="pullback", time_in_force={"GTB": 5})
|
|
)
|
|
|
|
|
|
def swept(fast=10, slow=40, k=1.0, reverse=False):
|
|
"""The guide's sweep: two periods and a threshold, all parameters. With
|
|
``reverse`` the same strategy declares its signals and parameters in the
|
|
opposite order."""
|
|
signals = [
|
|
("fast", sma(close, bt.param("fast", default=fast))),
|
|
("slow", sma(close, bt.param("slow", default=slow))),
|
|
("spread", bt.col("fast") - bt.col("slow")),
|
|
("go", bt.col("spread") > bt.param("k", default=k)),
|
|
]
|
|
if reverse:
|
|
# Parameters are declared by the first signal that reads them.
|
|
signals = [signals[i] for i in (3, 2, 1, 0)]
|
|
s = bt.Strategy.create("swept")
|
|
for name, expr in signals:
|
|
s = s.signal(name, expr)
|
|
return s.size(bt.when(bt.col("go"), 1.0, 0.0))
|
|
|
|
|
|
BAR_STRATEGIES = {
|
|
"trend_following": trend_following,
|
|
"ema_crossover": ema_crossover,
|
|
"multi_timeframe": multi_timeframe,
|
|
"entry_orders": entry_orders,
|
|
"swept": swept,
|
|
}
|
|
|
|
|
|
def avellaneda(swap=False):
|
|
"""The Avellaneda-Stoikov maker of the order-level harness: two signals,
|
|
two parameters, a bid and an ask. ``swap`` declares the ask first."""
|
|
from manifoldbt import book
|
|
from manifoldbt import indicators as ind
|
|
|
|
gamma, k = bt.param("gamma", default=0.02), bt.param("k", default=4.0)
|
|
mid = (book.bid_price(1) + book.ask_price(1)) * bt.lit(0.5)
|
|
sigma = mid.diff(1).rolling_std(Interval.seconds(30))
|
|
pos = bt.position()
|
|
s2g = bt.col("s2") * gamma
|
|
r = bt.col("mid") - pos / bt.lit(0.01) * s2g
|
|
delta = ind.max_val(bt.lit(0.1), s2g * bt.lit(0.5) + ind.log(bt.lit(1.0) + gamma / k) / gamma)
|
|
quotes = [
|
|
("buy", (r - delta).floor_to(0.1), pos < 0.05),
|
|
("sell", (r + delta).ceil_to(0.1), pos > -0.05),
|
|
]
|
|
if swap:
|
|
quotes.reverse()
|
|
s = bt.Strategy.create("avellaneda").signal("mid", mid).signal("s2", sigma * sigma)
|
|
for side, price, enabled in quotes:
|
|
s = s.quote(side, price, 0.01, tif="GTX", enabled=enabled)
|
|
return s
|
|
|
|
|
|
QUOTE_STRATEGIES = {"maker": maker, "avellaneda": avellaneda}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def bars(rows=3_000, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
px = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 0.002, rows)))
|
|
return pd.DataFrame({
|
|
"timestamp": pd.date_range("2024-01-01", periods=rows, freq="1min", tz="UTC"),
|
|
"open": px, "high": px * 1.001, "low": px * 0.999, "close": px,
|
|
"volume": rng.uniform(1.0, 10.0, rows),
|
|
})
|
|
|
|
|
|
def open_store(root):
|
|
df = bars()
|
|
os.makedirs(root, exist_ok=True)
|
|
store = bt.import_dataframe(
|
|
df, symbol="TEST", symbol_id=1, interval="1m",
|
|
data_root=os.path.join(root, "data"), metadata_db=os.path.join(root, "meta.sqlite"),
|
|
)
|
|
config = bt.BacktestConfig(
|
|
universe=[1], time_range_start=0,
|
|
time_range_end=int(df["timestamp"].iloc[-1].value) + 86_400_000_000_000,
|
|
bar_interval=Interval.minutes(1), initial_capital=10_000.0,
|
|
# The coarser grid of example 14.
|
|
extra_timeframes={"12h": Interval.hours(12)},
|
|
# A resting entry is placed from a bar that has closed (example 20).
|
|
execution=bt.ExecutionConfig(signal_delay=1),
|
|
)
|
|
return store, config
|
|
|
|
|
|
def bar_hash(strategy, store, config):
|
|
return bt.run(strategy, config, store).manifest["strategy_code_hash"]
|
|
|
|
|
|
_MARKET = []
|
|
|
|
|
|
def quote_hash(strategy):
|
|
if not _MARKET:
|
|
_MARKET.append(walk_market(seed=3, n=300))
|
|
raw, _ = _native.run_quotes(strategy.to_json(), quote_config().to_json(), None, _MARKET)
|
|
return raw.manifest["strategy_code_hash"]
|
|
|
|
|
|
def all_hashes(root):
|
|
"""Every strategy's hash, each from a fresh object: what a process sees."""
|
|
store, config = open_store(root)
|
|
out = {name: bar_hash(build(), store, config) for name, build in BAR_STRATEGIES.items()}
|
|
if _layer_open():
|
|
out.update({name: quote_hash(build()) for name, build in QUOTE_STRATEGIES.items()})
|
|
return out
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def store(tmp_path_factory):
|
|
return open_store(str(tmp_path_factory.mktemp("code_hash")))
|
|
|
|
|
|
needs_layer = pytest.mark.skipif(
|
|
not _layer_open(),
|
|
reason="order-level simulation locked (runs in the engine's own debug builds)",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The same strategy, the same hash
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_hash_is_the_same_in_every_process(tmp_path):
|
|
here = all_hashes(str(tmp_path / "parent"))
|
|
child = (
|
|
"import json, sys; sys.path.insert(0, sys.argv[1]); "
|
|
"import test_strategy_code_hash as t; print(json.dumps(t.all_hashes(sys.argv[2])))"
|
|
)
|
|
for i in range(3):
|
|
got = subprocess.run(
|
|
[sys.executable, "-c", child, HERE, str(tmp_path / f"child{i}")],
|
|
capture_output=True, text=True, timeout=600, env=os.environ.copy(),
|
|
)
|
|
assert got.returncode == 0, got.stderr[-2000:]
|
|
line = [x for x in got.stdout.splitlines() if x.startswith("{")][-1]
|
|
assert json.loads(line) == here, f"process {i}"
|
|
assert all(len(h) == 64 for h in here.values())
|
|
|
|
|
|
def test_the_hash_is_the_same_run_after_run(store):
|
|
s, config = store
|
|
for name, build in BAR_STRATEGIES.items():
|
|
assert len({bar_hash(build(), s, config) for _ in range(4)}) == 1, name
|
|
|
|
|
|
@needs_layer
|
|
def test_a_strategy_that_quotes_hashes_the_same_run_after_run():
|
|
for name, build in QUOTE_STRATEGIES.items():
|
|
assert len({quote_hash(build()) for _ in range(6)}) == 1, name
|
|
|
|
|
|
def test_the_order_signals_and_parameters_are_declared_in_does_not_count(store):
|
|
s, config = store
|
|
forward, backward = swept(), swept(reverse=True)
|
|
assert forward.to_json() != backward.to_json()
|
|
assert bar_hash(forward, s, config) == bar_hash(backward, s, config)
|
|
|
|
|
|
@needs_layer
|
|
def test_the_order_of_the_quotes_counts():
|
|
assert quote_hash(avellaneda()) != quote_hash(avellaneda(swap=True))
|
|
|
|
|
|
def test_a_combination_of_a_sweep_hashes_as_the_strategy_run_alone(store):
|
|
s, config = store
|
|
grid = {"fast": [10, 20], "k": [1.0, 2.0]}
|
|
sweep = bt.run_sweep(swept(), grid, config, s)
|
|
hashes = [r.manifest["strategy_code_hash"] for r in sweep]
|
|
assert len(set(hashes)) == 4
|
|
for r, h in zip(sweep, hashes):
|
|
p = r.manifest["parameters"]
|
|
alone = swept(fast=_value(p["fast"]), k=_value(p["k"]))
|
|
assert bar_hash(alone, s, config) == h, p
|
|
|
|
|
|
def _value(scalar):
|
|
"""``{"Int64": 20}`` -> 20."""
|
|
(v,) = scalar.values()
|
|
return v
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Different strategies, different hashes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_strategies_that_run_differently_hash_differently(store):
|
|
s, config = store
|
|
variants = dict(BAR_STRATEGIES)
|
|
variants["a parameter's value"] = lambda: swept(k=1.5)
|
|
variants["a period"] = lambda: swept(slow=41)
|
|
variants["the stop"] = lambda: entry_orders(stop=4.0)
|
|
variants["one more signal"] = lambda: ema_crossover().signal("extra", close)
|
|
# Each variant keeps its own defaults whatever was built before it (see
|
|
# test_param_defaut_par_strategie.py).
|
|
hashes = {name: bar_hash(build(), s, config) for name, build in variants.items()}
|
|
assert len(set(hashes.values())) == len(hashes), hashes
|