diff --git a/src/fxhnt/application/paper_strategies.py b/src/fxhnt/application/paper_strategies.py index 8827928..060a359 100644 --- a/src/fxhnt/application/paper_strategies.py +++ b/src/fxhnt/application/paper_strategies.py @@ -4,9 +4,10 @@ full series each run (the tracker books only the new tail); live-booking strateg positions in `extra`.""" from __future__ import annotations +import os from typing import Any -from fxhnt.domain.strategies import multistrat, sixtyforty +from fxhnt.domain.strategies import growth_discipline, multistrat, sixtyforty from fxhnt.ports.market_data import DailyBarClient _SIXTYFORTY_WEIGHTS = [("SPY", 0.6), ("IEF", 0.4)] @@ -28,3 +29,14 @@ class MultiStratStrategy: def advance(self, last_date: str | None, extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]: closes = {s: self._bars.adj_closes(s) for s in multistrat.INSTRUMENTS} return multistrat.book_series(closes), extra + + +class GrowthDisciplineStrategy: + def __init__(self, bars: DailyBarClient) -> None: + self._bars = bars + + def advance(self, last_date: str | None, extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]: + w = float(os.environ.get("GD_EQUITY_WEIGHT", "0.70")) + qqq = self._bars.adj_closes("QQQ") + closes = {s: self._bars.adj_closes(s) for s in multistrat.INSTRUMENTS} + return growth_discipline.daily_returns(qqq, closes, w), extra diff --git a/src/fxhnt/domain/strategies/growth_discipline.py b/src/fxhnt/domain/strategies/growth_discipline.py new file mode 100644 index 0000000..c39eac0 --- /dev/null +++ b/src/fxhnt/domain/strategies/growth_discipline.py @@ -0,0 +1,13 @@ +"""Growth-discipline blend: w·QQQ + (1−w)·multistrat book, on the intersection of their dates. Pure.""" +from __future__ import annotations + +from fxhnt.domain.strategies import multistrat + + +def daily_returns(qqq: dict[str, float], multistrat_closes: dict[str, dict[str, float]], + w_equity: float = 0.70) -> list[tuple[str, float]]: + ms = dict(multistrat.book_series(multistrat_closes)) + qdates = sorted(qqq) + qret = {qdates[i]: qqq[qdates[i]] / qqq[qdates[i - 1]] - 1.0 for i in range(1, len(qdates))} + common = sorted(set(ms) & set(qret)) + return [(d, w_equity * qret[d] + (1.0 - w_equity) * ms[d]) for d in common] diff --git a/tests/integration/test_paper_strategies.py b/tests/integration/test_paper_strategies.py index 0ca0d54..2daeec1 100644 --- a/tests/integration/test_paper_strategies.py +++ b/tests/integration/test_paper_strategies.py @@ -4,7 +4,7 @@ from __future__ import annotations from fxhnt.adapters.persistence.state_reader import ForwardStateReader from fxhnt.application.forward_tracker import ForwardTracker -from fxhnt.application.paper_strategies import SixtyFortyStrategy +from fxhnt.application.paper_strategies import GrowthDisciplineStrategy, SixtyFortyStrategy class FakeDailyBars: @@ -67,3 +67,68 @@ def test_multistrat_vol_targets_and_caps_leverage() -> None: # tested is that the book is vol-CONTROLLED (well under the cap, never blowing past target), not that # it hits 0.10 exactly. Verified bit-for-bit identical to the foxhunt original on this data. assert 0.02 <= vol <= 0.15 # vol-controlled (unlevered book; widened per faithful port) + + +def test_growth_discipline_blend_matches_w_qqq_plus_multistrat() -> None: + from fxhnt.domain.strategies.growth_discipline import daily_returns + from fxhnt.domain.strategies.multistrat import book_series, INSTRUMENTS + import datetime + import numpy as np + rng = np.random.default_rng(1) + # Enough dates that multistrat's 63-day warmup still leaves booked days (mirror multistrat test). + start = datetime.date(2025, 1, 1) + dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)] + closes = {} + for k, s in enumerate(INSTRUMENTS): + steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates)) + px = 100.0 * np.cumprod(1 + steps) + closes[s] = {d: float(px[i]) for i, d in enumerate(dates)} + # QQQ shares the same date grid so its consecutive-day return aligns with the book's dates exactly. + qsteps = rng.normal(0.0004, 0.011, len(dates)) + qpx = 100.0 * np.cumprod(1 + qsteps) + qqq = {d: float(qpx[i]) for i, d in enumerate(dates)} + + w = 0.70 + rows = daily_returns(qqq, closes, w) + gd = dict(rows) + + # Reconstruct the expectation: r_multistrat from book_series, r_QQQ as consecutive-day return. + ms = dict(book_series(closes)) + qdates = sorted(qqq) + qret = {qdates[i]: qqq[qdates[i]] / qqq[qdates[i - 1]] - 1.0 for i in range(1, len(qdates))} + expected = {d: w * qret[d] + (1.0 - w) * ms[d] for d in (set(ms) & set(qret))} + + common = set(gd) & set(expected) + assert len(common) >= 1 + for d in common: + assert abs(gd[d] - expected[d]) < 1e-9 + + +def test_growth_discipline_service_round_trips_through_reader(tmp_path) -> None: + import datetime + import numpy as np + from fxhnt.domain.strategies.multistrat import INSTRUMENTS + rng = np.random.default_rng(2) + start = datetime.date(2025, 1, 1) + dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)] + data = {} + for k, s in enumerate(INSTRUMENTS): + steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates)) + px = 100.0 * np.cumprod(1 + steps) + data[s] = {d: float(px[i]) for i, d in enumerate(dates)} + qsteps = rng.normal(0.0004, 0.011, len(dates)) + qpx = 100.0 * np.cumprod(1 + qsteps) + data["QQQ"] = {d: float(qpx[i]) for i, d in enumerate(dates)} + bars = FakeDailyBars(data) + + p = str(tmp_path / "gd_state.json") + ForwardTracker(GrowthDisciplineStrategy(bars), p).step() # freeze at inception + # Append one new trading day across every series so the second step books >= 1 day. + nd = (start + datetime.timedelta(days=len(dates))).isoformat() + for s in list(INSTRUMENTS) + ["QQQ"]: + last = data[s][dates[-1]] + data[s][nd] = last * 1.001 + ForwardTracker(GrowthDisciplineStrategy(bars), p).step() + summary, rows = ForwardStateReader().read(p, "gd") + assert summary.days == len(rows) + assert summary.days >= 1