"""Chronological network observations and frozen retrospective comparison helpers.""" from collections import Counter import numpy as np import pandas as pd from .research_network import partition_disagreement def graph_observation(graph, state, sectors, previous=None): """Measure topology and concentration; static sectors remain explicitly conditional.""" partition = state['partition'] degrees = state['degree_centrality'] total = sum(degrees.values()) weights = {s: d / total for s, d in degrees.items()} if total else {} known_weight = cross_weight = 0.0 for a, b, edge in graph.edges(data=True): if a in sectors and b in sectors: known_weight += edge['weight'] if sectors[a] != sectors[b]: cross_weight += edge['weight'] communities = Counter(partition.values()) result = { 'nodes': len(graph), 'edges': graph.number_of_edges(), 'communities': len(communities), 'isolates': sum(graph.degree(s) == 0 for s in graph), 'largest_community_share': max(communities.values(), default=0) / len(graph) if len(graph) else np.nan, 'cross_sector_weight_share': cross_weight / known_weight if known_weight else np.nan, 'community_change': np.nan, 'degree_mass_reallocation': np.nan, } if previous is not None and weights and previous['degree_weights']: if set(partition) != set(previous['partition']): raise ValueError('Changing universe requires an explicit common-universe comparison') result['community_change'] = partition_disagreement(previous['partition'], partition) result['degree_mass_reallocation'] = 0.5 * sum(abs(weights.get(s, 0) - previous['degree_weights'].get(s, 0)) for s in partition) return result, {'partition': partition, 'degree_weights': weights} def fit_frozen_alarm(features, train_end, quantile=0.95, minimum_training=60): """Fit feature normalization and alarm cut on training dates only.""" if not features.index.is_unique or not features.index.is_monotonic_increasing: raise ValueError('Feature dates must be unique and ordered') training = features.loc[:pd.Timestamp(train_end)].replace([np.inf, -np.inf], np.nan).dropna() if len(training) < minimum_training: return {'state': 'insufficient_training', 'training_rows': len(training)}, pd.Series(np.nan, index=features.index) mean = training.mean() scale = training.std(ddof=0).replace(0, 1) training_score = ((training - mean) / scale).max(axis=1) cut = float(training_score.quantile(quantile)) valid = features.replace([np.inf, -np.inf], np.nan).notna().all(axis=1) score = ((features - mean) / scale).max(axis=1).where(valid) model = {'state': 'fitted', 'train_end': str(pd.Timestamp(train_end).date()), 'training_rows': len(training), 'mean': mean.to_dict(), 'scale': scale.to_dict(), 'cut': cut, 'quantile': quantile, 'columns': list(features)} return model, score def forward_stress(index_returns, horizon=5, drop_cut=-0.02): """Price proxy: any index day <= cut in the next horizon sessions; no same-day label.""" if index_returns.isna().any().any(): raise ValueError('Index calendar must be complete; no forward filling') daily = index_returns.min(axis=1).le(drop_cut) target = pd.Series(pd.NA, index=index_returns.index, dtype='boolean') for i in range(len(index_returns) - horizon): target.iloc[i] = bool(daily.iloc[i+1:i+horizon+1].any()) onset = daily & ~daily.shift(1, fill_value=False) return target, onset def cooldown_alerts(raw, sessions=5): alerts = pd.Series(False, index=raw.index) next_eligible = 0 for i, value in enumerate(raw.fillna(False)): if value and i >= next_eligible: alerts.iloc[i] = True next_eligible = i + sessions return alerts def alarm_metrics(alerts, target, onsets, horizon=5): """Score fixed alerts and fully observable event lookback windows.""" common = alerts.index[alerts.notna()].intersection(target.index[target.notna()]) alert = alerts.reindex(common).astype(bool) labels = target.loc[common].astype(bool) matched = int((alert & labels).sum()) evaluable_onsets, detected = [], [] calendar = target.index.intersection(alerts.index) for i in range(horizon, len(calendar)): date = calendar[i] if bool(onsets.get(date, False)): lookback = calendar[i-horizon:i] if not alerts.reindex(lookback).notna().all() or not target.reindex(lookback).notna().all(): continue evaluable_onsets.append(str(date.date())) if alerts.reindex(lookback).astype(bool).any(): detected.append(str(date.date())) count = int(alert.sum()) return {'scorable_sessions': len(common), 'alerts': count, 'matched_alerts': matched, 'unmatched_alerts': count-matched, 'precision': matched/count if count else None, 'target_base_rate': float(labels.mean()) if len(labels) else None, 'event_onsets': len(evaluable_onsets), 'onsets_with_prior_alert': len(detected), 'onsets_without_prior_alert': len(evaluable_onsets)-len(detected), 'onset_dates': evaluable_onsets, 'detected_onset_dates': detected, 'scope': 'Fixed price-stress proxy; not independently adjudicated anomaly truth'} def paired_precision_interval(alerts, baseline, target, block=10, repeats=1000, seed=20261105): common = target.index.intersection(alerts.index).intersection(baseline.index) valid = (target.loc[common].notna() & alerts.loc[common].notna() & baseline.loc[common].notna()).to_numpy() y = target.loc[common].fillna(False).astype(bool).to_numpy() a = (alerts.loc[common].fillna(False).astype(bool).to_numpy() & valid) b = (baseline.loc[common].fillna(False).astype(bool).to_numpy() & valid) if not len(y): return {'usable_resamples': 0, 'interval_95': None} rng = np.random.default_rng(seed) differences = [] for _ in range(repeats): starts = rng.integers(0, len(y), size=(len(y)+block-1)//block) sampled = np.concatenate([(np.arange(block)+start) % len(y) for start in starts])[:len(y)] if a[sampled].sum() and b[sampled].sum(): differences.append(float(y[sampled][a[sampled]].mean()-y[sampled][b[sampled]].mean())) return {'usable_resamples': len(differences), 'block_sessions': block, 'interval_95': np.quantile(differences, [0.025, 0.975]).tolist() if differences else None}