Premchan369 commited on
Commit
4a54d99
·
verified ·
1 Parent(s): 72ce2c5

Add liquidity risk module - Amihud illiquidity, bid-ask bounce, price impact modeling

Browse files
Files changed (1) hide show
  1. 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))