feat(b2): TiingoFundamentalsClient (daily metrics + statements)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-16 17:35:51 +02:00
parent 98e264f14c
commit 8d4d389096
2 changed files with 317 additions and 0 deletions

View 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

View 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") == {}