# Single-Market Strategy Kinds + OHLC Contract 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 three structurally-distinct single-market strategy kinds (`seasonality`, `breakout`, `vol_managed_momentum`) — growing `available()` from 3 to 6 — and extend the `PriceSeries` contract with `high`/`low` filled by all four data adapters so `breakout` is a true Donchian channel.

**Architecture:** The strategy contract (`domain/strategies/base.py::Strategy`) is fixed and single-market: `positions(prices, params) -> np.ndarray` in `[-1,1]`, no lookahead, self-registered via `register()`. New kinds are pure functions of a `PriceSeries`; the fleet/gauntlet/factory/backtest need no changes (they consume `available()` + the contract). `PriceSeries` gains `high`/`low` keyword fields defaulting to `close` (a flat bar) so the 12 existing fixtures keep working while real adapters supply real OHLC. Each adapter's dataframe/payload→`PriceSeries` transformation is extracted into a pure helper so it is unit-testable without network or the `databento` library.

**Tech Stack:** Python 3.11, numpy, pydantic + frozen dataclasses (domain models), DuckDB (analytical store), pytest. Tests run with `python3 -m pytest <path> -q` (pythonpath=`src`).

**Reference spec:** `docs/superpowers/specs/2026-06-14-single-market-kinds-design.md`

**Conventions to follow:**
- Tests live under `tests/unit/` (pure/domain) and `tests/integration/` (DuckDB temp-file). Construct a `PriceSeries` test fixture exactly like `tests/unit/test_regime.py` does:
  ```python
  from fxhnt.domain.models import AssetClass, Market, PriceSeries
  def _prices(close, high=None, low=None):
      import numpy as np
      c = np.asarray(close, float)
      return PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE),
                         dates=tuple(str(i) for i in range(len(c))), close=c,
                         high=None if high is None else np.asarray(high, float),
                         low=None if low is None else np.asarray(low, float))
  ```
- Every kind class needs a `kind: str` and a non-empty `rationale: str` (the gauntlet requires one).
- Lookahead rule: a position applied to `return[t]` (= `close[t]/close[t-1]-1`) may use info only through `t-1`'s close. Close-derived signals therefore shift by one (`pos[1:] = signal[:-1]`); calendar/pre-known signals (seasonality) do **not** shift.

---

## Task 1: `PriceSeries` high/low contract

**Files:**
- Modify: `src/fxhnt/domain/models.py:41-59`
- Test: `tests/unit/test_models_ohlc.py`

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

```python
# tests/unit/test_models_ohlc.py
"""PriceSeries gains high/low keyword fields defaulting to close (a flat bar)."""
from __future__ import annotations

import numpy as np
import pytest

from fxhnt.domain.models import AssetClass, Market, PriceSeries

_M = Market(symbol="X", asset_class=AssetClass.FUTURE)


def test_high_low_default_to_close_when_absent() -> None:
    c = np.array([100.0, 101.0, 102.0])
    ps = PriceSeries(market=_M, dates=("0", "1", "2"), close=c)
    assert np.array_equal(ps.high, c)
    assert np.array_equal(ps.low, c)


def test_high_low_are_kept_when_supplied() -> None:
    c = np.array([100.0, 101.0])
    h = np.array([101.0, 102.0])
    low = np.array([99.0, 100.0])
    ps = PriceSeries(market=_M, dates=("0", "1"), close=c, high=h, low=low)
    assert np.array_equal(ps.high, h) and np.array_equal(ps.low, low)


def test_mismatched_high_length_raises() -> None:
    with pytest.raises(ValueError):
        PriceSeries(market=_M, dates=("0", "1"), close=np.array([1.0, 2.0]),
                    high=np.array([1.0]), low=np.array([1.0, 2.0]))
```

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

Run: `python3 -m pytest tests/unit/test_models_ohlc.py -q`
Expected: FAIL — `TypeError: __init__() got an unexpected keyword argument 'high'`.

- [ ] **Step 3: Implement the contract change**

In `src/fxhnt/domain/models.py`, replace the `PriceSeries` dataclass body (lines 41-59) with:

```python
@dataclass(frozen=True)
class PriceSeries:
    """Adjusted-close series for one market, dates ascending. In-memory numeric value object.
    `high`/`low` default to `close` when not supplied — a bar with no intraday-range info is a flat
    bar. Real data adapters fill them; synthetic/test series fall back to close."""
    market: Market
    dates: tuple[str, ...]
    close: np.ndarray
    high: np.ndarray | None = None
    low: np.ndarray | None = None

    def __post_init__(self) -> None:
        if len(self.dates) != len(self.close):
            raise ValueError("dates and close length mismatch")
        if self.high is None:
            object.__setattr__(self, "high", self.close)
        if self.low is None:
            object.__setattr__(self, "low", self.close)
        if len(self.high) != len(self.close) or len(self.low) != len(self.close):
            raise ValueError("high/low length must match close")

    def __len__(self) -> int:
        return len(self.close)

    def returns(self) -> np.ndarray:
        r = np.zeros(len(self.close))
        if len(self.close) > 1:
            r[1:] = self.close[1:] / self.close[:-1] - 1.0
        return r
```

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

Run: `python3 -m pytest tests/unit/test_models_ohlc.py -q`
Expected: PASS (3 passed).

- [ ] **Step 5: Verify no existing fixtures broke**

Run: `python3 -m pytest tests/ -q`
Expected: PASS — the 12 fixtures that build `PriceSeries(market, dates, close)` still work (high/low default to close).

- [ ] **Step 6: Commit**

