diff --git a/src/fxhnt/adapters/data/yahoo_daily.py b/src/fxhnt/adapters/data/yahoo_daily.py new file mode 100644 index 0000000..b11ccf3 --- /dev/null +++ b/src/fxhnt/adapters/data/yahoo_daily.py @@ -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 diff --git a/src/fxhnt/application/paper_strategies.py b/src/fxhnt/application/paper_strategies.py new file mode 100644 index 0000000..9047395 --- /dev/null +++ b/src/fxhnt/application/paper_strategies.py @@ -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 diff --git a/src/fxhnt/domain/strategies/sixtyforty.py b/src/fxhnt/domain/strategies/sixtyforty.py new file mode 100644 index 0000000..7607bea --- /dev/null +++ b/src/fxhnt/domain/strategies/sixtyforty.py @@ -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 diff --git a/tests/integration/test_paper_strategies.py b/tests/integration/test_paper_strategies.py new file mode 100644 index 0000000..033e3f4 --- /dev/null +++ b/tests/integration/test_paper_strategies.py @@ -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