Add liquidity risk module - Amihud illiquidity, bid-ask bounce, price impact modeling
Browse files- liquidity_risk.py +164 -0
liquidity_risk.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""liquidity_risk.py — Liquidity Risk Measurement & Price Impact
|
| 2 |
+
|
| 3 |
+
Measures liquidity risk using Amihud illiquidity, bid-ask spread estimation,
|
| 4 |
+
Kyle's lambda, and volume-synchronized probability of informed trading (VPIN).
|
| 5 |
+
Essential for sizing positions in illiquid assets.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
- Amihud 2002: "Illiquidity and Stock Returns"
|
| 9 |
+
- Kyle 1985: "Continuous Auctions and Insider Trading" (Kyle's lambda)
|
| 10 |
+
- Easley et al. 2012: "Flow Toxicity and Liquidity in a High-Frequency World" (VPIN)
|
| 11 |
+
- Goyenko et al. 2009: "Liquidity Risk and Expected Returns"
|
| 12 |
+
"""
|
| 13 |
+
import numpy as np, pandas as pd
|
| 14 |
+
from scipy import stats
|
| 15 |
+
|
| 16 |
+
class LiquidityRiskAnalyzer:
|
| 17 |
+
"""Analyzes liquidity risk and price impact for position sizing."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, window=63):
|
| 20 |
+
self.window = window
|
| 21 |
+
|
| 22 |
+
def amihud_illiquidity(self, prices, volumes):
|
| 23 |
+
"""Amihud (2002): |return| / dollar volume.
|
| 24 |
+
|
| 25 |
+
Higher = more illiquid.
|
| 26 |
+
"""
|
| 27 |
+
ret = prices.pct_change().abs()
|
| 28 |
+
dv = prices * volumes
|
| 29 |
+
illiq = ret / (dv + 1e-10)
|
| 30 |
+
return illiq.rolling(self.window).mean()
|
| 31 |
+
|
| 32 |
+
def effective_spread(self, prices, volumes, tick_size=0.01):
|
| 33 |
+
"""Estimate effective spread from Roll (1984) serial covariance.
|
| 34 |
+
|
| 35 |
+
Spread = 2 * sqrt(-cov(Δp_t, Δp_{t-1}))
|
| 36 |
+
"""
|
| 37 |
+
dp = prices.diff().dropna()
|
| 38 |
+
cov = dp.cov(dp.shift(1))
|
| 39 |
+
if cov >= 0: return tick_size # No bounce detected
|
| 40 |
+
return 2 * np.sqrt(-cov)
|
| 41 |
+
|
| 42 |
+
def kyle_lambda(self, prices, volumes):
|
| 43 |
+
"""Kyle's lambda: price impact per unit of order flow.
|
| 44 |
+
|
| 45 |
+
Δp = λ * V + noise
|
| 46 |
+
Higher λ = less liquid (more impact).
|
| 47 |
+
"""
|
| 48 |
+
dp = prices.diff().dropna()
|
| 49 |
+
v = volumes.reindex(dp.index).fillna(0)
|
| 50 |
+
valid = (dp != 0) & (v > 0)
|
| 51 |
+
if valid.sum() < 30: return 0.0
|
| 52 |
+
X = v[valid].values.reshape(-1, 1)
|
| 53 |
+
y = dp[valid].values
|
| 54 |
+
lambda_ = np.linalg.lstsq(X, y, rcond=None)[0][0]
|
| 55 |
+
return lambda_
|
| 56 |
+
|
| 57 |
+
def vpin(self, prices, volumes, bucket_size=50):
|
| 58 |
+
"""Volume-Synchronized Probability of Informed Trading.
|
| 59 |
+
|
| 60 |
+
VPIN ≈ 1 means toxic flow (informed traders dominating).
|
| 61 |
+
VPIN ≈ 0 means balanced flow.
|
| 62 |
+
"""
|
| 63 |
+
ret = prices.pct_change().abs().dropna()
|
| 64 |
+
v = volumes.reindex(ret.index).fillna(0)
|
| 65 |
+
# Classify as buy/sell-initiated based on price direction
|
| 66 |
+
buy_vol = v.where(prices.diff() > 0, 0)
|
| 67 |
+
sell_vol = v.where(prices.diff() <= 0, 0)
|
| 68 |
+
# Volume bucket approach
|
| 69 |
+
total_vol = v.cumsum()
|
| 70 |
+
buckets = []
|
| 71 |
+
current = 0; buy_sum = 0; sell_sum = 0
|
| 72 |
+
for i in range(len(v)):
|
| 73 |
+
current += v.iloc[i]
|
| 74 |
+
buy_sum += buy_vol.iloc[i]
|
| 75 |
+
sell_sum += sell_vol.iloc[i]
|
| 76 |
+
if current >= bucket_size:
|
| 77 |
+
buckets.append(abs(buy_sum - sell_sum) / current)
|
| 78 |
+
current = buy_sum = sell_sum = 0
|
| 79 |
+
if buckets:
|
| 80 |
+
return np.mean(buckets)
|
| 81 |
+
return 0.0
|
| 82 |
+
|
| 83 |
+
def liquidity_adjusted_var(self, returns, illiquidity, confidence=0.05, holding_period=1):
|
| 84 |
+
"""Liquidity-Adjusted VaR (LaVaR).
|
| 85 |
+
|
| 86 |
+
Accounts for the probability of not being able to exit at VaR price.
|
| 87 |
+
"""
|
| 88 |
+
r = returns.dropna()
|
| 89 |
+
var = np.percentile(r, confidence * 100)
|
| 90 |
+
# Liquidity cost component
|
| 91 |
+
liq_cost = illiquidity.dropna().mean() * holding_period
|
| 92 |
+
return float(var - liq_cost)
|
| 93 |
+
|
| 94 |
+
def position_capacity(self, prices, volumes, max_participation=0.05, max_impact_bps=50):
|
| 95 |
+
"""Maximum position size given liquidity constraints.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
max_participation: max fraction of ADV
|
| 99 |
+
max_impact_bps: max acceptable market impact in basis points
|
| 100 |
+
"""
|
| 101 |
+
adv = volumes.tail(20).mean()
|
| 102 |
+
price = prices.iloc[-1]
|
| 103 |
+
# Participation constraint
|
| 104 |
+
max_shares_part = adv * max_participation
|
| 105 |
+
# Impact constraint (square root law)
|
| 106 |
+
impact_frac = max_impact_bps / 10000.0
|
| 107 |
+
# Impact ≈ gamma * sigma * sqrt(participation)
|
| 108 |
+
sigma = prices.pct_change().tail(20).std()
|
| 109 |
+
# Solve for participation: impact_frac = sigma * sqrt(x) => x = (impact_frac/sigma)^2
|
| 110 |
+
if sigma > 0:
|
| 111 |
+
max_part_impact = (impact_frac / sigma) ** 2
|
| 112 |
+
max_shares_impact = adv * max_part_impact
|
| 113 |
+
else:
|
| 114 |
+
max_shares_impact = float('inf')
|
| 115 |
+
max_shares = min(max_shares_part, max_shares_impact)
|
| 116 |
+
max_notional = max_shares * price
|
| 117 |
+
return {
|
| 118 |
+
'max_shares': int(max_shares),
|
| 119 |
+
'max_notional': float(max_notional),
|
| 120 |
+
'adv': float(adv),
|
| 121 |
+
'participation_limit': float(max_participation),
|
| 122 |
+
'impact_limit': float(max_part_impact if sigma > 0 else 0),
|
| 123 |
+
'binding_constraint': 'participation' if max_shares_part < max_shares_impact else 'impact'
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
def report(self, prices, volumes):
|
| 127 |
+
"""Full liquidity risk report."""
|
| 128 |
+
illiq = self.amihud_illiquidity(prices, volumes)
|
| 129 |
+
spread = self.effective_spread(prices, volumes)
|
| 130 |
+
kyle = self.kyle_lambda(prices, volumes)
|
| 131 |
+
vpin_val = self.vpin(prices, volumes)
|
| 132 |
+
capacity = self.position_capacity(prices, volumes)
|
| 133 |
+
ret = prices.pct_change().dropna()
|
| 134 |
+
lavar = self.liquidity_adjusted_var(ret, illiq)
|
| 135 |
+
return f"""## Liquidity Risk Report
|
| 136 |
+
|
| 137 |
+
| Metric | Value | Interpretation |
|
| 138 |
+
|--------|-------|--------------|
|
| 139 |
+
| Amihud Illiquidity | {illiq.iloc[-1]*1e6:.2f} | {'Low' if illiq.iloc[-1]*1e6 < 0.1 else 'Medium' if illiq.iloc[-1]*1e6 < 1 else 'High'} |
|
| 140 |
+
| Effective Spread (est.) | {spread*100:.3f}% | {'Tight' if spread < 0.001 else 'Wide'} |
|
| 141 |
+
| Kyle's Lambda | {kyle:.6f} | {'Low impact' if abs(kyle) < 1e-6 else 'Medium' if abs(kyle) < 1e-5 else 'High impact'} |
|
| 142 |
+
| VPIN | {vpin_val:.3f} | {'Balanced' if vpin_val < 0.3 else 'Elevated' if vpin_val < 0.6 else 'Toxic'} |
|
| 143 |
+
| Liquidity-Adj VaR (5%) | {lavar*100:.2f}% | — |
|
| 144 |
+
|
| 145 |
+
**Position Capacity:**
|
| 146 |
+
|
| 147 |
+
| Constraint | Limit |
|
| 148 |
+
|------------|-------|
|
| 149 |
+
| Max Shares | {capacity['max_shares']:,} |
|
| 150 |
+
| Max Notional | ${capacity['max_notional']:,.0f} |
|
| 151 |
+
| ADV | {capacity['adv']:,.0f} |
|
| 152 |
+
| Binding Constraint | {capacity['binding_constraint'].upper()} |
|
| 153 |
+
|
| 154 |
+
**Recommendation:** {'✅ Liquid — standard sizing OK' if illiq.iloc[-1]*1e6 < 0.5 else '⚠️ Reduce position by 50%' if illiq.iloc[-1]*1e6 < 2 else '🔴 Highly illiquid — avoid or use limit orders'}
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
if __name__ == '__main__':
|
| 158 |
+
np.random.seed(42)
|
| 159 |
+
prices = pd.Series(np.cumprod(1 + np.random.normal(0.0005, 0.015, 500)),
|
| 160 |
+
index=pd.date_range('2022-01-01', periods=500, freq='B'))
|
| 161 |
+
volumes = pd.Series(np.random.lognormal(15, 0.5, 500),
|
| 162 |
+
index=pd.date_range('2022-01-01', periods=500, freq='B'))
|
| 163 |
+
lra = LiquidityRiskAnalyzer()
|
| 164 |
+
print(lra.report(prices, volumes))
|