```bash
git add src/fxhnt/domain/models.py tests/unit/test_models_ohlc.py
git commit -m "feat(models): PriceSeries gains high/low fields defaulting to close"
```

---

## Task 2: Causal `rolling_max` / `rolling_min` helpers

**Files:**
- Modify: `src/fxhnt/domain/strategies/base.py` (append after `rolling_std`, around line 59)
- Test: `tests/unit/test_strategies_single_market.py`

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

```python
# tests/unit/test_strategies_single_market.py
"""Single-market kinds + their causal rolling-extremum helpers."""
from __future__ import annotations

import numpy as np

from fxhnt.domain.models import AssetClass, Market, PriceSeries


def _prices(close, high=None, low=None) -> PriceSeries:
    c = np.asarray(close, float)
    return PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE),
                       dates=tuple(str(i) for i in range(len(c))), close=c,
                       high=None if high is None else np.asarray(high, float),
                       low=None if low is None else np.asarray(low, float))


def test_rolling_max_min_are_causal() -> None:
    from fxhnt.domain.strategies.base import rolling_max, rolling_min
    x = np.array([1.0, 3.0, 2.0, 5.0, 4.0])
    rmax = rolling_max(x, 3)
    rmin = rolling_min(x, 3)
    # window ends at t (inclusive), shrinks at the start, never looks ahead
    assert list(rmax) == [1.0, 3.0, 3.0, 5.0, 5.0]
    assert list(rmin) == [1.0, 1.0, 1.0, 2.0, 2.0]
```

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

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py::test_rolling_max_min_are_causal -q`
Expected: FAIL — `ImportError: cannot import name 'rolling_max'`.

- [ ] **Step 3: Implement the helpers**

Append to `src/fxhnt/domain/strategies/base.py`:

```python
def rolling_max(x: np.ndarray, window: int) -> np.ndarray:
    """Causal rolling maximum (uses only x[..t]); shorter window at the start, no lookahead."""
    out = np.empty(len(x))
    for t in range(len(x)):
        lo = max(0, t + 1 - window)
        out[t] = float(x[lo:t + 1].max())
    return out


def rolling_min(x: np.ndarray, window: int) -> np.ndarray:
    """Causal rolling minimum (uses only x[..t]); shorter window at the start, no lookahead."""
    out = np.empty(len(x))
    for t in range(len(x)):
        lo = max(0, t + 1 - window)
        out[t] = float(x[lo:t + 1].min())
    return out
```

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

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py::test_rolling_max_min_are_causal -q`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/domain/strategies/base.py tests/unit/test_strategies_single_market.py
git commit -m "feat(strategies): causal rolling_max/rolling_min helpers"
```

---

## Task 3: `breakout` kind (true Donchian channel)

**Files:**
- Create: `src/fxhnt/domain/strategies/breakout.py`
- Test: `tests/unit/test_strategies_single_market.py` (append)

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

Append to `tests/unit/test_strategies_single_market.py`:

```python
def test_breakout_goes_long_on_upside_break_no_lookahead() -> None:
    from fxhnt.domain.strategies.breakout import Breakout
    # flat at 100 for the window, then a clean upside breakout
    close = np.array([100.0] * 25 + [110.0, 111.0, 112.0])
    pos = Breakout().positions(_prices(close), {"window": 20})
    assert pos.min() >= -1.0 and pos.max() <= 1.0
    assert pos[-1] == 1.0                       # held long after the break
    # lookahead check: altering ONLY the last bar must not change any earlier position
    close2 = close.copy(); close2[-1] = 999.0
    pos2 = Breakout().positions(_prices(close2), {"window": 20})
    assert np.array_equal(pos[:-1], pos2[:-1])


def test_breakout_long_only_by_default_short_when_enabled() -> None:
    from fxhnt.domain.strategies.breakout import Breakout
    close = np.array([100.0] * 25 + [110.0] * 5 + [90.0, 89.0, 88.0])
    long_only = Breakout().positions(_prices(close), {"window": 20})
    assert long_only.min() == 0.0              # downside break -> flat (no short)
    with_short = Breakout().positions(_prices(close), {"window": 20, "long_short": 1.0})
    assert with_short.min() == -1.0            # downside break -> short
```

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

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py -k breakout -q`
Expected: FAIL — `ModuleNotFoundError: fxhnt.domain.strategies.breakout`.

- [ ] **Step 3: Implement the kind**

```python
# src/fxhnt/domain/strategies/breakout.py
"""Donchian-channel breakout — long on a new N-day high, short/flat on a new N-day low.

Rationale: channel breakout captures regime shifts via range expansion that a moving-average
threshold smooths over. The channel is read at t-1 (prior days only) so the breakout bar never
enters its own channel; the final shift makes the decision drive the next day's return — no lookahead.
"""
from __future__ import annotations

import numpy as np

from fxhnt.domain.models import PriceSeries
from fxhnt.domain.strategies.base import register, rolling_max, rolling_min


class Breakout:
    kind = "breakout"
    rationale = "channel breakout — captures regime shifts via range expansion a moving-average threshold smooths over"

    def positions(self, prices: PriceSeries, params: dict[str, float]) -> np.ndarray:
        window = int(params.get("window", 20))
        short_value = -1.0 if params.get("long_short", 0.0) else 0.0  # default long-only
        c = prices.close
        upper = rolling_max(prices.high, window)
        lower = rolling_min(prices.low, window)
        signal = np.zeros(len(c))
        for t in range(window, len(c)):
            if c[t] > upper[t - 1]:
                signal[t] = 1.0
            elif c[t] < lower[t - 1]:
                signal[t] = short_value
            else:
                signal[t] = signal[t - 1]   # hold inside the channel (Donchian stop-and-reverse/hold)
        pos = np.zeros(len(c))
        pos[1:] = signal[:-1]               # decision at t-1 drives return at t (no lookahead)
        return pos


register(Breakout())
```

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

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py -k breakout -q`
Expected: PASS (2 passed). Note: importing the module runs `register(Breakout())`; the test imports it directly.

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/domain/strategies/breakout.py tests/unit/test_strategies_single_market.py
git commit -m "feat(strategies): breakout kind (true Donchian channel)"
```

