Files
fxhnt/tests/integration/test_massive_daily.py
2026-06-16 08:38:29 +02:00

127 lines
6.0 KiB
Python

"""MassiveDailyClient — total-return reconstruction from Massive (Polygon) split-only adjusted closes
+ dividends. NO live network: the `_get` HTTP layer is monkeypatched to return fixed fixtures.
The critical invariant proven here: on an ex-dividend day, the day-over-day return of the returned
total-return series equals the raw PRICE return PLUS dividend/prev_close — i.e. the dividend is added
back, matching Yahoo adjclose semantics. With no dividends, TR closes == split-adjusted closes.
"""
from __future__ import annotations
import datetime as dt
from typing import Any
from fxhnt.adapters.data.massive_daily import MassiveDailyClient
def _ms(date: str) -> int:
"""Midnight-UTC epoch milliseconds for a YYYY-MM-DD date (matches Massive aggregate `t`)."""
d = dt.datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=dt.timezone.utc)
return int(d.timestamp() * 1000)
def _aggs(closes: dict[str, float]) -> dict[str, Any]:
return {"results": [{"t": _ms(d), "c": c} for d, c in sorted(closes.items())]}
def _divs(divs: list[tuple[str, float]]) -> dict[str, Any]:
return {"results": [{"ex_dividend_date": d, "cash_amount": c} for d, c in divs]}
def _install(client: MassiveDailyClient, closes: dict[str, float], divs: list[tuple[str, float]]) -> None:
"""Route `_get` by endpoint: aggregates vs dividends. No network."""
def fake_get(url: str, tries: int = 4) -> dict[str, Any]:
if "/v3/reference/dividends" in url:
return _divs(divs)
if "/v2/aggs/ticker/" in url:
return _aggs(closes)
raise AssertionError(f"unexpected url: {url}")
client._get = fake_get # type: ignore[method-assign]
def test_returns_date_to_price_dict() -> None:
client = MassiveDailyClient(api_key="x")
closes = {"2026-01-02": 100.0, "2026-01-03": 101.0, "2026-01-06": 102.0}
_install(client, closes, divs=[])
out = client.adj_closes("SPY")
assert isinstance(out, dict)
assert set(out) == set(closes)
assert all(isinstance(k, str) and isinstance(v, float) for k, v in out.items())
def test_no_dividends_returns_split_adjusted_unchanged() -> None:
client = MassiveDailyClient(api_key="x")
closes = {"2026-01-02": 100.0, "2026-01-03": 101.0, "2026-01-06": 102.0}
_install(client, closes, divs=[])
out = client.adj_closes("SPY")
for d, c in closes.items():
assert abs(out[d] - c) < 1e-12
def test_ex_div_day_return_includes_dividend() -> None:
"""The load-bearing test: a single $1.80 dividend ex-date 2026-01-03.
Raw price return on the ex-div day = c[03]/c[02] - 1.
Total-return on the ex-div day = tr[03]/tr[02] - 1, with the dividend added back: exactly
c_D/(prev_close - C) - 1 (canonical), ≈ price_return + dividend/prev_close (first order).
"""
cash = 1.80
closes = {"2026-01-02": 100.0, "2026-01-03": 99.0, "2026-01-06": 100.0}
client = MassiveDailyClient(api_key="x")
_install(client, closes, divs=[("2026-01-03", cash)])
out = client.adj_closes("SPY")
prev_close = closes["2026-01-02"]
price_return = closes["2026-01-03"] / prev_close - 1.0
tr_return = out["2026-01-03"] / out["2026-01-02"] - 1.0
# EXACT canonical (CRSP / Yahoo) back-adjustment identity: prior close scaled by
# (1 - C/prev_close) ⇒ ex-div-day total return = c_D/(prev_close - C) - 1, the dividend added back.
exact_tr_return = closes["2026-01-03"] / (prev_close - cash) - 1.0
assert abs(tr_return - exact_tr_return) < 1e-9
# First-order (additive) form the spec describes: price return PLUS dividend yield.
additive_tr_return = price_return + cash / prev_close
assert abs(tr_return - additive_tr_return) < 1e-3
# The most recent close is the anchor: factors only scale dates strictly before the ex-div date.
assert abs(out["2026-01-06"] - closes["2026-01-06"]) < 1e-12
assert abs(out["2026-01-03"] - closes["2026-01-03"]) < 1e-12
# Total return on an ex-div day is strictly higher than the raw price return.
assert tr_return > price_return
def test_multi_dividend_factors_compound() -> None:
"""Two dividends on different ex-dates: back-adjustment factors must COMPOUND.
Dates strictly before BOTH ex-divs receive both factors multiplied together; a date that lies
on/after the earlier ex-div but strictly before the later one receives ONLY the later factor; the
most-recent close (on the later ex-div date) is the anchor and is unscaled. This locks the
`factor[d] *= f` accumulation in MassiveDailyClient._total_return.
"""
# Split-adjusted closes on five consecutive trading dates.
closes = {
"2026-01-02": 100.0,
"2026-01-05": 101.0,
"2026-01-06": 99.0,
"2026-01-07": 102.0,
"2026-01-08": 100.0,
}
cash1 = 1.50 # ex-date 2026-01-06; trading day before = 2026-01-05 (close 101.0)
cash2 = 2.00 # ex-date 2026-01-08; trading day before = 2026-01-07 (close 102.0)
client = MassiveDailyClient(api_key="x")
_install(client, closes, divs=[("2026-01-06", cash1), ("2026-01-08", cash2)])
out = client.adj_closes("SPY")
f1 = 1.0 - cash1 / closes["2026-01-05"] # = 0.9851485148514851
f2 = 1.0 - cash2 / closes["2026-01-07"] # = 0.9803921568627451
# Strictly before BOTH ex-divs ⇒ BOTH factors compounded.
assert abs(out["2026-01-02"] - closes["2026-01-02"] * f1 * f2) < 1e-9 # 96.58318773053776
assert abs(out["2026-01-05"] - closes["2026-01-05"] * f1 * f2) < 1e-9 # 97.54901960784314
# On/after the earlier ex-div (2026-01-06) but strictly before the later (2026-01-08) ⇒ ONLY f2.
assert abs(out["2026-01-06"] - closes["2026-01-06"] * f2) < 1e-9 # 97.05882352941175
assert abs(out["2026-01-07"] - closes["2026-01-07"] * f2) < 1e-9 # 100.0
# The later ex-div date is the most-recent close: anchor, unscaled.
assert abs(out["2026-01-08"] - closes["2026-01-08"]) < 1e-9 # 100.0
# Sanity: the doubly-adjusted date is scaled strictly more than the singly-adjusted one (f1<1).
assert out["2026-01-05"] / closes["2026-01-05"] < out["2026-01-06"] / closes["2026-01-06"]