feat(b2): TiingoFundamentalsClient (daily metrics + statements)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
src/fxhnt/adapters/data/tiingo_fundamentals.py
Normal file
117
src/fxhnt/adapters/data/tiingo_fundamentals.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""urllib FundamentalsClient — Tiingo fundamentals for the equity-factor sleeve. Two reads:
|
||||
|
||||
metrics(symbol): latest DAILY valuation metrics (marketCap, enterpriseVal, peRatio,
|
||||
pbRatio, trailingPEG1Y, ...). Endpoint serves a flat JSON list of
|
||||
dated rows; we read the LATEST (max date) row.
|
||||
GET {base}/tiingo/fundamentals/{ticker}/daily?startDate=YYYY-MM-DD
|
||||
→ [{date, marketCap, enterpriseVal, peRatio, pbRatio, trailingPEG1Y}]
|
||||
|
||||
statement_metrics(symbol): latest reported STATEMENT metrics (piotroskiFScore, roe, roa,
|
||||
debtEquity, grossMargin, profitMargin, revenueQoQ, bookVal, epsDil, ...).
|
||||
Endpoint serves a JSON list of period entries; we take the LATEST
|
||||
(max date) entry and flatten its statementData sections (overview +
|
||||
incomeStatement + balanceSheet + cashFlow) into {dataCode: float(value)}.
|
||||
GET {base}/tiingo/fundamentals/{ticker}/statements?startDate=YYYY-MM-DD
|
||||
→ [{date, year, quarter, statementData: {overview: [{dataCode, value}],
|
||||
incomeStatement: [...], balanceSheet: [...], cashFlow: [...]}}]
|
||||
|
||||
Both default a startDate window of ~800 days (well inside entitlement; gives several recent quarters
|
||||
of statements and a long daily-metrics history) and read the most-recent available period.
|
||||
|
||||
Auth: header `Authorization: Token <key>` with TIINGO_API_KEY (env). The key is used ONLY in the
|
||||
request header — never logged or interpolated into a URL.
|
||||
|
||||
NOTE: shapes above are coded to the documented/verified Tiingo response shapes; this build env had
|
||||
no TIINGO_API_KEY, so the first live run will confirm. None-valued fields are skipped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
_DAILY = "{base}/tiingo/fundamentals/{sym}/daily?startDate={frm}&format=json"
|
||||
_STATEMENTS = "{base}/tiingo/fundamentals/{sym}/statements?startDate={frm}&format=json"
|
||||
|
||||
# Valuation fields read from a fundamentals/daily row (all but `date`).
|
||||
_DAILY_FIELDS = ("marketCap", "enterpriseVal", "peRatio", "pbRatio", "trailingPEG1Y")
|
||||
# statementData sections flattened by statement_metrics.
|
||||
_STATEMENT_SECTIONS = ("overview", "incomeStatement", "balanceSheet", "cashFlow")
|
||||
|
||||
|
||||
class TiingoFundamentalsClient:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
lookback_days: int = 800,
|
||||
base_url: str = "https://api.tiingo.com",
|
||||
) -> None:
|
||||
self._key = api_key if api_key is not None else os.environ["TIINGO_API_KEY"]
|
||||
self._lookback_days = lookback_days
|
||||
self._base = base_url.rstrip("/")
|
||||
|
||||
def _get(self, url: str, tries: int = 4) -> Any:
|
||||
for a in range(tries):
|
||||
try:
|
||||
# The key lives ONLY in this header — never in the URL or any log line.
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Token {self._key}"})
|
||||
return json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
except Exception: # noqa: BLE001 — transient HTTP; retry then re-raise
|
||||
if a == tries - 1:
|
||||
raise
|
||||
time.sleep(2 * (a + 1))
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
def _start(self) -> str:
|
||||
return (dt.date.today() - dt.timedelta(days=self._lookback_days)).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _latest(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""The row/entry with the max `date` (lexical compare on ISO/`YYYY-MM-DD...` strings)."""
|
||||
return max(rows, key=lambda r: str(r.get("date", "")))
|
||||
|
||||
def metrics(self, symbol: str) -> dict[str, float]:
|
||||
sym = urllib.parse.quote(symbol)
|
||||
rows: list[dict[str, Any]] = self._get(_DAILY.format(base=self._base, sym=sym, frm=self._start())) or []
|
||||
if not rows:
|
||||
return {}
|
||||
latest = self._latest(rows)
|
||||
out: dict[str, float] = {}
|
||||
for field in _DAILY_FIELDS:
|
||||
v = self._to_float(latest.get(field))
|
||||
if v is not None:
|
||||
out[field] = v
|
||||
return out
|
||||
|
||||
def statement_metrics(self, symbol: str) -> dict[str, float]:
|
||||
sym = urllib.parse.quote(symbol)
|
||||
entries: list[dict[str, Any]] = (
|
||||
self._get(_STATEMENTS.format(base=self._base, sym=sym, frm=self._start())) or []
|
||||
)
|
||||
if not entries:
|
||||
return {}
|
||||
latest = self._latest(entries)
|
||||
data: dict[str, Any] = latest.get("statementData") or {}
|
||||
out: dict[str, float] = {}
|
||||
for section in _STATEMENT_SECTIONS:
|
||||
for item in data.get(section) or []:
|
||||
code = item.get("dataCode")
|
||||
if code is None:
|
||||
continue
|
||||
v = self._to_float(item.get("value"))
|
||||
if v is not None:
|
||||
out[str(code)] = v
|
||||
return out
|
||||
200
tests/integration/test_tiingo_fundamentals.py
Normal file
200
tests/integration/test_tiingo_fundamentals.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""TiingoFundamentalsClient — latest daily valuation metrics + latest reported statement metrics.
|
||||
NO live network: the `_get` HTTP layer is monkeypatched to return fixed fixtures.
|
||||
|
||||
Invariants proven here:
|
||||
(1) metrics() hits the fundamentals/daily endpoint and returns the LATEST (max date) row's
|
||||
valuation fields (peRatio/pbRatio/marketCap, ...) as floats, skipping None.
|
||||
(2) statement_metrics() hits the fundamentals/statements endpoint, picks the LATEST (max date)
|
||||
entry, and flattens its statementData sections into a {dataCode: float(value)} dict.
|
||||
(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_fundamentals import TiingoFundamentalsClient
|
||||
|
||||
|
||||
def _daily(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Tiingo fundamentals/daily response: a flat list of valuation-metric rows."""
|
||||
return [
|
||||
{
|
||||
"date": f"{r['date']}T00:00:00.000Z",
|
||||
"marketCap": r.get("marketCap"),
|
||||
"enterpriseVal": r.get("enterpriseVal"),
|
||||
"peRatio": r.get("peRatio"),
|
||||
"pbRatio": r.get("pbRatio"),
|
||||
"trailingPEG1Y": r.get("trailingPEG1Y"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _statements(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Tiingo fundamentals/statements response: list of period entries with nested statementData."""
|
||||
return entries
|
||||
|
||||
|
||||
def test_metrics_returns_latest_daily_row() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x")
|
||||
# Out-of-order on purpose: the latest (max date) row must be chosen, not the first/last.
|
||||
rows = [
|
||||
{"date": "2026-01-02", "marketCap": 100.0, "peRatio": 20.0, "pbRatio": 3.0,
|
||||
"enterpriseVal": 110.0, "trailingPEG1Y": 1.5},
|
||||
{"date": "2026-01-06", "marketCap": 120.0, "peRatio": 22.0, "pbRatio": 3.3,
|
||||
"enterpriseVal": 130.0, "trailingPEG1Y": 1.7},
|
||||
{"date": "2026-01-03", "marketCap": 110.0, "peRatio": 21.0, "pbRatio": 3.1,
|
||||
"enterpriseVal": 120.0, "trailingPEG1Y": 1.6},
|
||||
]
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_get(url: str, tries: int = 4) -> Any:
|
||||
captured["url"] = url
|
||||
assert "/tiingo/fundamentals/AAPL/daily" in url
|
||||
return _daily(rows)
|
||||
|
||||
client._get = fake_get
|
||||
out = client.metrics("AAPL")
|
||||
|
||||
# Latest row (2026-01-06) chosen.
|
||||
assert out["marketCap"] == 120.0
|
||||
assert out["peRatio"] == 22.0
|
||||
assert out["pbRatio"] == 3.3
|
||||
assert out["enterpriseVal"] == 130.0
|
||||
assert out["trailingPEG1Y"] == 1.7
|
||||
assert "date" not in out
|
||||
assert all(isinstance(v, float) for v in out.values())
|
||||
assert "/statements" not in captured["url"]
|
||||
|
||||
|
||||
def test_metrics_skips_none_values() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x")
|
||||
rows = [
|
||||
{"date": "2026-01-06", "marketCap": 120.0, "peRatio": None, "pbRatio": 3.3,
|
||||
"enterpriseVal": None, "trailingPEG1Y": 1.7},
|
||||
]
|
||||
client._get = lambda url, tries=4: _daily(rows) # noqa: E731
|
||||
out = client.metrics("AAPL")
|
||||
assert "peRatio" not in out
|
||||
assert "enterpriseVal" not in out
|
||||
assert out["marketCap"] == 120.0
|
||||
assert out["pbRatio"] == 3.3
|
||||
|
||||
|
||||
def test_statement_metrics_flattens_latest_overview() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x")
|
||||
entries = [
|
||||
{
|
||||
"date": "2025-09-30T00:00:00.000Z", "year": 2025, "quarter": 3,
|
||||
"statementData": {
|
||||
"overview": [
|
||||
{"dataCode": "roe", "value": 0.10},
|
||||
{"dataCode": "piotroskiFScore", "value": 5},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"date": "2025-12-31T00:00:00.000Z", "year": 2025, "quarter": 4,
|
||||
"statementData": {
|
||||
"overview": [
|
||||
{"dataCode": "roe", "value": 0.42},
|
||||
{"dataCode": "piotroskiFScore", "value": 8},
|
||||
{"dataCode": "debtEquity", "value": 1.25},
|
||||
{"dataCode": "grossMargin", "value": 0.46},
|
||||
],
|
||||
"incomeStatement": [
|
||||
{"dataCode": "revenue", "value": 1000.0},
|
||||
],
|
||||
"balanceSheet": [
|
||||
{"dataCode": "totalAssets", "value": 5000.0},
|
||||
],
|
||||
"cashFlow": [
|
||||
{"dataCode": "freeCashFlow", "value": 250.0},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_get(url: str, tries: int = 4) -> Any:
|
||||
captured["url"] = url
|
||||
assert "/tiingo/fundamentals/AAPL/statements" in url
|
||||
return _statements(entries)
|
||||
|
||||
client._get = fake_get
|
||||
out = client.statement_metrics("AAPL")
|
||||
|
||||
# Latest entry (2025-12-31) chosen — NOT the 2025-09-30 one.
|
||||
assert out["roe"] == 0.42
|
||||
assert out["piotroskiFScore"] == 8.0
|
||||
assert out["debtEquity"] == 1.25
|
||||
assert out["grossMargin"] == 0.46
|
||||
# Other sections flattened too.
|
||||
assert out["revenue"] == 1000.0
|
||||
assert out["totalAssets"] == 5000.0
|
||||
assert out["freeCashFlow"] == 250.0
|
||||
assert all(isinstance(v, float) for v in out.values())
|
||||
assert "/daily" not in captured["url"].split("/statements")[0] + "/statements"
|
||||
|
||||
|
||||
def test_statement_metrics_skips_none_values() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x")
|
||||
entries = [
|
||||
{
|
||||
"date": "2025-12-31T00:00:00.000Z",
|
||||
"statementData": {
|
||||
"overview": [
|
||||
{"dataCode": "roe", "value": 0.42},
|
||||
{"dataCode": "debtEquity", "value": None},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
client._get = lambda url, tries=4: entries # noqa: E731
|
||||
out = client.statement_metrics("AAPL")
|
||||
assert out["roe"] == 0.42
|
||||
assert "debtEquity" not in out
|
||||
|
||||
|
||||
def test_metrics_startdate_window_from_lookback_days() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x", lookback_days=800)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_get(url: str, tries: int = 4) -> Any:
|
||||
captured["url"] = url
|
||||
return _daily([{"date": "2026-01-06", "marketCap": 1.0, "peRatio": 1.0, "pbRatio": 1.0}])
|
||||
|
||||
client._get = fake_get
|
||||
client.metrics("AAPL")
|
||||
expected_start = (dt.date.today() - dt.timedelta(days=800)).isoformat()
|
||||
assert f"startDate={expected_start}" in captured["url"]
|
||||
|
||||
|
||||
def test_statement_metrics_startdate_window_from_lookback_days() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x", lookback_days=800)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_get(url: str, tries: int = 4) -> Any:
|
||||
captured["url"] = url
|
||||
return _statements([
|
||||
{"date": "2025-12-31T00:00:00.000Z",
|
||||
"statementData": {"overview": [{"dataCode": "roe", "value": 0.1}]}},
|
||||
])
|
||||
|
||||
client._get = fake_get
|
||||
client.statement_metrics("AAPL")
|
||||
expected_start = (dt.date.today() - dt.timedelta(days=800)).isoformat()
|
||||
assert f"startDate={expected_start}" in captured["url"]
|
||||
|
||||
|
||||
def test_empty_metrics_response_returns_empty_dict() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x")
|
||||
client._get = lambda url, tries=4: [] # noqa: E731
|
||||
assert client.metrics("AAPL") == {}
|
||||
|
||||
|
||||
def test_empty_statements_response_returns_empty_dict() -> None:
|
||||
client = TiingoFundamentalsClient(api_key="x")
|
||||
client._get = lambda url, tries=4: [] # noqa: E731
|
||||
assert client.statement_metrics("AAPL") == {}
|
||||
Reference in New Issue
Block a user