135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""TiingoDailyClient — native total-return adjClose (equity/ETF) + crypto routing. NO live network:
|
|
the `_get` HTTP layer is monkeypatched to return fixed fixtures.
|
|
|
|
Invariants proven here:
|
|
(1) equity symbols hit the daily endpoint and read `adjClose` (NOT raw `close`).
|
|
(2) crypto `BTC-USD` is routed to the crypto endpoint, ticker mapped to `btcusd`, and the nested
|
|
`priceData[].close` is read (crypto has no adjClose).
|
|
(3) the request window's startDate is computed from `lookback_days` (today - lookback_days).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import Any
|
|
|
|
from fxhnt.adapters.data.tiingo_daily import TiingoDailyClient
|
|
|
|
|
|
def _daily(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Tiingo equity/ETF daily response: a flat list with both `close` and `adjClose`."""
|
|
return [
|
|
{
|
|
"date": f"{r['date']}T00:00:00.000Z",
|
|
"close": r["close"],
|
|
"adjClose": r["adjClose"],
|
|
"divCash": 0.0,
|
|
"splitFactor": 1.0,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _crypto(ticker: str, rows: list[tuple[str, float]]) -> list[dict[str, Any]]:
|
|
"""Tiingo crypto response: a one-element list with nested `priceData` (close only, no adjClose)."""
|
|
return [
|
|
{
|
|
"ticker": ticker,
|
|
"baseCurrency": "btc",
|
|
"quoteCurrency": "usd",
|
|
"priceData": [
|
|
{"date": f"{d}T00:00:00.000Z", "open": c, "high": c, "low": c, "close": c, "volume": 1.0}
|
|
for d, c in rows
|
|
],
|
|
}
|
|
]
|
|
|
|
|
|
def test_equity_uses_adjclose_not_close() -> None:
|
|
client = TiingoDailyClient(api_key="x")
|
|
# adjClose deliberately differs from close so we can prove which field is read.
|
|
rows = [
|
|
{"date": "2026-01-02", "close": 100.0, "adjClose": 90.0},
|
|
{"date": "2026-01-03", "close": 101.0, "adjClose": 91.5},
|
|
{"date": "2026-01-06", "close": 102.0, "adjClose": 93.0},
|
|
]
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_get(url: str, tries: int = 4) -> Any:
|
|
captured["url"] = url
|
|
assert "/tiingo/daily/SPY/prices" in url
|
|
return _daily(rows)
|
|
|
|
client._get = fake_get
|
|
out = client.adj_closes("SPY")
|
|
|
|
assert out == {"2026-01-02": 90.0, "2026-01-03": 91.5, "2026-01-06": 93.0}
|
|
assert all(isinstance(k, str) and isinstance(v, float) for k, v in out.items())
|
|
# Daily endpoint, NOT crypto.
|
|
assert "/tiingo/crypto/" not in captured["url"]
|
|
|
|
|
|
def test_crypto_btc_routed_and_ticker_mapped() -> None:
|
|
client = TiingoDailyClient(api_key="x")
|
|
rows = [("2026-01-02", 45000.0), ("2026-01-03", 46000.5), ("2026-01-06", 44000.25)]
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_get(url: str, tries: int = 4) -> Any:
|
|
captured["url"] = url
|
|
# Routed to the crypto endpoint with the Yahoo ticker mapped to btcusd.
|
|
assert "/tiingo/crypto/prices" in url
|
|
assert "tickers=btcusd" in url
|
|
assert "resampleFreq=1day" in url
|
|
assert "/tiingo/daily/" not in url
|
|
return _crypto("btcusd", rows)
|
|
|
|
client._get = fake_get
|
|
out = client.adj_closes("BTC-USD")
|
|
|
|
# Nested priceData[].close is read (crypto has no adjClose).
|
|
assert out == {"2026-01-02": 45000.0, "2026-01-03": 46000.5, "2026-01-06": 44000.25}
|
|
assert "tickers=btcusd" in captured["url"]
|
|
|
|
|
|
def test_startdate_window_from_lookback_days() -> None:
|
|
client = TiingoDailyClient(api_key="x", lookback_days=1100)
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_get(url: str, tries: int = 4) -> Any:
|
|
captured["url"] = url
|
|
return _daily([{"date": "2026-01-02", "close": 100.0, "adjClose": 100.0}])
|
|
|
|
client._get = fake_get
|
|
client.adj_closes("SPY")
|
|
|
|
expected_start = (dt.date.today() - dt.timedelta(days=1100)).isoformat()
|
|
expected_end = dt.date.today().isoformat()
|
|
assert f"startDate={expected_start}" in captured["url"]
|
|
assert f"endDate={expected_end}" in captured["url"]
|
|
|
|
|
|
def test_crypto_window_from_lookback_days() -> None:
|
|
client = TiingoDailyClient(api_key="x", lookback_days=365)
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_get(url: str, tries: int = 4) -> Any:
|
|
captured["url"] = url
|
|
return _crypto("btcusd", [("2026-01-02", 45000.0)])
|
|
|
|
client._get = fake_get
|
|
client.adj_closes("BTC-USD")
|
|
|
|
expected_start = (dt.date.today() - dt.timedelta(days=365)).isoformat()
|
|
assert f"startDate={expected_start}" in captured["url"]
|
|
|
|
|
|
def test_empty_equity_response_returns_empty_dict() -> None:
|
|
client = TiingoDailyClient(api_key="x")
|
|
client._get = lambda url, tries=4: [] # noqa: E731
|
|
assert client.adj_closes("SPY") == {}
|
|
|
|
|
|
def test_empty_crypto_response_returns_empty_dict() -> None:
|
|
client = TiingoDailyClient(api_key="x")
|
|
client._get = lambda url, tries=4: [] # noqa: E731
|
|
assert client.adj_closes("BTC-USD") == {}
|