diff --git a/src/fxhnt/adapters/data/tiingo_fundamentals.py b/src/fxhnt/adapters/data/tiingo_fundamentals.py new file mode 100644 index 0000000..51520d8 --- /dev/null +++ b/src/fxhnt/adapters/data/tiingo_fundamentals.py @@ -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 ` 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 diff --git a/tests/integration/test_tiingo_fundamentals.py b/tests/integration/test_tiingo_fundamentals.py new file mode 100644 index 0000000..69658af --- /dev/null +++ b/tests/integration/test_tiingo_fundamentals.py @@ -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") == {}