---

## Task 4: `seasonality` kind (move signal to domain, then add kind)

**Files:**
- Create: `src/fxhnt/domain/seasonality.py`
- Modify: `src/fxhnt/application/seasonality_research.py:11-41` (re-export, delete duplicated body)
- Create: `src/fxhnt/domain/strategies/seasonality.py`
- Test: `tests/unit/test_strategies_single_market.py` (append)

- [ ] **Step 1: Create the domain home for `seasonal_signal` + test it**

Create `src/fxhnt/domain/seasonality.py` (exact copy of the current pure function — it depends only on `PriceSeries`, so it belongs in the domain layer):

```python
# src/fxhnt/domain/seasonality.py
"""Causal monthly-seasonality signal (pure domain function). The signal for (year Y, month M) uses
ONLY prior years' month-M returns (yy < Y) — strictly causal, zero until min_prior_years of history."""
from __future__ import annotations

from collections import defaultdict

import numpy as np

from fxhnt.domain.models import PriceSeries


def seasonal_signal(prices: PriceSeries, *, min_prior_years: int = 3) -> tuple[tuple[str, ...], np.ndarray]:
    """For each day in (year Y, month M): the mean daily return in month M across ALL PRIOR years
    (< Y). Zero until min_prior_years of history — no lookahead."""
    dates = prices.dates
    ym = [(int(d[:4]), int(d[5:7])) for d in dates]
    c = np.asarray(prices.close, float)
    r = np.zeros(len(c))
    r[1:] = c[1:] / c[:-1] - 1.0
    r = np.nan_to_num(r)

    bucket: dict[tuple[int, int], list[float]] = defaultdict(list)
    for i, key in enumerate(ym):
        bucket[key].append(r[i])
    monthly_mean = {k: float(np.mean(v)) for k, v in bucket.items()}
    years = sorted({y for y, _ in ym})

    sig = np.zeros(len(c))
    for i, (y, m) in enumerate(ym):
        prior = [monthly_mean[(yy, m)] for yy in years if yy < y and (yy, m) in monthly_mean]
        if len(prior) >= min_prior_years:
            sig[i] = float(np.mean(prior))
    return tuple(dates), sig
```

Add to `tests/unit/test_strategies_single_market.py`:

```python
def test_seasonal_signal_is_causal_zero_in_warmup() -> None:
    from fxhnt.domain.seasonality import seasonal_signal
    # two years of daily dates; warmup (min_prior_years=3) means all-zero signal
    dates = tuple(f"{y}-{m:02d}-01" for y in (2020, 2021) for m in range(1, 13))
    close = np.cumprod(1.0 + np.full(len(dates), 0.01)) * 100.0
    from fxhnt.domain.models import AssetClass, Market, PriceSeries
    ps = PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    _, sig = seasonal_signal(ps, min_prior_years=3)
    assert np.allclose(sig, 0.0)               # never ≥3 prior years -> all zero
```

- [ ] **Step 2: Run the new test to verify it passes (new module exists)**

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py::test_seasonal_signal_is_causal_zero_in_warmup -q`
Expected: PASS.

- [ ] **Step 3: Re-export from the application module (remove duplication)**

Replace the **entire contents** of `src/fxhnt/application/seasonality_research.py` with the following (a new docstring + a re-export) so existing importers keep working and the logic lives in one place:

```python
"""Seasonality research — the creative, dataset-native edge. Commodities (ags/energy) have hard seasonal
patterns from real storage/weather/harvest/demand cycles (natgas winter, gasoline summer, grain harvest,
livestock cycles) — economically grounded and structurally under-arbitraged.

The causal estimator now lives in the domain layer (`fxhnt.domain.seasonality`); re-exported here so the
research module and the `seasonality` strategy kind share one implementation (DRY). Discipline against the
overfit trap: the signal is strictly causal, validated with deflated Sharpe + IS/OOS + costs, and must beat
a NEGATIVE CONTROL on financials (FX/rates/equity), which have no seasonal economics.
"""
from __future__ import annotations

from fxhnt.domain.seasonality import seasonal_signal

__all__ = ["seasonal_signal"]
```

- [ ] **Step 4: Run the full suite to verify nothing broke**

Run: `python3 -m pytest tests/ -q`
Expected: PASS (no test imports the deleted body; `seasonal_signal` is now re-exported).

- [ ] **Step 5: Write the failing kind tests**

Append to `tests/unit/test_strategies_single_market.py`:

```python
def _monthly_prices(years, jan_drift, other_drift):
    """Daily-ish series: every January gets jan_drift per step, other months other_drift."""
    import numpy as np
    dates, rets = [], []
    for y in years:
        for m in range(1, 13):
            for day in range(1, 21):
                dates.append(f"{y}-{m:02d}-{day:02d}")
                rets.append(jan_drift if m == 1 else other_drift)
    close = 100.0 * np.cumprod(1.0 + np.asarray(rets))
    return tuple(dates), close


