# Crypto TS-Trend Paper-Track Sleeve — Design (rev. 2)

**Date:** 2026-06-21
**Status:** Design (pending approval) — rev. 2 after critical review against fxhnt source
**Repo:** fxhnt (Python agentic fund)

## Motivation

The "speed up profits" exploration (2026-06-20/21) falsified the high-frequency direction twice (crypto
intraday cross-sectional momentum net SR −21→−0.67; cascade-reversion losers keep losing = continuation;
see `pearl_crypto_intraday_no_edge_faster_is_feetrap`). The crypto edges are daily-or-slower and
flow-based. The one validated, uncorrelated, **not-yet-deployed** return engine is crypto time-series
trend (TSMOM, long/flat):

- Re-verified 2026-06-21 by running the actual `TrendRunner` on the real survivorship-free `crypto_pit`
  panel (180 coins, 2472 days): `long_short=False` → annualized Sharpe **+1.25** (memory ~1.23 ✓), in
  **8.2 s** wall-clock (full recompute — cheap enough to run nightly).
- corr ~0 to the funding edge → adds return at diversified risk. It is a **directional crypto-beta-timing**
  engine (carries crypto beta when "on"), NOT market-neutral alpha. Intended: the book's directional sleeve.

Missing piece = the **live paper-forward track** so it accumulates a real forward record and graduates
through the gate like `xsfunding` / `unlock`.

## Goal

Add `crypto_tstrend` as a live paper-track sleeve that books daily paper NAV from inception via the
nightly Dagster combined-book job and surfaces in the cockpit with `gate=WAIT` (60 days, Sharpe ≥ 0.5)
until forward-proven. No capital until the gate clears.

## Key design decisions (after critical review)

1. **Recomputable-series pattern, NOT the xsfunding snapshot/position-carry pattern.** Trend's return is
   just the realized price return of held positions, which `TrendRunner` already computes from a price
   panel. So the sleeve mirrors `CombinedBookStrategy` (forward_tracker.py:123): each night it recomputes
   the full series and returns rows; the tracker books only days strictly after `last_date`. This
   **reuses `TrendRunner.run()` unchanged** → zero live/backtest drift, no bespoke `advance` math.
2. **Reuse the existing crypto_pit panel — no new data adapter.** `crypto_bars` (assets.py:43) already
   REFRESHes `crypto_pit` nightly (survivorship-free top-120-by-volume ∪ dead) and `CryptoPitPanelSource`
   (crypto_pit_panel.py) reads it: `{sym: {day: (close, qvol, funding)}}`. The sleeve reads close-only
   from it. The earlier scorecard worry ("trend needs a rolling-price-history live source, more plumbing")
   is wrong — the source already exists.
3. **Static (configurable) vol-target, NOT adaptive ISV re-sizing.** Own ML/RL research: continuous
   trust-scaling on a single noisy edge whipsaws (Bongaerts FAJ 2020) and re-introduces the RL
   train→eval-collapse pathology. The legitimate adaptive-control home is the book-of-books continuous
   edge-decay layer — a SEPARATE later spec. This sleeve gets a SIMPLE BINARY decay kill only.
4. **`target_vol` semantics made precise.** `TrendRunner` sizes **per-asset** (`p = sig·target_vol_daily/rv`)
   then caps GROSS at 1.0 → book vol is emergent (~86%), NOT equal to `target_vol`. The configurable
   book-vol knob is a SEPARATE **causal portfolio vol-target overlay** applied on top (this is the sizer
   whose −15%/−23%/−30% maxDD at 10/15/20% the 2026-06-21 probe measured). TrendRunner's internal
   per-asset `target_vol` stays at its validated default; the overlay normalizes book vol to the target.

## Architecture

Reuses generic forward-tracker / registry / Dagster-schedule / ingest / cockpit infra. New code = one
domain helper (decay detector) + one strategy + wiring.

### 1. Edge-decay detector (NEW, reusable)

**File:** `src/fxhnt/domain/edge_decay.py` — **Class:** `PageHinkleyDecay`

Canonical Page-Hinkley **mean-shift** detector with binary kill + hysteresis (per
`pearl_edge_decay_detection_is_a_missing_abstraction_layer`; shift-form, not level — level froze in the
RL context per `pearl_edge_decay_detector_phase1_validated_train_also_decays`).

```python
@dataclass
class PageHinkleyDecay:
    delta: float = 0.0        # deadband: larger tolerates more downward deviation (LESS sensitive)
    lam: float = 0.20         # cumulative-deviation threshold to KILL (calibrated to ~0.5% daily std)
    reenter_days: int = 10    # consecutive non-negative SHADOW days to re-arm (hysteresis)
    # mutable state (all JSON scalars): mean, n, m_t, ph_min, killed, recover
    def update(self, ret: float) -> bool:
        """Feed one realized SHADOW daily return; return True if ALIVE, False if KILLED.
        ALIVE→KILLED when m_t - min(m_t) > lam (sustained downward mean shift).
        KILLED→ALIVE only after `reenter_days` consecutive ret >= 0 (then reset PH accumulators)."""
    def to_dict(self) -> dict: ...
    @classmethod
    def from_dict(cls, d: dict) -> "PageHinkleyDecay": ...
```

