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.
@@ -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()