# Single-Market Strategy Kinds + OHLC Contract — Design Spec

**Date:** 2026-06-14
**Status:** Approved (design)
**Sub-project:** A of the "widen the idea space" effort. Sub-project B (cross-sectional/multi-data harness for carry/VRP) is a separate later spec.

## Goal

Widen the analyst fleet's backtestable strategy palette from 3 kinds to 6 by adding three
structurally-distinct single-market kinds — `seasonality`, `breakout` (true Donchian), and
`vol_managed_momentum` — and, to support a real Donchian, extend the `PriceSeries` data contract
with `high`/`low` filled by all data adapters.

## Context

The strategy contract (`domain/strategies/base.py`) is single-market and already fixed:

```python
class Strategy(Protocol):
    kind: str
    rationale: str  # the structural reason the edge should exist (the gauntlet requires one)
    def positions(self, prices: PriceSeries, params: dict[str, float]) -> np.ndarray: ...
```

`positions` returns a target position in `[-1, 1]` per day, set from info up to and including day `t`
and applied to day `t+1`'s return (no lookahead). A kind self-registers via `register(Instance())`
and is imported for side-effect in `strategies/__init__.py`. The fleet/gauntlet/factory consume
`available()` + the contract — they need **no changes**.

`available()` today: `['buy_hold', 'mean_reversion', 'trend']`. After this work:
`['breakout', 'buy_hold', 'mean_reversion', 'seasonality', 'trend', 'vol_managed_momentum']`.

The **rationale requirement** is the overfit brake: every kind must carry a structural economic
reason, which the Deflated-Sharpe gauntlet bar exists to test against the honest global trial count.

## Why these three kinds

