mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
321 lines
15 KiB
Python
321 lines
15 KiB
Python
"""A one-sided market maker on the trade clock, with a queue, a latency and marks.
|
|
|
|
A maker does not get filled because the price reached its level. It gets filled
|
|
because everything resting in front of it at that level went first, and by then
|
|
the price is usually leaving. That sentence is the whole file: every setting
|
|
below exists to stop a backtest from booking a half-spread it would never have
|
|
captured.
|
|
|
|
One BTCUSDT day, one simulation row per print, and the seven doors of the tape
|
|
layer opened in the order the guide gives them
|
|
(`docs/strategy-authoring.md`, "Backtesting on the Tape"):
|
|
|
|
1. the tape `bt.ingest_trades` what the venue printed
|
|
2. the book `bt.ingest_book` what was resting, ten levels a side
|
|
3. the clock `bar_interval=Interval.trades()`
|
|
4. durations a flow window in seconds, a quote that lives one second
|
|
5. the queue `fill_model={"queue": ...}`
|
|
6. the latency `execution.latency`
|
|
7. the marks `execution.fill_marks`
|
|
|
|
The strategy is deliberately plain: rest at the best bid, step back two ticks
|
|
per clip already held, and quote only while the last ten seconds of flow were
|
|
not one-sided selling. It is a device for reading the counters, not a strategy
|
|
to trade. The number to look at is the last one printed: what the quotes
|
|
actually captured at the touch, against the drift measured one second later.
|
|
|
|
Demonstrates:
|
|
- `Interval.trades()`: one simulation row per print
|
|
- `position()` inside an order's level: the quote steps away from inventory
|
|
- `time_in_force=Interval.seconds(1)`: a quote that lives in time, not in rows
|
|
- `fill_model={"queue": ...}`: the volume ahead decides the fill
|
|
- `execution.latency`: the quote is seen late and withdrawn late
|
|
- `result.fill_fragility["queue"]`, `result.order_activity`, `result.fill_marks`
|
|
- `bt.plot.fill_marks(result)`
|
|
|
|
NEEDS THE RESEARCHER PLAN. Reading a stored tape or a stored book is a
|
|
Researcher feature; a Pro licence does not unlock it. Without it the file
|
|
exits with the refusal message rather than a traceback; nothing in it is a placeholder.
|
|
|
|
Data: self-contained (network), and it is not small. Two public Bybit spot
|
|
archives for one UTC day are fetched once into `data/tape/`:
|
|
|
|
- the trades, `public.bybit.com/spot/BTCUSDT/BTCUSDT_<day>.csv.gz`, a few
|
|
megabytes, stored as one Arrow file under `data/tape/mega/bybit/ticks/`;
|
|
- the 200-level book, `public.bybit.com/.../<day>_BTCUSDT_ob200.data.zip`,
|
|
about 90 MB compressed and several gigabytes inflated. It is streamed, not
|
|
held, and only the top ten levels a side are kept, under
|
|
`data/tape/mega/bybit/book/`. The raw archive is kept in
|
|
`data/tape/archives/` so a second run re-reads it instead of the network.
|
|
|
|
A re-run touches the network only for what is missing. Real prices: the figures
|
|
move with the day.
|
|
|
|
Usage:
|
|
python examples/28_trade_clock_market_maker.py
|
|
python examples/28_trade_clock_market_maker.py 2025-08-13 # pin a day
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import manifoldbt as mbt
|
|
from manifoldbt import position
|
|
from manifoldbt.expr import col, lit
|
|
from manifoldbt.helpers import Interval, Slippage, time_range
|
|
|
|
# -- The day and the market ---------------------------------------------------
|
|
PROVIDER = "bybit"
|
|
CATEGORY = "spot"
|
|
SYMBOL = "BTCUSDT"
|
|
SYMBOL_ID = 1
|
|
LEVELS = 10 # levels kept per side; the queue reads its own
|
|
|
|
# The venues keep a rolling archive window of roughly a year, so a hardcoded
|
|
# date eventually stops existing and the ingest says so by name. Pass a day on
|
|
# the command line to pin a run.
|
|
_TODAY = datetime.now(timezone.utc).date()
|
|
DAY = sys.argv[1] if len(sys.argv) > 1 else str(_TODAY - timedelta(days=3))
|
|
NEXT_DAY = str(datetime.strptime(DAY, "%Y-%m-%d").date() + timedelta(days=1))
|
|
|
|
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
DATA_ROOT = os.path.join(_ROOT, "data", "tape")
|
|
METADATA_DB = os.path.join(DATA_ROOT, "metadata.sqlite")
|
|
ARCHIVE_DIR = os.path.join(DATA_ROOT, "archives")
|
|
TAPE_FILE = os.path.join(DATA_ROOT, "mega", PROVIDER, "ticks", SYMBOL, f"{DAY}.arrow")
|
|
# The day of book, stored as deltas (book_delta/), or as whole ladders (book/)
|
|
# by an earlier version.
|
|
BOOK_FILES = [
|
|
os.path.join(DATA_ROOT, "mega", PROVIDER, shape, SYMBOL, f"{DAY}.arrow")
|
|
for shape in ("book_delta", "book")
|
|
]
|
|
|
|
# -- The quote ----------------------------------------------------------------
|
|
# The price grid of the market, and it matters more than it looks. The queue
|
|
# reads the depth stored AT THE ORDER'S OWN LEVEL: a level off the grid, or on
|
|
# it with nothing resting there, is a level the stored book has no row for. The
|
|
# order then joins an `assumed_queue` instead of an observed one, and
|
|
# `book_unknown_at_post` counts it. The run below posts about a fifth of its
|
|
# orders that way, all of them when the inventory skew has walked the level down
|
|
# to a price point nobody was quoting.
|
|
#
|
|
# So the level is built out of whole ticks only. `fair` is the best bid the
|
|
# venue published, which is on the grid by construction; the edge and the
|
|
# inventory skew are whole multiples of a tick. The DSL has no `floor`, so
|
|
# rounding an arbitrary fair price onto the grid has to happen upstream of the
|
|
# engine, or the level has to be one the venue itself published -- which is what
|
|
# this file does.
|
|
TICK = 0.01
|
|
CLIP = 0.01 # BTC: the unit of inventory the skew is priced per
|
|
MAX_INVENTORY = 0.05 # BTC, five clips: the target the orders close towards
|
|
EDGE_TICKS = 0 # how far behind the touch the quote rests
|
|
SKEW_TICKS_PER_CLIP = 2 # how much further back, per clip already held
|
|
|
|
FLOW_WINDOW = Interval.seconds(10)
|
|
FLOW_FLOOR = -0.35 # do not quote a bid into a one-sided sell flow
|
|
QUOTE_LIFE = Interval.seconds(1)
|
|
ROUND_TRIP = Interval.millis(20)
|
|
|
|
|
|
def open_store():
|
|
"""One UTC day of tape and one of book, fetched once, reused after that."""
|
|
os.makedirs(ARCHIVE_DIR, exist_ok=True)
|
|
|
|
if os.path.exists(TAPE_FILE):
|
|
store = mbt.DataStore(
|
|
data_root=DATA_ROOT,
|
|
metadata_db=METADATA_DB,
|
|
arrow_dir=os.path.join(DATA_ROOT, "mega"),
|
|
)
|
|
else:
|
|
print(f"Fetching {SYMBOL} trades for {DAY} from {PROVIDER} (once)...")
|
|
store = mbt.ingest_trades(
|
|
PROVIDER, SYMBOL, symbol_id=SYMBOL_ID, start=DAY, end=DAY,
|
|
data_root=DATA_ROOT, metadata_db=METADATA_DB,
|
|
)
|
|
|
|
if not any(os.path.exists(p) for p in BOOK_FILES):
|
|
print(f"Fetching {SYMBOL} book for {DAY} (once, ~90 MB compressed)...")
|
|
store = mbt.ingest_book(
|
|
PROVIDER, SYMBOL, symbol_id=SYMBOL_ID, start=DAY, end=DAY,
|
|
levels=LEVELS, category=CATEGORY,
|
|
data_root=DATA_ROOT, metadata_db=METADATA_DB,
|
|
cache_dir=ARCHIVE_DIR,
|
|
)
|
|
return store
|
|
|
|
|
|
def build_strategy():
|
|
"""Quote behind the touch, step away from inventory, gate on recent flow."""
|
|
# 4. Durations. A window given as an Interval is read on the timestamps, so
|
|
# it is ten SECONDS of flow whatever number of prints that is. Counted in
|
|
# rows it would be ten prints, which on this tape is a few milliseconds.
|
|
bought = col("buy_volume").rolling_sum(FLOW_WINDOW)
|
|
sold = col("sell_volume").rolling_sum(FLOW_WINDOW)
|
|
imbalance = (bought - sold) / (bought + sold + lit(1e-12))
|
|
|
|
# 2. The book. `bid` is the best bid standing at or before the print, joined
|
|
# as of it. It is null until the book's first row, and a null level posts
|
|
# nothing, which is the honest behaviour during the warm-up.
|
|
fair = col("bid")
|
|
# Our own edge behind the touch, in whole ticks. Zero here: the quote rests
|
|
# AT the best bid, the one price the stored ladder is certain to have a row
|
|
# for. Raise it and watch `book_unknown_at_post` climb, because on a 0.01
|
|
# tick the price points between the venue's own levels carry nothing, and a
|
|
# level with no row is a level the book cannot answer for.
|
|
half = lit(EDGE_TICKS * TICK)
|
|
# Price units per unit of inventory. A clip is CLIP BTC, so this is
|
|
# SKEW_TICKS_PER_CLIP ticks of retreat per clip held -- whole ticks, which
|
|
# is what keeps the level on the grid.
|
|
skew = lit(SKEW_TICKS_PER_CLIP * TICK / CLIP)
|
|
|
|
return (
|
|
mbt.Strategy.create("tape_maker")
|
|
# `position()` is the signed inventory at the instant the order is
|
|
# posted, not a column: the engine walks the few nodes that read it once
|
|
# per posted order. It is readable in an entry order's LEVEL and nowhere
|
|
# else, so nothing but the order may read this signal.
|
|
.signal("quote", fair - half - skew * position())
|
|
.signal("flow", imbalance)
|
|
.size(mbt.when(imbalance > lit(FLOW_FLOOR), lit(MAX_INVENTORY), lit(0.0)))
|
|
# 4. A quote that lives one SECOND. Expiring is how a maker requotes:
|
|
# the order is posted again on the next event, at that event's level,
|
|
# for as long as the target holds. `{"GTB": 1}` here would be one
|
|
# print, a few milliseconds.
|
|
.limit_entry(signal="quote", time_in_force=QUOTE_LIFE)
|
|
# A maker's exit, in PERCENT: 2 bps of profit, 6 of loss. The
|
|
# take-profit rests, so the queue serves it too -- and because a bracket
|
|
# cannot exit half a position, it only fires once its whole size is
|
|
# served.
|
|
.take_profit(pct=0.02, side="long")
|
|
.stop_loss(pct=0.06, side="long")
|
|
.describe("Rest at the bid, retreat two ticks per clip of inventory")
|
|
)
|
|
|
|
|
|
def build_config():
|
|
start, end = time_range(DAY, NEXT_DAY)
|
|
return mbt.BacktestConfig(
|
|
universe=[SYMBOL_ID],
|
|
time_range_start=start,
|
|
time_range_end=end,
|
|
# 3. The clock: one simulation row per print, nothing aggregated.
|
|
# `output_resolution` then defaults to one second under this clock,
|
|
# because a liquid day is millions of events and handing them all
|
|
# back is a memory bill nobody asked for.
|
|
bar_interval=Interval.trades(),
|
|
initial_capital=100_000.0,
|
|
# Costs are off so the counters read cleanly. Turn them on before
|
|
# believing any P&L: a maker lives or dies on its fee schedule.
|
|
fees=mbt.FeeConfig.zero(),
|
|
slippage=Slippage.none(),
|
|
execution=mbt.ExecutionConfig(
|
|
signal_delay=1, # decide on a print, act on the next one
|
|
allow_short=False,
|
|
position_sizing_mode="Units",
|
|
max_position_pct=1.0,
|
|
# 5. The queue. The order joins behind the depth the stored book
|
|
# holds at its own level and instant; a print AT the level burns
|
|
# that volume first and only the remainder fills. `assumed_queue`
|
|
# is the declared fallback for an instant the book cannot answer,
|
|
# in multiples of the order's own size -- without it, such an
|
|
# instant is refused rather than served from the front.
|
|
fill_model={"queue": {"depth_source": "book", "assumed_queue": 2.0}},
|
|
# 6. The latency. The quote is seen 20 ms after the decision and
|
|
# withdrawn 20 ms after the cancel, so a requote leaves two orders
|
|
# live in between and both can fill.
|
|
latency={"order": ROUND_TRIP, "cancel": ROUND_TRIP},
|
|
# 7. The marks. One ordered walk after the loop; the simulation
|
|
# itself is byte for byte the run without them.
|
|
fill_marks=True,
|
|
),
|
|
)
|
|
|
|
|
|
def horizon(marks, name):
|
|
for h in marks["horizons"]:
|
|
if h["horizon"] == name:
|
|
return h
|
|
return None
|
|
|
|
|
|
def row(label, value):
|
|
print(f" {label:<40}{value:>18}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
store = open_store()
|
|
result = mbt.run(build_strategy(), build_config(), store)
|
|
except PermissionError as exc:
|
|
raise SystemExit(f"\n {exc}\n") from None
|
|
|
|
activity = result.order_activity
|
|
frag = result.fill_fragility
|
|
queue = frag["queue"]
|
|
marks = result.fill_marks
|
|
served = queue["queue_decided_fills"] + queue["traverse_fills"]
|
|
|
|
print(f"\n{SYMBOL} {DAY}, one simulation row per print, "
|
|
f"{result.tape_resolution['bars_on_tape']:,} of them with an order live.")
|
|
print(f"Resting at the best bid, {SKEW_TICKS_PER_CLIP} ticks further back per "
|
|
f"clip held, {CLIP} BTC a clip, one second a quote.\n")
|
|
|
|
print(" The quotes")
|
|
row("orders posted", f"{activity['orders_posted']:,}")
|
|
row("of those, requotes", f"{activity['requotes']:,}")
|
|
row("expired unfilled", f"{activity['expired_unfilled']:,}")
|
|
row("service rate", f"{served / max(activity['orders_posted'], 1):.2%}")
|
|
row("live beside a cancelled one", f"{activity['overlapping_live_orders']:,}")
|
|
row("filled after their cancel (stale)", f"{activity['stale_fills']:,}")
|
|
|
|
print("\n How each fill was served")
|
|
row("queue-decided (the volume ahead went)", f"{queue['queue_decided_fills']:,}")
|
|
row("a print through the level", f"{queue['traverse_fills']:,}")
|
|
row(" of those, a book cross", f"{queue['fills_from_book_cross']:,}")
|
|
row("partial fills", f"{queue['partial_fills']:,}")
|
|
row("posted behind an assumed queue", f"{queue['book_unknown_at_post']:,}")
|
|
row("`touch` would have filled", f"{frag['would_fill_touch']:,}")
|
|
row("`traverse` would have filled", f"{frag['would_fill_traverse']:,}")
|
|
print(" The queue decides about one fill in fifteen. The rest is the market")
|
|
print(" walking through the level, the mechanism `traverse` already gave for")
|
|
print(" free -- and the reason `touch` flatters a maker so badly: it grants")
|
|
print(" more than twice the fills, every one of them at the level.")
|
|
print(" The orders posted behind an assumed queue are the ones the inventory")
|
|
print(" skew walked down to a price point nobody was quoting.")
|
|
|
|
print("\n What being quoted cost (basis points, negative is adverse)")
|
|
row("half-spread captured", f"{marks['half_spread_captured_bps']:+.4f}")
|
|
row("the market's own half-spread", f"{marks['book_half_spread_bps']:+.4f}")
|
|
for name in ("100ms", "1s", "10s"):
|
|
h = horizon(marks, name)
|
|
if h:
|
|
row(f"markout at +{name}", f"{h['mean_bps']:+.4f} +/- {h['stderr_bps']:.4f}")
|
|
row("marked fills", f"{marks['fills_total']:,}")
|
|
|
|
captured = marks["half_spread_captured_bps"]
|
|
market = marks["book_half_spread_bps"]
|
|
one_sec = horizon(marks, "1s")
|
|
if one_sec is not None and captured is not None:
|
|
print(f"\n Resting at the touch, the quotes captured no half-spread at all: "
|
|
f"{captured:+.4f} bps,")
|
|
print(f" against the {market:+.4f} bps the market's own spread was worth at "
|
|
"the same instants.")
|
|
print(f" One second on, {one_sec['mean_bps']:+.4f} bps. A fill granted by a "
|
|
"print going THROUGH the")
|
|
print(" level is a fill granted because the price was leaving, and nine in "
|
|
"ten of them are.")
|
|
print(" Fees and a fill count never show that. It is what a maker has to "
|
|
"answer for.")
|
|
|
|
try:
|
|
from manifoldbt import plot
|
|
except ImportError as exc:
|
|
print(f"\n[chart skipped] {exc}", file=sys.stderr)
|
|
else:
|
|
out = os.path.join(_ROOT, "output", "fill_marks.png")
|
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
plot.fill_marks(result, save=out, show=False)
|
|
print(f"\n Chart: {out}")
|