Pre-registered: `lam=0.20, delta=0.0, reenter_days=10`. `lam=0.20` is calibrated to the strategy's
~0.5%/day return std (the PH statistic is a near-driftless random walk with ~0.5% steps; `lam=0.05`
≈ only 10 adverse steps → false-kills a healthy edge ~71%/yr, `lam=0.20` ≈ 38 cumulative adverse
steps → ~92% healthy-edge survival/yr while still catching genuine sustained decay). Falsification:
must NOT kill on a clean stationary-positive series; must KILL on a synthetic mean-flip series.

### 2. Live-booking strategy (NEW)

**File:** `src/fxhnt/application/ts_trend_strategy.py` — **Class:** `TsTrendForward` (implements `ForwardStrategy`)

```python
class TsTrendForward:
    def __init__(self, load_panel: Callable[[], dict[str, dict[int, float]]], *,
                 lookbacks=(20, 60, 120), vol_window=30, internal_target_vol=0.10,
                 gross_cap=1.0, cost_bps=10.0, periods_per_year=365,
                 book_target_vol=0.15, lev_cap=3.0, vol_buf_window=30,
                 decay_lam=0.20, decay_reenter_days=10) -> None: ...

    def advance(self, last_date, extra) -> tuple[list[tuple[str, float]], dict]:
        """Recomputable series + causal overlay + binary decay kill.

        1. panel = load_panel()  # {sym:{day:close}} (close extracted from CryptoPitPanelSource)
        2. res = TrendRunner(panel, lookbacks, vol_window, internal_target_vol, gross_cap,
                             long_short=False, cost_bps, periods_per_year).run()
           → raw_rets[], dates[]  (dates = epoch-day each return is REALIZED on; ~8s)
        3. INCEPTION (extra empty / last_date None):
             - warm vol_buf = trailing `vol_buf_window` raw_rets from history (overlay warm-start ONLY)
             - PH = fresh ALIVE  (history decay must NOT pre-kill the forward track)
             - return ALL rows [(iso(d), raw) ...] so tracker freezes inception at latest date
               (tracker books NONE on inception); persist extra {ph, vol_buf}
        4. SUBSEQUENT — for each d in dates with iso(d) > last_date, in chronological order:
             tv  = std(vol_buf) (daily); lev = clip(book_target_vol/sqrt(ppy) / max(tv,eps), 0, lev_cap)
             alive = ph.update(raw_d)            # PH consumes SHADOW raw return (continuous)
             booked = lev*raw_d if alive else 0.0
             rows.append((iso(d), booked)); vol_buf.append(raw_d) [trim]
           persist extra {ph: ph.to_dict(), vol_buf}; return ONLY new rows
        """
```