- **seasonality** — commodity storage/weather/harvest/demand cycles are real, economically grounded,
  and structurally under-arbitraged. The causal estimator and negative-control discipline already
  live in `application/seasonality_research.py` (port, don't duplicate).
- **breakout (Donchian)** — channel breakout captures regime shifts via range expansion that a
  moving-average threshold smooths over; structurally distinct from the existing `trend` kind.
- **vol_managed_momentum** — Moreira-Muir: scaling exposure inversely to realized volatility raises
  Sharpe because volatility is persistent and forecastable while returns are not.

## Design

### Part 1 — Data contract (`domain/models.py`)

`PriceSeries` gains `high` and `low` as keyword fields defaulting to `close` when not supplied.

```python
@dataclass(frozen=True)
class PriceSeries:
    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")
```

**Decision (high/low default to close):** a bar with no intraday-range info is a flat bar. All four
real adapters supply *real* high/low; the default only covers degenerate/synthetic series (e.g. the
12 existing test fixtures that construct `PriceSeries(market, dates, close)`). This is the "full
path" — full contract + full adapter coverage + migration — and is distinct from the rejected
"optional/nullable" approach, where adapters themselves would fall back to close (half coverage).
`returns()` stays close-based (unchanged).

### Part 2 — Adapters (real high/low)

All four construction sites fill real high/low:

- **`adapters/data/databento.py`** — the fetch already uses `schema="ohlcv-1d"`; the dataframe has
  `high`/`low`. Add `high = df["high"].to_numpy(dtype=float)` and `low = df["low"].to_numpy(...)`.
  **NaN guard:** any NaN high/low must be replaced by that bar's close before constructing the series
  (`np.where(np.isfinite(high), high, close)`), else it propagates through `rolling_max`/`rolling_min`
  into NaN positions. Apply the same guard in every adapter that can emit NaN bars (yahoo, dbn_local).
- **`adapters/data/yahoo.py`** — Yahoo gives *adjusted* close but *raw* high/low, so raw high/low
  must be scaled by the per-row `adjclose/raw_close` ratio (else splits/dividends corrupt the range).
  Read `quote = ind["quote"][0]`; for each kept row `high_adj = raw_high * ratio`,
  `low_adj = raw_low * ratio` where `ratio = adjclose/raw_close`. **None-policy (do NOT drop rows):**
  keep the existing row-skip *only* on adjusted-close None (unchanged). For a kept row, if `raw_close`,
  `raw_high`, or `raw_low` is None — or the series fell back to raw close so there is no separate
  adjclose (then `ratio = 1`) — default that bar's `high_adj`/`low_adj` to its own adjusted close.
  This honors the contract's `high=low=close` degenerate-bar default instead of discarding a valid
  close observation.
- **`adapters/data/dbn_local.py`** — the continuous front-month series is synthetic
  (`synthetic_close = 100*exp(cumsum(ret))`, ratio back-adjusted). Pull each front day's raw
  `high`/`low` (alongside `close`) and apply the same per-day adjust factor
  `factor[t] = synthetic_close[t] / raw_close[t]`: `synthetic_high[t] = raw_high[t] * factor[t]`,
  likewise low. This keeps the intraday range proportionally correct on the adjusted scale.
- **`adapters/persistence/analytical.py`** (DuckDB) — schema migration: in `__init__`, after the
  `CREATE TABLE`, run `ALTER TABLE prices ADD COLUMN IF NOT EXISTS high DOUBLE` and same for `low`.
  `save_prices` writes the 6-column row; `load_prices` selects `date, close, high, low` and falls
  back to close when high/low is NULL (rows written before the migration).

### Part 3 — Strategy helpers + kinds

**`domain/strategies/base.py`** — add causal `rolling_max`/`rolling_min` (same shape as the existing
`rolling_mean`/`rolling_std`, using only `x[..t]`):

```python
def rolling_max(x: np.ndarray, window: int) -> np.ndarray:
    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
# rolling_min: identical with .min()
```

**`domain/strategies/breakout.py`** — true Donchian on high/low channel:

```python
class Breakout:
    kind = "breakout"
    rationale = "channel breakout — captures regime shifts via range expansion a moving-average threshold smooths over"
    def positions(self, prices, params):
        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)
        # decision uses channel of days [..t-1] vs close[t]; warmup until the channel is meaningful
        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 is a stop-and-reverse/hold rule)
        pos = np.zeros(len(c))
        pos[1:] = signal[:-1]  # shift: decision at t-1 drives return at t (no lookahead)
        return pos
```

Note the channel is read at `t-1` (`upper[t-1]`/`lower[t-1]`) so the breakout bar itself never enters
its own channel — combined with the final `pos` shift this is doubly lookahead-safe.

**`domain/strategies/seasonality.py`** — port `application/seasonality_research.seasonal_signal`:

```python
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, params):
        min_prior_years = int(params.get("min_prior_years", 3))
        _, sig = seasonal_signal(prices, min_prior_years=min_prior_years)
        # NO shift (unlike the close-derived kinds): seasonal_signal[t] is a function of the calendar +
        # PRIOR years only (yy < y), so it is already known at t-1's close and correctly aligned to day t.
        # Shifting it would add a spurious 1-day lag and misalign positions at month boundaries.
        return np.sign(sig)              # long if month historically positive, short if negative; 0 in warmup
```

This imports `seasonal_signal` from the application layer. `seasonal_signal` is pure (no I/O) and
takes only a `PriceSeries`, so the dependency direction is acceptable; if a strict domain→application
boundary is preferred during implementation, move `seasonal_signal` into a domain helper and have the
research module import it from there (the implementer may choose, but must not duplicate the logic).

**`domain/strategies/vol_managed_momentum.py`**:

```python
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, params):
        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]
        return pos
```

**Known limitation (vol_managed):** clipping `scale` to `[0, 1]` makes this *de-risk only* — it scales
exposure down in high-vol regimes but never levers *up* toward `target_vol` in calm ones. This is a
deliberate consequence of the `[-1, 1]` position contract, not full Moreira-Muir vol-targeting; the
`rationale` and any reporting must not over-claim. `target_vol` therefore acts as a vol *ceiling*.

**`domain/strategies/__init__.py`** — add `breakout, seasonality, vol_managed_momentum` to the
side-effect import block.

### Part 4 — Tests

**`tests/domain/test_strategies_single_market.py`** — per kind:
- **lookahead-free:** appending/altering the *last* bar must not change earlier positions; position[t]
  depends only on `prices[..t-1]`.
- **warmup:** zero position until enough history.
- **signal correctness on a constructed series:** seasonality on a synthetic "January always +"
  series goes long in January; breakout on a staircase goes long on the up-break and (with
  `long_short=1`) short on the down-break; vol_managed scales down when a vol spike is injected.
- **range:** all positions within `[-1, 1]`.
- **negative-control (seasonality):** on an i.i.d.-random (no-season) series the mean signal ≈ 0.

**`tests/adapters/test_ohlc.py`**:
- yahoo: a row with a known split-style `adjclose/close` ratio yields high/low scaled by that ratio.
- dbn_local: across a roll, high/low are ratio-adjusted by the same factor as close (range preserved
  proportionally; no cross-contract gap).
- DuckDB: save→load round-trips high/low; a pre-migration row (NULL high/low) loads with high=low=close.

**`tests/domain/test_registration.py`** (or extend the kinds test file):
- `available()` returns exactly the 6 kinds after import.
- each new kind is retrievable via `get_strategy(kind)` and exposes a non-empty `rationale`.
- end-to-end: `run_backtest` runs each new kind on a small `PriceSeries` and returns finite stats
  (catches contract/registration wiring breakage, not just the pure `positions` math).

**Existing fixtures:** the 12 test sites constructing `PriceSeries(market, dates, close)` stay
unchanged (default high=low=close).

## Out of scope

- Cross-sectional / multi-data kinds (carry term-structure, VRP options/IV) — sub-project B, its own
  spec → plan → build cycle.
- Any change to the fleet, gauntlet, governance, or backtest engine.
- HERC, intraday bars, or new data sources.

## Testing strategy

TDD per task (failing test → minimal impl → green → commit). Domain kinds are pure functions of a
`PriceSeries`, so every behavior is unit-testable on constructed series with known answers. Adapter
tests use small synthetic dataframes / a temp DuckDB file — no network, no live Databento.

## File summary

| File | Change |
|---|---|
| `domain/models.py` | `PriceSeries` + high/low keyword fields, `__post_init__` default+validate |
| `domain/strategies/base.py` | add `rolling_max`, `rolling_min` |
| `domain/strategies/breakout.py` | **create** — true Donchian kind |
| `domain/strategies/seasonality.py` | **create** — port of `seasonal_signal` |
| `domain/strategies/vol_managed_momentum.py` | **create** — Moreira-Muir vol-managed kind |
| `domain/strategies/__init__.py` | register the 3 new kinds |
| `adapters/data/databento.py` | fill high/low from ohlcv-1d df |
| `adapters/data/yahoo.py` | fill adjustment-scaled high/low |
| `adapters/data/dbn_local.py` | fill ratio-adjusted synthetic high/low |
| `adapters/persistence/analytical.py` | DuckDB schema migration + save/load high/low |
| `tests/domain/test_strategies_single_market.py` | **create** — per-kind tests |
| `tests/domain/test_registration.py` | **create** — `available()`==6, get_strategy, end-to-end run_backtest |
| `tests/adapters/test_ohlc.py` | **create** — adapter high/low tests |

## Critical review (2026-06-14, post-draft)

Adversarial pass over the draft; findings folded in above:

- **[HIGH] Seasonality must not be shifted.** `seasonal_signal[t]` depends only on the calendar +
  prior years, so it is known at t-1's close and already aligned to day t. The draft's `pos[1:] =
  signal[:-1]` added a spurious 1-day lag and month-boundary misalignment. **Fixed:** `return
  np.sign(sig)` with no shift.
- **[MEDIUM] Yahoo None-policy.** The draft dropped any row with a None raw high/low, discarding
  valid close observations and contradicting the contract's degenerate-bar default. **Fixed:** keep
  the row, default missing high/low to that bar's adjusted close; handle the adjclose-absent fallback
  (`ratio = 1`).
- **[LOW-MED] NaN high/low** propagate through `rolling_max`/`rolling_min`. **Fixed:** every adapter
  guards `np.where(np.isfinite(.), ., close)` before constructing the series.
- **[DOC] vol_managed is de-risk only**, not full vol-targeting (clip caps leverage at 1×). **Fixed:**
  documented as a `target_vol` ceiling; rationale must not over-claim.
- **[LOW] Registration coverage** was missing. **Fixed:** added `available()`==6 + end-to-end
  `run_backtest` test.
- **Verified OK (no change):** DuckDB `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` is supported and
  idempotent (v1.5.3); breakout lookahead (channel at `t-1` + final shift) is safe; dbn_local
  ratio-factor preserves intraday range correctly across rolls (high/close/low on day t share one
  contract).
