nirvana-quant 1.0.0__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.
- nirvana/__init__.py +75 -0
- nirvana/domains/__init__.py +41 -0
- nirvana/domains/banking.py +77 -0
- nirvana/domains/consumer.py +132 -0
- nirvana/domains/cybersecurity.py +58 -0
- nirvana/domains/substitute.py +79 -0
- nirvana/domains/supply_chain.py +56 -0
- nirvana/experimental/__init__.py +22 -0
- nirvana/experimental/multiverse_invariance.py +269 -0
- nirvana/kalman.py +282 -0
- nirvana/manifold.py +184 -0
- nirvana/multiplex.py +75 -0
- nirvana/multiverse.py +217 -0
- nirvana/network.py +675 -0
- nirvana/proof.py +958 -0
- nirvana/stata.py +497 -0
- nirvana/static.py +774 -0
- nirvana/tree.py +497 -0
- nirvana/volatility.py +121 -0
- nirvana_quant-1.0.0.dist-info/METADATA +168 -0
- nirvana_quant-1.0.0.dist-info/RECORD +24 -0
- nirvana_quant-1.0.0.dist-info/WHEEL +5 -0
- nirvana_quant-1.0.0.dist-info/licenses/LICENSE +239 -0
- nirvana_quant-1.0.0.dist-info/top_level.txt +2 -0
nirvana/network.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nirvana Systemic Risk Connectedness Engine
|
|
3
|
+
==========================================
|
|
4
|
+
Replaces / enhances traditional Diebold-Yılmaz (2012, 2014) TVP-VAR GFEVD with:
|
|
5
|
+
- Causal State-Space Kalman Filtering (zero lookahead)
|
|
6
|
+
- Base-12 Manifold Phase Resonance Tracking
|
|
7
|
+
- Apoptosis-pruned Causal Transfer Estimation
|
|
8
|
+
|
|
9
|
+
Produces academic-grade Systemic Risk Tables:
|
|
10
|
+
- Pairwise Directional Connectedness (S_ij)
|
|
11
|
+
- Directional TO Others (Transmitter of Risk)
|
|
12
|
+
- Directional FROM Others (Receiver of Risk)
|
|
13
|
+
- NET Systemic Spillover (NET = TO - FROM)
|
|
14
|
+
- Total Connectedness Index (TCI)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import List, Dict, Optional, Union, Tuple
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from .kalman import MultivariateCausalKalman
|
|
23
|
+
from .manifold import Base12Manifold
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SystemicRiskReport:
|
|
28
|
+
spillover_table: pd.DataFrame
|
|
29
|
+
total_connectedness_index: float
|
|
30
|
+
normalized_tci: float
|
|
31
|
+
net_spillovers: Dict[str, float]
|
|
32
|
+
top_transmitters: List[str]
|
|
33
|
+
top_receivers: List[str]
|
|
34
|
+
asset_weighted: bool = False
|
|
35
|
+
p_values_table: Optional[pd.DataFrame] = None
|
|
36
|
+
eigenvector_hub: Optional[Dict[str, float]] = None
|
|
37
|
+
eigenvector_authority: Optional[Dict[str, float]] = None
|
|
38
|
+
betweenness_centrality: Optional[Dict[str, float]] = None
|
|
39
|
+
flight_to_safety_index: Optional[Dict[str, float]] = None
|
|
40
|
+
market_beta: Optional[Dict[str, float]] = None
|
|
41
|
+
market_variance_share: Optional[float] = None
|
|
42
|
+
|
|
43
|
+
def summary(self) -> str:
|
|
44
|
+
lines = []
|
|
45
|
+
lines.append("=" * 78)
|
|
46
|
+
lines.append("NIRVANA SYSTEMIC RISK CONNECTEDNESS REPORT (BANKING NETWORK)")
|
|
47
|
+
lines.append("=" * 78)
|
|
48
|
+
lines.append(f"Raw Total Connectedness Index (TCI): {self.total_connectedness_index:.2f}%")
|
|
49
|
+
lines.append(f"Dimension-Normalized TCI (Adjusted for N): {self.normalized_tci:.2f}%")
|
|
50
|
+
lines.append(f"Asset-Weighted Footprint Active: {'YES' if self.asset_weighted else 'NO'}")
|
|
51
|
+
if self.market_variance_share is not None:
|
|
52
|
+
lines.append(f"Market Orthogonalization Active: YES (Market Factor: {self.market_variance_share:.2f}% var)")
|
|
53
|
+
if self.p_values_table is not None:
|
|
54
|
+
lines.append("Bootstrap Edge Significance Active: YES (p < 0.05 Apoptosis)")
|
|
55
|
+
lines.append("\nSpillover Matrix (%):")
|
|
56
|
+
lines.append(self.spillover_table.to_string())
|
|
57
|
+
lines.append("\nTop Systemic Risk Transmitters (NET > 0):")
|
|
58
|
+
for b in self.top_transmitters:
|
|
59
|
+
lines.append(f" - {b}: +{self.net_spillovers[b]:.2f}%")
|
|
60
|
+
lines.append("\nTop Systemic Risk Receivers / Absorbers (NET < 0):")
|
|
61
|
+
for b in self.top_receivers:
|
|
62
|
+
lines.append(f" - {b}: {self.net_spillovers[b]:.2f}%")
|
|
63
|
+
if self.eigenvector_hub:
|
|
64
|
+
lines.append("\nTop Hub Centrality (Systemic Shock Transmitters):")
|
|
65
|
+
sorted_hub = sorted(self.eigenvector_hub.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
66
|
+
for b, score in sorted_hub:
|
|
67
|
+
lines.append(f" - {b}: {score:.2f}%")
|
|
68
|
+
if self.eigenvector_authority:
|
|
69
|
+
lines.append("\nTop Authority Centrality (Systemic Shock Vulnerability / Sinks):")
|
|
70
|
+
sorted_auth = sorted(self.eigenvector_authority.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
71
|
+
for b, score in sorted_auth:
|
|
72
|
+
lines.append(f" - {b}: {score:.2f}%")
|
|
73
|
+
if self.betweenness_centrality:
|
|
74
|
+
lines.append("\nTop Betweenness Centrality (Bridge / Transmission Bottlenecks):")
|
|
75
|
+
sorted_bc = sorted(self.betweenness_centrality.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
76
|
+
for b, score in sorted_bc:
|
|
77
|
+
lines.append(f" - {b}: {score:.2f}%")
|
|
78
|
+
if self.flight_to_safety_index:
|
|
79
|
+
lines.append("\nTop Flight-to-Safety Absorbers (Safe-Haven Assets):")
|
|
80
|
+
sorted_fts = sorted(self.flight_to_safety_index.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
81
|
+
for b, score in sorted_fts:
|
|
82
|
+
lines.append(f" - {b}: {score:.2f}%")
|
|
83
|
+
lines.append("=" * 78)
|
|
84
|
+
return "\n".join(lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class DynamicSystemicRiskReport:
|
|
89
|
+
tci_history: pd.Series
|
|
90
|
+
normalized_tci_history: pd.Series
|
|
91
|
+
rolling_net_spillovers: pd.DataFrame
|
|
92
|
+
phase_transition_dates: List[Union[pd.Timestamp, str]]
|
|
93
|
+
mean_tci: float
|
|
94
|
+
max_tci: float
|
|
95
|
+
max_tci_date: Optional[Union[pd.Timestamp, str]] = None
|
|
96
|
+
|
|
97
|
+
def summary(self) -> str:
|
|
98
|
+
lines = []
|
|
99
|
+
lines.append("=" * 78)
|
|
100
|
+
lines.append("NIRVANA DYNAMIC TIME-VARYING RISK REPORT (ROLLING CONNECTEDNESS)")
|
|
101
|
+
lines.append("=" * 78)
|
|
102
|
+
lines.append(f"Mean Rolling TCI: {self.mean_tci:.2f}%")
|
|
103
|
+
lines.append(f"Max TCI Peak Stress: {self.max_tci:.2f}% (Observed at: {self.max_tci_date})")
|
|
104
|
+
lines.append(f"Detected Phase Transitions: {len(self.phase_transition_dates)} events")
|
|
105
|
+
for d in self.phase_transition_dates[:5]:
|
|
106
|
+
lines.append(f" - Surge Transition: {d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else d}")
|
|
107
|
+
lines.append("=" * 78)
|
|
108
|
+
return "\n".join(lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Decoupled domain-specific parameters imported from banking domain module
|
|
112
|
+
from .domains.banking import DEFAULT_EXCHANGE_BANDS, DEFAULT_ASSET_WEIGHTS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class NirvanaConnectedness:
|
|
116
|
+
"""
|
|
117
|
+
Connectedness estimator for banking networks.
|
|
118
|
+
Features:
|
|
119
|
+
- Base-12 Dual-Clock Manifold harmonic transformation
|
|
120
|
+
- Causal Online Multivariate Kalman Filter
|
|
121
|
+
- Exchange Price-Band Normalization (HOSE 7% vs HNX 10% vs UPCoM 15%)
|
|
122
|
+
- Multi-lag asymmetric causal transmission (T+1, T+2, T+3 settlement cycle)
|
|
123
|
+
- Sub-linear Asset Elasticity Footprint (sqrt assets)
|
|
124
|
+
- Bootstrap edge significance & Empirical Fisher Apoptosis
|
|
125
|
+
- Dimension-adjusted Normalized TCI
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
bank_names: Optional[List[str]] = None,
|
|
131
|
+
lags: Optional[Union[int, List[int]]] = None,
|
|
132
|
+
lag_weights: Optional[List[float]] = None,
|
|
133
|
+
multi_lag: bool = True,
|
|
134
|
+
kalman_q: float = 1e-3,
|
|
135
|
+
kalman_r: float = 1e-2,
|
|
136
|
+
kalman_phi: float = 0.98,
|
|
137
|
+
kalman_outlier_threshold: Optional[float] = None,
|
|
138
|
+
apoptosis_threshold: float = 0.015,
|
|
139
|
+
p_value_threshold: float = 0.05,
|
|
140
|
+
n_boot: int = 0,
|
|
141
|
+
orthogonalize_market: bool = False,
|
|
142
|
+
n_market_factors: int = 1,
|
|
143
|
+
causality_mode: str = "linear",
|
|
144
|
+
threshold_quantile: float = 0.70,
|
|
145
|
+
normalize_bands: bool = True,
|
|
146
|
+
exchange_bands: Optional[Dict[str, float]] = None,
|
|
147
|
+
asset_weights: Optional[Union[Dict[str, float], bool, str]] = None,
|
|
148
|
+
asset_elasticity: float = 0.5,
|
|
149
|
+
volume_quantum: float = 1000.0,
|
|
150
|
+
macro_periods: Optional[Tuple[float, float]] = None,
|
|
151
|
+
):
|
|
152
|
+
self.bank_names = bank_names
|
|
153
|
+
self.lags = lags
|
|
154
|
+
self.lag_weights = lag_weights
|
|
155
|
+
self.multi_lag = multi_lag
|
|
156
|
+
self.kalman_q = kalman_q
|
|
157
|
+
self.kalman_r = kalman_r
|
|
158
|
+
self.kalman_phi = kalman_phi
|
|
159
|
+
self.kalman_outlier_threshold = kalman_outlier_threshold
|
|
160
|
+
self.apoptosis_threshold = apoptosis_threshold
|
|
161
|
+
self.p_value_threshold = p_value_threshold
|
|
162
|
+
self.n_boot = n_boot
|
|
163
|
+
self.orthogonalize_market = orthogonalize_market
|
|
164
|
+
self.n_market_factors = n_market_factors
|
|
165
|
+
self.causality_mode = causality_mode
|
|
166
|
+
self.threshold_quantile = threshold_quantile
|
|
167
|
+
self.normalize_bands = normalize_bands
|
|
168
|
+
self.exchange_bands = exchange_bands
|
|
169
|
+
self.asset_weights = asset_weights
|
|
170
|
+
self.asset_elasticity = asset_elasticity
|
|
171
|
+
self.volume_quantum = volume_quantum
|
|
172
|
+
self.macro_periods = macro_periods
|
|
173
|
+
self.manifold = Base12Manifold(volume_quantum=volume_quantum, macro_periods=macro_periods)
|
|
174
|
+
|
|
175
|
+
def fit(
|
|
176
|
+
self,
|
|
177
|
+
data: Union[pd.DataFrame, np.ndarray],
|
|
178
|
+
asset_weights: Optional[Union[Dict[str, float], bool, str]] = None,
|
|
179
|
+
asset_elasticity: Optional[float] = None,
|
|
180
|
+
normalize_bands: Optional[bool] = None,
|
|
181
|
+
multi_lag: Optional[bool] = None,
|
|
182
|
+
lags: Optional[Union[int, List[int]]] = None,
|
|
183
|
+
lag_weights: Optional[List[float]] = None,
|
|
184
|
+
n_boot: Optional[int] = None,
|
|
185
|
+
orthogonalize_market: Optional[bool] = None,
|
|
186
|
+
exchange_bands: Optional[Dict[str, float]] = None,
|
|
187
|
+
kalman_q: Optional[float] = None,
|
|
188
|
+
kalman_r: Optional[float] = None,
|
|
189
|
+
kalman_phi: Optional[float] = None,
|
|
190
|
+
apoptosis_threshold: Optional[float] = None,
|
|
191
|
+
p_value_threshold: Optional[float] = None,
|
|
192
|
+
n_market_factors: Optional[int] = None,
|
|
193
|
+
causality_mode: Optional[str] = None,
|
|
194
|
+
threshold_quantile: Optional[float] = None,
|
|
195
|
+
kalman_outlier_threshold: Optional[float] = None,
|
|
196
|
+
macro_periods: Optional[Tuple[float, float]] = None,
|
|
197
|
+
) -> SystemicRiskReport:
|
|
198
|
+
# Resolve hyperparameters (fit-level overrides self-level)
|
|
199
|
+
active_lags = self.lags if lags is None else lags
|
|
200
|
+
active_lag_weights = self.lag_weights if lag_weights is None else lag_weights
|
|
201
|
+
active_multi_lag = self.multi_lag if multi_lag is None else multi_lag
|
|
202
|
+
active_kalman_q = self.kalman_q if kalman_q is None else kalman_q
|
|
203
|
+
active_kalman_r = self.kalman_r if kalman_r is None else kalman_r
|
|
204
|
+
active_kalman_phi = self.kalman_phi if kalman_phi is None else kalman_phi
|
|
205
|
+
active_kalman_outlier_thresh = (
|
|
206
|
+
self.kalman_outlier_threshold
|
|
207
|
+
if kalman_outlier_threshold is None
|
|
208
|
+
else kalman_outlier_threshold
|
|
209
|
+
)
|
|
210
|
+
active_macro_periods = self.macro_periods if macro_periods is None else macro_periods
|
|
211
|
+
active_apoptosis_threshold = (
|
|
212
|
+
self.apoptosis_threshold if apoptosis_threshold is None else apoptosis_threshold
|
|
213
|
+
)
|
|
214
|
+
active_p_val_thresh = (
|
|
215
|
+
self.p_value_threshold if p_value_threshold is None else p_value_threshold
|
|
216
|
+
)
|
|
217
|
+
active_n_boot = self.n_boot if n_boot is None else n_boot
|
|
218
|
+
active_ortho = (
|
|
219
|
+
self.orthogonalize_market if orthogonalize_market is None else orthogonalize_market
|
|
220
|
+
)
|
|
221
|
+
active_n_market_factors = (
|
|
222
|
+
self.n_market_factors if n_market_factors is None else n_market_factors
|
|
223
|
+
)
|
|
224
|
+
active_causality_mode = (
|
|
225
|
+
self.causality_mode if causality_mode is None else causality_mode
|
|
226
|
+
)
|
|
227
|
+
active_threshold_quantile = (
|
|
228
|
+
self.threshold_quantile if threshold_quantile is None else threshold_quantile
|
|
229
|
+
)
|
|
230
|
+
active_norm_bands = self.normalize_bands if normalize_bands is None else normalize_bands
|
|
231
|
+
active_exchange_bands = self.exchange_bands if exchange_bands is None else exchange_bands
|
|
232
|
+
active_asset_weights = self.asset_weights if asset_weights is None else asset_weights
|
|
233
|
+
active_asset_elasticity = (
|
|
234
|
+
self.asset_elasticity if asset_elasticity is None else asset_elasticity
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if active_asset_weights is True or active_asset_weights == "default":
|
|
238
|
+
active_asset_weights = DEFAULT_ASSET_WEIGHTS
|
|
239
|
+
|
|
240
|
+
if isinstance(data, pd.DataFrame):
|
|
241
|
+
names = list(data.columns)
|
|
242
|
+
raw_mat = data.values.copy()
|
|
243
|
+
else:
|
|
244
|
+
names = self.bank_names or [f"B_{i+1}" for i in range(data.shape[1])]
|
|
245
|
+
raw_mat = data.copy()
|
|
246
|
+
|
|
247
|
+
t_steps, n_nodes = raw_mat.shape
|
|
248
|
+
|
|
249
|
+
if n_nodes < 2:
|
|
250
|
+
single_table = pd.DataFrame(
|
|
251
|
+
[[100.0, 0.0]], index=names, columns=names + ["FROM_others"]
|
|
252
|
+
)
|
|
253
|
+
row_to = pd.Series([0.0, 0.0], index=names + ["FROM_others"], name="TO_others")
|
|
254
|
+
row_net = pd.Series([0.0, 0.0], index=names + ["FROM_others"], name="NET_spillover")
|
|
255
|
+
single_table = pd.concat([single_table, row_to.to_frame().T, row_net.to_frame().T])
|
|
256
|
+
return SystemicRiskReport(
|
|
257
|
+
spillover_table=single_table,
|
|
258
|
+
total_connectedness_index=0.0,
|
|
259
|
+
normalized_tci=0.0,
|
|
260
|
+
net_spillovers={names[0]: 0.0},
|
|
261
|
+
top_transmitters=[],
|
|
262
|
+
top_receivers=[],
|
|
263
|
+
asset_weighted=False,
|
|
264
|
+
eigenvector_hub={names[0]: 100.0},
|
|
265
|
+
eigenvector_authority={names[0]: 100.0},
|
|
266
|
+
betweenness_centrality={names[0]: 0.0},
|
|
267
|
+
flight_to_safety_index={names[0]: 0.0},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# 0. Exchange Price-Band Heteroskedasticity Mapping
|
|
271
|
+
# In state-space filtering, wider price bands (UPCoM 15%, HNX 10%) imply wider microstructure noise.
|
|
272
|
+
# We calibrate this into per-node observation noise covariance R_i rather than distorting log-price scales.
|
|
273
|
+
r_vec = np.full(n_nodes, active_kalman_r)
|
|
274
|
+
if active_norm_bands and isinstance(data, pd.DataFrame):
|
|
275
|
+
band_map = active_exchange_bands or DEFAULT_EXCHANGE_BANDS
|
|
276
|
+
for col_idx, b in enumerate(names):
|
|
277
|
+
band = band_map.get(b, 0.07)
|
|
278
|
+
r_vec[col_idx] = active_kalman_r * ((band / 0.07) ** 2)
|
|
279
|
+
|
|
280
|
+
# 1. Causal Kalman State Estimation with Mean Reversion & Exchange Noise Calibration
|
|
281
|
+
mv_kalman = MultivariateCausalKalman(
|
|
282
|
+
n_dim=n_nodes,
|
|
283
|
+
q_diag=active_kalman_q,
|
|
284
|
+
r_diag=r_vec,
|
|
285
|
+
phi=active_kalman_phi,
|
|
286
|
+
outlier_threshold=active_kalman_outlier_thresh,
|
|
287
|
+
)
|
|
288
|
+
filtered_states = mv_kalman.filter_dataframe(raw_mat)
|
|
289
|
+
|
|
290
|
+
# 2. Dynamic Innovation Residuals
|
|
291
|
+
innovations = raw_mat - filtered_states
|
|
292
|
+
|
|
293
|
+
# 2b. Multi-Factor SVD Market Orthogonalization
|
|
294
|
+
market_beta_dict = None
|
|
295
|
+
market_var_share = None
|
|
296
|
+
if active_ortho:
|
|
297
|
+
k_factors = min(max(1, active_n_market_factors), n_nodes - 1)
|
|
298
|
+
u, s, _ = np.linalg.svd(innovations - np.mean(innovations, axis=0), full_matrices=False)
|
|
299
|
+
factors = u[:, :k_factors] * s[:k_factors]
|
|
300
|
+
sys_mean = np.mean(innovations, axis=1)
|
|
301
|
+
for f_idx in range(k_factors):
|
|
302
|
+
if np.corrcoef(factors[:, f_idx], sys_mean)[0, 1] < 0:
|
|
303
|
+
factors[:, f_idx] = -factors[:, f_idx]
|
|
304
|
+
market_var_share = float(np.sum(s[:k_factors]**2) / np.sum(s**2) * 100.0)
|
|
305
|
+
|
|
306
|
+
idio_innovations = np.empty_like(innovations)
|
|
307
|
+
betas = {}
|
|
308
|
+
X_reg = np.column_stack([np.ones(len(factors)), factors])
|
|
309
|
+
for col_idx in range(n_nodes):
|
|
310
|
+
y = innovations[:, col_idx]
|
|
311
|
+
b = np.linalg.lstsq(X_reg, y, rcond=None)[0]
|
|
312
|
+
idio_innovations[:, col_idx] = y - X_reg @ b
|
|
313
|
+
betas[names[col_idx]] = float(np.round(b[1], 3)) if k_factors == 1 else [float(np.round(val, 3)) for val in b[1:]]
|
|
314
|
+
|
|
315
|
+
innovations = idio_innovations
|
|
316
|
+
market_beta_dict = betas
|
|
317
|
+
|
|
318
|
+
# 3. Base-12 Manifold Harmonic Phase Transform (Aligned with financial cycles)
|
|
319
|
+
calendar_idx = np.arange(t_steps)
|
|
320
|
+
dates_arg = data.index if isinstance(data, pd.DataFrame) else None
|
|
321
|
+
manifold_engine = (
|
|
322
|
+
Base12Manifold(volume_quantum=self.volume_quantum, macro_periods=active_macro_periods)
|
|
323
|
+
if active_macro_periods is not None
|
|
324
|
+
else self.manifold
|
|
325
|
+
)
|
|
326
|
+
manifold_coords = manifold_engine.batch_transform(calendar_idx, dates=dates_arg)
|
|
327
|
+
gamma = manifold_coords[:, 8] # Phase resonance index
|
|
328
|
+
|
|
329
|
+
# 4. Causal Transfer & Dynamic Covariance Matrix
|
|
330
|
+
weighted_innovations = innovations * (1.0 + 0.5 * gamma[:, None])
|
|
331
|
+
min_p = min(10, max(2, t_steps - 1))
|
|
332
|
+
cov_df = pd.DataFrame(weighted_innovations).cov(min_periods=min_p).fillna(0.0)
|
|
333
|
+
cov_mat = cov_df.values
|
|
334
|
+
|
|
335
|
+
# Standardize into correlation / impact matrix
|
|
336
|
+
std_devs = np.sqrt(np.maximum(np.diag(cov_mat), 1e-8))
|
|
337
|
+
corr_mat = cov_mat / (np.outer(std_devs, std_devs) + 1e-8)
|
|
338
|
+
np.nan_to_num(corr_mat, copy=False, nan=0.0)
|
|
339
|
+
|
|
340
|
+
# 5. Multi-Lag Directional Asymmetry (T+2.5 Settlement Flow: Lags 1, 2, 3)
|
|
341
|
+
# Canonical Diebold-Yilmaz (2012, 2014) convention:
|
|
342
|
+
# K_ij is impact ON bank i (Row i = Target, Future) FROM bank j (Col j = Source, Past)
|
|
343
|
+
if active_lags is None:
|
|
344
|
+
lags_to_eval = [1, 2, 3] if active_multi_lag else [1]
|
|
345
|
+
elif isinstance(active_lags, int):
|
|
346
|
+
lags_to_eval = list(range(1, active_lags + 1))
|
|
347
|
+
else:
|
|
348
|
+
lags_to_eval = list(active_lags)
|
|
349
|
+
|
|
350
|
+
if active_lag_weights is None:
|
|
351
|
+
decay_weights = np.array([0.5, 0.3, 0.2] if active_multi_lag else [1.0])
|
|
352
|
+
if len(lags_to_eval) != len(decay_weights):
|
|
353
|
+
decay_weights = np.ones(len(lags_to_eval)) / float(len(lags_to_eval))
|
|
354
|
+
else:
|
|
355
|
+
raw_lw = np.asarray(active_lag_weights, dtype=float)
|
|
356
|
+
decay_weights = raw_lw / (np.sum(raw_lw) + 1e-8)
|
|
357
|
+
|
|
358
|
+
def _calc_lead_lag(mat: np.ndarray) -> np.ndarray:
|
|
359
|
+
asym = np.zeros((n_nodes, n_nodes))
|
|
360
|
+
for l_idx, lag in enumerate(lags_to_eval):
|
|
361
|
+
p = mat[:-lag]
|
|
362
|
+
f = mat[lag:]
|
|
363
|
+
if active_causality_mode == "threshold":
|
|
364
|
+
# Upper quantile threshold activation for tail/panic shocks
|
|
365
|
+
q_val = np.quantile(np.abs(p), active_threshold_quantile, axis=0, keepdims=True)
|
|
366
|
+
p_act = np.where(np.abs(p) >= q_val, p, 0.0)
|
|
367
|
+
p_norm = (p_act - np.mean(p_act, axis=0)) / (np.std(p_act, axis=0) + 1e-8)
|
|
368
|
+
f_norm = (f - np.mean(f, axis=0)) / (np.std(f, axis=0) + 1e-8)
|
|
369
|
+
else:
|
|
370
|
+
p_norm = (p - np.mean(p, axis=0)) / (np.std(p, axis=0) + 1e-8)
|
|
371
|
+
f_norm = (f - np.mean(f, axis=0)) / (np.std(f, axis=0) + 1e-8)
|
|
372
|
+
c_mat = (f_norm.T @ p_norm) / float(len(p) - 1)
|
|
373
|
+
np.nan_to_num(c_mat, copy=False, nan=0.0)
|
|
374
|
+
# Anti-symmetric net directional lead: Delta_ij = c_ij - c_ji
|
|
375
|
+
# Delta_ij > 0 means source j leads target i
|
|
376
|
+
asym += decay_weights[l_idx] * (c_mat - c_mat.T)
|
|
377
|
+
np.fill_diagonal(asym, 0.0)
|
|
378
|
+
return np.clip(asym, -1.0, 1.0)
|
|
379
|
+
|
|
380
|
+
lead_lag_asymmetry = _calc_lead_lag(weighted_innovations)
|
|
381
|
+
|
|
382
|
+
# 6. Raw Spillover Kernel: K_ij = Corr_ij * Asym_ij (Row i = Target, Col j = Source)
|
|
383
|
+
kernel = np.abs(corr_mat) * (0.5 + 0.5 * lead_lag_asymmetry)
|
|
384
|
+
|
|
385
|
+
# 7. Bootstrap Significance Testing (if active_n_boot > 0)
|
|
386
|
+
p_val_mat = None
|
|
387
|
+
if active_n_boot > 0:
|
|
388
|
+
# ponytail: circular roll per column preserves autocorrelation while destroying cross-correlation
|
|
389
|
+
boot_exceed_counts = np.zeros((n_nodes, n_nodes))
|
|
390
|
+
rng = np.random.default_rng(seed=42)
|
|
391
|
+
|
|
392
|
+
for _ in range(active_n_boot):
|
|
393
|
+
shuffled_innov = np.empty_like(weighted_innovations)
|
|
394
|
+
for col in range(n_nodes):
|
|
395
|
+
shift = int(rng.integers(20, t_steps - 20))
|
|
396
|
+
shuffled_innov[:, col] = np.roll(weighted_innovations[:, col], shift)
|
|
397
|
+
|
|
398
|
+
boot_corr = np.abs(np.corrcoef(shuffled_innov, rowvar=False))
|
|
399
|
+
np.nan_to_num(boot_corr, copy=False, nan=0.0)
|
|
400
|
+
boot_asym = _calc_lead_lag(shuffled_innov)
|
|
401
|
+
boot_stat = boot_corr * (0.5 + 0.5 * boot_asym)
|
|
402
|
+
boot_exceed_counts += (boot_stat >= kernel)
|
|
403
|
+
|
|
404
|
+
p_val_mat = boot_exceed_counts / float(active_n_boot)
|
|
405
|
+
# Excise non-significant edges (p >= active_p_val_thresh) via Apoptosis
|
|
406
|
+
insignificant_mask = (p_val_mat >= active_p_val_thresh) & ~np.eye(n_nodes, dtype=bool)
|
|
407
|
+
kernel[insignificant_mask] = 0.0
|
|
408
|
+
|
|
409
|
+
# Baseline Apoptosis threshold pruning
|
|
410
|
+
kernel[kernel < active_apoptosis_threshold] = 0.0
|
|
411
|
+
# Standardized own-variance diagonal is 1.0 (consistent with correlation off-diagonals)
|
|
412
|
+
np.fill_diagonal(kernel, 1.0)
|
|
413
|
+
|
|
414
|
+
# 8. Row normalization (Each bank's total variance explained = 100%)
|
|
415
|
+
row_sums = kernel.sum(axis=1, keepdims=True)
|
|
416
|
+
row_sums[row_sums == 0] = 1.0
|
|
417
|
+
norm_kernel = (kernel / row_sums) * 100.0
|
|
418
|
+
|
|
419
|
+
# 9. Compute Academic Metrics
|
|
420
|
+
from_others = np.sum(norm_kernel, axis=1) - np.diag(norm_kernel)
|
|
421
|
+
raw_to_others = np.sum(norm_kernel, axis=0) - np.diag(norm_kernel)
|
|
422
|
+
|
|
423
|
+
# Asset-weighting modulation
|
|
424
|
+
is_weighted = False
|
|
425
|
+
if active_asset_weights is not None:
|
|
426
|
+
# Sub-linear asset elasticity (default 0.5 = square root of assets)
|
|
427
|
+
valid_vals = [float(v) for v in active_asset_weights.values() if float(v) > 0]
|
|
428
|
+
median_w = float(np.median(valid_vals)) if valid_vals else 100.0
|
|
429
|
+
raw_w = np.array([float(active_asset_weights.get(b, median_w)) ** active_asset_elasticity for b in names])
|
|
430
|
+
mean_w = np.mean(raw_w) if np.mean(raw_w) > 0 else 1.0
|
|
431
|
+
norm_w = raw_w / mean_w
|
|
432
|
+
to_others = raw_to_others * norm_w
|
|
433
|
+
is_weighted = True
|
|
434
|
+
else:
|
|
435
|
+
to_others = raw_to_others
|
|
436
|
+
|
|
437
|
+
net_spillover = to_others - from_others
|
|
438
|
+
|
|
439
|
+
# Total Connectedness Index (Raw & Dimension-Normalized)
|
|
440
|
+
tci = float(np.sum(from_others) / n_nodes)
|
|
441
|
+
max_possible_tci = (n_nodes - 1.0) / float(n_nodes) * 100.0
|
|
442
|
+
normalized_tci = float((tci / max_possible_tci) * 100.0)
|
|
443
|
+
|
|
444
|
+
# Build Presentation DataFrame
|
|
445
|
+
table = pd.DataFrame(
|
|
446
|
+
np.round(norm_kernel, 2),
|
|
447
|
+
index=names,
|
|
448
|
+
columns=names,
|
|
449
|
+
)
|
|
450
|
+
table["FROM_others"] = np.round(from_others, 2)
|
|
451
|
+
|
|
452
|
+
row_to = pd.Series(np.round(to_others, 2), index=names, name="TO_others")
|
|
453
|
+
row_to["FROM_others"] = np.round(np.sum(from_others), 2)
|
|
454
|
+
|
|
455
|
+
row_net = pd.Series(np.round(net_spillover, 2), index=names, name="NET_spillover")
|
|
456
|
+
row_net["FROM_others"] = np.round(tci, 2)
|
|
457
|
+
|
|
458
|
+
table = pd.concat([table, row_to.to_frame().T, row_net.to_frame().T])
|
|
459
|
+
|
|
460
|
+
net_dict = {names[i]: float(net_spillover[i]) for i in range(n_nodes)}
|
|
461
|
+
sorted_transmitters = sorted(
|
|
462
|
+
[k for k, v in net_dict.items() if v > 0],
|
|
463
|
+
key=lambda k: net_dict[k],
|
|
464
|
+
reverse=True,
|
|
465
|
+
)
|
|
466
|
+
sorted_receivers = sorted(
|
|
467
|
+
[k for k, v in net_dict.items() if v <= 0],
|
|
468
|
+
key=lambda k: net_dict[k],
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
p_val_df = pd.DataFrame(np.round(p_val_mat, 3), index=names, columns=names) if p_val_mat is not None else None
|
|
472
|
+
|
|
473
|
+
# 10. Complex Graph Topology Centrality Metrics
|
|
474
|
+
# Adjacency A = norm_kernel with zero diagonal (A_ij is impact on i from j)
|
|
475
|
+
A = norm_kernel.copy()
|
|
476
|
+
np.fill_diagonal(A, 0.0)
|
|
477
|
+
|
|
478
|
+
# Hub Centrality: Transmitting power of source j into the network (Perron-Frobenius damped power iteration on A^T)
|
|
479
|
+
v_hub = np.ones(n_nodes) / np.sqrt(n_nodes)
|
|
480
|
+
v_teleport = np.ones(n_nodes) / np.sqrt(n_nodes)
|
|
481
|
+
for _ in range(50):
|
|
482
|
+
v_next = 0.85 * (A.T @ v_hub) + 0.15 * v_teleport
|
|
483
|
+
norm = np.linalg.norm(v_next)
|
|
484
|
+
if norm > 1e-12:
|
|
485
|
+
v_hub = v_next / norm
|
|
486
|
+
else:
|
|
487
|
+
break
|
|
488
|
+
hub_centrality = {names[i]: float(np.round(v_hub[i] / (np.sum(v_hub) + 1e-12) * 100.0, 2)) for i in range(n_nodes)}
|
|
489
|
+
|
|
490
|
+
# Authority / Vulnerability Centrality: Vulnerability of receiver i absorbing from system (Perron-Frobenius damped power iteration on A)
|
|
491
|
+
v_auth = np.ones(n_nodes) / np.sqrt(n_nodes)
|
|
492
|
+
for _ in range(50):
|
|
493
|
+
v_next = 0.85 * (A @ v_auth) + 0.15 * v_teleport
|
|
494
|
+
norm = np.linalg.norm(v_next)
|
|
495
|
+
if norm > 1e-12:
|
|
496
|
+
v_auth = v_next / norm
|
|
497
|
+
else:
|
|
498
|
+
break
|
|
499
|
+
authority_centrality = {names[i]: float(np.round(v_auth[i] / (np.sum(v_auth) + 1e-12) * 100.0, 2)) for i in range(n_nodes)}
|
|
500
|
+
|
|
501
|
+
# Betweenness Centrality (Bridge / Bottleneck Banks via Floyd-Warshall on transmission friction)
|
|
502
|
+
# Directed transmission: edge u -> v transmits shock with strength norm_kernel[v, u] (norm_kernel.T[u, v]).
|
|
503
|
+
A_trans = norm_kernel.T.copy()
|
|
504
|
+
np.fill_diagonal(A_trans, 0.0)
|
|
505
|
+
D = np.where(A_trans > 0, 1.0 / (A_trans + 1e-4), 1e6)
|
|
506
|
+
np.fill_diagonal(D, 0.0)
|
|
507
|
+
|
|
508
|
+
for k in range(n_nodes):
|
|
509
|
+
D = np.minimum(D, D[:, k:k+1] + D[k:k+1, :])
|
|
510
|
+
|
|
511
|
+
bc = np.zeros(n_nodes)
|
|
512
|
+
for k in range(n_nodes):
|
|
513
|
+
diff = (D[:, k:k+1] + D[k:k+1, :]) - D
|
|
514
|
+
is_on_path = (np.abs(diff) < 1e-5) & (D < 1e5)
|
|
515
|
+
is_on_path[k, :] = False
|
|
516
|
+
is_on_path[:, k] = False
|
|
517
|
+
np.fill_diagonal(is_on_path, False)
|
|
518
|
+
bc[k] = np.sum(is_on_path)
|
|
519
|
+
|
|
520
|
+
denom = (n_nodes - 1) * (n_nodes - 2) if n_nodes > 2 else 1.0
|
|
521
|
+
bc_norm = (bc / denom) * 100.0
|
|
522
|
+
betweenness_centrality = {names[i]: float(np.round(bc_norm[i], 2)) for i in range(n_nodes)}
|
|
523
|
+
|
|
524
|
+
# Flight-to-Safety Index (Safe-haven absorption under systemic lead-lag stress)
|
|
525
|
+
# Vectorized calculation: safe havens exhibit negative correlation with systemic distress
|
|
526
|
+
fts = np.zeros(n_nodes)
|
|
527
|
+
for l_idx, lag in enumerate(lags_to_eval):
|
|
528
|
+
p = innovations[:-lag]
|
|
529
|
+
f = innovations[lag:]
|
|
530
|
+
p_norm = (p - np.mean(p, axis=0)) / (np.std(p, axis=0) + 1e-8)
|
|
531
|
+
f_norm = (f - np.mean(f, axis=0)) / (np.std(f, axis=0) + 1e-8)
|
|
532
|
+
c_mat = (f_norm.T @ p_norm) / float(len(p) - 1)
|
|
533
|
+
np.nan_to_num(c_mat, copy=False, nan=0.0)
|
|
534
|
+
neg_c = np.maximum(-c_mat, 0.0)
|
|
535
|
+
np.fill_diagonal(neg_c, 0.0)
|
|
536
|
+
fts += decay_weights[l_idx] * np.sum(neg_c, axis=1)
|
|
537
|
+
|
|
538
|
+
fts_norm = (fts / (np.sum(fts) + 1e-12)) * 100.0 if np.sum(fts) > 0 else fts
|
|
539
|
+
flight_to_safety = {names[i]: float(np.round(fts_norm[i], 2)) for i in range(n_nodes)}
|
|
540
|
+
|
|
541
|
+
return SystemicRiskReport(
|
|
542
|
+
spillover_table=table,
|
|
543
|
+
total_connectedness_index=tci,
|
|
544
|
+
normalized_tci=normalized_tci,
|
|
545
|
+
net_spillovers=net_dict,
|
|
546
|
+
top_transmitters=sorted_transmitters,
|
|
547
|
+
top_receivers=sorted_receivers,
|
|
548
|
+
asset_weighted=is_weighted,
|
|
549
|
+
p_values_table=p_val_df,
|
|
550
|
+
eigenvector_hub=hub_centrality,
|
|
551
|
+
eigenvector_authority=authority_centrality,
|
|
552
|
+
betweenness_centrality=betweenness_centrality,
|
|
553
|
+
flight_to_safety_index=flight_to_safety,
|
|
554
|
+
market_beta=market_beta_dict,
|
|
555
|
+
market_variance_share=market_var_share,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def fit_regimes(
|
|
559
|
+
self,
|
|
560
|
+
data: pd.DataFrame,
|
|
561
|
+
regimes: Optional[Dict[str, tuple]] = None,
|
|
562
|
+
asset_weights: Optional[Dict[str, float]] = None,
|
|
563
|
+
**fit_kwargs,
|
|
564
|
+
) -> Dict[str, SystemicRiskReport]:
|
|
565
|
+
"""
|
|
566
|
+
Fits Nirvana connectedness across distinct macro/financial regimes.
|
|
567
|
+
Default regimes:
|
|
568
|
+
- Pre-Covid (2017-2019)
|
|
569
|
+
- Covid-19 Crisis (2020-2021)
|
|
570
|
+
- Bond & SCB Shock (2022-2023)
|
|
571
|
+
- Post-Crisis Recovery (2024-2025)
|
|
572
|
+
"""
|
|
573
|
+
if regimes is None:
|
|
574
|
+
regimes = {
|
|
575
|
+
"Pre-Covid (2017-2019)": ("2017-01-01", "2019-12-31"),
|
|
576
|
+
"Covid-19 Crisis (2020-2021)": ("2020-01-01", "2021-12-31"),
|
|
577
|
+
"Bond & SCB Shock (2022-2023)": ("2022-01-01", "2023-12-31"),
|
|
578
|
+
"Post-Crisis Recovery (2024-2025)": ("2024-01-01", "2025-12-31"),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
reports = {}
|
|
582
|
+
is_dt = isinstance(data.index, pd.DatetimeIndex)
|
|
583
|
+
for regime_name, (start_d, end_d) in regimes.items():
|
|
584
|
+
if is_dt:
|
|
585
|
+
start_ts = pd.to_datetime(start_d)
|
|
586
|
+
end_ts = pd.to_datetime(end_d) + pd.Timedelta(days=1) - pd.Timedelta(nanoseconds=1)
|
|
587
|
+
mask = (data.index >= start_ts) & (data.index <= end_ts)
|
|
588
|
+
else:
|
|
589
|
+
mask = (data.index >= str(start_d)) & (data.index <= str(end_d))
|
|
590
|
+
sub_df = data.loc[mask]
|
|
591
|
+
if len(sub_df) < 15:
|
|
592
|
+
continue
|
|
593
|
+
rep = self.fit(sub_df, asset_weights=asset_weights, **fit_kwargs)
|
|
594
|
+
reports[regime_name] = rep
|
|
595
|
+
|
|
596
|
+
return reports
|
|
597
|
+
|
|
598
|
+
def rolling_tci(
|
|
599
|
+
self,
|
|
600
|
+
data: pd.DataFrame,
|
|
601
|
+
window: int = 200,
|
|
602
|
+
step: int = 20,
|
|
603
|
+
asset_weights: Optional[Dict[str, float]] = None,
|
|
604
|
+
**fit_kwargs,
|
|
605
|
+
) -> pd.Series:
|
|
606
|
+
"""
|
|
607
|
+
Calculates time-varying dynamic Total Connectedness Index (TCI) across rolling windows.
|
|
608
|
+
"""
|
|
609
|
+
dates = data.index
|
|
610
|
+
n = len(data)
|
|
611
|
+
tci_series = {}
|
|
612
|
+
|
|
613
|
+
for end_idx in range(window, n + 1, step):
|
|
614
|
+
sub_df = data.iloc[end_idx - window : end_idx].ffill().bfill().fillna(0.0)
|
|
615
|
+
rep = self.fit(sub_df, asset_weights=asset_weights, **fit_kwargs)
|
|
616
|
+
tci_series[dates[end_idx - 1]] = rep.total_connectedness_index
|
|
617
|
+
|
|
618
|
+
return pd.Series(tci_series, name="Nirvana_Rolling_TCI")
|
|
619
|
+
|
|
620
|
+
def rolling_fit(
|
|
621
|
+
self,
|
|
622
|
+
data: pd.DataFrame,
|
|
623
|
+
window: int = 120,
|
|
624
|
+
step: int = 10,
|
|
625
|
+
asset_weights: Optional[Dict[str, float]] = None,
|
|
626
|
+
surge_std_threshold: float = 2.0,
|
|
627
|
+
**fit_kwargs,
|
|
628
|
+
) -> DynamicSystemicRiskReport:
|
|
629
|
+
"""
|
|
630
|
+
Executes rolling time-varying network estimation across windows.
|
|
631
|
+
Tracks dynamic TCI, normalized TCI, per-node Net spillovers,
|
|
632
|
+
and flags structural phase transitions (spikes > surge_std_threshold * rolling std).
|
|
633
|
+
"""
|
|
634
|
+
dates = data.index
|
|
635
|
+
n = len(data)
|
|
636
|
+
tci_dict = {}
|
|
637
|
+
norm_tci_dict = {}
|
|
638
|
+
net_records = []
|
|
639
|
+
date_records = []
|
|
640
|
+
|
|
641
|
+
for end_idx in range(window, n + 1, step):
|
|
642
|
+
sub_df = data.iloc[end_idx - window : end_idx].ffill().bfill().fillna(0.0)
|
|
643
|
+
rep = self.fit(sub_df, asset_weights=asset_weights, **fit_kwargs)
|
|
644
|
+
d = dates[end_idx - 1]
|
|
645
|
+
tci_dict[d] = rep.total_connectedness_index
|
|
646
|
+
norm_tci_dict[d] = rep.normalized_tci
|
|
647
|
+
net_records.append(rep.net_spillovers)
|
|
648
|
+
date_records.append(d)
|
|
649
|
+
|
|
650
|
+
tci_series = pd.Series(tci_dict, name="Rolling_TCI")
|
|
651
|
+
norm_tci_series = pd.Series(norm_tci_dict, name="Rolling_Normalized_TCI")
|
|
652
|
+
rolling_net_df = pd.DataFrame(net_records, index=date_records)
|
|
653
|
+
|
|
654
|
+
# Detect Phase Transitions (sudden surges exceeding moving baseline)
|
|
655
|
+
phase_transitions = []
|
|
656
|
+
if len(tci_series) >= 5:
|
|
657
|
+
rolling_mean = tci_series.rolling(min(10, len(tci_series)), min_periods=3).mean()
|
|
658
|
+
rolling_std = tci_series.rolling(min(10, len(tci_series)), min_periods=3).std().fillna(1.0)
|
|
659
|
+
z_surges = (tci_series - rolling_mean) / (rolling_std + 1e-6)
|
|
660
|
+
surge_mask = z_surges >= surge_std_threshold
|
|
661
|
+
phase_transitions = list(tci_series.index[surge_mask])
|
|
662
|
+
|
|
663
|
+
max_tci_val = float(tci_series.max()) if len(tci_series) > 0 else 0.0
|
|
664
|
+
max_tci_dt = tci_series.idxmax() if len(tci_series) > 0 else None
|
|
665
|
+
mean_tci_val = float(tci_series.mean()) if len(tci_series) > 0 else 0.0
|
|
666
|
+
|
|
667
|
+
return DynamicSystemicRiskReport(
|
|
668
|
+
tci_history=tci_series,
|
|
669
|
+
normalized_tci_history=norm_tci_series,
|
|
670
|
+
rolling_net_spillovers=rolling_net_df,
|
|
671
|
+
phase_transition_dates=phase_transitions,
|
|
672
|
+
mean_tci=mean_tci_val,
|
|
673
|
+
max_tci=max_tci_val,
|
|
674
|
+
max_tci_date=max_tci_dt,
|
|
675
|
+
)
|