# Correlation Regime

> Correlation-regime detection and crisis attribution — edge-density regime states with hysteresis, causal (no look-ahead) smoothing, regime-aware exposure context, first-mover crisis attribution with honest NAME / MACRO / AMBIGUOUS / ABSTAIN verdicts, and a correlation-rewiring leaderboard that catches slow bleed-outs

- Skill: `hkuds/correlation-regime` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hkuds/correlation-regime`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hkuds/correlation-regime/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: HKUDS (https://skillmd.com/u/hkuds)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/hkuds/correlation-regime

---


# Correlation-Regime Detection and Crisis Attribution

## Overview

The `correlation-analysis` skill answers *"how correlated are these assets?"* — a snapshot.
This skill answers the temporal questions a snapshot cannot:

1. **When** did the market fuse into one highly-correlated bloc, and when did it release?
   (Mode 1 — regime detection)
2. **What** does a fused regime mean for position sizing? (Mode 2 — risk context)
3. **Who** moved first when a crisis broke — is there a nameable trigger asset?
   (Mode 3 — first-mover attribution)
4. **Who** quietly rewired their relationship to the rest of the market, even without a
   violent move? (Mode 4 — rewiring leaderboard)

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.

---

## Mode 1: Correlation-Regime Detection (Edge Density + Hysteresis)

**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.

### Workflow

```
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.

```python
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,
    )
```

### Threshold Selection Guide

| 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. |

### Look-Ahead Warning

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.

---

## Mode 2: Regime-Aware Risk Context (De-Grossing)

**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:

- **De-gross, don't liquidate.** In the author's internal replays, halving gross exposure
  during fused regimes improved risk-adjusted outcomes versus buy-and-hold, while
  going fully to cash destroyed them — regime onset lags the price top, so full
  liquidation locks in the worst prints.
- **State the anti-claim in the same breath**: fused-regime detection cannot time
  tops. Tested against a plain trailing price stop, regime-based exits lost; the
  ~double-digit "give-up" between a crash's start and any regime confirmation is a
  property of crash tapes themselves.

```python
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",
    )
```

---

## Mode 3: Crisis First-Mover Attribution

**Use case**: A crisis episode has opened (Mode 1 fired, or an exogenous alert
arrived). Answer "who broke first?" without ever guessing.

### The Honesty Protocol

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`.

### Workflow

```
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
```

```python
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,
    }
```

### Calibration Discipline

- **The alarm bar must be chosen walk-forward** on historical events whose culprit
  labels come from the public record (post-mortems, filings) — never from the
  system's own output. "The culprit is whoever we named" is circular and voids any
  zero-false-names claim.
- **Baseline from detected calm, not from a blind trailing window**, in production:
  source the median/MAD baseline from Mode 1's defused regimes. Trailing windows
  that overlap a previous crisis produce contaminated baselines and inflated bars —
  this was a real failure class in validation (post-crisis "calm" that wasn't).
- Set the bar high and let the protocol abstain. A bar low enough to catch every
  event mislabels non-events; the validated posture is "when it names, it has been
  right; when it cannot know, it says so."

---

## Mode 4: Correlation-Rewiring Leaderboard

**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).

```python
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")
```

---

## Dependencies

```bash
pip install pandas numpy
```

(matplotlib only if you plot the regime timeline.)

---

## Output Format

```markdown
## 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 |
```

---

## Notes

1. **Causality is the whole game.** No centered smoothing, no same-bar baselines,
   no thresholds tuned on the episode being scored. Every historical claim should
   survive the question "could this have been computed at that bar?"
2. **Not a trading signal.** Regime exits were tested against a plain price stop
   and lost. Present Modes 1–4 as risk context and attribution, never as buy/sell.
3. **Thresholds do not port across correlation estimators.** An `edge_threshold`
   calibrated on raw-return correlations is wrong for partial/residual
   correlations (their edges are systematically smaller). Recalibrate per estimator.
4. **Event-window scans are left-censored.** Markets often re-fuse faster than a
   short scan can see; prefer one continuous tape over stitched event windows when
   validating regime counts.
5. **Survivor bias truncates attribution.** Delisted assets vanish from vendor
   tapes precisely when they matter most (the dying asset is the story). Pull raw
   histories that include delisted symbols before validating Mode 3/4 claims.
6. **Scope of the attribution claim.** The alarm mechanically names only fast,
   violent collapses; slow bleeds surface in the watch tier and Mode 4; macro
   shocks resolve to MACRO by construction. Quote the calm-period false-alarm rate
   (per day) as the honesty metric.
7. **Labels are human, walk-forward.** Culprit labels for calibration events come
   from the public record before scoring runs, and future re-calibration uses only
   events fully adjudicated in the past.
8. **MAD can be zero** in dead markets (stale prints); guard the denominator (the
   snippet maps 0 → NaN) rather than letting z-scores explode.

---

## References

- Streaming reference implementation (JVM): the corrcalc-graphs pipeline —
  https://github.com/tarvyn-analytics/corrcalc-graphs-pipeline (Apache-2.0). Its
  README's "The math — from bars to a fire" section derives the density/hysteresis
  regime machinery, and "Validation — a pinned historical-event regression" pins an
  eight-event crypto replay regression (17 symbols, 1-minute bars) reproduced by
  its CI. The finer-grained numbers quoted in this skill (regime-cycle count,
  false-alarm rate, attribution results) are the author's unpublished internal
  replays on that pipeline — they are not part of the public regression and the
  public repo ships no crisis-naming system.
- Maven Central artifacts for JVM users:
  `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).
- For static pair analysis, cointegration, and pair-trading signals, see the
  `correlation-analysis` skill; for volatility-based regime work, see `volatility`.

