mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""``fill_model={"queue": ...}``: what it refuses, and what it reports.
|
|
|
|
The queue model decides whether a resting maker order was really served, or
|
|
merely stood at a price the market reached. The simulation loop consumes it now,
|
|
so this file covers the two halves a user meets:
|
|
|
|
* the refusals, which need no tape and no licence and are what a wrong config
|
|
hits first: a malformed queue is named as malformed, and a queue asked for
|
|
where there are no prints to burn is named as such;
|
|
* the counters, which need a store holding a tape AND a book, since the whole
|
|
point is reading the depth at the level of an order. A tape enters a store
|
|
only through ``bt.ingest_trades``, which fetches a venue's archive over the
|
|
network, so that half skips by name unless ``MANIFOLDBT_TEST_TAPE_STORE``
|
|
points at a store that already holds one, as
|
|
``<root>|<meta.sqlite>|<symbol_id>|<YYYY-MM-DD>``.
|
|
|
|
The service rule itself is pinned by the engine's own tests: the model alone,
|
|
the model against the study's oracle, and the loop that consumes it.
|
|
"""
|
|
import os
|
|
|
|
import pytest
|
|
|
|
pd = pytest.importorskip("pandas")
|
|
np = pytest.importorskip("numpy")
|
|
|
|
import manifoldbt as bt # noqa: E402
|
|
from manifoldbt.expr import col, lit # noqa: E402
|
|
from manifoldbt.exceptions import BacktesterError # noqa: E402
|
|
from manifoldbt.helpers import Interval, Slippage # noqa: E402
|
|
|
|
QUEUE_KEYS = {
|
|
"queue_decided_fills",
|
|
"traverse_fills",
|
|
"partial_fills",
|
|
"book_unknown_at_post",
|
|
"fills_from_book_cross",
|
|
}
|
|
|
|
|
|
def _store(tmp_path):
|
|
n = 8
|
|
df = pd.DataFrame(
|
|
{
|
|
"timestamp": pd.date_range("2021-03-01", periods=n, freq="1min", tz="UTC"),
|
|
"open": np.full(n, 100.0),
|
|
"high": np.full(n, 101.0),
|
|
"low": np.full(n, 99.0),
|
|
"close": np.full(n, 100.0),
|
|
"volume": np.full(n, 1_000.0),
|
|
}
|
|
)
|
|
root = tmp_path / "store"
|
|
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"),
|
|
)
|
|
return store, int(df["timestamp"].iloc[-1].value)
|
|
|
|
|
|
def _config(last_ns, queue=None, **fill):
|
|
fill_model = dict(fill)
|
|
if queue is not None:
|
|
fill_model["queue"] = queue
|
|
return bt.BacktestConfig(
|
|
universe=[1],
|
|
time_range_start=0,
|
|
time_range_end=last_ns + 60_000_000_000,
|
|
bar_interval=Interval.minutes(1),
|
|
initial_capital=10_000.0,
|
|
execution=bt.ExecutionConfig(
|
|
signal_delay=1,
|
|
execution_price="AtClose",
|
|
allow_short=False,
|
|
fill_model=fill_model or None,
|
|
),
|
|
fees=bt.FeeConfig.zero(),
|
|
slippage=Slippage.none(),
|
|
warmup_bars=0,
|
|
)
|
|
|
|
|
|
def _strategy():
|
|
return (
|
|
bt.Strategy.create("queue")
|
|
.signal("sig", lit(1.0))
|
|
.size(col("sig"))
|
|
.limit_entry(price=99.5)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# What refuses, and why
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_queue_without_prints_is_refused_by_name(tmp_path):
|
|
"""A bar has a high and a low, not an order of trades: no queue to burn."""
|
|
store, last_ns = _store(tmp_path)
|
|
config = _config(last_ns, {"depth_source": "book", "assumed_queue": 2.0})
|
|
with pytest.raises(BacktesterError, match="fill_model.queue needs the prints"):
|
|
bt.run(_strategy(), config, store)
|
|
|
|
|
|
def test_the_queue_and_the_participation_cap_are_refused_together(tmp_path):
|
|
"""Both cap one fill, and the queue is the finer of the two."""
|
|
store, last_ns = _store(tmp_path)
|
|
config = _config(
|
|
last_ns,
|
|
{"depth_source": "book", "assumed_queue": 2.0},
|
|
fill_resolution="ticks",
|
|
max_participation_rate=0.1,
|
|
)
|
|
with pytest.raises((BacktesterError, PermissionError)) as err:
|
|
bt.run(_strategy(), config, store)
|
|
# Without the tick-layer key the licence gate answers first, and that is
|
|
# the right answer too: the run is refused either way, by name.
|
|
assert "max_participation_rate" in str(err.value) or isinstance(
|
|
err.value, PermissionError
|
|
)
|
|
|
|
|
|
def test_the_lite_paths_refuse_the_queue_by_name(tmp_path):
|
|
store, last_ns = _store(tmp_path)
|
|
config = _config(last_ns, {"depth_source": "book", "assumed_queue": 2.0})
|
|
with pytest.raises(BacktesterError, match="lite paths"):
|
|
bt.run_batch_lite([_strategy()], config, store)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"queue, message",
|
|
[
|
|
({"depth_source": "assumed"}, "assumed_queue"),
|
|
({"assumed_queue": -1.0}, "assumed_queue"),
|
|
({"cancel_ahead_rate": -0.5}, "cancel_ahead_rate"),
|
|
({"assumed_qeue": 2.0}, "unknown field"),
|
|
({"depth_source": "carnet"}, "unknown variant"),
|
|
],
|
|
)
|
|
def test_a_malformed_queue_is_named_before_anything_else(tmp_path, queue, message):
|
|
"""A typo in a parameter that makes the P&L is reported as a typo, before
|
|
any question about resolutions or licences."""
|
|
store, last_ns = _store(tmp_path)
|
|
with pytest.raises(BacktesterError, match=message):
|
|
bt.run(_strategy(), _config(last_ns, queue), store)
|
|
|
|
|
|
def test_a_config_without_a_queue_is_unchanged(tmp_path):
|
|
"""The field is optional and skipped when absent, so a run that does not
|
|
mention it keeps the fill model it always had -- and reports no queue."""
|
|
store, last_ns = _store(tmp_path)
|
|
plain = bt.run(_strategy(), _config(last_ns), store)
|
|
explicit = _config(last_ns)
|
|
explicit.execution.fill_model = {"passive_fill": "traverse"}
|
|
other = bt.run(_strategy(), explicit, store)
|
|
for res in (plain, other):
|
|
frag = res.fill_fragility
|
|
assert frag is not None
|
|
assert set(frag) == {"maker_fills", "touch_only_fills"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End to end, on a store that holds a tape and a book
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TAPE = os.environ.get("MANIFOLDBT_TEST_TAPE_STORE", "")
|
|
_needs_tape = pytest.mark.skipif(
|
|
not _TAPE,
|
|
reason="set MANIFOLDBT_TEST_TAPE_STORE=<root>|<meta.sqlite>|<symbol_id>|<YYYY-MM-DD> "
|
|
"to run the queue end to end (a tape and a book enter a store only through "
|
|
"bt.ingest_trades / bt.ingest_book, which need the network), and hold the "
|
|
"tick-layer key",
|
|
)
|
|
|
|
|
|
def _tape_store():
|
|
root, meta, sid, day = _TAPE.split("|")
|
|
store = bt.DataStore(
|
|
data_root=root, metadata_db=meta, arrow_dir=os.path.join(root, "mega")
|
|
)
|
|
start = int(bt.date_to_ns(day))
|
|
return store, int(sid), start, start + 86_400_000_000_000
|
|
|
|
|
|
def _tape_config(sid, start, end, queue):
|
|
cfg = bt.BacktestConfig(
|
|
universe=[sid],
|
|
time_range_start=start,
|
|
time_range_end=end,
|
|
bar_interval=Interval.seconds(1),
|
|
# DAILY, and on purpose: a Community wheel returns one row a day
|
|
# whatever is asked, so a test that read an intraday position would
|
|
# pass here and fail in the public CI. Fills carry their own stamps.
|
|
output_resolution=Interval.days(1),
|
|
initial_capital=100_000.0,
|
|
warmup_bars=120,
|
|
execution=bt.ExecutionConfig(
|
|
signal_delay=1,
|
|
allow_short=False,
|
|
position_sizing_mode="Units",
|
|
fill_model={"queue": queue, "fill_resolution": "ticks"},
|
|
),
|
|
fees=bt.FeeConfig.zero(),
|
|
slippage=Slippage.none(),
|
|
)
|
|
return cfg
|
|
|
|
|
|
def _maker(offset_bps: float = 2.0):
|
|
return (
|
|
bt.Strategy.create("maker")
|
|
.signal("sig", lit(1.0))
|
|
.size(col("sig") * lit(0.01))
|
|
.limit_entry(offset_bps=offset_bps, time_in_force={"GTB": 1})
|
|
.take_profit(pct=0.02, side="long")
|
|
.stop_loss(pct=0.06, side="long")
|
|
)
|
|
|
|
|
|
@_needs_tape
|
|
def test_the_queue_reports_its_five_counters_and_the_envelope():
|
|
store, sid, start, end = _tape_store()
|
|
res = bt.run(
|
|
_maker(),
|
|
_tape_config(sid, start, end, {"depth_source": "book", "assumed_queue": 2.0}),
|
|
store,
|
|
)
|
|
frag = res.fill_fragility
|
|
assert set(frag) == {
|
|
"maker_fills",
|
|
"touch_only_fills",
|
|
"queue",
|
|
"would_fill_touch",
|
|
"would_fill_traverse",
|
|
}
|
|
assert set(frag["queue"]) == QUEUE_KEYS
|
|
assert all(isinstance(v, int) and v >= 0 for v in frag["queue"].values())
|
|
|
|
q = frag["queue"]
|
|
served = q["queue_decided_fills"] + q["traverse_fills"]
|
|
assert served > 0, "a day of a liquid perpetual serves something"
|
|
# The envelope, on the order lives THIS run produced: a traversal always
|
|
# serves the queue, and a queue fill always needs a print at the level --
|
|
# except the ones the book crossed, which no print witnessed.
|
|
assert frag["would_fill_traverse"] <= served
|
|
assert served <= frag["would_fill_touch"] + q["fills_from_book_cross"]
|
|
|
|
# Fills, never intraday positions: the count is the same on a Community
|
|
# wheel, which returns one output row a day.
|
|
trades = res.trades_df()
|
|
assert len(trades) > 0
|
|
assert set(trades["side"].unique()) <= {1, 2}
|
|
|
|
|
|
@_needs_tape
|
|
def test_two_runs_of_the_same_queue_are_identical():
|
|
store, sid, start, end = _tape_store()
|
|
cfg = _tape_config(sid, start, end, {"depth_source": "book", "assumed_queue": 2.0})
|
|
a = bt.run(_maker(), cfg, store)
|
|
b = bt.run(_maker(), cfg, store)
|
|
assert a.fill_fragility == b.fill_fragility
|
|
assert a.order_activity == b.order_activity
|
|
pd.testing.assert_frame_equal(a.trades_df(), b.trades_df())
|
|
|
|
|
|
@_needs_tape
|
|
def test_an_unknown_book_refuses_rather_than_filling_from_the_front():
|
|
"""A level the stored ladder cannot answer at is a queue nobody saw. With
|
|
no declared fallback the run says so; with one, it is used AND counted."""
|
|
store, sid, start, end = _tape_store()
|
|
# A limit two percent under the close is far outside any stored ladder.
|
|
strat = _maker(offset_bps=200.0)
|
|
with pytest.raises(BacktesterError, match="says nothing at"):
|
|
bt.run(strat, _tape_config(sid, start, end, {"depth_source": "book"}), store)
|
|
|
|
res = bt.run(
|
|
strat,
|
|
_tape_config(sid, start, end, {"depth_source": "book", "assumed_queue": 2.0}),
|
|
store,
|
|
)
|
|
assert res.fill_fragility["queue"]["book_unknown_at_post"] > 0
|