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 +18 -0
- lobkit/analysis.py +262 -0
- lobkit/book.py +113 -0
- lobkit/features.py +85 -0
- lobkit/market_making.py +168 -0
- lobkit/plotting.py +147 -0
- lobkit/recorder.py +222 -0
- lobkit/simulator.py +234 -0
- lobkit-0.2.1.dist-info/METADATA +289 -0
- lobkit-0.2.1.dist-info/RECORD +13 -0
- lobkit-0.2.1.dist-info/WHEEL +5 -0
- lobkit-0.2.1.dist-info/licenses/LICENSE +21 -0
- lobkit-0.2.1.dist-info/top_level.txt +1 -0
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()
|
lobkit/market_making.py
ADDED
|
@@ -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)
|