# Crypto TS-Trend Paper-Track Sleeve — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a `crypto_tstrend` live paper-track sleeve (long/flat crypto TSMOM) that books daily paper NAV via the nightly Dagster combined-book job, with a configurable portfolio vol-target overlay and a binary Page-Hinkley edge-decay kill, gated `WAIT` until forward-proven.

**Architecture:** Recomputable-series `ForwardStrategy` (mirrors `CombinedBookStrategy`) that reuses the validated `TrendRunner.run()` unchanged on the existing `crypto_pit` panel, then applies a causal portfolio vol-target overlay + a `PageHinkleyDecay` binary kill incrementally over new days (state carried in the tracker's `extra`). No new data adapter.

**Tech Stack:** Python 3, pytest, numpy, Dagster 1.12, fxhnt (`TrendRunner`, `ForwardTracker`, `CryptoPitPanelSource`, `STRATEGY_REGISTRY`).

**Spec:** `docs/superpowers/specs/2026-06-21-crypto-tstrend-sleeve-design.md`

---

## File Structure

- **Create** `src/fxhnt/domain/edge_decay.py` — `PageHinkleyDecay` (pure, reusable mean-shift detector).
- **Create** `src/fxhnt/application/ts_trend_strategy.py` — `TsTrendForward` (recomputable ForwardStrategy + overlay + kill).
- **Modify** `src/fxhnt/registry.py` — add `crypto_tstrend` to `STRATEGY_REGISTRY`.
- **Modify** `src/fxhnt/adapters/orchestration/assets.py` — add `crypto_tstrend_nav` asset; add it to `cockpit_forward` deps.
- **Modify** `src/fxhnt/adapters/orchestration/definitions.py` — import + add to `combined_book_job.selection` + `defs.assets`.
- **Create** `tests/unit/test_edge_decay.py`, `tests/integration/test_tstrend_strategy.py`, `tests/unit/test_registry_tstrend.py`, `tests/integration/test_tstrend_wiring.py`.

---

## Task 0: Branch

- [ ] **Step 1: Create a feature branch** (fxhnt is on `master`; never commit feature work to the default branch)

```bash
cd /home/jgrusewski/Work/fxhnt
git checkout -b feat/crypto-tstrend-sleeve
```

- [ ] **Step 2: Commit the spec** (written during brainstorming, currently uncommitted)

```bash
git add docs/superpowers/specs/2026-06-21-crypto-tstrend-sleeve-design.md docs/superpowers/plans/2026-06-21-crypto-tstrend-sleeve.md
git commit -m "docs: crypto TS-trend sleeve spec + implementation plan"
```

---

## Task 1: PageHinkleyDecay edge-decay detector

**Files:**
- Create: `src/fxhnt/domain/edge_decay.py`
- Test: `tests/unit/test_edge_decay.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/unit/test_edge_decay.py
import json

import numpy as np

from fxhnt.domain.edge_decay import PageHinkleyDecay


def test_stationary_positive_never_kills():
    ph = PageHinkleyDecay(lam=0.05, reenter_days=10)
    rng = np.random.default_rng(0)
    alive = [ph.update(0.002 + 0.0005 * float(rng.standard_normal())) for _ in range(500)]
    assert all(alive)
    assert not ph.killed


def test_mean_flip_kills():
    ph = PageHinkleyDecay(lam=0.05, reenter_days=10)
    for _ in range(200):
        ph.update(0.002)
    killed_at = None
    for i in range(200):
        if not ph.update(-0.01):
            killed_at = i
            break
    assert killed_at is not None
    assert ph.killed


def test_rearm_after_hysteresis():
    ph = PageHinkleyDecay(lam=0.01, reenter_days=5)
    for _ in range(50):
        ph.update(0.001)
    for _ in range(50):
        if not ph.update(-0.02):
            break
    assert ph.killed
    states = [ph.update(0.0) for _ in range(5)]
    assert states[-1] is True
    assert not ph.killed


def test_to_from_dict_roundtrip():
    ph = PageHinkleyDecay(lam=0.05)
    for _ in range(20):
        ph.update(0.001)
    d = json.loads(json.dumps(ph.to_dict()))
    ph2 = PageHinkleyDecay.from_dict(d)
    assert ph2.to_dict() == ph.to_dict()
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/unit/test_edge_decay.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'fxhnt.domain.edge_decay'`

- [ ] **Step 3: Implement `PageHinkleyDecay`**

```python
# src/fxhnt/domain/edge_decay.py
"""Page-Hinkley mean-SHIFT edge-decay detector with binary kill + hysteresis. Pure, no I/O.

Detects a sustained DOWNWARD shift in a return stream's mean (slow-bleed edge death a drawdown
trigger misses). Shift-form (not level — level froze during normal operation in the RL context;
see pearl_edge_decay_detector_phase1_validated_train_also_decays). Consumes the SHADOW (unkilled)
return so it can detect recovery and re-arm while booked exposure is flat.
"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass
class PageHinkleyDecay:
    delta: float = 0.0        # deadband below the running mean before drift accumulates
    lam: float = 0.05         # cumulative-deviation threshold to KILL (pre-registered, conservative)
    reenter_days: int = 10    # consecutive non-negative shadow returns required to re-arm
    n: int = 0
    mean: float = 0.0
    m_t: float = 0.0
    m_max: float = 0.0
    killed: bool = False
    recover: int = 0

    def update(self, ret: float) -> bool:
        """Feed one realized SHADOW daily return; return True if ALIVE (book), False if KILLED (flat)."""
        if self.killed:
            self.recover = self.recover + 1 if ret >= 0.0 else 0
            if self.recover >= self.reenter_days:
                self.n, self.mean, self.m_t, self.m_max = 0, 0.0, 0.0, 0.0
                self.killed, self.recover = False, 0
            return not self.killed
        self.n += 1
        self.mean += (ret - self.mean) / self.n
        self.m_t += (ret - self.mean + self.delta)
        if self.m_t > self.m_max:
            self.m_max = self.m_t
        if (self.m_max - self.m_t) > self.lam:
            self.killed, self.recover = True, 0
            return False
        return True

    def to_dict(self) -> dict:
        return {"delta": self.delta, "lam": self.lam, "reenter_days": self.reenter_days,
                "n": self.n, "mean": self.mean, "m_t": self.m_t, "m_max": self.m_max,
                "killed": self.killed, "recover": self.recover}

    @classmethod
    def from_dict(cls, d: dict) -> "PageHinkleyDecay":
        return cls(**d)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/unit/test_edge_decay.py -v`
Expected: PASS (4 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/domain/edge_decay.py tests/unit/test_edge_decay.py
git commit -m "feat(domain): PageHinkleyDecay binary edge-decay detector"
```

---

## Task 2: TsTrendForward strategy

**Files:**
- Create: `src/fxhnt/application/ts_trend_strategy.py`
- Test: `tests/integration/test_tstrend_strategy.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/integration/test_tstrend_strategy.py
import json
import math

import numpy as np

from fxhnt.application.forward_tracker import ForwardTracker
from fxhnt.application.trend_runner import TrendRunner
from fxhnt.application.ts_trend_strategy import TsTrendForward, _iso


def _coin(n, start_day, drift, seed):
    rng = np.random.default_rng(seed)
    rets = drift + 0.01 * rng.standard_normal(n)
    close = 100.0 * np.cumprod(1.0 + rets)
    return {int(start_day + i): float(close[i]) for i in range(n)}


def _panel(n=160):
    return {f"C{j}": _coin(n, 0, 0.001, seed=j) for j in range(6)}


def _trend_returns(panel):
    return TrendRunner(panel, lookbacks=(20, 60, 120), vol_window=30, target_vol=0.10,
                       gross_cap=1.0, long_short=False, cost_bps=10.0,
                       periods_per_year=365).run()


def test_inception_books_nothing_and_warms_state(tmp_path):
    panel = _panel()
    strat = TsTrendForward(lambda: panel, book_target_vol=0.15)
    path = str(tmp_path / "st.json")
    status = ForwardTracker(strat, path).step()
    assert status.forward_days == 0                         # inception books nothing
    state = json.load(open(path))
    assert state["extra"]["ph"]["killed"] is False          # fresh, alive (history can't pre-kill)
    assert len(state["extra"]["vol_buf"]) > 0               # overlay warm-started
    assert state["inception"] == state["last_date"]


def test_subsequent_books_lev_times_raw_no_drift(tmp_path):
    panel1 = _panel()
    holder = {"p": panel1}
    # decay_lam high → never kills in this test; isolates the overlay + no-drift property
    strat = TsTrendForward(lambda: holder["p"], book_target_vol=0.15, lev_cap=3.0, decay_lam=1e9)
    path = str(tmp_path / "st.json")
    ForwardTracker(strat, path).step()                      # inception
    vb = json.load(open(path))["extra"]["vol_buf"]
    # extend the panel by exactly one day per coin
    last = max(next(iter(panel1.values())).keys())
    panel2 = {s: dict(series) for s, series in panel1.items()}
    for s in panel2:
        panel2[s][last + 1] = panel2[s][last] * 1.002
    holder["p"] = panel2
    status = ForwardTracker(strat, path).step()
    assert status.booked_today == 1
    res = _trend_returns(panel2)
    raw_last = float(res.returns[-1])
    date_last = _iso(int(res.dates[-1]))
    tv = float(np.std(vb))
    lev = min(3.0, (0.15 / math.sqrt(365)) / tv) if tv > 1e-12 else 1.0
    booked = next(d["ret"] for d in json.load(open(path))["days"] if d["date"] == date_last)
    assert booked == lev * raw_last                         # booked == lev*raw AND raw == TrendRunner (no drift)


def test_killed_state_flattens_booking(tmp_path):
    panel1 = _panel()
    holder = {"p": panel1}
    strat = TsTrendForward(lambda: holder["p"], book_target_vol=0.15)
    path = str(tmp_path / "st.json")
    ForwardTracker(strat, path).step()                      # inception
    # force the carried PH into a killed, non-recovering state
    state = json.load(open(path))
    state["extra"]["ph"]["killed"] = True
    state["extra"]["ph"]["recover"] = 0
    json.dump(state, open(path, "w"))
    last = max(next(iter(panel1.values())).keys())
    panel2 = {s: dict(series) for s, series in panel1.items()}
    for s in panel2:
        panel2[s][last + 1] = panel2[s][last] * 0.99        # a down day (keeps it killed)
    holder["p"] = panel2
    status = ForwardTracker(strat, path).step()
    assert status.booked_today == 1
    state2 = json.load(open(path))
    assert state2["days"][-1]["ret"] == 0.0                 # flattened while killed
    assert len(state2["extra"]["vol_buf"]) > 0              # shadow buffer still advances


def test_extra_json_roundtrips(tmp_path):
    panel = _panel()
    strat = TsTrendForward(lambda: panel, book_target_vol=0.15)
    path = str(tmp_path / "st.json")
    ForwardTracker(strat, path).step()
    state = json.load(open(path))
    json.dumps(state["extra"])                              # must not raise
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/integration/test_tstrend_strategy.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'fxhnt.application.ts_trend_strategy'`

- [ ] **Step 3: Implement `TsTrendForward`**

```python
# src/fxhnt/application/ts_trend_strategy.py
"""Live-booking crypto TS-trend (long/flat TSMOM) paper track — recomputable series.

Reuses the validated TrendRunner unchanged on the existing crypto_pit panel, then applies a CAUSAL
portfolio vol-target overlay (the configurable book-vol knob) and a binary PageHinkleyDecay kill
incrementally over new days, carrying overlay-buffer + detector state in the tracker's `extra`.
Mirrors the ForwardStrategy contract: advance(last_date, extra) -> (rows, extra)."""
from __future__ import annotations

import datetime as dt
import math
from typing import Any, Callable

import numpy as np

from fxhnt.application.trend_runner import TrendRunner
from fxhnt.domain.edge_decay import PageHinkleyDecay

_EPOCH = dt.date(1970, 1, 1)


def _today() -> str:
    return dt.date.today().isoformat()


def _iso(epoch_day: int) -> str:
    return (_EPOCH + dt.timedelta(days=int(epoch_day))).isoformat()


class TsTrendForward:
    def __init__(self, load_panel: Callable[[], dict[str, dict[int, float]]], *,
                 lookbacks: tuple[int, ...] = (20, 60, 120), vol_window: int = 30,
                 internal_target_vol: float = 0.10, gross_cap: float = 1.0,
                 cost_bps: float = 10.0, periods_per_year: int = 365,
                 book_target_vol: float = 0.15, lev_cap: float = 3.0, vol_buf_window: int = 30,
                 decay_lam: float = 0.05, decay_reenter_days: int = 10,
                 clock: Callable[[], str] = _today) -> None:
        self._load_panel = load_panel
        self._lb = tuple(lookbacks)
        self._vw = vol_window
        self._itv = internal_target_vol
        self._gcap = gross_cap
        self._cost = cost_bps
        self._ppy = periods_per_year
        self._btv = book_target_vol
        self._levcap = lev_cap
        self._bufw = vol_buf_window
        self._lam = decay_lam
        self._reenter = decay_reenter_days
        self._clock = clock

    def _raw_series(self) -> tuple[list[int], list[float]]:
        panel = self._load_panel()
        res = TrendRunner(panel, lookbacks=self._lb, vol_window=self._vw, target_vol=self._itv,
                          gross_cap=self._gcap, long_short=False, cost_bps=self._cost,
                          periods_per_year=self._ppy).run()
        return [int(d) for d in res.dates], [float(r) for r in res.returns]

    def advance(self, last_date: str | None,
                extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]:
        dates, raw = self._raw_series()

        if not extra:                                       # INCEPTION: warm overlay, fresh PH, book nothing
            vol_buf = raw[-self._bufw:] if raw else []
            ph = PageHinkleyDecay(lam=self._lam, reenter_days=self._reenter)
            rows = [(_iso(d), r) for d, r in zip(dates, raw)]   # tracker freezes inception at latest, books none
            new_extra = {"ph": ph.to_dict(), "vol_buf": vol_buf,
                         "last_day": (int(dates[-1]) if dates else None)}
            return rows, new_extra

        ph = PageHinkleyDecay.from_dict(extra["ph"])
        vol_buf = [float(x) for x in extra.get("vol_buf", [])]
        cutoff = last_date or ""
        btv_daily = self._btv / math.sqrt(self._ppy)
        rows: list[tuple[str, float]] = []
        for d, r in zip(dates, raw):
            iso = _iso(d)
            if iso <= cutoff:                               # already booked / pre-inception
                continue
            tv = float(np.std(vol_buf)) if len(vol_buf) >= 2 else 0.0
            lev = min(self._levcap, btv_daily / tv) if tv > 1e-12 else 1.0
            alive = ph.update(r)                            # PH consumes shadow raw return
            rows.append((iso, lev * r if alive else 0.0))
            vol_buf.append(r)                               # shadow buffer always advances
            if len(vol_buf) > self._bufw:
                vol_buf = vol_buf[-self._bufw:]
        new_extra = {"ph": ph.to_dict(), "vol_buf": vol_buf,
                     "last_day": (int(dates[-1]) if dates else extra.get("last_day"))}
        return rows, new_extra
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/integration/test_tstrend_strategy.py -v`
Expected: PASS (5 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/application/ts_trend_strategy.py tests/integration/test_tstrend_strategy.py
git commit -m "feat(application): TsTrendForward live paper-track strategy (overlay + decay kill)"
```

---

## Task 3: Registry entry

**Files:**
- Modify: `src/fxhnt/registry.py` (inside the `STRATEGY_REGISTRY` dict)
- Test: `tests/unit/test_registry_tstrend.py`

- [ ] **Step 1: Write the failing test**

```python
# tests/unit/test_registry_tstrend.py
from fxhnt.registry import STRATEGY_REGISTRY


def test_crypto_tstrend_registered():
    e = STRATEGY_REGISTRY["crypto_tstrend"]
    assert e["state_file"] == "crypto_tstrend_state"
    assert e["sleeve"] == "crypto-trend"
    assert e["venue"] == "binance-perp"
    assert e["gate_spec"] == {"min_days": 60, "min_total_return": 0.0, "min_sharpe": 0.5}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/unit/test_registry_tstrend.py -v`
Expected: FAIL — `KeyError: 'crypto_tstrend'`

- [ ] **Step 3: Add the registry entry**

In `src/fxhnt/registry.py`, add this entry inside the `STRATEGY_REGISTRY` dict, immediately after the `"unlock"` entry:

```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},
    },
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/unit/test_registry_tstrend.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/registry.py tests/unit/test_registry_tstrend.py
git commit -m "feat(registry): register crypto_tstrend sleeve (gate WAIT 60d)"
```

---

## Task 4: Dagster wiring

**Files:**
- Modify: `src/fxhnt/adapters/orchestration/assets.py` (add `crypto_tstrend_nav`; add to `cockpit_forward` deps)
- Modify: `src/fxhnt/adapters/orchestration/definitions.py` (import + job selection + `defs.assets`)
- Test: `tests/integration/test_tstrend_wiring.py`

- [ ] **Step 1: Write the failing test**

```python
# tests/integration/test_tstrend_wiring.py
def test_asset_importable():
    from fxhnt.adapters.orchestration.assets import crypto_tstrend_nav
    assert crypto_tstrend_nav is not None


def test_definitions_load_and_include_tstrend():
    # Importing `defs` constructs Definitions(...), which validates the asset graph — a missing
    # crypto_tstrend_nav (referenced by cockpit_forward's deps) would raise here.
    from fxhnt.adapters.orchestration.assets import crypto_tstrend_nav
    from fxhnt.adapters.orchestration.definitions import combined_book_job, defs
    assert crypto_tstrend_nav in defs.assets          # in the Definitions assets list
    assert combined_book_job is not None              # job built without error
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/integration/test_tstrend_wiring.py -v`
Expected: FAIL — `ImportError: cannot import name 'crypto_tstrend_nav'`

- [ ] **Step 3: Add the asset in `assets.py`**

Add this asset definition immediately after the `xsfunding_nav` asset (around line 188):

```python
@asset(deps=[crypto_bars])   # dep on crypto_bars so the crypto_pit panel is fresh before we read it
def crypto_tstrend_nav(context: AssetExecutionContext) -> dict:
    """Paper-forward crypto TS-momentum (long/flat daily TSMOM on the survivorship-free crypto_pit panel)."""
    from fxhnt.adapters.data.crypto_pit_panel import CryptoPitPanelSource
    from fxhnt.application.ts_trend_strategy import TsTrendForward
    s = get_settings()

    def load_panel() -> dict:
        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",
    )
```

- [ ] **Step 4: Add `crypto_tstrend_nav` to the `cockpit_forward` deps**

In `assets.py` change the `cockpit_forward` decorator (line ~207) from:

```python
@asset(deps=[combined_forward_nav, sixtyforty_nav, xsfunding_nav, unlock_nav])
```

to:

```python
@asset(deps=[combined_forward_nav, sixtyforty_nav, xsfunding_nav, unlock_nav, crypto_tstrend_nav])
```

- [ ] **Step 5: Wire it in `definitions.py`**

Add `crypto_tstrend_nav,` to the import block from `fxhnt.adapters.orchestration.assets`:

```python
from fxhnt.adapters.orchestration.assets import (
    cockpit_forward,
    combined_forward_nav,
    crypto_bars,
    crypto_tstrend_nav,
    futures_bars,
    sixtyforty_nav,
    unlock_nav,
    xsfunding_nav,
)
```

Add `crypto_tstrend_nav,` to BOTH the `combined_book_job` selection and the `defs` assets list (same line as `xsfunding_nav, unlock_nav,`):

```python
        xsfunding_nav, unlock_nav, crypto_tstrend_nav,
```

- [ ] **Step 6: Run tests to verify they pass**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest tests/integration/test_tstrend_wiring.py -v`
Expected: PASS (2 passed)

- [ ] **Step 7: Commit**

```bash
git add src/fxhnt/adapters/orchestration/assets.py src/fxhnt/adapters/orchestration/definitions.py tests/integration/test_tstrend_wiring.py
git commit -m "feat(orchestration): wire crypto_tstrend_nav into nightly combined-book job + cockpit"
```

---

## Task 5: Full verification

- [ ] **Step 1: Run the full test suite**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m pytest -q`
Expected: all green (the ~467 existing + new tests)

- [ ] **Step 2: Type-check**

Run: `cd /home/jgrusewski/Work/fxhnt && python -m mypy src/fxhnt`
Expected: clean (no new errors)

- [ ] **Step 3: Final commit (if any fixups were needed)**

```bash
git add -A
git commit -m "chore: crypto_tstrend sleeve — green tests + mypy clean"
```

---

## Notes for the executor

- **Honest falsification:** the `PageHinkleyDecay` unit tests assert no-kill on a *synthetic stationary-positive* series (the defensible no-false-positive guarantee). On the REAL validated series it is expected and CORRECT for PH to kill during the 2022 crypto bear and re-arm in 2023 — that is the feature working, not a bug; do not add a hard assertion that it never kills on real history.
- **No-drift is the load-bearing property:** `TsTrendForward` calls `TrendRunner.run()` directly, so the live signal IS the validated backtest signal. `test_subsequent_books_lev_times_raw_no_drift` guards this — keep it.
- **Do not wire the tracker `kill_dd`** for this sleeve (would double-kill with the PH detector). The PH decay is the sleeve's edge-death guard.
- **`s.crypto_pit_dir`** is the settings field already used by `xsfunding`/momentum assets; if it is absent in `get_settings()`, use the same path expression those assets use (verify against `assets.py` line ~196 / 202).
```