def test_seasonality_goes_long_in_a_historically_positive_month() -> None:
    from fxhnt.domain.strategies.seasonality import Seasonality
    from fxhnt.domain.models import AssetClass, Market, PriceSeries
    dates, close = _monthly_prices(range(2016, 2022), jan_drift=0.01, other_drift=0.0)
    ps = PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    pos = Seasonality().positions(ps, {"min_prior_years": 3})
    assert pos.min() >= -1.0 and pos.max() <= 1.0
    jan = np.array([d[5:7] == "01" for d in dates])
    later_jan = jan & (np.array([int(d[:4]) for d in dates]) >= 2020)  # past warmup
    assert pos[later_jan].mean() > 0.9          # long in January once ≥3 prior Januaries exist


def test_seasonality_negative_control_is_flat_on_noise() -> None:
    from fxhnt.domain.strategies.seasonality import Seasonality
    from fxhnt.domain.models import AssetClass, Market, PriceSeries
    rng = np.random.default_rng(0)
    dates, _ = _monthly_prices(range(2010, 2022), jan_drift=0.0, other_drift=0.0)
    close = 100.0 * np.cumprod(1.0 + rng.normal(0, 0.01, len(dates)))  # i.i.d., no season
    ps = PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    pos = Seasonality().positions(ps, {"min_prior_years": 3})
    assert abs(pos.mean()) < 0.2                # no structural season -> near-flat on average
```

- [ ] **Step 6: Run the kind tests to verify they fail**

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py -k seasonality -q`
Expected: FAIL — `ModuleNotFoundError: fxhnt.domain.strategies.seasonality`.

- [ ] **Step 7: Implement the kind (NO shift — the signal is pre-known)**

```python
# src/fxhnt/domain/strategies/seasonality.py
"""Monthly seasonality — long if the current month is historically positive, short if negative.

Rationale: commodity storage/weather/harvest cycles are economically grounded and structurally
under-arbitraged. The signal is strictly causal (prior years only) and is validated against a
negative control on financials, which have no seasonal economics.

It is NOT shifted (unlike the close-derived kinds): seasonal_signal[t] depends only on the calendar
and prior years, so it is already known at t-1's close and aligned to day t. Shifting it would add a
spurious 1-day lag and misalign positions at month boundaries.
"""
from __future__ import annotations

import numpy as np

from fxhnt.domain.models import PriceSeries
from fxhnt.domain.seasonality import seasonal_signal
from fxhnt.domain.strategies.base import register


class Seasonality:
    kind = "seasonality"
    rationale = "commodity storage/weather/harvest cycles — economically grounded, structurally under-arbitraged; negative-control on financials guards against data-mining"

    def positions(self, prices: PriceSeries, params: dict[str, float]) -> np.ndarray:
        min_prior_years = int(params.get("min_prior_years", 3))
        _, sig = seasonal_signal(prices, min_prior_years=min_prior_years)
        return np.sign(sig)   # long if month historically positive, short if negative; 0 during warmup


register(Seasonality())
```

- [ ] **Step 8: Run the kind tests to verify they pass**

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py -k seasonality -q`
Expected: PASS (3 passed, including the domain-signal test).

- [ ] **Step 9: Commit**

```bash
git add src/fxhnt/domain/seasonality.py src/fxhnt/application/seasonality_research.py \
        src/fxhnt/domain/strategies/seasonality.py tests/unit/test_strategies_single_market.py
git commit -m "feat(strategies): seasonality kind; move seasonal_signal to domain (DRY, no shift)"
```

---

## Task 5: `vol_managed_momentum` kind

**Files:**
- Create: `src/fxhnt/domain/strategies/vol_managed_momentum.py`
- Test: `tests/unit/test_strategies_single_market.py` (append)

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

Append to `tests/unit/test_strategies_single_market.py`:

```python
def test_vol_managed_scales_down_when_vol_spikes() -> None:
    from fxhnt.domain.strategies.vol_managed_momentum import VolManagedMomentum
    rng = np.random.default_rng(1)
    calm = 0.0005 + rng.normal(0, 0.003, 200)      # low-vol uptrend
    wild = 0.0005 + rng.normal(0, 0.030, 200)      # same drift, 10x vol
    close = 100.0 * np.cumprod(1.0 + np.concatenate([calm, wild]))
    pos = VolManagedMomentum().positions(_prices(close),
                                         {"window": 100, "vol_window": 20, "target_vol": 0.15})
    assert pos.min() >= -1.0 and pos.max() <= 1.0
    calm_exposure = np.abs(pos[150:200]).mean()
    wild_exposure = np.abs(pos[350:400]).mean()
    assert wild_exposure < calm_exposure           # de-risks in the high-vol regime


def test_vol_managed_no_lookahead() -> None:
    from fxhnt.domain.strategies.vol_managed_momentum import VolManagedMomentum
    rng = np.random.default_rng(2)
    close = 100.0 * np.cumprod(1.0 + (0.0005 + rng.normal(0, 0.01, 300)))
    pos = VolManagedMomentum().positions(_prices(close), {})
    close2 = close.copy(); close2[-1] = 500.0
    pos2 = VolManagedMomentum().positions(_prices(close2), {})
    assert np.array_equal(pos[:-1], pos2[:-1])     # last bar cannot affect earlier positions
```

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

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py -k vol_managed -q`
Expected: FAIL — `ModuleNotFoundError: fxhnt.domain.strategies.vol_managed_momentum`.

- [ ] **Step 3: Implement the kind**

