127 lines
6.0 KiB
Python
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"]
|