Files
lob-latent-regimes/Experiments/v1.py
T
2026-04-10 17:48:42 +05:30

650 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ============================================================
# Latent Micro-Regimes in Limit Order Books:
# Identification and Early Detection
# ─────────────────────────────────────────
# Research-grade pipeline — Colab-ready single script
# Authors: [redacted for blind review]
# ============================================================
# Install (uncomment in Colab):
# !pip install hmmlearn scikit-learn scipy numpy pandas matplotlib
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from scipy import stats
from scipy.stats import gaussian_kde
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix
from hmmlearn.hmm import GaussianHMM
# ─────────────────────────────────────────
# 0. Global Configuration
# ─────────────────────────────────────────
SEED = 42
T = 12_000 # total timesteps
N_REGIMES = 3 # latent states: Normal, Stressed, Crisis
MAX_LAG = 60 # evaluation window (timesteps)
FW_WINDOW = 20 # forward window for stress event definition
N_BOOT = 2_000 # bootstrap replicates
STRESS_PCT = 95 # percentile threshold for stress definition
np.random.seed(SEED)
# ─────────────────────────────────────────
# 1. Structured Latent Regime Data Generator
# ─────────────────────────────────────────
def build_transition_matrix(persist: list[float]) -> np.ndarray:
"""
Build a row-stochastic transition matrix from persistence probabilities.
Off-diagonal mass is split evenly among other states.
Parameters
----------
persist : list of length N_REGIMES
Self-transition probability for each state.
Returns
-------
P : (N_REGIMES, N_REGIMES) ndarray
"""
K = len(persist)
P = np.zeros((K, K))
for i, p in enumerate(persist):
P[i, i] = p
off = (1 - p) / (K - 1)
for j in range(K):
if j != i:
P[i, j] = off
return P
def simulate_latent_chain(T: int, P: np.ndarray, pi0: np.ndarray,
rng: np.random.Generator) -> np.ndarray:
"""
Simulate a discrete-time Markov chain.
Parameters
----------
T : int — number of timesteps
P : (K, K) transition matrix
pi0 : (K,) initial distribution
rng : numpy Generator
Returns
-------
Z : (T,) int array of latent states
"""
K = P.shape[0]
Z = np.empty(T, dtype=int)
Z[0] = rng.choice(K, p=pi0)
for t in range(1, T):
Z[t] = rng.choice(K, p=P[Z[t - 1]])
return Z
# Regime-specific parameter sets
# State 0 — Normal: tight spread, deep book, balanced flow
# State 1 — Stressed: wider spread, shallower book, directional flow
# State 2 — Crisis: very wide spread, thin book, extreme imbalance
REGIME_PARAMS = {
# spread_mu, spread_sig, depth_mu, depth_sig, imb_mu, imb_sig, vol_base
0: dict(sp_mu=1.5, sp_sig=0.30, dp_mu=120, dp_sig=12, ib_mu=0.00, ib_sig=0.08, vb=0.40),
1: dict(sp_mu=3.5, sp_sig=0.60, dp_mu= 85, dp_sig=18, ib_mu=0.18, ib_sig=0.14, vb=1.20),
2: dict(sp_mu=7.0, sp_sig=1.20, dp_mu= 45, dp_sig=22, ib_mu=0.40, ib_sig=0.22, vb=2.80),
}
def generate_lob_data(T: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
"""
Generate synthetic LOB microstructure data driven by a latent Markov chain.
Design choices:
- Regime-conditional mean and volatility for each feature.
- Spread follows a regime-switching log-normal process (always positive).
- Depth follows a regime-switching AR(1) process (mean-reverting).
- Order-flow imbalance is beta-distributed (bounded in [-1, 1]).
- Cross-sectional noise is injected so no feature alone trivially reveals
the regime.
- A Hawkes-inspired self-exciting component adds burst clustering to spread.
Returns
-------
X : (T, 5) feature matrix [spread, depth, imbalance, roll_vol, ofi]
Z : (T,) true latent state sequence
"""
# 1) Latent chain
persist = [0.97, 0.93, 0.89] # high persistence → realistic regimes
P = build_transition_matrix(persist)
pi0 = np.array([0.80, 0.15, 0.05])
Z = simulate_latent_chain(T, P, pi0, rng)
spread = np.zeros(T)
depth = np.zeros(T)
imbalance = np.zeros(T)
# ── Spread: log-normal with Hawkes-like self-excitation ──
hawkes_intensity = np.zeros(T)
decay = 0.90
for t in range(T):
p = REGIME_PARAMS[Z[t]]
hawkes_intensity[t] = (hawkes_intensity[t - 1] * decay if t > 0 else 0)
noise = rng.normal(0, p['sp_sig'])
log_sp = np.log(p['sp_mu']) + 0.15 * hawkes_intensity[t] + noise
spread[t] = np.exp(log_sp)
if spread[t] > np.exp(np.log(p['sp_mu']) + p['sp_sig']):
hawkes_intensity[t] += 0.30 # self-excite on spike
# ── Depth: mean-reverting AR(1) per regime ──
depth[0] = REGIME_PARAMS[Z[0]]['dp_mu']
phi = 0.92 # AR coefficient
for t in range(1, T):
p = REGIME_PARAMS[Z[t]]
depth[t] = phi * depth[t - 1] + (1 - phi) * p['dp_mu'] + rng.normal(0, p['dp_sig'])
depth = np.clip(depth, 5, None) # depth always positive
# ── Order-flow imbalance: truncated normal ──
for t in range(T):
p = REGIME_PARAMS[Z[t]]
raw = rng.normal(p['ib_mu'], p['ib_sig'])
imbalance[t] = np.clip(raw, -1, 1)
# ── Engineered features ──
# Rolling 20-step realised volatility of spread
roll_vol = pd.Series(spread).pct_change().rolling(20).std().fillna(0).values
# Order-flow imbalance sign × magnitude (OFI proxy)
ofi = imbalance * np.abs(np.diff(spread, prepend=spread[0]))
X = np.column_stack([spread, depth, imbalance, roll_vol, ofi])
return X, Z
# ─────────────────────────────────────────
# 2. Feature Engineering & Normalisation
# ─────────────────────────────────────────
def engineer_features(X_raw: np.ndarray) -> tuple[np.ndarray, StandardScaler]:
"""
Augment and normalise the raw feature matrix.
Raw columns : [spread, depth, imbalance, roll_vol, ofi]
Added : [spread/depth ratio, |imbalance|, cumulative ofi (50-step)]
Returns
-------
X_scaled : (T, n_features) normalised array
scaler : fitted StandardScaler (for reproducibility)
"""
spread = X_raw[:, 0]
depth = X_raw[:, 1]
imbalance = X_raw[:, 2]
roll_vol = X_raw[:, 3]
ofi = X_raw[:, 4]
spread_depth_ratio = spread / (depth + 1e-6)
abs_imb = np.abs(imbalance)
cum_ofi = pd.Series(ofi).rolling(50, min_periods=1).mean().values
X_full = np.column_stack([
spread, depth, imbalance, roll_vol, ofi,
spread_depth_ratio, abs_imb, cum_ofi
])
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_full)
return X_scaled, scaler
# ─────────────────────────────────────────
# 3. HMM Fitting with Robust Initialisation
# ─────────────────────────────────────────
def fit_hmm(X: np.ndarray, n_components: int = N_REGIMES,
n_restarts: int = 10, rng_seed: int = SEED) -> GaussianHMM:
"""
Fit a Gaussian HMM with multiple random restarts and select
the run with highest log-likelihood.
Parameters
----------
X : normalised feature matrix
n_components: number of latent states
n_restarts : number of random restarts
rng_seed : base seed
Returns
-------
best_model : GaussianHMM with highest converged log-likelihood
"""
best_score = -np.inf
best_model = None
for k in range(n_restarts):
model = GaussianHMM(
n_components = n_components,
covariance_type = "full",
n_iter = 200,
tol = 1e-5,
random_state = rng_seed + k,
init_params = "stmc",
params = "stmc",
)
try:
model.fit(X)
score = model.score(X)
if score > best_score:
best_score = score
best_model = model
except Exception:
continue
if best_model is None:
raise RuntimeError("HMM fitting failed across all restarts.")
return best_model
# ─────────────────────────────────────────
# 4. Liquidity Stress Event Definition
# ─────────────────────────────────────────
def define_stress_events(X_raw: np.ndarray, fw: int = FW_WINDOW,
pct: float = STRESS_PCT) -> np.ndarray:
"""
Identify liquidity stress events WITHOUT lookahead leakage.
A timestep t is a stress event if the mean spread over
[t+1, t+fw] exceeds the global spread 'pct'-percentile.
The threshold is computed on the FULL spread series but
the forward window ensures the label at t uses only future data.
Parameters
----------
X_raw : raw feature matrix (column 0 = spread)
fw : forward window length
pct : percentile for threshold
Returns
-------
sigma : 1-D array of stress event timestep indices
"""
spread = X_raw[:, 0]
threshold = np.percentile(spread, pct)
sigma = []
for t in range(len(spread) - fw):
if np.mean(spread[t + 1: t + fw + 1]) > threshold:
sigma.append(t)
return np.array(sigma, dtype=int)
# ─────────────────────────────────────────
# 5. Regime Transition Detection
# ─────────────────────────────────────────
def detect_transitions(Z_hat: np.ndarray) -> np.ndarray:
"""
Return indices just *before* each detected regime change.
Parameters
----------
Z_hat : (T,) array of inferred (or true) state labels
Returns
-------
tau : 1-D int array of transition indices
"""
return np.where(np.diff(Z_hat) != 0)[0]
# ─────────────────────────────────────────
# 6. Lead-Time Evaluation
# ─────────────────────────────────────────
PENALTY = -MAX_LAG # assigned delta when no stress event found in window
def compute_lead_times(tau: np.ndarray, sigma: np.ndarray,
max_lag: int = MAX_LAG) -> np.ndarray:
"""
For each detected transition τ, find the first stress event σ
in (τ, τ + max_lag].
Returns
-------
deltas : (len(tau),) array
Positive → transition preceded stress (early detection)
PENALTY → no stress event in window (missed / false alarm)
"""
deltas = np.empty(len(tau), dtype=float)
for i, t in enumerate(tau):
candidates = sigma[(sigma > t) & (sigma <= t + max_lag)]
deltas[i] = (candidates[0] - t) if len(candidates) > 0 else PENALTY
return deltas
def evaluation_metrics(deltas: np.ndarray, max_lag: int = MAX_LAG
) -> dict:
"""
Summarise lead-time performance.
Metrics
-------
mean_delta : mean lead time (penalised)
pct_early : fraction of transitions that preceded a stress event
mean_early : mean lead time conditional on early detection
std_delta : standard deviation of delta
n_tau : total number of detected transitions
n_early : number of early detections
"""
valid = deltas > 0
return dict(
mean_delta = float(np.mean(deltas)),
pct_early = float(np.mean(valid)),
mean_early = float(np.mean(deltas[valid])) if valid.any() else 0.0,
std_delta = float(np.std(deltas)),
n_tau = int(len(deltas)),
n_early = int(valid.sum()),
)
# ─────────────────────────────────────────
# 7. Baseline Detectors
# ─────────────────────────────────────────
def imbalance_baseline(X_raw: np.ndarray, pct: float = 90) -> np.ndarray:
"""
Trigger when |order-flow imbalance| exceeds a percentile threshold.
Noise is NOT added here; the baseline uses the same raw features as the
HMM to ensure a fair comparison.
"""
imb = np.abs(X_raw[:, 2])
threshold = np.percentile(imb, pct)
return np.where(imb > threshold)[0]
def volatility_baseline(X_raw: np.ndarray, pct: float = 90) -> np.ndarray:
"""
Trigger when rolling spread volatility exceeds a percentile threshold.
"""
roll_vol = X_raw[:, 3]
threshold = np.percentile(roll_vol, pct)
return np.where(roll_vol > threshold)[0]
# ─────────────────────────────────────────
# 8. Bootstrap Confidence Intervals
# ─────────────────────────────────────────
def bootstrap_ci(deltas: np.ndarray, stat_fn=np.mean,
n_boot: int = N_BOOT, alpha: float = 0.05,
seed: int = SEED) -> tuple[float, float]:
"""
Percentile bootstrap confidence interval for a scalar statistic.
Returns
-------
(lower, upper) CI at (1-alpha) level
"""
rng = np.random.default_rng(seed)
boot_stats = np.array([
stat_fn(rng.choice(deltas, size=len(deltas), replace=True))
for _ in range(n_boot)
])
return (float(np.percentile(boot_stats, 100 * alpha / 2)),
float(np.percentile(boot_stats, 100 * (1 - alpha / 2))))
def mannwhitney_test(a: np.ndarray, b: np.ndarray) -> tuple[float, float]:
"""
Two-sided MannWhitney U test (non-parametric, appropriate for
skewed lead-time distributions).
Returns (U-statistic, p-value).
"""
return stats.mannwhitneyu(a, b, alternative="two-sided")
# ─────────────────────────────────────────
# 9. Publication-Quality Visualisation
# ─────────────────────────────────────────
PALETTE = {
"Model" : "#2C6FAC",
"Imbalance" : "#D94F3D",
"Volatility" : "#5AAE61",
}
plt.rcParams.update({
"font.family" : "serif",
"font.size" : 11,
"axes.spines.top" : False,
"axes.spines.right": False,
"axes.linewidth" : 0.8,
"xtick.major.width": 0.8,
"ytick.major.width": 0.8,
"figure.dpi" : 150,
})
def plot_lead_time_densities(delta_dict: dict, max_lag: int = MAX_LAG,
save_path: str = None):
"""
Overlapping KDE plots of lead-time distributions for each detector.
Parameters
----------
delta_dict : {'Model': deltas, 'Imbalance': deltas, 'Volatility': deltas}
max_lag : used to shade the penalty region
save_path : if provided, saves the figure to this path
"""
fig, ax = plt.subplots(figsize=(8, 4.5))
x_grid = np.linspace(-max_lag - 5, max_lag + 5, 500)
for name, deltas in delta_dict.items():
color = PALETTE[name]
# KDE on non-penalty values only (for readability)
valid = deltas[deltas > PENALTY]
if len(valid) > 5:
kde = gaussian_kde(valid, bw_method="scott")
ax.plot(x_grid, kde(x_grid), lw=2.2, color=color, label=name)
ax.fill_between(x_grid, kde(x_grid), alpha=0.12, color=color)
# Mark penalty mass as a tick at the left edge
penalty_frac = np.mean(deltas <= PENALTY)
ax.annotate(
f" missed={penalty_frac:.1%}",
xy=(-max_lag, 0),
xytext=(-max_lag + 2, 0.012 * (list(delta_dict).index(name) + 1)),
color=color, fontsize=8.5, va="center"
)
ax.axvline(0, color="gray", lw=1.0, ls="--", label="Zero lead-time")
ax.set_xlabel("Lead time Δ (timesteps)", labelpad=8)
ax.set_ylabel("Density", labelpad=8)
ax.set_title("Lead-Time Distribution of Regime Detectors", fontsize=13, pad=10)
ax.legend(frameon=False, fontsize=10)
ax.set_xlim(-max_lag - 2, max_lag + 2)
fig.tight_layout()
if save_path:
fig.savefig(save_path, bbox_inches="tight")
plt.show()
def plot_regime_overlay(X_raw: np.ndarray, Z_true: np.ndarray,
Z_hat: np.ndarray, sigma: np.ndarray,
n_show: int = 3000, save_path: str = None):
"""
Three-panel figure:
Top — bid-ask spread with true regime shading
Middle — inferred HMM state sequence
Bottom — depth time-series with stress events marked
"""
t_end = min(n_show, len(Z_true))
t_ax = np.arange(t_end)
spread = X_raw[:t_end, 0]
depth = X_raw[:t_end, 1]
sigma_visible = sigma[sigma < t_end]
regime_colors = {0: "#DDEEFF", 1: "#FFEEDD", 2: "#FFDDDD"}
fig, axes = plt.subplots(3, 1, figsize=(11, 7), sharex=True,
gridspec_kw={"height_ratios": [2, 1, 2]})
# ── Panel 1: Spread + true regime shading ──
ax = axes[0]
for k, color in regime_colors.items():
mask = Z_true[:t_end] == k
ax.fill_between(t_ax, 0, spread.max() * 1.1,
where=mask, color=color, alpha=0.5,
label=f"True state {k}")
ax.plot(t_ax, spread, lw=0.7, color="#1A1A2E")
ax.set_ylabel("Bid-Ask Spread")
ax.legend(loc="upper right", fontsize=8, frameon=False, ncol=3)
# ── Panel 2: Inferred HMM states ──
ax = axes[1]
ax.step(t_ax, Z_hat[:t_end], lw=0.9, color=PALETTE["Model"])
ax.set_ylabel("HMM State")
ax.set_yticks([0, 1, 2])
# ── Panel 3: Depth + stress events ──
ax = axes[2]
ax.plot(t_ax, depth, lw=0.7, color="#2D6A4F")
ax.vlines(sigma_visible, depth.min(), depth.max(),
color=PALETTE["Imbalance"], lw=0.6, alpha=0.5, label="Stress event σ")
ax.set_ylabel("Market Depth")
ax.set_xlabel("Timestep")
ax.legend(loc="upper right", fontsize=8, frameon=False)
fig.suptitle("Simulated LOB: True Regimes, Inferred States & Stress Events",
fontsize=13, y=1.01)
fig.tight_layout()
if save_path:
fig.savefig(save_path, bbox_inches="tight")
plt.show()
def plot_results_table(results_df: pd.DataFrame):
"""Render results DataFrame as a styled matplotlib table."""
fig, ax = plt.subplots(figsize=(10, 2.2))
ax.axis("off")
col_labels = results_df.columns.tolist()
rows = results_df.values.tolist()
tbl = ax.table(cellText=rows, colLabels=col_labels,
cellLoc="center", loc="center")
tbl.auto_set_font_size(False)
tbl.set_fontsize(9.5)
tbl.scale(1.2, 1.5)
# Highlight header
for j in range(len(col_labels)):
tbl[0, j].set_facecolor("#2C6FAC")
tbl[0, j].set_text_props(color="white", fontweight="bold")
fig.tight_layout()
plt.show()
# ─────────────────────────────────────────
# 10. Main Experimental Pipeline
# ─────────────────────────────────────────
def run_experiment():
rng = np.random.default_rng(SEED)
# ── 10.1 Data generation ──
print("=" * 60)
print(" Step 1 / 6 — Generating structured LOB data …")
X_raw, Z_true = generate_lob_data(T, rng)
print(f" Generated {T:,} timesteps | "
f"Regime distribution: "
+ " | ".join([f"State {k}: {(Z_true==k).mean():.1%}"
for k in range(N_REGIMES)]))
# ── 10.2 Feature engineering ──
print("\n Step 2 / 6 — Engineering and normalising features …")
X_scaled, scaler = engineer_features(X_raw)
print(f" Feature matrix shape: {X_scaled.shape}")
# ── 10.3 HMM fitting ──
print("\n Step 3 / 6 — Fitting Gaussian HMM (multiple restarts) …")
model = fit_hmm(X_scaled)
Z_hat = model.predict(X_scaled)
ll = model.score(X_scaled)
print(f" Best log-likelihood: {ll:,.2f} | "
f"Converged: {model.monitor_.converged}")
# ── 10.4 Stress event definition ──
print("\n Step 4 / 6 — Defining leakage-free stress events …")
sigma = define_stress_events(X_raw)
print(f" Stress events identified: {len(sigma):,} "
f"({len(sigma)/T:.1%} of timesteps)")
# ── 10.5 Transition detection for all detectors ──
print("\n Step 5 / 6 — Evaluating detectors …")
tau_model = detect_transitions(Z_hat)
tau_imb = imbalance_baseline(X_raw)
tau_vol = volatility_baseline(X_raw)
delta_model = compute_lead_times(tau_model, sigma)
delta_imb = compute_lead_times(tau_imb, sigma)
delta_vol = compute_lead_times(tau_vol, sigma)
delta_dict = {
"Model" : delta_model,
"Imbalance" : delta_imb,
"Volatility": delta_vol,
}
# ── 10.6 Statistical validation ──
print("\n Step 6 / 6 — Statistical validation (bootstrap + MWU) …")
rows = []
for name, deltas in delta_dict.items():
m = evaluation_metrics(deltas)
lo, hi = bootstrap_ci(deltas, stat_fn=np.mean)
rows.append({
"Detector" : name,
"Mean Δ" : f"{m['mean_delta']:+.2f}",
"95 % CI" : f"[{lo:+.2f}, {hi:+.2f}]",
"% Early" : f"{m['pct_early']:.1%}",
"Mean Δ | early" : f"{m['mean_early']:+.2f}",
"Std Δ" : f"{m['std_delta']:.2f}",
"N(τ)" : m['n_tau'],
"N(early)" : m['n_early'],
})
results_df = pd.DataFrame(rows)
print("\n" + results_df.to_string(index=False))
# Pairwise MannWhitney tests
print("\n Pairwise MannWhitney U tests (two-sided):")
pairs = [("Model", "Imbalance"), ("Model", "Volatility"),
("Imbalance", "Volatility")]
for a_name, b_name in pairs:
u, p = mannwhitney_test(delta_dict[a_name], delta_dict[b_name])
sig = "***" if p < 0.001 else ("**" if p < 0.01 else ("*" if p < 0.05 else "ns"))
print(f" {a_name:12s} vs {b_name:12s}: U={u:,.0f} p={p:.4f} {sig}")
# ── 10.7 Plots ──
print("\n Rendering publication-quality figures …")
plot_regime_overlay(X_raw, Z_true, Z_hat, sigma)
plot_lead_time_densities(delta_dict)
plot_results_table(results_df)
print("\n Experiment complete.")
return results_df, delta_dict, model, X_raw, Z_true, Z_hat, sigma
# ─────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────
if __name__ == "__main__":
results_df, delta_dict, model, X_raw, Z_true, Z_hat, sigma = run_experiment()