```python
# src/fxhnt/domain/strategies/vol_managed_momentum.py
"""Vol-managed momentum — momentum direction scaled inversely by realized volatility.

Rationale (Moreira-Muir): scaling exposure inversely to realized vol raises Sharpe because
volatility is persistent and forecastable while returns are not. Known limitation: exposure is
clipped to [0,1], so this is DE-RISK ONLY (target_vol is a ceiling) — it never levers up toward
target_vol in calm regimes, a deliberate consequence of the [-1,1] position contract.
"""
from __future__ import annotations

import numpy as np

from fxhnt.domain.models import PriceSeries
from fxhnt.domain.strategies.base import register, rolling_mean, rolling_std


class VolManagedMomentum:
    kind = "vol_managed_momentum"
    rationale = "vol-managed momentum (Moreira-Muir): scaling exposure inversely to realized vol raises Sharpe — volatility is persistent and forecastable, returns are not"

    def positions(self, prices: PriceSeries, params: dict[str, float]) -> np.ndarray:
        window = int(params.get("window", 100))
        vol_window = int(params.get("vol_window", 20))
        target_vol = float(params.get("target_vol", 0.15))
        c = prices.close
        ma = rolling_mean(c, window)
        direction = np.sign(c - ma)
        r = prices.returns()
        realized = rolling_std(r, vol_window) * np.sqrt(252.0)            # annualized realized vol
        scale = np.divide(target_vol, realized, out=np.zeros_like(c), where=realized > 0)
        scale = np.clip(scale, 0.0, 1.0)                                  # de-risk only: never lever past 1x
        signal = direction * scale
        signal[:max(window, vol_window)] = 0.0                            # warmup
        pos = np.zeros(len(c))
        pos[1:] = signal[:-1]                                             # decision at t-1 drives return at t
        return pos


register(VolManagedMomentum())
```

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

Run: `python3 -m pytest tests/unit/test_strategies_single_market.py -k vol_managed -q`
Expected: PASS (2 passed).

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/domain/strategies/vol_managed_momentum.py tests/unit/test_strategies_single_market.py
git commit -m "feat(strategies): vol_managed_momentum kind (Moreira-Muir, de-risk only)"
```

---

## Task 6: Register the new kinds + registration/end-to-end test

**Files:**
- Modify: `src/fxhnt/domain/strategies/__init__.py:2-6`
- Test: `tests/unit/test_registration.py`

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

```python
# tests/unit/test_registration.py
"""All six kinds register on package import and run end-to-end through the backtest engine."""
from __future__ import annotations

import numpy as np

from fxhnt.domain.models import AssetClass, Market, PriceSeries, StrategySpec
from fxhnt.domain.strategies import available, get_strategy


def test_available_lists_all_six_kinds() -> None:
    assert available() == ["breakout", "buy_hold", "mean_reversion", "seasonality", "trend",
                           "vol_managed_momentum"]


def test_each_new_kind_has_a_rationale() -> None:
    for kind in ("breakout", "seasonality", "vol_managed_momentum"):
        strat = get_strategy(kind)
        assert isinstance(strat.rationale, str) and strat.rationale.strip()


