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:
@@ -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
|
||||
|
||||
13
src/fxhnt/domain/strategies/growth_discipline.py
Normal file
13
src/fxhnt/domain/strategies/growth_discipline.py
Normal file
@@ -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]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user