feat(b1): growth-discipline paper-forward blend (composes multistrat, faithful)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-16 06:54:27 +02:00
parent f2aade706a
commit 6e004f1749
3 changed files with 92 additions and 2 deletions

View File

@@ -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

View File

@@ -0,0 +1,13 @@
"""Growth-discipline blend: w·QQQ + (1w)·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]

View File

@@ -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