def test_new_kinds_run_end_to_end_through_backtest() -> None:
    from fxhnt.domain.backtest import run_backtest
    rng = np.random.default_rng(3)
    n = 400
    close = 100.0 * np.cumprod(1.0 + (0.0004 + rng.normal(0, 0.01, n)))
    dates = tuple(f"{2016 + i // 252}-{(i % 12) + 1:02d}-{(i % 27) + 1:02d}" for i in range(n))
    ps = PriceSeries(market=Market(symbol="ES", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    for kind in ("breakout", "seasonality", "vol_managed_momentum"):
        res = run_backtest(ps, StrategySpec(kind=kind, params={}))
        assert np.isfinite(res.stats.sharpe)
        assert len(res.returns) == n
```

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

Run: `python3 -m pytest tests/unit/test_registration.py -q`
Expected: FAIL — `available()` returns only `['buy_hold', 'mean_reversion', 'trend']` (the new kinds are not imported yet, so registration never runs).

- [ ] **Step 3: Register the new kinds**

Replace the import block in `src/fxhnt/domain/strategies/__init__.py` (lines 2-6) with:

```python
from fxhnt.domain.strategies import (  # noqa: F401  (import for side-effect: registration)
    breakout,
    buy_hold,
    mean_reversion,
    seasonality,
    trend,
    vol_managed_momentum,
)
```

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

Run: `python3 -m pytest tests/unit/test_registration.py -q`
Expected: PASS (3 passed).

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/domain/strategies/__init__.py tests/unit/test_registration.py
git commit -m "feat(strategies): register breakout/seasonality/vol_managed_momentum (3->6 kinds)"
```

---

## Task 7: Databento adapter — fill high/low (+ NaN guard)

**Files:**
- Modify: `src/fxhnt/adapters/data/databento.py:39-63`
- Test: `tests/unit/test_adapters_ohlc.py`

- [ ] **Step 1: Write the failing test (pure helper, no network)**

```python
# tests/unit/test_adapters_ohlc.py
"""Adapter dataframe/payload -> PriceSeries transforms fill real high/low (pure helpers, no network)."""
from __future__ import annotations

import numpy as np
import pandas as pd

from fxhnt.domain.models import AssetClass, Market

_M = Market(symbol="ES", asset_class=AssetClass.FUTURE)


def test_databento_df_to_series_fills_high_low_and_guards_nan() -> None:
    from fxhnt.adapters.data.databento import _df_to_series
    idx = pd.to_datetime(["2020-01-01", "2020-01-02", "2020-01-03"])
    df = pd.DataFrame({"close": [100.0, 101.0, 102.0],
                       "high": [101.0, np.nan, 103.0],     # NaN must fall back to close
                       "low": [99.0, 100.0, 101.0]}, index=idx)
    ps = _df_to_series(_M, df)
    assert list(ps.dates) == ["2020-01-01", "2020-01-02", "2020-01-03"]
    assert list(ps.high) == [101.0, 101.0, 103.0]          # NaN -> close (101.0)
    assert list(ps.low) == [99.0, 100.0, 101.0]
```

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

Run: `python3 -m pytest tests/unit/test_adapters_ohlc.py::test_databento_df_to_series_fills_high_low_and_guards_nan -q`
Expected: FAIL — `ImportError: cannot import name '_df_to_series'`.

- [ ] **Step 3: Implement the helper and call it from `fetch`**

In `src/fxhnt/adapters/data/databento.py`, add this module-level helper (e.g. after the imports / before the class):

```python
def _df_to_series(market: Market, df) -> PriceSeries:
    """Pure transform: an ohlcv-1d dataframe -> PriceSeries with NaN-guarded high/low."""
    dates = tuple(str(idx.date()) for idx in df.index)
    close = df["close"].to_numpy(dtype=float)
    high = df["high"].to_numpy(dtype=float) if "high" in df.columns else close
    low = df["low"].to_numpy(dtype=float) if "low" in df.columns else close
    high = np.where(np.isfinite(high), high, close)        # NaN bar -> flat (close)
    low = np.where(np.isfinite(low), low, close)
    return PriceSeries(market=market, dates=dates, close=close, high=high, low=low)
```

Then replace the tail of `fetch` (the current lines 58-63 that build `dates`/`close` and return) with:

```python
        df = data.to_df()
        if df.empty:
            raise RuntimeError(f"databento returned no data for {market} ({dataset}/{symbol})")
        return _df_to_series(market, df)
```

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

Run: `python3 -m pytest tests/unit/test_adapters_ohlc.py::test_databento_df_to_series_fills_high_low_and_guards_nan -q`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/data/databento.py tests/unit/test_adapters_ohlc.py
git commit -m "feat(databento): fill NaN-guarded high/low via pure _df_to_series helper"
```

---

## Task 8: Yahoo adapter — adjustment-scaled high/low

**Files:**
- Modify: `src/fxhnt/adapters/data/yahoo.py:26-48`
- Test: `tests/unit/test_adapters_ohlc.py` (append)

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

Append to `tests/unit/test_adapters_ohlc.py`:

```python
def test_yahoo_build_series_scales_high_low_by_adjustment_ratio() -> None:
    from fxhnt.adapters.data.yahoo import _build_series
    # day 0: adjusted (ratio 0.5 -> a 2:1 split adjustment); day 1: ratio 1.0
    ts = [1577836800, 1577923200]              # 2020-01-01, 2020-01-02 (UTC)
    adjclose = [50.0, 101.0]
    raw_close = [100.0, 101.0]                 # ratio = adj/raw = 0.5, then 1.0
    raw_high = [110.0, 102.0]
    raw_low = [90.0, 100.0]
    ps = _build_series(_M, ts, adjclose, raw_close, raw_high, raw_low, today="2099-01-01",
                       start=None, end=None)
    assert list(ps.close) == [50.0, 101.0]
    assert list(ps.high) == [55.0, 102.0]      # 110*0.5, 102*1.0
    assert list(ps.low) == [45.0, 100.0]       # 90*0.5, 100*1.0


def test_yahoo_build_series_defaults_missing_high_low_to_close() -> None:
    from fxhnt.adapters.data.yahoo import _build_series
    ts = [1577836800, 1577923200]
    ps = _build_series(_M, ts, adjclose=[100.0, 101.0], raw_close=[100.0, 101.0],
                       raw_high=[None, 102.0], raw_low=[99.0, None], today="2099-01-01",
                       start=None, end=None)
    assert list(ps.high) == [100.0, 102.0]     # None high -> that bar's close
    assert list(ps.low) == [99.0, 101.0]       # None low -> that bar's close
```

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

Run: `python3 -m pytest tests/unit/test_adapters_ohlc.py -k yahoo -q`
Expected: FAIL — `ImportError: cannot import name '_build_series'`.

- [ ] **Step 3: Implement the helper and rewire `fetch`**

In `src/fxhnt/adapters/data/yahoo.py`, add this module-level helper:

```python
def _build_series(market: Market, ts, adjclose, raw_close, raw_high, raw_low,
                  today: str, start: str | None, end: str | None) -> PriceSeries:
    """Pure transform of Yahoo arrays -> PriceSeries. Adjusted close is canonical; raw high/low are
    scaled by the per-row adjclose/raw_close ratio. Missing high/low (or no adjclose) default to that
    bar's adjusted close — a row is dropped only when its adjusted close is None (unchanged policy)."""
    dates: list[str] = []
    close: list[float] = []
    high: list[float] = []
    low: list[float] = []
    for i, t in enumerate(ts):
        ac = adjclose[i] if adjclose is not None else None
        rc = raw_close[i] if raw_close is not None else None
        c = ac if ac is not None else rc           # fall back to raw close when no adjclose
        if c is None:
            continue                               # drop only when there is no close at all
        d = dt.datetime.fromtimestamp(t, dt.timezone.utc).strftime("%Y-%m-%d")
        if d == today or (start and d < start) or (end and d > end):
            continue
        c = float(c)
        ratio = (c / float(rc)) if (rc not in (None, 0)) else 1.0
        rh = raw_high[i] if raw_high is not None else None
        rl = raw_low[i] if raw_low is not None else None
        dates.append(d)
        close.append(c)
        high.append(float(rh) * ratio if rh is not None else c)
        low.append(float(rl) * ratio if rl is not None else c)
    return PriceSeries(market=market, dates=tuple(dates), close=np.array(close, dtype=float),
                       high=np.array(high, dtype=float), low=np.array(low, dtype=float))
```

Then replace the body of `fetch` after the `payload = ...` line (current lines 30-48) with:

```python
        res = payload["chart"]["result"][0]
        ts = res["timestamp"]
        ind = res["indicators"]
        quote = ind["quote"][0]
        adjclose = ind.get("adjclose", [{}])[0].get("adjclose")
        today = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d")
        return _build_series(market, ts, adjclose, quote.get("close"), quote.get("high"),
                             quote.get("low"), today, start, end)
```

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

Run: `python3 -m pytest tests/unit/test_adapters_ohlc.py -k yahoo -q`
Expected: PASS (2 passed).

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/data/yahoo.py tests/unit/test_adapters_ohlc.py
git commit -m "feat(yahoo): adjustment-scaled high/low via pure _build_series helper"
```

---

## Task 9: Local DBN loader — ratio-adjusted synthetic high/low

**Files:**
- Modify: `src/fxhnt/adapters/data/dbn_local.py:25-53`
- Test: `tests/unit/test_adapters_ohlc.py` (append)

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

Append to `tests/unit/test_adapters_ohlc.py`:

```python
def test_dbn_local_front_to_series_preserves_range_across_roll() -> None:
    from fxhnt.adapters.data.dbn_local import _front_to_series
    days = np.array([0, 1, 2], dtype=np.int64)
    inst = np.array([10, 10, 20], dtype=np.int64)      # roll between day 1 and day 2 (new contract)
    close = np.array([100.0, 102.0, 50.0])             # day 2 is a different contract price level
    high = np.array([101.0, 103.0, 51.0])
    low = np.array([99.0, 101.0, 49.0])
    ps = _front_to_series("ES", AssetClass.FUTURE, days, inst, close, high, low)
    # synthetic ratio-adjusted: roll day books 0 return (no cross-contract gap)
    assert ps.close[2] == ps.close[1]                  # roll -> flat synthetic level
    # intraday range is preserved proportionally on the adjusted scale (high/close on day 2 == raw 51/50)
    assert ps.high[2] / ps.close[2] == 51.0 / 50.0
    assert ps.low[2] / ps.close[2] == 49.0 / 50.0
    assert ps.high[0] >= ps.close[0] >= ps.low[0]
```

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

Run: `python3 -m pytest tests/unit/test_adapters_ohlc.py::test_dbn_local_front_to_series_preserves_range_across_roll -q`
Expected: FAIL — `ImportError: cannot import name '_front_to_series'`.

- [ ] **Step 3: Implement the helper and rewire `load`**

In `src/fxhnt/adapters/data/dbn_local.py`, add this module-level helper:

```python
def _front_to_series(symbol: str, asset_class: AssetClass, days: np.ndarray, inst: np.ndarray,
                     close: np.ndarray, high: np.ndarray, low: np.ndarray) -> PriceSeries:
    """Build the continuous front-month PriceSeries. Close is ratio back-adjusted (roll books 0 return,
    never a cross-contract gap); high/low are scaled by the same per-day factor so the intraday range is
    preserved proportionally on the adjusted scale."""
    log_close = np.log(close)
    ret = np.zeros(len(close))
    ret[1:] = log_close[1:] - log_close[:-1]
    same_contract = np.zeros(len(close), bool)
    same_contract[1:] = inst[1:] == inst[:-1]
    ret = np.where(same_contract & np.isfinite(ret), ret, 0.0)        # roll day -> 0, no cross-contract gap
    synthetic = 100.0 * np.exp(np.cumsum(ret))                        # ratio-adjusted continuous close
    factor = np.divide(synthetic, close, out=np.ones_like(synthetic), where=close > 0)
    syn_high = np.where(np.isfinite(high), high, close) * factor
    syn_low = np.where(np.isfinite(low), low, close) * factor
    dates = tuple(dt.datetime.fromtimestamp(int(d) * 86_400, dt.timezone.utc).strftime("%Y-%m-%d")
                  for d in days)
    return PriceSeries(market=Market(symbol=symbol, asset_class=asset_class), dates=dates,
                       close=synthetic, high=syn_high, low=syn_low)
```

Then in `load`, change the front-row selection to carry high/low and delegate to the helper. Replace the current lines that build `front`, `days`, `inst`, `close`, and the synthetic series + return (current lines 39-53) with:

```python
        front = df.loc[df.groupby("day")["volume"].idxmax(),
                       ["day", "instrument_id", "close", "high", "low"]].sort_values("day")
        return _front_to_series(
            symbol, asset_class,
            front["day"].to_numpy(np.int64), front["instrument_id"].to_numpy(np.int64),
            front["close"].to_numpy(np.float64), front["high"].to_numpy(np.float64),
            front["low"].to_numpy(np.float64),
        )
```

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

Run: `python3 -m pytest tests/unit/test_adapters_ohlc.py::test_dbn_local_front_to_series_preserves_range_across_roll -q`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/data/dbn_local.py tests/unit/test_adapters_ohlc.py
git commit -m "feat(dbn_local): ratio-adjusted synthetic high/low via pure _front_to_series helper"
```

---

## Task 10: DuckDB analytical store — schema migration + save/load high/low

**Files:**
- Modify: `src/fxhnt/adapters/persistence/analytical.py:11-37`
- Test: `tests/integration/test_duckdb_ohlc.py`

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

```python
# tests/integration/test_duckdb_ohlc.py
"""DuckDB store round-trips high/low and migrates a pre-existing close-only DB."""
from __future__ import annotations

import numpy as np

from fxhnt.adapters.persistence.analytical import DuckDbAnalyticalStore
from fxhnt.domain.models import AssetClass, Market, PriceSeries

_M = Market(symbol="ES", asset_class=AssetClass.FUTURE)


def test_save_load_round_trips_high_low(tmp_path) -> None:
    store = DuckDbAnalyticalStore(str(tmp_path / "a.duckdb"))
    ps = PriceSeries(market=_M, dates=("2020-01-01", "2020-01-02"),
                     close=np.array([100.0, 101.0]), high=np.array([101.0, 102.0]),
                     low=np.array([99.0, 100.0]))
    store.save_prices(ps)
    got = store.load_prices(_M)
    assert got is not None
    assert list(got.high) == [101.0, 102.0]
    assert list(got.low) == [99.0, 100.0]
    store.close()


def test_pre_migration_rows_load_with_high_low_equal_close(tmp_path) -> None:
    import duckdb
    db = str(tmp_path / "old.duckdb")
    con = duckdb.connect(db)                       # simulate an OLD close-only schema + row
    con.execute("CREATE TABLE prices (symbol VARCHAR, asset_class VARCHAR, date VARCHAR, close DOUBLE)")
    con.execute("INSERT INTO prices VALUES ('ES', 'future', '2019-12-31', 95.0)")
    con.close()
    store = DuckDbAnalyticalStore(db)              # __init__ migrates (ADD COLUMN IF NOT EXISTS)
    got = store.load_prices(_M)
    assert got is not None
    assert list(got.close) == [95.0]
    assert list(got.high) == [95.0] and list(got.low) == [95.0]   # NULL high/low -> close
    store.close()
```

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

Run: `python3 -m pytest tests/integration/test_duckdb_ohlc.py -q`
Expected: FAIL — `save_prices` inserts 4 columns / `load_prices` selects only `close`, so high/low are absent (AssertionError or binder error).

- [ ] **Step 3: Implement the migration + save/load**

In `src/fxhnt/adapters/persistence/analytical.py`, update `__init__` to run the migration after the schema, and rewrite `save_prices`/`load_prices` to carry high/low. Replace lines 11-37 with:

```python
_SCHEMA = [
    "CREATE TABLE IF NOT EXISTS prices (symbol VARCHAR, asset_class VARCHAR, date VARCHAR, close DOUBLE)",
    "CREATE TABLE IF NOT EXISTS returns (run_id VARCHAR, date VARCHAR, ret DOUBLE)",
]
_MIGRATIONS = [
    "ALTER TABLE prices ADD COLUMN IF NOT EXISTS high DOUBLE",
    "ALTER TABLE prices ADD COLUMN IF NOT EXISTS low DOUBLE",
]


class DuckDbAnalyticalStore:
    def __init__(self, path: str) -> None:
        self._con = duckdb.connect(path)
        for ddl in _SCHEMA:
            self._con.execute(ddl)
        for ddl in _MIGRATIONS:                     # additive migration: safe on new and existing DBs
            self._con.execute(ddl)

    def save_prices(self, prices: PriceSeries) -> None:
        m = prices.market
        rows = [(m.symbol, m.asset_class.value, d, float(c), float(h), float(lo))
                for d, c, h, lo in zip(prices.dates, prices.close, prices.high, prices.low)]
        self._con.execute("DELETE FROM prices WHERE symbol = ? AND asset_class = ?",
                          [m.symbol, m.asset_class.value])
        self._con.executemany(
            "INSERT INTO prices (symbol, asset_class, date, close, high, low) VALUES (?, ?, ?, ?, ?, ?)",
            rows)

    def load_prices(self, market: Market) -> PriceSeries | None:
        rows = self._con.execute(
            "SELECT date, close, COALESCE(high, close), COALESCE(low, close) FROM prices "
            "WHERE symbol = ? AND asset_class = ? ORDER BY date",
            [market.symbol, market.asset_class.value],
        ).fetchall()
        if not rows:
            return None
        return PriceSeries(market=market, dates=tuple(r[0] for r in rows),
                           close=np.array([r[1] for r in rows], dtype=float),
                           high=np.array([r[2] for r in rows], dtype=float),
                           low=np.array([r[3] for r in rows], dtype=float))
```

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

Run: `python3 -m pytest tests/integration/test_duckdb_ohlc.py -q`
Expected: PASS (2 passed).

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/persistence/analytical.py tests/integration/test_duckdb_ohlc.py
git commit -m "feat(duckdb): migrate + round-trip high/low (COALESCE fallback to close)"
```

---

## Final verification

- [ ] **Run the full suite**

Run: `python3 -m pytest tests/ -q`
Expected: PASS — all new tests plus the pre-existing suite (the 12 close-only fixtures still pass via the high/low default).

- [ ] **Sanity-check the new palette**

Run: `python3 -c "from fxhnt.domain.strategies import available; print(available())"`
Expected: `['breakout', 'buy_hold', 'mean_reversion', 'seasonality', 'trend', 'vol_managed_momentum']`

- [ ] **Lint (if configured)**

Run: `python3 -m ruff check src/fxhnt/domain/strategies src/fxhnt/adapters/data src/fxhnt/adapters/persistence`
Expected: no errors (or only pre-existing, unrelated ones).
