Correlation-regime detection and crisis attribution — edge-density regime states with hysteresis, causal (no look-ahead) smoothing, regime-aware exposure contex
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-correlation-regime-b40a99016798 ,按照其中的说明把「correlation-regime」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
The correlation-analysis skill answers "how correlated are these assets?" — a snapshot.
This skill answers the temporal questions a snapshot cannot:
The methodology comes from an open-source streaming pipeline (see References) whose public repository pins the regime machinery's math and an eight-event historical replay regression (COVID, May-2021, China ban, Nov-2021 top, LUNA, FTX, SVB, yen-carry — 17 crypto symbols, 1-minute bars) that its CI reproduces bit-for-bit. The finer-grained numbers quoted in this skill — 13 fused/defused regime cycles on the continuous 2020–2024 tape at a ~0.008/day calm false-alarm rate, and zero wrong culprit names across 10 labeled crises (2 held out-of-sample), including naming FTT roughly two days before the November 2022 collapse — are the author's unpublished internal replays on that same pipeline and are not independently verifiable. All of it is historical replay, never live results, and the method is market-agnostic even though the validation tape is crypto.
What this skill is NOT: a trade-timing signal. The same validation program tested regime-based exits head-to-head against a plain price stop and lost — correlation regimes cannot time tops, and the give-up cost of selling into a crash is a property of the tape, not of any signal. Use these modes for risk context, monitoring, and post-hoc attribution; never present them as buy/sell triggers.
Use case: Maintain a live, causal answer to "is the market currently one bloc?" Diversification quietly disappears when pairwise correlations fuse; a regime state machine turns that into an explicit, monitorable state with few false alarms.
1. Compute rolling-window pairwise correlations of returns
2. Reduce each correlation matrix to one number: edge density
= fraction of asset pairs with |ρ| ≥ edge_threshold
3. Smooth the density series with a TRAILING window (causal — see warning below)
4. Run a hysteresis (Schmitt-trigger) state machine over the smoothed series:
enter FUSED when density ≥ enter_threshold, exit only when ≤ exit_threshold
5. Emit regime state + transition timestamps for monitoring / reporting
Two thresholds with a dead band between them are the entire trick: a single threshold chatters (fires dozens of times as density oscillates around it), while hysteresis yields a handful of clean regime cycles per market cycle.
import numpy as np
import pandas as pd
def compute_edge_density(
returns: pd.DataFrame,
corr_window: int = 60,
edge_threshold: float = 0.5,
) -> pd.Series:
"""Reduce rolling correlation matrices to an edge-density series.
Edge density is the fraction of distinct asset pairs whose rolling
|correlation| clears ``edge_threshold`` — a scalar "how fused is the
market" gauge in [0, 1].
Args:
returns: Multi-asset return matrix, columns are symbols
corr_window: Rolling window length (bars) for pairwise correlation
edge_threshold: |ρ| level at which a pair counts as an "edge"
Returns:
Edge-density series aligned to ``returns.index`` (NaN during warmup)
"""
n_assets = returns.shape[1]
n_pairs = n_assets * (n_assets - 1) // 2
upper_mask = np.triu(np.ones((n_assets, n_assets), dtype=bool), k=1)
density = pd.Series(np.nan, index=returns.index)
for i in range(corr_window, len(returns) + 1):
corr = returns.iloc[i - corr_window:i].corr().abs().to_numpy()
density.iloc[i - 1] = float((corr[upper_mask] >= edge_threshold).sum()) / n_pairs
return density
def detect_regimes(
density: pd.Series,
smooth_window: int = 5,
enter_threshold: float = 0.65,
exit_threshold: float = 0.45,
) -> pd.DataFrame:
"""Hysteresis (Schmitt-trigger) regime state machine on smoothed density.
The market is FUSED once smoothed density reaches ``enter_threshold`` and
stays FUSED until it falls back to ``exit_threshold``. The dead band
between the two thresholds is what suppresses chatter.
Args:
density: Edge-density series from :func:`compute_edge_density`
smooth_window: Trailing smoothing window (causal; never centered)
enter_threshold: Density level that opens a FUSED regime
exit_threshold: Density level that closes it (must be < enter_threshold)
Returns:
DataFrame with columns ``density``, ``smoothed``, ``fused`` (0/1)
"""
if exit_threshold >= enter_threshold:
raise ValueError("exit_threshold must be below enter_threshold")
# Trailing mean = causal. A centered window here silently reads the future.
smoothed = density.rolling(smooth_window, min_periods=1).mean()
fused = False
states = np.zeros(len(smoothed), dtype=int)
for i, value in enumerate(smoothed.to_numpy()):
if np.isnan(value):
states[i] = int(fused)
continue
if not fused and value >= enter_threshold:
fused = True
elif fused and value <= exit_threshold:
fused = False
states[i] = int(fused)
return pd.DataFrame(
{"density": density, "smoothed": smoothed, "fused": states},
index=density.index,
)
| Parameter | Guidance |
|---|---|
edge_threshold | 0.5 works for raw daily/intraday return correlations in crypto and equities. It does not transfer to partial/residual correlations, whose edges are systematically smaller — recalibrate per estimator. |
enter_threshold / exit_threshold | Anchor to the calm-period density distribution: enter near a high calm percentile (e.g. 90–95th), exit near the calm median. Keep a wide dead band; a narrow one reintroduces chatter. |
corr_window | Shorter reacts faster but is noisier. 30–90 bars is a reasonable band for daily data; on intraday bars use hundreds. |
smooth_window | Just enough to kill single-bar spikes. Oversmoothing delays regime onsets. |
The single most common silent bug in regime detection is centered smoothing
(e.g. rolling(..., center=True) or any symmetric filter). It leaks up to half a
window of future data into each point, making historical regime onsets appear
earlier and cleaner than anything achievable live. Every smoothing step in a regime
pipeline must be trailing-only, and any claimed onset lead time should be re-checked
after replacing each filter with its causal version — in the author's internal
replays that re-check moved onsets later by 1–2 days and the detector still passed,
which is the honest number to quote.
Use case: Translate the FUSED state into portfolio-risk language for a report or a monitoring dashboard.
A fused regime means cross-asset diversification is effectively gone: the portfolio has collapsed into a single position with leverage. The defensible, replay-tested framing for what to do about it:
def regime_exposure_context(
regimes: pd.DataFrame,
base_gross: float = 1.0,
fused_gross: float = 0.5,
) -> pd.Series:
"""Descriptive gross-exposure context per bar (NOT a trade signal).
Args:
regimes: Output of :func:`detect_regimes`
base_gross: Reference gross exposure during defused (calm) regimes
fused_gross: Reference gross exposure during fused regimes
Returns:
Per-bar reference gross-exposure series for risk reporting
"""
return pd.Series(
np.where(regimes["fused"] == 1, fused_gross, base_gross),
index=regimes.index,
name="reference_gross",
)
Use case: A crisis episode has opened (Mode 1 fired, or an exogenous alert arrived). Answer "who broke first?" without ever guessing.
The protocol's defining property is that it prefers silence to a wrong name. Every episode resolves to exactly one of four verdicts:
| Verdict | Meaning | Condition |
|---|---|---|
NAME | One asset is the likely trigger | Exactly one asset crossed the alarm bar with a clear lead over the pack |
MACRO | Market-wide shock, no culprit | The pack crossed together within a tight window |
AMBIGUOUS | Multiple candidates, refuses to pick | Several assets crossed close together, ahead of the rest |
ABSTAIN | Nothing to say | No asset crossed the alarm bar |
In the author's internal 10-crisis replays (unpublished — see Overview) this
protocol never emitted a wrong NAME — because the alarm bar is set very high
and everything below it downgrades to AMBIGUOUS, MACRO, or ABSTAIN.
1. For each asset, compute a short-horizon move-intensity series
(rolling sum of |returns|)
2. Score it as a robust z-score against the asset's OWN calm baseline
(median/MAD, baseline strictly prior to the bar being scored)
3. Watch tier (low z): informational watchlist only — never allowed to accuse
4. Alarm tier (very high z): record each asset's first crossing time
5. Apply the verdict rules: lead-gap ⇒ NAME, pack-together ⇒ MACRO,
near-tie leaders ⇒ AMBIGUOUS, no crossings ⇒ ABSTAIN
def first_mover_attribution(
returns: pd.DataFrame,
baseline_window: int = 120,
move_window: int = 3,
alarm_z: float = 8.0,
watch_z: float = 3.0,
lead_gap: int = 2,
macro_span: int = 1,
macro_fraction: float = 0.6,
) -> dict:
"""First-mover crisis attribution with abstention.
Each asset's short-horizon move intensity is scored as a robust z-score
against its own trailing calm baseline (median / MAD, shifted so the move
being scored never contaminates its own baseline). Verdicts follow the
honesty protocol: NAME only on a clear solo lead, otherwise MACRO /
AMBIGUOUS / ABSTAIN.
Args:
returns: Multi-asset return matrix, columns are symbols
baseline_window: Trailing window (bars) for the per-asset calm baseline
move_window: Short horizon (bars) of the move-intensity sum
alarm_z: Robust z at which an asset counts as "in violent collapse"
watch_z: Informational watch-tier level (never used for naming)
lead_gap: Minimum lead (bars) of the first crosser over the second
required to NAME it
macro_span: If the pack crosses within this many bars of the first
crossing, the episode is MACRO
macro_fraction: Fraction of assets that must cross to call MACRO
Returns:
Dict with ``verdict`` (NAME / MACRO / AMBIGUOUS / ABSTAIN),
``named`` (symbol or None), ``candidates``, ``crossings``
(symbol → first alarm timestamp), ``watchlist``, and ``z`` (the
full z-score DataFrame for inspection)
"""
intensity = returns.abs().rolling(move_window).sum()
# Proper rolling MAD: each window's deviations from its OWN median.
# (Nesting two full-length rolling medians instead stacks their warmups —
# the score would silently stay NaN for 2x baseline_window bars.)
# shift(1) keeps the bar being scored out of its own baseline.
med = intensity.rolling(baseline_window).median().shift(1)
mad = intensity.rolling(baseline_window).apply(
lambda window: np.median(np.abs(window - np.median(window))), raw=True
).shift(1)
z = (intensity - med) / (1.4826 * mad.replace(0.0, np.nan))
crossings: dict[str, pd.Timestamp] = {}
for symbol in z.columns:
hits = z.index[z[symbol] >= alarm_z]
if len(hits) > 0:
crossings[symbol] = hits[0]
watch_hits = (z >= watch_z).any()
watchlist = sorted(watch_hits.index[watch_hits])
if not crossings:
return {
"verdict": "ABSTAIN", "named": None, "candidates": [],
"crossings": {}, "watchlist": watchlist, "z": z,
}
ordered = sorted(crossings.items(), key=lambda item: item[1])
first_symbol, first_time = ordered[0]
positions = {ts: i for i, ts in enumerate(z.index)}
first_pos = positions[first_time]
pack_size = sum(
1 for _, ts in ordered if positions[ts] - first_pos <= macro_span
)
if pack_size >= max(2, int(np.ceil(macro_fraction * returns.shape[1]))):
return {
"verdict": "MACRO", "named": None,
"candidates": [s for s, _ in ordered],
"crossings": crossings, "watchlist": watchlist, "z": z,
}
leaders = [
symbol for symbol, ts in ordered if positions[ts] - first_pos < lead_gap
]
if len(leaders) == 1 and (
len(ordered) == 1 or positions[ordered[1][1]] - first_pos >= lead_gap
):
return {
"verdict": "NAME", "named": first_symbol, "candidates": leaders,
"crossings": crossings, "watchlist": watchlist, "z": z,
}
return {
"verdict": "AMBIGUOUS", "named": None, "candidates": leaders,
"crossings": crossings, "watchlist": watchlist, "z": z,
}
Use case: Catch the slow bleed-outs. Some collapses (weeks-long, grinding) never move violently enough to trip Mode 3's alarm — but the dying asset's correlation profile to the rest of the market rewires dramatically. Rank assets by how much their correlation row changed versus calm.
Modes 3 and 4 cover each other's blind spots — fast violent collapses trip the alarm, slow bleeds top the rewiring board — so report them together, never alone.
Choose the event window with care: during a full-market fusion episode every asset rewires by construction and the leaderboard degenerates into "everyone". The mode is most informative on the run-up window before a regime onset, or on a suspect stretch that never fused at all (the classic slow-bleed shape).
def rewiring_leaderboard(
returns: pd.DataFrame,
calm_mask: pd.Series,
event_mask: pd.Series,
min_bars: int = 40,
) -> pd.DataFrame:
"""Per-asset correlation-rewiring score: event vs calm baseline.
Score = row mean of |Δρ| between the event-window correlation matrix and
the calm-baseline correlation matrix. High score = the asset's
relationship to the rest of the market changed the most.
Args:
returns: Multi-asset return matrix, columns are symbols
calm_mask: Boolean series marking calm-baseline bars
(e.g. ``regimes["fused"] == 0`` from Mode 1)
event_mask: Boolean series marking the episode under examination
min_bars: Minimum bars required in each window
Returns:
DataFrame indexed by symbol with ``rewiring_score``, sorted descending
"""
calm = returns.loc[calm_mask.reindex(returns.index, fill_value=False)]
event = returns.loc[event_mask.reindex(returns.index, fill_value=False)]
if len(calm) < min_bars or len(event) < min_bars:
raise ValueError(
f"need >= {min_bars} bars in each window "
f"(calm={len(calm)}, event={len(event)})"
)
delta = (event.corr() - calm.corr()).abs()
matrix = delta.to_numpy(copy=True) # copy: DataFrame internals may be read-only
np.fill_diagonal(matrix, np.nan)
scores = pd.Series(np.nanmean(matrix, axis=1), index=delta.index)
return scores.sort_values(ascending=False).to_frame("rewiring_score")
pip install pandas numpy
(matplotlib only if you plot the regime timeline.)
## Correlation-Regime and Attribution Report
### Universe: [N assets] ([Start Date] - [End Date], [bar size])
#### Regime Summary (Mode 1)
| Metric | Value |
|------|-----|
| Regime cycles (fused/defused) | 5 |
| Time in fused regime | 18% |
| Current state | DEFUSED (density 0.31, smoothed 0.34) |
| Last transition | 2024-08-05 fused → 2024-08-19 defused |
#### Risk Context (Mode 2)
| Regime | Reference gross | Rationale |
|------|-----|------|
| Defused | 1.00 | Diversification intact |
| Fused | 0.50 | Portfolio ≈ one levered position; de-gross, don't liquidate |
> Not a trade signal: regime detection cannot time tops (validated).
#### Episode Attribution (Mode 3)
| Field | Value |
|------|-----|
| Verdict | NAME |
| Named asset | [SYMBOL] (alarm z = 9.4, lead 2 bars over the pack) |
| Watchlist (informational) | [SYM1], [SYM2] |
#### Rewiring Leaderboard (Mode 4)
| Rank | Asset | Rewiring score |
|------|------|------|
| 1 | [SYMBOL] | 0.42 |
| 2 | [SYMBOL] | 0.19 |
edge_threshold
calibrated on raw-return correlations is wrong for partial/residual
correlations (their edges are systematically smaller). Recalibrate per estimator.io.github.tarvyn-analytics.corrcalc:corrcalc-lib-core (streaming correlation
engine), io.github.tarvyn-analytics.graphs:graphs-algos-lib (graph analyses on
correlation matrices), io.github.tarvyn-analytics.corrcalc.graphs:corrcalc-graphs-pipeline
(the replay pipeline).correlation-analysis skill; for volatility-based regime work, see volatility.