**Shadow semantics** (mirror the tracker's `kill_dd` shadow): the overlay buffer and PH always consume
the *raw* (unkilled) return, so the kill can detect recovery and re-arm even while booked exposure is 0.
**Causality:** `lev` uses the trailing buffer of returns *before* `d`; `raw_d` is realized over `[d, nxt]`
by TrendRunner. No lookahead. **Path-stability:** PH + vol_buf are carried in `extra` and advanced only
over NEW days, so panel top-N churn / historical revision cannot retro-change a booked kill decision (the
tracker also books only strictly-new days, so past NAV is sticky regardless).

### 3. Registry entry

**File:** `src/fxhnt/registry.py`
```python
"crypto_tstrend": {
    "display_name": "Crypto TS-momentum (long/flat daily trend, 20/60/120d)",
    "sleeve": "crypto-trend", "venue": "binance-perp",
    "state_file": "crypto_tstrend_state",
    "gate_spec": {"min_days": 60, "min_total_return": 0.0, "min_sharpe": 0.5},
},
```

### 4. Dagster wiring

**File:** `src/fxhnt/adapters/orchestration/assets.py`
```python
@asset(deps=[crypto_bars])     # MUST dep on crypto_bars so the panel is fresh before we read it
def crypto_tstrend_nav(context: AssetExecutionContext) -> dict:
    from fxhnt.adapters.data.crypto_pit_panel import CryptoPitPanelSource
    from fxhnt.application.ts_trend_strategy import TsTrendForward
    s = get_settings()
    def load_panel():
        raw = CryptoPitPanelSource(s.crypto_pit_dir).panel()   # {sym:{day:(close,qvol,funding)}}
        return {sym: {d: v[0] for d, v in series.items()} for sym, series in raw.items()}
    return _run_paper_tracker(
        context, "crypto_tstrend_nav",
        lambda: TsTrendForward(load_panel, book_target_vol=0.15),
        "crypto_tstrend_state",
    )
```

**File:** `src/fxhnt/adapters/orchestration/definitions.py` — add `crypto_tstrend_nav` to the
`combined_book_job` selection.

**File:** `assets.py` — **add `crypto_tstrend_nav` to the explicit `cockpit_forward` dep list**
(currently `@asset(deps=[combined_forward_nav, sixtyforty_nav, xsfunding_nav, unlock_nav])`, line ~207),
else cockpit may run before the track materializes / omit it. **(latent-bug catch from review.)**

`book_target_vol` (and `decay_lam`/`decay_reenter_days`) are set at this composition root → re-tunable
with no strategy-code change.

### 5. Drawdown kill-switch note (corrected)

`_run_paper_tracker` (assets.py:127) builds `ForwardTracker(strategy, path).step()` and **does NOT pass
`kill_dd`** — so per-sleeve paper tracks (xsfunding, unlock, and this one) have **no** tracker drawdown
kill. The strategy-level `PageHinkleyDecay` is therefore this sleeve's **primary** (and only) edge-death
guard, not a complement. We deliberately leave the tracker `kill_dd` OFF to avoid a double-kill
interaction; it remains available if a level-trigger is wanted later.

### 6. Tests (mirror xsfunding pattern + new)

- `tests/unit/test_edge_decay.py` — `PageHinkleyDecay`: stays ALIVE on stationary-positive series; KILLS
  on positive→negative mean-flip; RE-ARMS only after `reenter_days` non-negative days; `to_dict`/`from_dict`
  round-trip; does NOT kill on the validated long/flat backtest return series (falsification).
- `tests/integration/test_tstrend_strategy.py` — fake `load_panel` (synthetic uptrend/downtrend panel):
  - inception → returns rows, tracker books none, extra warm-started (vol_buf full, PH alive);
  - subsequent day → booked == `lev*raw` (hand-computed), `lev` from carried buffer, causal;
  - **no-drift**: the raw return for a new day equals `TrendRunner.run()`'s tail value for that date;
  - decay kill → booked flattens to 0, vol_buf still advances (shadow), re-arms after hysteresis;
  - `extra` JSON round-trips through `json.dumps`/`loads`.
- `tests/integration/test_orchestration_assets.py` — extend: `crypto_tstrend_nav` materializes a valid
  days-list state file from a synthetic panel; asset is reachable after `crypto_bars`.
- `tests/unit/test_registry.py` — extend: `crypto_tstrend` present with required fields.

## Data flow

```
nightly 23:30 UTC combined_book_job
  → crypto_bars (REFRESH crypto_pit panel)
  → crypto_tstrend_nav  [deps=crypto_bars]
      → TsTrendForward(load_panel, book_target_vol=0.15)
          → TrendRunner.run(long_short=False, ppy=365)   (~8s) → raw_rets, dates
          → per new day: causal vol-overlay (lev) · PH shadow kill → booked
      → ForwardTracker.step()  → crypto_tstrend_state.json (days-list NAV)
  → cockpit_forward [deps += crypto_tstrend_nav]  → cockpit DB (gate=WAIT until 60d & Sharpe≥0.5)
```

## Error handling

- Transient (network/disk): handled by `_run_paper_tracker` (logged, state unchanged, job not blocked).
- Genuine bugs (ValueError/KeyError/AttributeError): propagate and fail loudly.
- Empty panel / all symbols too short: TrendRunner returns empty `dates` → strategy books nothing that
  day (no crash). At inception with empty history: freeze at `_today_iso()`, empty extra warmed lazily.
- Insufficient vol_buf (shouldn't happen post-warm-start): `lev` falls back to 1.0 (eps-guarded std).

## Risks / honest caveats

- **Directional beta, not alpha** — long crypto when trends are up; goes flat in bears but bled in 2022.
  corr~0 to funding is the diversification claim, not market-neutrality.
- **Backtest → deployable haircut** — Sharpe ~1.25 backtest → realistically ~0.8–1.0 deployable; the
  forward track is the truth-teller (same role xsfunding's forward track plays for tail-basis).
- **Crash co-movement** — in a crypto deleveraging cascade funding (short-convex) and trend (de-risks but
  lags fast cascades) can both hurt before trend flattens. Book-of-books sizing (later) handles this; this
  sleeve alone does not.
- **Recompute redundancy** — full 8s recompute nightly is wasteful but correct and drift-free; acceptable
  at this cadence. If the panel grows large enough to matter, factor a single-day position helper later.

## Acceptance criteria

- `crypto_tstrend` registered; nightly job materializes `crypto_tstrend_state.json` (days-list shape);
  `cockpit_forward` depends on it and surfaces it with `gate=WAIT`, NAV booking from inception.
- **No drift**: a test asserts the strategy's raw per-day return equals `TrendRunner.run()`'s value for
  the same date on a synthetic panel.
- `PageHinkleyDecay`: no-kill on the validated window, kills on a synthetic mean-flip, re-arms with
  hysteresis, state persists through `extra` JSON.
- Overlay is causal (uses only trailing returns) and `book_target_vol` controls realized book vol;
  the −15/−23/−30% maxDD at 10/15/20% targets reproduces the 2026-06-21 probe.
- All new + existing tests green; mypy clean.
- `book_target_vol`, `decay_lam`, `decay_reenter_days` adjustable at the asset composition root without
  touching strategy code.
```
