backtest2live 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backtest2live/__init__.py +12 -0
- backtest2live/__main__.py +5 -0
- backtest2live/cli.py +68 -0
- backtest2live/convergence.py +567 -0
- backtest2live/fillgap/__init__.py +57 -0
- backtest2live/fillgap/data.py +289 -0
- backtest2live/fillgap/engine.py +169 -0
- backtest2live/fillgap/report.py +101 -0
- backtest2live/fillgap/sim.py +192 -0
- backtest2live-0.2.0.dist-info/METADATA +218 -0
- backtest2live-0.2.0.dist-info/RECORD +14 -0
- backtest2live-0.2.0.dist-info/WHEEL +4 -0
- backtest2live-0.2.0.dist-info/entry_points.txt +2 -0
- backtest2live-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""backtest2live · fill-gap — how many of your backtest's passive fills survive a queue proxy.
|
|
2
|
+
|
|
3
|
+
Re-runs each resting order in your backtest's order log against public
|
|
4
|
+
top-of-book and trade prints, under a queue proxy: the order fills only after
|
|
5
|
+
the volume printed at its price covers the displayed queue ahead of it plus
|
|
6
|
+
its own size. Reports the backtest's fill rate against the proxy's.
|
|
7
|
+
|
|
8
|
+
numpy and pandas are imported inside run(), so `bt2live convergence` keeps
|
|
9
|
+
working on a bare Python install.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
SCHEMA = "backtest2live.fill-gap/1"
|
|
15
|
+
VENUES = ("binance-um",)
|
|
16
|
+
CTA_URL = "https://martianmobile.com/fill-autopsy?utm_source=oss&utm_medium=cli&utm_campaign=fill-gap"
|
|
17
|
+
VERDICT_EXIT = {"CONSISTENT": 0, "OVERSTATED": 1}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def add_arguments(p):
|
|
21
|
+
p.add_argument("orders", help="Your backtest's order log (CSV): time, side, price, size, and the "
|
|
22
|
+
"backtest's filled flag. See README for accepted column names.")
|
|
23
|
+
src = p.add_argument_group("market data (pick one)")
|
|
24
|
+
src.add_argument("--venue", choices=VENUES,
|
|
25
|
+
help="Download public data: binance-um = Binance USDT-M futures archive "
|
|
26
|
+
"(bookTicker ends 2024-03-30)")
|
|
27
|
+
src.add_argument("--pair", help="Symbol for --venue, e.g. BTCUSDT")
|
|
28
|
+
src.add_argument("--book", help="Your own top-of-book CSV: ts, bid_px, bid_qty, ask_px, ask_qty")
|
|
29
|
+
src.add_argument("--trades", help="Your own trades CSV: ts, price, qty, is_buyer_maker (or taker side)")
|
|
30
|
+
p.add_argument("--max-rest", type=float, default=60.0,
|
|
31
|
+
help="Seconds an order rests when the log has no end/cancel column (default 60)")
|
|
32
|
+
p.add_argument("--latency-ms", type=float, default=0.0,
|
|
33
|
+
help="Delay each order's arrival at the book by this much (default 0)")
|
|
34
|
+
p.add_argument("--tick", type=float, help="Price tick (inferred from the book if omitted)")
|
|
35
|
+
p.add_argument("--tolerance", type=float, default=0.10,
|
|
36
|
+
help="Max share of claimed fills the proxy may miss before the verdict is "
|
|
37
|
+
"OVERSTATED (default 0.10)")
|
|
38
|
+
p.add_argument("--offline", action="store_true", help="Use cached data only; never download")
|
|
39
|
+
p.add_argument("--run-name", help="Label for the report header")
|
|
40
|
+
p.add_argument("--json", action="store_true", help="Print a machine-readable result instead of Markdown")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(args):
|
|
44
|
+
try:
|
|
45
|
+
import numpy # noqa: F401
|
|
46
|
+
import pandas # noqa: F401
|
|
47
|
+
except ImportError:
|
|
48
|
+
raise SystemExit("error: fill-gap needs numpy and pandas: pip install 'backtest2live[data]'")
|
|
49
|
+
from backtest2live.fillgap import engine, report
|
|
50
|
+
|
|
51
|
+
res = engine.evaluate(args, log=lambda m: print(m, file=sys.stderr))
|
|
52
|
+
if args.json:
|
|
53
|
+
import json
|
|
54
|
+
print(json.dumps(report.to_json(res), indent=2))
|
|
55
|
+
else:
|
|
56
|
+
print(report.markdown(res))
|
|
57
|
+
return VERDICT_EXIT[res["verdict"]]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Inputs for fill-gap: the user's order log, and L1 book + trade prints.
|
|
2
|
+
|
|
3
|
+
Book and trades come from a loader: the Binance public archive (USDT-M
|
|
4
|
+
futures, downloaded once and cached) or the user's own CSVs. Every loader
|
|
5
|
+
returns the same normalized arrays: times in int64 microseconds, prices as
|
|
6
|
+
floats (converted to ticks by the caller), sizes in base units.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zipfile
|
|
13
|
+
from datetime import date, datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
ORDER_ALIASES = {
|
|
19
|
+
"t": ("ts", "timestamp", "time", "datetime", "created_at", "submit_time", "t"),
|
|
20
|
+
"side": ("side", "direction", "dir"),
|
|
21
|
+
"px": ("price", "px", "limit_price", "limit"),
|
|
22
|
+
"size": ("size", "qty", "quantity", "amount", "sz"),
|
|
23
|
+
"filled": ("filled", "is_filled", "fill", "backtest_filled"),
|
|
24
|
+
"fill_qty": ("fill_qty", "filled_qty", "executed_qty"),
|
|
25
|
+
"t_end": ("end_ts", "cancel_ts", "expire_ts", "cancel_time", "end_time", "expiry"),
|
|
26
|
+
"pnl": ("pnl", "pnl_usd", "realized_pnl", "profit"),
|
|
27
|
+
"id": ("order_id", "id", "client_order_id"),
|
|
28
|
+
}
|
|
29
|
+
BOOK_ALIASES = {
|
|
30
|
+
"t": ("ts", "timestamp", "time", "transaction_time", "event_time", "t"),
|
|
31
|
+
"bid_px": ("bid_px", "bid_price", "best_bid_price", "bid"),
|
|
32
|
+
"bid_qty": ("bid_qty", "bid_size", "best_bid_qty", "bid_sz"),
|
|
33
|
+
"ask_px": ("ask_px", "ask_price", "best_ask_price", "ask"),
|
|
34
|
+
"ask_qty": ("ask_qty", "ask_size", "best_ask_qty", "ask_sz"),
|
|
35
|
+
}
|
|
36
|
+
TRADE_ALIASES = {
|
|
37
|
+
"t": ("ts", "timestamp", "time", "transact_time", "trade_time", "t"),
|
|
38
|
+
"px": ("price", "px"),
|
|
39
|
+
"qty": ("qty", "quantity", "size", "amount"),
|
|
40
|
+
"is_buyer_maker": ("is_buyer_maker", "buyer_maker", "m"),
|
|
41
|
+
"aggr_side": ("side", "aggressor_side", "taker_side"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
BUY_WORDS = {"buy", "b", "bid", "long", "1", "+1"}
|
|
45
|
+
SELL_WORDS = {"sell", "s", "ask", "short", "-1"}
|
|
46
|
+
TRUE_WORDS = {"true", "t", "1", "yes", "y", "1.0"}
|
|
47
|
+
FALSE_WORDS = {"false", "f", "0", "no", "n", "0.0", ""}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve(df, aliases, required, what):
|
|
51
|
+
cols = {c.lower().strip(): c for c in df.columns}
|
|
52
|
+
out = {}
|
|
53
|
+
for role, names in aliases.items():
|
|
54
|
+
for n in names:
|
|
55
|
+
if n in cols:
|
|
56
|
+
out[role] = cols[n]
|
|
57
|
+
break
|
|
58
|
+
missing = [r for r in required if r not in out]
|
|
59
|
+
if missing:
|
|
60
|
+
want = "; ".join(f"{r}: one of {', '.join(aliases[r])}" for r in missing)
|
|
61
|
+
raise SystemExit(f"error: {what} is missing columns ({want}). Found: {', '.join(df.columns)}")
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def to_us(col):
|
|
66
|
+
"""Epoch s/ms/us/ns numbers or ISO-8601 strings → int64 microseconds UTC."""
|
|
67
|
+
s = pd.Series(col)
|
|
68
|
+
if pd.api.types.is_numeric_dtype(s):
|
|
69
|
+
v = s.to_numpy(dtype=np.float64)
|
|
70
|
+
if np.isnan(v).any():
|
|
71
|
+
raise SystemExit(f"error: blank or non-numeric timestamp in column {s.name!r}")
|
|
72
|
+
mag = np.nanmedian(np.abs(v)) if len(v) else 0
|
|
73
|
+
if mag > 1e17:
|
|
74
|
+
f = 1e-3 # ns
|
|
75
|
+
elif mag > 1e14:
|
|
76
|
+
f = 1.0 # us
|
|
77
|
+
elif mag > 1e11:
|
|
78
|
+
f = 1e3 # ms
|
|
79
|
+
else:
|
|
80
|
+
f = 1e6 # s
|
|
81
|
+
return np.rint(v * f).astype(np.int64)
|
|
82
|
+
parsed = pd.to_datetime(s, utc=True, errors="coerce")
|
|
83
|
+
if parsed.isna().any():
|
|
84
|
+
bad = s[parsed.isna()].iloc[0]
|
|
85
|
+
raise SystemExit(f"error: unparseable timestamp {bad!r}")
|
|
86
|
+
# resolution-independent (pandas 2 may parse to s/ms/us/ns)
|
|
87
|
+
return ((parsed - pd.Timestamp(0, tz="UTC")) // pd.Timedelta(microseconds=1)).to_numpy(dtype=np.int64)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bool(col, what):
|
|
91
|
+
s = pd.Series(col).astype(str).str.strip().str.lower()
|
|
92
|
+
bad = ~s.isin(TRUE_WORDS | FALSE_WORDS)
|
|
93
|
+
if bad.any():
|
|
94
|
+
raise SystemExit(f"error: {what} must be true/false or 1/0; got {s[bad].iloc[0]!r}")
|
|
95
|
+
return s.isin(TRUE_WORDS).to_numpy()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_csv(path, what):
|
|
99
|
+
try:
|
|
100
|
+
df = pd.read_csv(path)
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
raise SystemExit(f"error: {what} not found: {path!r}")
|
|
103
|
+
except (OSError, UnicodeDecodeError, pd.errors.EmptyDataError, pd.errors.ParserError) as e:
|
|
104
|
+
raise SystemExit(f"error: could not read {what} {path!r}: {e}")
|
|
105
|
+
if df.empty:
|
|
106
|
+
raise SystemExit(f"error: no data rows in {what} {path!r}")
|
|
107
|
+
return df
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _numeric(df, col, what):
|
|
111
|
+
v = pd.to_numeric(df[col], errors="coerce")
|
|
112
|
+
if v.isna().any():
|
|
113
|
+
bad = df[col][v.isna()].iloc[0]
|
|
114
|
+
raise SystemExit(f"error: non-numeric {what} in column {col!r}: {bad!r}")
|
|
115
|
+
return v.to_numpy(np.float64)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_orders(path, max_rest_s):
|
|
119
|
+
df = _read_csv(path, "order log")
|
|
120
|
+
c = _resolve(df, ORDER_ALIASES, ("t", "side", "px", "size"), "order log")
|
|
121
|
+
|
|
122
|
+
side = df[c["side"]].astype(str).str.strip().str.lower()
|
|
123
|
+
unknown = ~side.isin(BUY_WORDS | SELL_WORDS)
|
|
124
|
+
if unknown.any():
|
|
125
|
+
raise SystemExit(f"error: side must be buy/sell; got {side[unknown].iloc[0]!r}")
|
|
126
|
+
|
|
127
|
+
t = to_us(df[c["t"]])
|
|
128
|
+
if "t_end" in c:
|
|
129
|
+
t_end = to_us(df[c["t_end"]])
|
|
130
|
+
end_source = c["t_end"]
|
|
131
|
+
else:
|
|
132
|
+
t_end = t + int(max_rest_s * 1e6)
|
|
133
|
+
end_source = f"--max-rest {max_rest_s:g}s"
|
|
134
|
+
|
|
135
|
+
if "filled" in c:
|
|
136
|
+
claimed = _bool(df[c["filled"]], c["filled"])
|
|
137
|
+
elif "fill_qty" in c:
|
|
138
|
+
claimed = pd.to_numeric(df[c["fill_qty"]], errors="coerce").fillna(0).to_numpy() > 0
|
|
139
|
+
else:
|
|
140
|
+
claimed = None
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"t": t,
|
|
144
|
+
"t_end": t_end,
|
|
145
|
+
"buy": side.isin(BUY_WORDS).to_numpy(),
|
|
146
|
+
"px_f": _numeric(df, c["px"], "price"),
|
|
147
|
+
"size": _numeric(df, c["size"], "size"),
|
|
148
|
+
"claimed": claimed,
|
|
149
|
+
"pnl": pd.to_numeric(df[c["pnl"]], errors="coerce").to_numpy(np.float64) if "pnl" in c else None,
|
|
150
|
+
"id": df[c["id"]].astype(str).to_numpy() if "id" in c else np.arange(len(df)).astype(str),
|
|
151
|
+
"columns": c,
|
|
152
|
+
"end_source": end_source,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- user-supplied book and trades ------------------------------------------
|
|
157
|
+
|
|
158
|
+
def load_user_book(path):
|
|
159
|
+
df = _read_csv(path, "book file")
|
|
160
|
+
c = _resolve(df, BOOK_ALIASES, tuple(BOOK_ALIASES), "book file")
|
|
161
|
+
out = {"t": to_us(df[c["t"]])}
|
|
162
|
+
for k in ("bid_px", "bid_qty", "ask_px", "ask_qty"):
|
|
163
|
+
out[k] = _numeric(df, c[k], k)
|
|
164
|
+
return _sort(out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load_user_trades(path):
|
|
168
|
+
df = _read_csv(path, "trades file")
|
|
169
|
+
c = _resolve(df, TRADE_ALIASES, ("t", "px", "qty"), "trades file")
|
|
170
|
+
if "is_buyer_maker" in c:
|
|
171
|
+
sell_aggr = _bool(df[c["is_buyer_maker"]], c["is_buyer_maker"])
|
|
172
|
+
elif "aggr_side" in c:
|
|
173
|
+
s = df[c["aggr_side"]].astype(str).str.strip().str.lower()
|
|
174
|
+
sell_aggr = s.isin(SELL_WORDS).to_numpy()
|
|
175
|
+
else:
|
|
176
|
+
raise SystemExit("error: trades file needs the aggressor side: is_buyer_maker (true = seller hit the bid) "
|
|
177
|
+
"or side (buy/sell = the taker's side)")
|
|
178
|
+
return _sort({"t": to_us(df[c["t"]]), "px": _numeric(df, c["px"], "price"),
|
|
179
|
+
"qty": _numeric(df, c["qty"], "qty"), "sell_aggr": sell_aggr})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _sort(d):
|
|
183
|
+
o = np.argsort(d["t"], kind="stable")
|
|
184
|
+
return {k: v[o] for k, v in d.items()}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- Binance public archive (USDT-M futures) ----------------------------------
|
|
188
|
+
|
|
189
|
+
BINANCE_ROOT = "https://data.binance.vision/data/futures/um/daily"
|
|
190
|
+
BINANCE_BOOKTICKER_LAST = date(2024, 3, 30) # the archive stops publishing bookTicker after this day
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cache_dir():
|
|
194
|
+
base = os.environ.get("BT2LIVE_CACHE") or os.path.join(
|
|
195
|
+
os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "backtest2live")
|
|
196
|
+
return base
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fetch(kind, pair, day, offline, log):
|
|
200
|
+
name = f"{pair}-{kind}-{day.isoformat()}.zip"
|
|
201
|
+
url = f"{BINANCE_ROOT}/{kind}/{pair}/{name}"
|
|
202
|
+
path = os.path.join(cache_dir(), "data.binance.vision", "data", "futures", "um", "daily", kind, pair, name)
|
|
203
|
+
if os.path.exists(path):
|
|
204
|
+
return path
|
|
205
|
+
if offline:
|
|
206
|
+
raise SystemExit(f"error: {name} is not cached and --offline is set (cache: {cache_dir()})")
|
|
207
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
208
|
+
log(f"downloading {url}")
|
|
209
|
+
try:
|
|
210
|
+
with urllib.request.urlopen(url + ".CHECKSUM", timeout=60) as r:
|
|
211
|
+
want = r.read().decode().split()[0]
|
|
212
|
+
tmp = path + ".part"
|
|
213
|
+
h = hashlib.sha256()
|
|
214
|
+
with urllib.request.urlopen(url, timeout=600) as r, open(tmp, "wb") as fh:
|
|
215
|
+
while True:
|
|
216
|
+
chunk = r.read(1 << 20)
|
|
217
|
+
if not chunk:
|
|
218
|
+
break
|
|
219
|
+
h.update(chunk)
|
|
220
|
+
fh.write(chunk)
|
|
221
|
+
except OSError as e:
|
|
222
|
+
raise SystemExit(f"error: could not download {url}: {e}")
|
|
223
|
+
if h.hexdigest() != want:
|
|
224
|
+
os.remove(tmp)
|
|
225
|
+
raise SystemExit(f"error: checksum mismatch for {name}")
|
|
226
|
+
os.replace(tmp, path)
|
|
227
|
+
return path
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _read_zip_csv(path, usecols, dtype=None):
|
|
231
|
+
try:
|
|
232
|
+
with zipfile.ZipFile(path) as z:
|
|
233
|
+
with z.open(z.namelist()[0]) as fh:
|
|
234
|
+
return pd.read_csv(fh, usecols=usecols, dtype=dtype)
|
|
235
|
+
except (zipfile.BadZipFile, ValueError, IndexError) as e:
|
|
236
|
+
raise SystemExit(f"error: {os.path.basename(path)} is not a readable archive ({e}); "
|
|
237
|
+
f"delete it from the cache and retry")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load_binance_um(pair, days, offline=False, log=print):
|
|
241
|
+
"""bookTicker + aggTrades for the given UTC days, concatenated.
|
|
242
|
+
|
|
243
|
+
days[0] is the arrival day and must exist in the archive. Later days are
|
|
244
|
+
only the tail of the rest window: past the archive end they are dropped,
|
|
245
|
+
and those orders are evaluated on the data available.
|
|
246
|
+
"""
|
|
247
|
+
if days[0] > BINANCE_BOOKTICKER_LAST:
|
|
248
|
+
raise SystemExit(
|
|
249
|
+
f"error: Binance publishes USDT-M bookTicker only through {BINANCE_BOOKTICKER_LAST}; your orders "
|
|
250
|
+
f"arrive on {days[0]}. Pass your own top-of-book and trades with --book and --trades.")
|
|
251
|
+
days = [d for d in days if d <= BINANCE_BOOKTICKER_LAST]
|
|
252
|
+
books, trades = [], []
|
|
253
|
+
for d in days:
|
|
254
|
+
b = _read_zip_csv(_fetch("bookTicker", pair, d, offline, log),
|
|
255
|
+
["best_bid_price", "best_bid_qty", "best_ask_price", "best_ask_qty", "transaction_time"],
|
|
256
|
+
{"best_bid_price": "f8", "best_bid_qty": "f8", "best_ask_price": "f8",
|
|
257
|
+
"best_ask_qty": "f8", "transaction_time": "i8"})
|
|
258
|
+
tr = _read_zip_csv(_fetch("aggTrades", pair, d, offline, log),
|
|
259
|
+
["price", "quantity", "transact_time", "is_buyer_maker"])
|
|
260
|
+
books.append(b)
|
|
261
|
+
trades.append(tr)
|
|
262
|
+
b = pd.concat(books, ignore_index=True)
|
|
263
|
+
tr = pd.concat(trades, ignore_index=True)
|
|
264
|
+
book = _sort({"t": b["transaction_time"].to_numpy(np.int64) * 1000,
|
|
265
|
+
"bid_px": b["best_bid_price"].to_numpy(), "bid_qty": b["best_bid_qty"].to_numpy(),
|
|
266
|
+
"ask_px": b["best_ask_price"].to_numpy(), "ask_qty": b["best_ask_qty"].to_numpy()})
|
|
267
|
+
ibm = tr["is_buyer_maker"]
|
|
268
|
+
if ibm.dtype != bool:
|
|
269
|
+
ibm = ibm.astype(str).str.lower().eq("true")
|
|
270
|
+
trd = _sort({"t": tr["transact_time"].to_numpy(np.int64) * 1000, "px": tr["price"].to_numpy(np.float64),
|
|
271
|
+
"qty": tr["quantity"].to_numpy(np.float64), "sell_aggr": ibm.to_numpy()})
|
|
272
|
+
return book, trd
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def utc_days(t_from_us, t_to_us):
|
|
276
|
+
d0 = datetime.fromtimestamp(t_from_us / 1e6, tz=timezone.utc).date()
|
|
277
|
+
d1 = datetime.fromtimestamp(t_to_us / 1e6, tz=timezone.utc).date()
|
|
278
|
+
return [d0 + timedelta(days=i) for i in range((d1 - d0).days + 1)]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def infer_tick(book):
|
|
282
|
+
"""Smallest price step seen in the book, rounded to a clean decimal."""
|
|
283
|
+
px = np.unique(np.r_[book["bid_px"][:200_000], book["ask_px"][:200_000]])
|
|
284
|
+
d = np.diff(px)
|
|
285
|
+
d = d[d > 0]
|
|
286
|
+
if not len(d):
|
|
287
|
+
raise SystemExit("error: cannot infer the tick size from the book; pass --tick")
|
|
288
|
+
step = float(d.min())
|
|
289
|
+
return float(f"{step:.8g}") # 0.09999999999 → 0.1
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Load inputs, run the simulation day by day, and aggregate the result."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from backtest2live.fillgap import data, sim
|
|
8
|
+
|
|
9
|
+
US_PER_S = 1_000_000
|
|
10
|
+
US_PER_H = 3_600 * US_PER_S
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ticks(px, tick):
|
|
14
|
+
return np.rint(np.asarray(px, np.float64) / tick).astype(np.int64)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_chunk(book, trades, orders, idx, tick, latency_us):
|
|
18
|
+
b = {"t": book["t"], "bid_px": _ticks(book["bid_px"], tick), "ask_px": _ticks(book["ask_px"], tick),
|
|
19
|
+
"bid_qty": book["bid_qty"], "ask_qty": book["ask_qty"]}
|
|
20
|
+
tr = {"t": trades["t"], "px": _ticks(trades["px"], tick), "qty": trades["qty"],
|
|
21
|
+
"sell_aggr": trades["sell_aggr"]}
|
|
22
|
+
px = orders["px_f"][idx] / tick
|
|
23
|
+
off = np.abs(px - np.rint(px)) > 1e-6
|
|
24
|
+
if off.any():
|
|
25
|
+
raise SystemExit(f"error: {int(off.sum())} order price(s) are not on the {tick:g} tick grid "
|
|
26
|
+
f"(first: {orders['px_f'][idx][off][0]:g}); pass --tick or fix the log")
|
|
27
|
+
o = {"t": orders["t"][idx], "t_end": orders["t_end"][idx], "buy": orders["buy"][idx],
|
|
28
|
+
"px": np.rint(px).astype(np.int64), "size": orders["size"][idx]}
|
|
29
|
+
r = sim.simulate(b, tr, o, latency_us)
|
|
30
|
+
r["range_bps"] = sim.trailing_range_bps(
|
|
31
|
+
{"t": book["t"], "bid_px": book["bid_px"], "ask_px": book["ask_px"]}, r["t_arr"]) \
|
|
32
|
+
if len(book["t"]) else np.full(len(idx), np.nan)
|
|
33
|
+
return r
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def evaluate(args, log=print):
|
|
37
|
+
if bool(args.venue) == bool(args.book or args.trades):
|
|
38
|
+
raise SystemExit("error: pass either --venue with --pair, or --book with --trades")
|
|
39
|
+
if args.venue and not args.pair:
|
|
40
|
+
raise SystemExit("error: --venue needs --pair (e.g. --pair BTCUSDT)")
|
|
41
|
+
if (args.book or args.trades) and not (args.book and args.trades):
|
|
42
|
+
raise SystemExit("error: --book and --trades go together")
|
|
43
|
+
|
|
44
|
+
orders = data.load_orders(args.orders, args.max_rest)
|
|
45
|
+
n = len(orders["t"])
|
|
46
|
+
latency_us = int(round(args.latency_ms * 1000))
|
|
47
|
+
tick = args.tick
|
|
48
|
+
|
|
49
|
+
fields = ("status", "queue", "t_act", "touch", "t_touch", "proxy", "t_proxy", "t_arr", "range_bps")
|
|
50
|
+
out = {k: None for k in fields}
|
|
51
|
+
|
|
52
|
+
def put(idx, r):
|
|
53
|
+
for k in fields:
|
|
54
|
+
if out[k] is None:
|
|
55
|
+
out[k] = np.zeros(n, dtype=r[k].dtype)
|
|
56
|
+
if r[k].dtype.kind in "if":
|
|
57
|
+
out[k][:] = -1 if r[k].dtype.kind == "i" else np.nan
|
|
58
|
+
out[k][idx] = r[k]
|
|
59
|
+
|
|
60
|
+
if args.book:
|
|
61
|
+
book, trades = data.load_user_book(args.book), data.load_user_trades(args.trades)
|
|
62
|
+
tick = tick or data.infer_tick(book)
|
|
63
|
+
put(np.arange(n), _run_chunk(book, trades, orders, np.arange(n), tick, latency_us))
|
|
64
|
+
source = f"your files ({os.path.basename(args.book)}, {os.path.basename(args.trades)})"
|
|
65
|
+
else:
|
|
66
|
+
pair = args.pair.upper()
|
|
67
|
+
t_arr = orders["t"] + latency_us
|
|
68
|
+
day_of = (t_arr // (86_400 * US_PER_S))
|
|
69
|
+
for d in np.unique(day_of):
|
|
70
|
+
idx = np.flatnonzero(day_of == d)
|
|
71
|
+
days = data.utc_days(int(t_arr[idx].min()), int(orders["t_end"][idx].max()))
|
|
72
|
+
book, trades = data.load_binance_um(pair, days, offline=args.offline, log=log)
|
|
73
|
+
tick = tick or data.infer_tick(book)
|
|
74
|
+
put(idx, _run_chunk(book, trades, orders, idx, tick, latency_us))
|
|
75
|
+
source = f"Binance USDT-M futures public archive, {pair}"
|
|
76
|
+
|
|
77
|
+
return aggregate(orders, out, args, tick, source)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rate(mask, base):
|
|
81
|
+
k = int(base.sum())
|
|
82
|
+
return float(mask[base].mean()) if k else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _median_s(t_fill, t_arr, mask):
|
|
86
|
+
m = mask & (t_fill >= 0)
|
|
87
|
+
return float(np.median((t_fill[m] - t_arr[m]) / US_PER_S)) if m.any() else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def aggregate(orders, r, args, tick, source):
|
|
91
|
+
st = r["status"]
|
|
92
|
+
passive = (st == sim.ACTIVE) | (st == sim.NEVER_REACHED)
|
|
93
|
+
claimed = orders["claimed"]
|
|
94
|
+
has_claim = claimed is not None
|
|
95
|
+
base = claimed if has_claim else r["touch"]
|
|
96
|
+
proxy = r["proxy"]
|
|
97
|
+
|
|
98
|
+
missed = passive & base & ~proxy
|
|
99
|
+
extra = passive & proxy & ~base
|
|
100
|
+
n_base = int((passive & base).sum())
|
|
101
|
+
missed_share = (int(missed.sum()) / n_base) if n_base else 0.0
|
|
102
|
+
verdict = "OVERSTATED" if missed_share > args.tolerance else "CONSISTENT"
|
|
103
|
+
|
|
104
|
+
hours = ((r["t_arr"] // US_PER_H) % 24).astype(int)
|
|
105
|
+
by_hour = []
|
|
106
|
+
for h in range(24):
|
|
107
|
+
m = passive & (hours == h)
|
|
108
|
+
if m.any():
|
|
109
|
+
by_hour.append({"hour": h, "orders": int(m.sum()), "baseline": _rate(base, m),
|
|
110
|
+
"proxy": _rate(proxy, m)})
|
|
111
|
+
|
|
112
|
+
by_vol = []
|
|
113
|
+
rng = r["range_bps"]
|
|
114
|
+
ok = passive & np.isfinite(rng)
|
|
115
|
+
if ok.sum() >= 3:
|
|
116
|
+
cuts = np.quantile(rng[ok], [1 / 3, 2 / 3])
|
|
117
|
+
tercile = np.where(rng <= cuts[0], 0, np.where(rng <= cuts[1], 1, 2))
|
|
118
|
+
lo = float(np.nanmin(rng[ok]))
|
|
119
|
+
edges = [lo, float(cuts[0]), float(cuts[1]), float(np.nanmax(rng[ok]))]
|
|
120
|
+
for q, name in enumerate(("low", "mid", "high")):
|
|
121
|
+
m = ok & (tercile == q)
|
|
122
|
+
by_vol.append({"tercile": name, "range_bps": [edges[q], edges[q + 1]], "orders": int(m.sum()),
|
|
123
|
+
"baseline": _rate(base, m), "proxy": _rate(proxy, m)})
|
|
124
|
+
|
|
125
|
+
pnl = None
|
|
126
|
+
if orders["pnl"] is not None:
|
|
127
|
+
p = np.nan_to_num(orders["pnl"])
|
|
128
|
+
pnl = {"baseline_fills": float(p[passive & base].sum()), "missed_fills": float(p[missed].sum())}
|
|
129
|
+
|
|
130
|
+
t = orders["t"]
|
|
131
|
+
return {
|
|
132
|
+
"verdict": verdict,
|
|
133
|
+
"orders_file": args.orders,
|
|
134
|
+
"run_name": args.run_name or os.path.basename(args.orders),
|
|
135
|
+
"source": source,
|
|
136
|
+
"window_utc": [int(t.min()), int(orders["t_end"].max())],
|
|
137
|
+
"tick": tick,
|
|
138
|
+
"latency_ms": args.latency_ms,
|
|
139
|
+
"order_end": orders["end_source"],
|
|
140
|
+
"tolerance": args.tolerance,
|
|
141
|
+
"baseline": "your backtest's fill flag" if has_claim else "touch rule (no fill flag in the log)",
|
|
142
|
+
"has_claim": has_claim,
|
|
143
|
+
"counts": {
|
|
144
|
+
"orders": int(len(t)),
|
|
145
|
+
"passive": int(passive.sum()),
|
|
146
|
+
"marketable": int((st == sim.MARKETABLE).sum()),
|
|
147
|
+
"no_data": int((st == sim.NO_DATA).sum()),
|
|
148
|
+
"never_reached": int((st == sim.NEVER_REACHED).sum()),
|
|
149
|
+
"baseline_fills": n_base,
|
|
150
|
+
"touch_fills": int((passive & r["touch"]).sum()),
|
|
151
|
+
"proxy_fills": int((passive & proxy).sum()),
|
|
152
|
+
"missed": int(missed.sum()),
|
|
153
|
+
"extra": int(extra.sum()),
|
|
154
|
+
},
|
|
155
|
+
"rates": {
|
|
156
|
+
"baseline": _rate(base, passive),
|
|
157
|
+
"touch": _rate(r["touch"], passive),
|
|
158
|
+
"proxy": _rate(proxy, passive),
|
|
159
|
+
"missed_share_of_baseline": missed_share,
|
|
160
|
+
},
|
|
161
|
+
"median_time_to_fill_s": {
|
|
162
|
+
"touch": _median_s(r["t_touch"], r["t_arr"], passive & r["touch"]),
|
|
163
|
+
"proxy": _median_s(r["t_proxy"], r["t_arr"], passive & proxy),
|
|
164
|
+
},
|
|
165
|
+
"by_hour_utc": by_hour,
|
|
166
|
+
"by_volatility": by_vol,
|
|
167
|
+
"pnl": pnl,
|
|
168
|
+
"missed_ids": orders["id"][missed][:1000].tolist(),
|
|
169
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Markdown and JSON output for fill-gap."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from backtest2live.fillgap import CTA_URL, SCHEMA
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pct(x):
|
|
9
|
+
return "n/a" if x is None else f"{x * 100:.1f}%"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _secs(x):
|
|
13
|
+
if x is None:
|
|
14
|
+
return "n/a"
|
|
15
|
+
return f"{x * 1000:.0f} ms" if x < 1 else f"{x:.1f} s"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _day(us):
|
|
19
|
+
return datetime.fromtimestamp(us / 1e6, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _summary(res):
|
|
23
|
+
r, c = res["rates"], res["counts"]
|
|
24
|
+
who = "Your backtest fills" if res["has_claim"] else "Filling at touch gives"
|
|
25
|
+
line = (f"{who} {_pct(r['baseline'])} of its resting orders; the queue proxy fills "
|
|
26
|
+
f"{_pct(r['proxy'])}.")
|
|
27
|
+
if c["baseline_fills"]:
|
|
28
|
+
line += (f" {_pct(r['missed_share_of_baseline'])} of the counted fills "
|
|
29
|
+
f"({c['missed']:,} of {c['baseline_fills']:,}) are not granted on this data.")
|
|
30
|
+
return line
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def markdown(res):
|
|
34
|
+
c, r = res["counts"], res["rates"]
|
|
35
|
+
w0, w1 = _day(res["window_utc"][0]), _day(res["window_utc"][1])
|
|
36
|
+
window = w0 if w0 == w1 else f"{w0} .. {w1}"
|
|
37
|
+
L = [f"# Fill Gap | {res['run_name']} | {window}", "",
|
|
38
|
+
f"## Verdict: {res['verdict']}", "", f"**{_summary(res)}**", "", "---", "",
|
|
39
|
+
"## Fill rates", "",
|
|
40
|
+
"| Rule | Fills | Fill rate |", "|------|------:|----------:|"]
|
|
41
|
+
if res["has_claim"]:
|
|
42
|
+
L.append(f"| Your backtest (`filled`) | {c['baseline_fills']:,} | {_pct(r['baseline'])} |")
|
|
43
|
+
L.append(f"| Touch rule: any print at or through your price | {c['touch_fills']:,} | {_pct(r['touch'])} |")
|
|
44
|
+
L.append(f"| Queue proxy | {c['proxy_fills']:,} | {_pct(r['proxy'])} |")
|
|
45
|
+
L += ["",
|
|
46
|
+
f"Rates are over {c['passive']:,} resting orders. Skipped: {c['marketable']:,} marketable at arrival "
|
|
47
|
+
f"(taker orders, out of scope) and {c['no_data']:,} outside the market data. "
|
|
48
|
+
f"{c['never_reached']:,} rested behind the touch and the price never came to them.", ""]
|
|
49
|
+
mt = res["median_time_to_fill_s"]
|
|
50
|
+
L.append(f"Median time to fill: {_secs(mt['touch'])} at touch, {_secs(mt['proxy'])} under the proxy.")
|
|
51
|
+
if c["extra"]:
|
|
52
|
+
L.append(f"The proxy grants {c['extra']:,} fills the baseline did not count.")
|
|
53
|
+
if res["pnl"]:
|
|
54
|
+
p = res["pnl"]
|
|
55
|
+
share = f" ({p['missed_fills'] / p['baseline_fills'] * 100:.0f}% of it)" if p["baseline_fills"] else ""
|
|
56
|
+
L.append(f"P&L your backtest booked on fills the proxy does not grant: {p['missed_fills']:,.2f} "
|
|
57
|
+
f"of {p['baseline_fills']:,.2f}{share}.")
|
|
58
|
+
L += ["", "---", "", "## Where the fills go missing", ""]
|
|
59
|
+
if res["by_volatility"]:
|
|
60
|
+
L += ["By volatility at arrival (trailing 5-minute range of the mid):", "",
|
|
61
|
+
"| Tercile | Range (bps) | Orders | Baseline | Proxy |", "|--------|------------|-------:|------:|------:|"]
|
|
62
|
+
for v in res["by_volatility"]:
|
|
63
|
+
L.append(f"| {v['tercile']} | {v['range_bps'][0]:.1f}–{v['range_bps'][1]:.1f} | {v['orders']:,} | "
|
|
64
|
+
f"{_pct(v['baseline'])} | {_pct(v['proxy'])} |")
|
|
65
|
+
L.append("")
|
|
66
|
+
if res["by_hour_utc"]:
|
|
67
|
+
L += ["By hour (UTC):", "", "| Hour | Orders | Baseline | Proxy |", "|-----:|-------:|------:|------:|"]
|
|
68
|
+
for h in res["by_hour_utc"]:
|
|
69
|
+
L.append(f"| {h['hour']:02d} | {h['orders']:,} | {_pct(h['baseline'])} | {_pct(h['proxy'])} |")
|
|
70
|
+
L.append("")
|
|
71
|
+
L += ["---", "", "## Inputs", "",
|
|
72
|
+
f"- Market data: {res['source']}; tick {res['tick']:g}",
|
|
73
|
+
f"- Order life: until `{res['order_end']}`" if not res["order_end"].startswith("--") else
|
|
74
|
+
f"- Order life: {res['order_end']} (no end/cancel column in the log)",
|
|
75
|
+
f"- Arrival latency: {res['latency_ms']:g} ms",
|
|
76
|
+
f"- Baseline: {res['baseline']}; verdict OVERSTATED when more than {_pct(res['tolerance'])} "
|
|
77
|
+
f"of baseline fills are not granted",
|
|
78
|
+
"", "---", "", "## What the proxy can and cannot see", "",
|
|
79
|
+
"The touch rule is an upper bound: a resting order fills only if something trades at or through "
|
|
80
|
+
"its price. The queue proxy is an estimate below that bound, built from public top-of-book and trades:",
|
|
81
|
+
"",
|
|
82
|
+
"- It grants fills too easily where it cannot see hidden or iceberg size ahead of you, your own "
|
|
83
|
+
"latency to reach the book (unless `--latency-ms` is set), or your order's effect on the flow.",
|
|
84
|
+
"- It grants fills too hard where displayed size ahead of you cancels rather than trades, and "
|
|
85
|
+
"where it measures your place in the queue from the size shown when the price reached you.",
|
|
86
|
+
"",
|
|
87
|
+
"Which way it nets out depends on the venue, the size and the regime. Your own live fills settle it: "
|
|
88
|
+
f"the calibrated check against live fills is at {CTA_URL}", ""]
|
|
89
|
+
return "\n".join(L)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_json(res):
|
|
93
|
+
out = {"schema": SCHEMA}
|
|
94
|
+
out.update({k: v for k, v in res.items()})
|
|
95
|
+
out["window_utc"] = [_iso(t) for t in res["window_utc"]]
|
|
96
|
+
out["calibrated_check"] = CTA_URL
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _iso(us):
|
|
101
|
+
return datetime.fromtimestamp(us / 1e6, tz=timezone.utc).isoformat()
|