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/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
+ )