feat(b1): sixtyforty paper-forward strategy (Yahoo) — template

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-16 06:40:41 +02:00
parent 94dbf79133
commit 2e7ba4764c
4 changed files with 116 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
"""urllib DailyBarClient — Yahoo free daily adjusted closes. Retries with backoff; adjusted closes give
true total return (dividends + coupons). Mirrors foxhunt sixtyforty_paper.daily_adjclose."""
from __future__ import annotations
import datetime as dt
import json
import time
import urllib.request
from typing import Any
_BASE = "https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range={rng}"
class YahooDailyClient:
def __init__(self, rng: str = "2y") -> None:
self._rng = rng
def _get(self, url: str, tries: int = 4) -> dict[str, Any]:
for a in range(tries):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
data: dict[str, Any] = json.loads(urllib.request.urlopen(req, timeout=30).read())
return data
except Exception: # noqa: BLE001 — transient HTTP; retry then re-raise
if a == tries - 1:
raise
time.sleep(2 * (a + 1))
raise RuntimeError("unreachable")
def adj_closes(self, symbol: str) -> dict[str, float]:
res = self._get(_BASE.format(sym=symbol, rng=self._rng))["chart"]["result"][0]
ts = res["timestamp"]
ind = res["indicators"]
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
out: dict[str, float] = {}
for t, c in zip(ts, adj):
if c is not None:
out[dt.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d")] = float(c)
return out

View File

@@ -0,0 +1,21 @@
"""The paper-forward ForwardStrategy services: each composes a market-data port + a pure domain module
and yields candidate (date, ret) rows for the generic ForwardTracker. Recomputable strategies return the
full series each run (the tracker books only the new tail); live-booking strategies book today + carry
positions in `extra`."""
from __future__ import annotations
from typing import Any
from fxhnt.domain.strategies import sixtyforty
from fxhnt.ports.market_data import DailyBarClient
_SIXTYFORTY_WEIGHTS = [("SPY", 0.6), ("IEF", 0.4)]
class SixtyFortyStrategy:
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]]:
closes = {s: self._bars.adj_closes(s) for s, _ in _SIXTYFORTY_WEIGHTS}
return sixtyforty.daily_returns(closes, _SIXTYFORTY_WEIGHTS), extra

View File

@@ -0,0 +1,14 @@
"""Pure 60/40 daily-rebalanced return: for each consecutive date pair, the weighted mean of per-asset
returns on adjusted closes. No I/O."""
from __future__ import annotations
def daily_returns(closes: dict[str, dict[str, float]], weights: list[tuple[str, float]]) -> list[tuple[str, float]]:
syms = [s for s, _ in weights]
dates = sorted(set.intersection(*[set(closes[s]) for s in syms]))
out: list[tuple[str, float]] = []
for i in range(1, len(dates)):
d, dprev = dates[i], dates[i - 1]
r = sum(w * (closes[s][d] / closes[s][dprev] - 1.0) for s, w in weights)
out.append((d, r))
return out

View File

@@ -0,0 +1,42 @@
"""Paper-forward strategy services: deterministic domain + service over fake data ports, and a
round-trip through ForwardStateReader so the cockpit contract is verified."""
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
class FakeDailyBars:
def __init__(self, data: dict[str, dict[str, float]]) -> None:
self._data = data
def adj_closes(self, symbol: str) -> dict[str, float]:
return self._data[symbol]
def test_sixtyforty_daily_return_is_weighted_mean() -> None:
from fxhnt.domain.strategies.sixtyforty import daily_returns
closes = {
"SPY": {"2026-01-01": 100.0, "2026-01-02": 110.0}, # +10%
"IEF": {"2026-01-01": 100.0, "2026-01-02": 105.0}, # +5%
}
rows = daily_returns(closes, [("SPY", 0.6), ("IEF", 0.4)])
assert len(rows) == 1 and rows[0][0] == "2026-01-02"
assert abs(rows[0][1] - (0.6 * 0.10 + 0.4 * 0.05)) < 1e-12 # 0.08
def test_sixtyforty_service_round_trips_through_reader(tmp_path) -> None:
bars = FakeDailyBars({
"SPY": {"2026-01-01": 100.0, "2026-01-02": 110.0, "2026-01-03": 110.0},
"IEF": {"2026-01-01": 100.0, "2026-01-02": 105.0, "2026-01-03": 105.0},
})
p = str(tmp_path / "sixtyforty_state.json")
ForwardTracker(SixtyFortyStrategy(bars), p).step() # freeze at 2026-01-03
bars._data["SPY"]["2026-01-04"] = 121.0 # +10%
bars._data["IEF"]["2026-01-04"] = 105.0 # 0%
st = ForwardTracker(SixtyFortyStrategy(bars), p).step()
assert st.forward_days == 1
summary, rows = ForwardStateReader().read(p, "sixtyforty")
assert summary.days == 1
assert abs(summary.nav - (1.0 + 0.06)) < 1e-9 # 0.6*0.10 + 0.4*0