lobkit 0.2.1__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.
lobkit/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """lobkit: limit order book reconstruction, order-flow analytics and market making."""
2
+
3
+ from lobkit.analysis import (
4
+ MicroPrice, RegressionResult, cross_venue_impact, estimate_microprice, impact_by_depth, imbalance_next_move,
5
+ lead_lag, microprice_forecast_error, multi_level_impact, ofi_price_impact, predictive_r2,
6
+ )
7
+ from lobkit.book import BinanceDepthSync, OrderBook, SequenceGap
8
+ from lobkit.features import bucket_features, l1_features, multi_level_ofi, order_flow_imbalance
9
+ from lobkit.market_making import ASParams, ASResult, optimal_spread, reservation_price, simulate_as
10
+ from lobkit.simulator import SimulationResult, simulate_lob
11
+
12
+ __all__ = [
13
+ "ASParams", "ASResult", "BinanceDepthSync", "MicroPrice", "OrderBook", "RegressionResult", "SequenceGap",
14
+ "SimulationResult", "bucket_features", "cross_venue_impact", "estimate_microprice", "imbalance_next_move",
15
+ "impact_by_depth", "l1_features", "lead_lag", "microprice_forecast_error", "multi_level_impact",
16
+ "multi_level_ofi", "ofi_price_impact", "optimal_spread", "order_flow_imbalance", "predictive_r2",
17
+ "reservation_price", "simulate_as", "simulate_lob",
18
+ ]
lobkit/analysis.py ADDED
@@ -0,0 +1,262 @@
1
+ """Price impact and short-horizon predictability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RegressionResult:
13
+ alpha: float
14
+ beta: float
15
+ t_stat: float
16
+ r2: float
17
+ n: int
18
+
19
+
20
+ def _ols(x, y) -> RegressionResult:
21
+ x, y = np.asarray(x, dtype=float), np.asarray(y, dtype=float)
22
+ X = np.column_stack([np.ones_like(x), x])
23
+ coef, *_ = np.linalg.lstsq(X, y, rcond=None)
24
+ resid = y - X @ coef
25
+ n = len(y)
26
+ s2 = resid @ resid / (n - 2)
27
+ se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[1, 1])
28
+ r2 = 1 - resid @ resid / np.sum((y - y.mean()) ** 2)
29
+ return RegressionResult(float(coef[0]), float(coef[1]), float(coef[1] / se), float(r2), n)
30
+
31
+
32
+ def ofi_price_impact(buckets: pd.DataFrame) -> RegressionResult:
33
+ """Contemporaneous regression d_mid = alpha + beta * OFI (Cont et al. 2014, eq. 3)."""
34
+ return _ols(buckets["ofi"], buckets["d_mid"])
35
+
36
+
37
+ def impact_by_depth(buckets: pd.DataFrame, n_groups: int = 5) -> tuple[pd.DataFrame, RegressionResult]:
38
+ """Impact coefficient per depth quantile, and beta regressed on 1/depth.
39
+
40
+ Cont et al. find beta is inversely proportional to market depth: the same
41
+ order flow moves a thin book further.
42
+ """
43
+ b = buckets.assign(group=pd.qcut(buckets["depth"], n_groups, labels=False, duplicates="drop"))
44
+ rows = []
45
+ for g, s in b.groupby("group"):
46
+ r = _ols(s["ofi"], s["d_mid"])
47
+ rows.append({"group": g, "mean_depth": s["depth"].mean(), "beta": r.beta, "r2": r.r2, "n": r.n})
48
+ table = pd.DataFrame(rows)
49
+ return table, _ols(1 / table["mean_depth"], table["beta"])
50
+
51
+
52
+ def predictive_r2(buckets: pd.DataFrame, features: list[str], horizon: int = 1,
53
+ train_frac: float = 0.7) -> dict:
54
+ """Predict the NEXT bucket's mid change from this bucket's features.
55
+
56
+ Chronological split; out-of-sample R^2 is measured against the training
57
+ mean, so a positive value means genuine predictive content.
58
+ """
59
+ df = buckets[features].assign(target=buckets["d_mid"].shift(-horizon)).dropna()
60
+ split = int(len(df) * train_frac)
61
+ train, test = df.iloc[:split], df.iloc[split:]
62
+ X_tr = np.column_stack([np.ones(len(train)), train[features]])
63
+ X_te = np.column_stack([np.ones(len(test)), test[features]])
64
+ coef, *_ = np.linalg.lstsq(X_tr, train["target"], rcond=None)
65
+ pred = X_te @ coef
66
+ y = test["target"].to_numpy()
67
+ sse, sst = np.sum((y - pred) ** 2), np.sum((y - train["target"].mean()) ** 2)
68
+ moving = y != 0
69
+ hit = np.mean(np.sign(pred[moving]) == np.sign(y[moving])) if moving.any() else np.nan
70
+ return {"oos_r2": float(1 - sse / sst), "hit_rate": float(hit), "n_test": len(test),
71
+ "coef": dict(zip(["const", *features], map(float, coef), strict=True))}
72
+
73
+
74
+ def imbalance_next_move(features: pd.DataFrame, n_bins: int = 10) -> pd.DataFrame:
75
+ """P(next mid move is up | queue imbalance), as in Gould & Bonart (2016)."""
76
+ sign = np.sign(features["mid"].diff())
77
+ next_sign = sign.where(sign != 0).shift(-1).bfill()
78
+ df = pd.DataFrame({"imbalance": features["imbalance"], "next_up": next_sign > 0,
79
+ "valid": next_sign.notna()})
80
+ df = df[df["valid"]]
81
+ df["bin"] = pd.cut(df["imbalance"], np.linspace(0, 1, n_bins + 1), include_lowest=True)
82
+ out = df.groupby("bin", observed=True).agg(p_up=("next_up", "mean"), n=("next_up", "size"))
83
+ out["imbalance_mid"] = [iv.mid for iv in out.index]
84
+ return out.reset_index(drop=True)
85
+
86
+
87
+ def multi_level_impact(buckets: pd.DataFrame, train_frac: float = 0.7) -> pd.DataFrame:
88
+ """Explain d_mid with the OFI of the best 1, 2, ..., M levels (Xu, Gould & Howison 2019).
89
+
90
+ In-sample R^2 can only rise as levels are added, so the fair comparison is
91
+ the out-of-sample R^2 on a chronological split.
92
+ """
93
+ cols = sorted((c for c in buckets.columns if c.startswith("ofi_")), key=lambda c: int(c[4:]))
94
+ y = buckets["d_mid"].to_numpy(dtype=float)
95
+ split = int(len(y) * train_frac)
96
+ rows = []
97
+ for m in range(1, len(cols) + 1):
98
+ X = np.column_stack([np.ones(len(y)), buckets[cols[:m]]])
99
+ coef, *_ = np.linalg.lstsq(X, y, rcond=None)
100
+ r2 = 1 - np.sum((y - X @ coef) ** 2) / np.sum((y - y.mean()) ** 2)
101
+ coef, *_ = np.linalg.lstsq(X[:split], y[:split], rcond=None)
102
+ oos = 1 - np.sum((y[split:] - X[split:] @ coef) ** 2) / np.sum((y[split:] - y[:split].mean()) ** 2)
103
+ rows.append({"levels": m, "r2": float(r2), "oos_r2": float(oos)})
104
+ return pd.DataFrame(rows)
105
+
106
+
107
+ def microprice_forecast_error(features: pd.DataFrame, horizons=(1, 10, 100, 1000),
108
+ predictors=("microprice",), clock: str = "updates") -> pd.DataFrame:
109
+ """Do the predictor columns forecast the future mid better than the mid itself?
110
+
111
+ clock="updates": the mid h book updates ahead.
112
+ clock="moves": the mid just after the h-th future mid change, the horizon
113
+ Stoikov's (2018) micro-price is built for.
114
+ improvement_<p>_pct > 0 means a lower mean squared error than the mid.
115
+ """
116
+ if clock not in ("updates", "moves"):
117
+ raise ValueError("clock must be 'updates' or 'moves'")
118
+ mid = features["mid"].to_numpy(dtype=float)
119
+ rows_idx = np.arange(len(mid))
120
+ changes = np.flatnonzero(np.diff(mid) != 0) + 1
121
+ next_change = np.searchsorted(changes, rows_idx, side="right")
122
+ rows = []
123
+ for h in horizons:
124
+ if clock == "updates":
125
+ target = rows_idx + h
126
+ ok = target < len(mid)
127
+ future = mid[target[ok]]
128
+ else:
129
+ j = next_change + h - 1
130
+ ok = j < len(changes)
131
+ future = mid[changes[j[ok]]]
132
+ mse_mid = np.mean((future - mid[ok]) ** 2)
133
+ row = {f"horizon_{clock}": h, "mse_mid": mse_mid}
134
+ for c in predictors:
135
+ mse = np.mean((future - features[c].to_numpy(dtype=float)[ok]) ** 2)
136
+ row[f"mse_{c}"], row[f"improvement_{c}_pct"] = mse, 100 * (1 - mse / mse_mid)
137
+ rows.append(row)
138
+ return pd.DataFrame(rows)
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class MicroPrice:
143
+ """Fitted Stoikov (2018) micro-price: mid + adjustment[spread - 1, imbalance bucket].
144
+
145
+ States never seen in the fitting data (NaN) or with a spread outside
146
+ 1..n_spread ticks get no adjustment (the mid).
147
+ """
148
+ adjustment: np.ndarray # (n_spread, n_imb), in price units
149
+ tick: float
150
+
151
+ def __call__(self, features: pd.DataFrame) -> pd.Series:
152
+ n_spread, n_imb = self.adjustment.shape
153
+ s = np.rint(features["spread"].to_numpy() / self.tick).astype(int)
154
+ b = _imbalance_bucket(features["imbalance"].to_numpy(), n_imb)
155
+ ok = (s >= 1) & (s <= n_spread) & np.isfinite(features["imbalance"].to_numpy()) # NaN: an empty book
156
+ adj = np.where(ok, np.nan_to_num(self.adjustment)[np.clip(s, 1, n_spread) - 1, b], 0.0)
157
+ return features["mid"] + adj
158
+
159
+
160
+ def _imbalance_bucket(imb: np.ndarray, n_imb: int) -> np.ndarray:
161
+ """Equal-width buckets with bucket(1 - x) == n_imb - 1 - bucket(x), so symmetrisation is exact."""
162
+ imb = np.nan_to_num(imb, nan=0.5) # callers mask NaN rows; this only keeps the index valid
163
+ lo = np.minimum((imb * n_imb).astype(int), n_imb - 1)
164
+ hi = n_imb - 1 - np.minimum(((1 - imb) * n_imb).astype(int), n_imb - 1)
165
+ return np.where(imb < 0.5, lo, hi)
166
+
167
+
168
+ def estimate_microprice(features: pd.DataFrame, tick: float = 1.0, n_imb: int = 11, n_spread: int = 2,
169
+ n_iter: int = 6) -> MicroPrice:
170
+ """Stoikov (2018), "The micro-price", Sections 3-4.
171
+
172
+ The state is (spread in ticks, imbalance bucket). Consecutive updates give
173
+ transitions that either leave the mid unchanged (Q) or move it by
174
+ K = (-1, -1/2, 1/2, 1) ticks (R1, landing in a new state R2). Then
175
+ g1 = (I - Q)^-1 R1 K expected next mid move
176
+ B = (I - Q)^-1 R2 state just after that move
177
+ g = g1 + B g1 + ... + B^(n_iter-1) g1
178
+ The data is symmetrised (imbalance -> 1 - imbalance, move -> -move), as in
179
+ the paper, so the estimate has no drift. Larger moves and wider spreads are
180
+ dropped. n_imb is odd so a balanced book (imbalance 0.5, common when queues
181
+ are small integers) gets its own centre bucket.
182
+ """
183
+ s = np.rint(features["spread"].to_numpy() / tick).astype(int)
184
+ imb = features["imbalance"].to_numpy()
185
+ dm = np.rint(2 * np.diff(features["mid"].to_numpy()) / tick) / 2 # half-tick resolution
186
+ keep = (s[:-1] >= 1) & (s[:-1] <= n_spread) & (s[1:] >= 1) & (s[1:] <= n_spread) & (np.abs(dm) <= 1)
187
+ keep &= np.isfinite(imb[:-1]) & np.isfinite(imb[1:])
188
+ s0, s1, i0, i1, dm = s[:-1][keep], s[1:][keep], imb[:-1][keep], imb[1:][keep], dm[keep]
189
+ s0, s1 = np.r_[s0, s0], np.r_[s1, s1]
190
+ i0, i1, dm = np.r_[i0, 1 - i0], np.r_[i1, 1 - i1], np.r_[dm, -dm]
191
+
192
+ n = n_spread * n_imb
193
+ x0 = (s0 - 1) * n_imb + _imbalance_bucket(i0, n_imb)
194
+ x1 = (s1 - 1) * n_imb + _imbalance_bucket(i1, n_imb)
195
+ K = np.array([-1.0, -0.5, 0.5, 1.0])
196
+ move = dm != 0
197
+ Q, R1, R2 = np.zeros((n, n)), np.zeros((n, 4)), np.zeros((n, n))
198
+ np.add.at(Q, (x0[~move], x1[~move]), 1)
199
+ np.add.at(R1, (x0[move], np.searchsorted(K, dm[move])), 1)
200
+ np.add.at(R2, (x0[move], x1[move]), 1)
201
+ total = Q.sum(axis=1) + R1.sum(axis=1)
202
+ norm = np.where(total > 0, total, 1.0)[:, None] # unvisited states: no adjustment
203
+ Q, R1, R2 = Q / norm, R1 / norm, R2 / norm
204
+
205
+ inv = np.linalg.pinv(np.eye(n) - Q) # = inv() unless a sparse state never leads to a move
206
+ g1, B = inv @ R1 @ K, inv @ R2
207
+ g, term = g1.copy(), g1
208
+ for _ in range(n_iter - 1):
209
+ term = B @ term
210
+ g += term
211
+ g[total == 0] = np.nan # never observed
212
+ return MicroPrice((g * tick).reshape(n_spread, n_imb), tick)
213
+
214
+
215
+ def lead_lag(a: pd.DataFrame, b: pd.DataFrame, step: str = "50ms", max_lag: int = 40) -> pd.DataFrame:
216
+ """Cross-correlation of two venues' mid returns, corr(r_a(t), r_b(t + lag)).
217
+
218
+ a and b need columns ts (seconds, one shared clock) and mid. Both are
219
+ sampled on a common grid of `step`; a peak at a positive lag means venue a
220
+ moves first and b follows lag seconds later (Hayashi & Yoshida 2005 avoid
221
+ the grid; at these sampling rates a fine grid is a close approximation).
222
+ """
223
+ def sampled(f):
224
+ s = pd.Series(np.log(f["mid"].to_numpy()), index=pd.to_datetime(f["ts"], unit="s"))
225
+ return s[~s.index.duplicated(keep="last")]
226
+
227
+ sa, sb = sampled(a), sampled(b)
228
+ grid = pd.date_range(max(sa.index[0], sb.index[0]).ceil(step), min(sa.index[-1], sb.index[-1]), freq=step)
229
+ ra = sa.reindex(sa.index.union(grid)).ffill().reindex(grid).diff().to_numpy()[1:]
230
+ rb = sb.reindex(sb.index.union(grid)).ffill().reindex(grid).diff().to_numpy()[1:]
231
+ dt = pd.Timedelta(step).total_seconds()
232
+ rows = []
233
+ for lag in range(-max_lag, max_lag + 1):
234
+ x, y = (ra[:len(ra) - lag], rb[lag:]) if lag >= 0 else (ra[-lag:], rb[:len(rb) + lag])
235
+ rows.append({"lag_s": lag * dt, "corr": float(np.corrcoef(x, y)[0, 1])})
236
+ return pd.DataFrame(rows)
237
+
238
+
239
+ def cross_venue_impact(buckets_a: pd.DataFrame, buckets_b: pd.DataFrame, names=("a", "b"),
240
+ train_frac: float = 0.7) -> pd.DataFrame:
241
+ """Whose order flow moves whose price, now and one bucket ahead.
242
+
243
+ For each venue as the target: in-sample R^2 of its d_mid on its own OFI, the
244
+ other venue's OFI, and both (contemporaneous); and out-of-sample R^2 of its
245
+ NEXT bucket's d_mid from the other venue's OFI and d_mid in this bucket. If
246
+ one venue leads, its flow and returns predict the other's next move.
247
+ """
248
+ j = buckets_a[["ofi", "d_mid"]].add_suffix("_a").join(buckets_b[["ofi", "d_mid"]].add_suffix("_b"), how="inner")
249
+ rows = []
250
+ for tgt, oth, tname in (("a", "b", names[0]), ("b", "a", names[1])):
251
+ y = j[f"d_mid_{tgt}"].to_numpy(dtype=float)
252
+ row = {"target": tname}
253
+ for label, cols in (("own_ofi", [f"ofi_{tgt}"]), ("other_ofi", [f"ofi_{oth}"]),
254
+ ("both_ofi", [f"ofi_{tgt}", f"ofi_{oth}"])):
255
+ X = np.column_stack([np.ones(len(j)), j[cols]])
256
+ coef, *_ = np.linalg.lstsq(X, y, rcond=None)
257
+ row[f"r2_{label}"] = float(1 - np.sum((y - X @ coef) ** 2) / np.sum((y - y.mean()) ** 2))
258
+ lead = j.rename(columns={f"d_mid_{tgt}": "d_mid", f"ofi_{oth}": "other_ofi", f"d_mid_{oth}": "other_d_mid"})
259
+ for feats in (["other_ofi"], ["other_d_mid"]):
260
+ row[f"next_oos_r2_{feats[0]}"] = predictive_r2(lead, feats, train_frac=train_frac)["oos_r2"]
261
+ rows.append(row)
262
+ return pd.DataFrame(rows)
lobkit/book.py ADDED
@@ -0,0 +1,113 @@
1
+ """Level-2 order book and Binance depth-stream synchronisation.
2
+
3
+ Binance publishes a REST snapshot plus a stream of diffs carrying absolute
4
+ quantities per price level. A correct local book needs the sequence rules
5
+ (from the Binance spot API docs):
6
+
7
+ 1. Buffer stream events, then fetch a snapshot with `lastUpdateId`.
8
+ 2. Drop events with u <= lastUpdateId.
9
+ 3. The first applied event must satisfy U <= lastUpdateId + 1 <= u.
10
+ 4. Every later event must have U == previous u + 1, otherwise resync.
11
+ 5. A quantity of 0 removes the level.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+
17
+ class SequenceGap(Exception):
18
+ """An update was missed; the local book is no longer trustworthy."""
19
+
20
+
21
+ class OrderBook:
22
+ def __init__(self) -> None:
23
+ self.bids: dict[float, float] = {}
24
+ self.asks: dict[float, float] = {}
25
+
26
+ def apply(self, side: str, price: float, qty: float) -> None:
27
+ book = self.bids if side == "bid" else self.asks
28
+ if qty == 0:
29
+ book.pop(price, None) # removing an unknown level is normal on Binance
30
+ else:
31
+ book[price] = qty
32
+
33
+ def load_snapshot(self, bids, asks) -> None:
34
+ self.bids.clear()
35
+ self.asks.clear()
36
+ for p, q in bids:
37
+ self.apply("bid", float(p), float(q))
38
+ for p, q in asks:
39
+ self.apply("ask", float(p), float(q))
40
+
41
+ def best_bid(self) -> tuple[float, float] | None:
42
+ if not self.bids:
43
+ return None
44
+ p = max(self.bids)
45
+ return p, self.bids[p]
46
+
47
+ def best_ask(self) -> tuple[float, float] | None:
48
+ if not self.asks:
49
+ return None
50
+ p = min(self.asks)
51
+ return p, self.asks[p]
52
+
53
+ def levels(self, side: str, n: int = 10) -> list[tuple[float, float]]:
54
+ book = self.bids if side == "bid" else self.asks
55
+ prices = sorted(book, reverse=(side == "bid"))[:n]
56
+ return [(p, book[p]) for p in prices]
57
+
58
+ @property
59
+ def mid(self) -> float | None:
60
+ bb, ba = self.best_bid(), self.best_ask()
61
+ return None if bb is None or ba is None else 0.5 * (bb[0] + ba[0])
62
+
63
+ @property
64
+ def is_crossed(self) -> bool:
65
+ bb, ba = self.best_bid(), self.best_ask()
66
+ return bb is not None and ba is not None and bb[0] >= ba[0]
67
+
68
+
69
+ class BinanceDepthSync:
70
+ """Maintains an OrderBook from a Binance diff-depth stream plus REST snapshot."""
71
+
72
+ def __init__(self) -> None:
73
+ self.book = OrderBook()
74
+ self.last_update_id: int | None = None
75
+ self.prev_u: int | None = None
76
+ self.buffer: list[dict] = []
77
+
78
+ @property
79
+ def synced(self) -> bool:
80
+ return self.last_update_id is not None
81
+
82
+ def on_event(self, event: dict) -> bool:
83
+ """Feed one diff event (keys U, u, b, a). Returns True if it changed the book."""
84
+ if not self.synced:
85
+ self.buffer.append(event)
86
+ return False
87
+ return self._apply(event)
88
+
89
+ def on_snapshot(self, snapshot: dict) -> bool:
90
+ """Install a REST snapshot. Returns False if it is too old and must be refetched."""
91
+ if self.buffer and snapshot["lastUpdateId"] < self.buffer[0]["U"]:
92
+ return False
93
+ self.book.load_snapshot(snapshot["bids"], snapshot["asks"])
94
+ self.last_update_id, self.prev_u = snapshot["lastUpdateId"], None
95
+ pending, self.buffer = self.buffer, []
96
+ for ev in pending:
97
+ self._apply(ev)
98
+ return True
99
+
100
+ def _apply(self, ev: dict) -> bool:
101
+ if ev["u"] <= self.last_update_id:
102
+ return False # already in the snapshot
103
+ if self.prev_u is None:
104
+ if not ev["U"] <= self.last_update_id + 1 <= ev["u"]:
105
+ raise SequenceGap(f"first event U={ev['U']} u={ev['u']} vs snapshot {self.last_update_id}")
106
+ elif ev["U"] != self.prev_u + 1:
107
+ raise SequenceGap(f"expected U={self.prev_u + 1}, got U={ev['U']}")
108
+ for p, q in ev["b"]:
109
+ self.book.apply("bid", float(p), float(q))
110
+ for p, q in ev["a"]:
111
+ self.book.apply("ask", float(p), float(q))
112
+ self.prev_u = self.last_update_id = ev["u"]
113
+ return True
lobkit/features.py ADDED
@@ -0,0 +1,85 @@
1
+ """Top-of-book features and order-flow imbalance (Cont, Kukanov & Stoikov 2014).
2
+
3
+ Input quotes: columns ts (seconds), bid_px, bid_sz, ask_px, ask_sz, one row
4
+ per book update; deeper levels, if any, as bid_px_2, bid_sz_2, ask_px_2, ...
5
+ Trades: ts, price, qty, side (+1 buyer-initiated).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ def order_flow_imbalance(quotes: pd.DataFrame) -> np.ndarray:
15
+ """Per-update OFI contribution e_n.
16
+
17
+ e_n = 1{Pb_n >= Pb_n-1} qb_n - 1{Pb_n <= Pb_n-1} qb_n-1
18
+ - 1{Pa_n <= Pa_n-1} qa_n + 1{Pa_n >= Pa_n-1} qa_n-1
19
+
20
+ Positive values mean net buying pressure: bids added or asks removed.
21
+ """
22
+ pb, qb = quotes["bid_px"].to_numpy(), quotes["bid_sz"].to_numpy()
23
+ pa, qa = quotes["ask_px"].to_numpy(), quotes["ask_sz"].to_numpy()
24
+ e = np.zeros(len(quotes))
25
+ e[1:] = ((pb[1:] >= pb[:-1]) * qb[1:] - (pb[1:] <= pb[:-1]) * qb[:-1]
26
+ - (pa[1:] <= pa[:-1]) * qa[1:] + (pa[1:] >= pa[:-1]) * qa[:-1])
27
+ return e
28
+
29
+
30
+ def multi_level_ofi(quotes: pd.DataFrame) -> pd.DataFrame:
31
+ """OFI at each recorded level, columns ofi_1..ofi_M (Xu, Gould & Howison 2019, Section 2).
32
+
33
+ Level m is the m-th best non-empty price level, and its OFI applies the
34
+ Cont et al. formula to that level's prices and sizes.
35
+ """
36
+ n_levels = 1 + sum(c.startswith("bid_px_") for c in quotes.columns)
37
+ out = {}
38
+ for m in range(1, n_levels + 1):
39
+ sfx = "" if m == 1 else f"_{m}"
40
+ cols = {f"{k}{sfx}": k for k in ("bid_px", "bid_sz", "ask_px", "ask_sz")}
41
+ out[f"ofi_{m}"] = order_flow_imbalance(quotes[list(cols)].rename(columns=cols))
42
+ return pd.DataFrame(out, index=quotes.index)
43
+
44
+
45
+ def l1_features(quotes: pd.DataFrame) -> pd.DataFrame:
46
+ q = quotes.reset_index(drop=True).copy()
47
+ depth = q["bid_sz"] + q["ask_sz"]
48
+ q["mid"] = 0.5 * (q["bid_px"] + q["ask_px"])
49
+ q["spread"] = q["ask_px"] - q["bid_px"]
50
+ q["imbalance"] = q["bid_sz"] / depth # 1 = all depth on the bid
51
+ q["microprice"] = (q["bid_px"] * q["ask_sz"] + q["ask_px"] * q["bid_sz"]) / depth
52
+ q["ofi"] = order_flow_imbalance(q)
53
+ return q
54
+
55
+
56
+ def bucket_features(features: pd.DataFrame, trades: pd.DataFrame | None = None,
57
+ freq: str = "10s") -> pd.DataFrame:
58
+ """Aggregate per-update features into fixed time buckets.
59
+
60
+ d_mid is the change in mid from the end of the previous bucket to the end
61
+ of this one, so it lines up with the OFI accumulated inside the bucket.
62
+ A bucket with no updates is kept, with zero flow and the book state carried
63
+ forward, so rows form a regular time grid: the next row is the next interval.
64
+ """
65
+ f = features.set_index(pd.to_datetime(features["ts"], unit="s"))
66
+ g = f.resample(freq)
67
+ out = pd.DataFrame({
68
+ "ofi": g["ofi"].sum(),
69
+ "mid": g["mid"].last(),
70
+ "depth": (0.5 * (f["bid_sz"] + f["ask_sz"])).resample(freq).mean(),
71
+ "spread": g["spread"].mean(),
72
+ "imbalance": g["imbalance"].last(),
73
+ "micro_gap": (f["microprice"] - f["mid"]).resample(freq).last(),
74
+ "n_updates": g.size(),
75
+ })
76
+ for c in f.columns:
77
+ if c.startswith("ofi_"): # multi-level OFI, if computed
78
+ out[c] = g[c].sum()
79
+ state = ["mid", "depth", "spread", "imbalance", "micro_gap"]
80
+ out[state] = out[state].ffill() # empty bucket: the book did not change
81
+ out["d_mid"] = out["mid"].diff()
82
+ if trades is not None and len(trades):
83
+ t = trades.set_index(pd.to_datetime(trades["ts"], unit="s"))
84
+ out["trade_imbalance"] = (t["side"] * t["qty"]).resample(freq).sum().reindex(out.index).fillna(0.0)
85
+ return out.dropna()
@@ -0,0 +1,168 @@
1
+ """Avellaneda & Stoikov (2008), "High-frequency trading in a limit order book".
2
+
3
+ A market maker with CARA utility (risk aversion gamma) quotes around a
4
+ reservation price that is skewed against inventory:
5
+ r = s - q * gamma * sigma^2 * (T - t)
6
+ with total spread
7
+ delta_a + delta_b = gamma * sigma^2 * (T - t) + (2 / gamma) * ln(1 + gamma / k).
8
+ Fills arrive with intensity A * exp(-k * delta), delta = distance from mid.
9
+
10
+ simulate_as replicates the paper, where that fill model holds by construction.
11
+ simulate_as_lob runs the same quotes inside the zero-intelligence order book,
12
+ where fills depend on queue position and arrive just before adverse moves.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from lobkit.simulator import Quoter, simulate_lob
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ASParams:
27
+ s0: float = 100.0
28
+ T: float = 1.0
29
+ sigma: float = 2.0
30
+ dt: float = 0.005
31
+ gamma: float = 0.1
32
+ k: float = 1.5
33
+ A: float = 140.0
34
+
35
+
36
+ def reservation_price(s, q, t, p: ASParams):
37
+ return s - q * p.gamma * p.sigma**2 * (p.T - t)
38
+
39
+
40
+ def optimal_spread(t, p: ASParams):
41
+ return p.gamma * p.sigma**2 * (p.T - t) + 2 / p.gamma * np.log(1 + p.gamma / p.k)
42
+
43
+
44
+ @dataclass
45
+ class ASResult:
46
+ pnl: np.ndarray
47
+ final_inventory: np.ndarray
48
+ inventory_paths: np.ndarray # (n_keep, n_steps + 1)
49
+ mid_paths: np.ndarray
50
+ mean_spread: float
51
+
52
+ def summary(self) -> dict:
53
+ return {"mean_pnl": float(self.pnl.mean()), "std_pnl": float(self.pnl.std(ddof=1)),
54
+ "mean_final_q": float(self.final_inventory.mean()),
55
+ "std_final_q": float(self.final_inventory.std(ddof=1)),
56
+ "mean_spread": self.mean_spread}
57
+
58
+
59
+ def simulate_as(p: ASParams = ASParams(), strategy: str = "inventory", n_paths: int = 1000,
60
+ seed: int | None = 0, keep_paths: int = 5) -> ASResult:
61
+ """Monte Carlo of the paper's experiment.
62
+
63
+ strategy="inventory": quotes centred on the reservation price.
64
+ strategy="symmetric": same spread, centred on the mid (ignores inventory).
65
+ """
66
+ _check_strategy(strategy)
67
+ rng = np.random.default_rng(seed)
68
+ n_steps = round(p.T / p.dt)
69
+ keep_paths = min(keep_paths, n_paths)
70
+ s = np.full(n_paths, p.s0)
71
+ q = np.zeros(n_paths)
72
+ cash = np.zeros(n_paths)
73
+ inv = np.zeros((keep_paths, n_steps + 1))
74
+ mids = np.zeros((keep_paths, n_steps + 1))
75
+ mids[:, 0] = p.s0
76
+ spreads = []
77
+
78
+ for i in range(n_steps):
79
+ t = i * p.dt
80
+ spread = optimal_spread(t, p)
81
+ spreads.append(spread)
82
+ centre = reservation_price(s, q, t, p) if strategy == "inventory" else s
83
+ bid, ask = centre - spread / 2, centre + spread / 2
84
+ # The mid moves while quotes rest, and fills are judged against the new mid.
85
+ # This builds in adverse selection: quotes the price moved through fill more often.
86
+ s = s + p.sigma * np.sqrt(p.dt) * np.where(rng.random(n_paths) < 0.5, 1.0, -1.0)
87
+ p_ask = np.clip(p.A * np.exp(-p.k * (ask - s)) * p.dt, 0, 1)
88
+ p_bid = np.clip(p.A * np.exp(-p.k * (s - bid)) * p.dt, 0, 1)
89
+ sold = rng.random(n_paths) < p_ask
90
+ bought = rng.random(n_paths) < p_bid
91
+ q += bought.astype(float) - sold
92
+ cash += sold * ask - bought * bid
93
+ inv[:, i + 1], mids[:, i + 1] = q[:keep_paths], s[:keep_paths]
94
+
95
+ return ASResult(cash + q * s, q, inv, mids, float(np.mean(spreads)))
96
+
97
+
98
+ def _check_strategy(strategy: str) -> None:
99
+ if strategy not in ("inventory", "symmetric"):
100
+ raise ValueError("strategy must be 'inventory' or 'symmetric'")
101
+
102
+
103
+ def as_quoter(p: ASParams, strategy: str = "inventory") -> Quoter:
104
+ """Avellaneda-Stoikov quotes as a simulate_lob quoter; t and p.T in seconds, prices in ticks."""
105
+ _check_strategy(strategy)
106
+
107
+ def quote(t: float, mid: float, q: int) -> tuple[float, float]:
108
+ t = min(t, p.T)
109
+ centre = reservation_price(mid, q, t, p) if strategy == "inventory" else mid
110
+ half = optimal_spread(t, p) / 2
111
+ return centre - half, centre + half
112
+
113
+ return quote
114
+
115
+
116
+ def simulate_as_lob(p: ASParams, strategy: str = "inventory", n_episodes: int = 100, requote_every: float = 1.0,
117
+ markouts=(1.0, 10.0), seed: int = 0, **lob_kwargs) -> pd.DataFrame:
118
+ """The Avellaneda-Stoikov market maker inside simulate_lob, one row per episode of p.T seconds.
119
+
120
+ p.sigma, p.k and p.T must be in the book's units (ticks, seconds); p.s0 is
121
+ unused. Columns:
122
+ pnl cash + final inventory marked at the final mid
123
+ final_q, n_fills
124
+ spread_capture mean over fills of side * (mid - fill price): what the quote earned on the spot
125
+ adverse_<m>s mean over fills of side * (mid m seconds later - mid at fill), for m in markouts;
126
+ fills less than m seconds before the episode ends are left out
127
+ """
128
+ quoter = as_quoter(p, strategy)
129
+ rows = []
130
+ for e in range(n_episodes):
131
+ n_max = int(100 * p.T) + 1000 # far above the ~25 events/s of the default book
132
+ r = simulate_lob(n_max, seed=seed + e, quoter=quoter, requote_every=requote_every, t_end=p.T, **lob_kwargs)
133
+ if len(r.quotes) == n_max:
134
+ raise RuntimeError("episode ran out of events before p.T; the book is busier than expected")
135
+ f, q = r.fills, r.quotes
136
+ mid = 0.5 * (q["bid_px"] + q["ask_px"]).to_numpy()
137
+ side = f["side"].to_numpy()
138
+ row = {
139
+ "pnl": float(-(side * f["price"]).sum() + side.sum() * mid[-1]),
140
+ "final_q": int(side.sum()),
141
+ "n_fills": len(f),
142
+ "spread_capture": float(np.mean(side * (f["mid"] - f["price"]))) if len(f) else np.nan,
143
+ }
144
+ for m in markouts:
145
+ full = (f["ts"] + m <= p.T).to_numpy()
146
+ later = mid[np.searchsorted(q["ts"], f["ts"][full] + m) - 1]
147
+ row[f"adverse_{m:g}s"] = float(np.mean(side[full] * (later - f["mid"][full]))) if full.any() else np.nan
148
+ rows.append(row)
149
+ return pd.DataFrame(rows)
150
+
151
+
152
+ def fill_intensity(deltas=(0.5, 1.0, 1.5, 2.0, 2.5, 3.0), t_end: float = 3000.0, seed: int = 7,
153
+ **lob_kwargs) -> tuple[pd.DataFrame, float, float]:
154
+ """Calibrate the paper's fill model A * exp(-k * delta) on simulate_lob.
155
+
156
+ A quoter rests one unit delta from the mid on each side, requoting every
157
+ second; fills per second per side are regressed in logs on delta.
158
+ Returns (table, A, k).
159
+ """
160
+ rows = []
161
+ for d in deltas:
162
+ r = simulate_lob(int(100 * t_end) + 1000, seed=seed, quoter=lambda t, m, q, d=d: (m - d, m + d),
163
+ t_end=t_end, **lob_kwargs)
164
+ rows.append({"delta": d, "fills_per_s": len(r.fills) / 2 / t_end})
165
+ table = pd.DataFrame(rows)
166
+ ok = table["fills_per_s"] > 0
167
+ slope, intercept = np.polyfit(table["delta"][ok], np.log(table["fills_per_s"][ok]), 1)
168
+ return table, float(np.exp(intercept)), float(-slope)