diff --git a/collectors/fundamentals.py b/collectors/fundamentals.py index 7fcceea..980d891 100644 --- a/collectors/fundamentals.py +++ b/collectors/fundamentals.py @@ -217,6 +217,12 @@ def _pick(metrics: dict, *keys: str, default: float = 0.0) -> float: # Neutral values for tickers where Finnhub returns nothing usable +# +# Phase 3a of attractiveness-pillars-260520 (2026-05-20): added 5 new +# fundamental fields backing the Growth + Stewardship pillar quant +# subscores. All Finnhub ``/stock/metric?metric=all`` derived — no new +# API integrations. The composites that consume these fields are added +# in alpha-engine-research/scoring/factor_scoring.py Phase 3b. NEUTRAL = { "pe_ratio": 0.0, "pb_ratio": 0.0, @@ -226,6 +232,17 @@ def _pick(metrics: dict, *keys: str, default: float = 0.0) -> float: "gross_margin": 0.0, "roe": 0.0, "current_ratio": 0.0, + # Growth pillar substrate (Phase 3a) — 3y CAGR signals (smoother than + # TTM YoY; less noise from base-effect / single-quarter anomalies) + "revenue_growth_3y": 0.0, + "eps_growth_3y": 0.0, + # Stewardship pillar substrate (Phase 3a) — payout discipline + + # reinvestment intensity. Insider-ownership not surfaced here + # (Finnhub doesn't expose it via metric=all; deferred to a separate + # PR if/when it becomes load-bearing). + "payout_ratio": 0.0, + "dividend_yield": 0.0, + "capex_growth_5y": 0.0, } @@ -285,6 +302,31 @@ def _fetch_single_ticker(ticker: str) -> dict: else: fcf_yield_raw = 0.0 + # ── Growth pillar substrate (Phase 3a of attractiveness-pillars-260520) ── + # 3-year CAGR signals from Finnhub. Smoother than TTM YoY for + # composite ranking (base-effect noise + single-quarter anomalies + # average out). Annual fallbacks for newer listings without a full + # 3y history. + revenue_growth_3y_raw = _pick(metrics, "revenueGrowth3Y", "revenueGrowth5Y") + eps_growth_3y_raw = _pick( + metrics, "epsGrowth3Y", "epsBasicExclExtraItemsAnnual5Y", "epsGrowth5Y", + ) + + # ── Stewardship pillar substrate (Phase 3a) ── + # Payout ratio + dividend yield + capex growth proxy. Insider ownership + # is NOT here — Finnhub's metric=all does not surface it; would require + # a separate /stock/insider-transactions integration. Deferred to a + # follow-up if/when stewardship gains discriminative weight in the + # composite. The three signals here cover the "capital allocation + # discipline" axis: payout (return-of-capital intensity), dividend + # yield (vs. payout, identifies low-yield + low-payout = buyback- + # heavy retainers), and capex growth (reinvestment intensity). + payout_ratio_raw = _pick(metrics, "payoutRatioTTM", "payoutRatioAnnual") + dividend_yield_raw = _pick( + metrics, "dividendYieldIndicatedAnnual", "currentDividendYieldTTM", + ) + capex_growth_5y_raw = _pick(metrics, "capitalSpendingGrowth5Y") + # Finnhub returns gross margin and ROE as fractions (e.g. 0.42 for 42%); # FMP returned them the same way. Clipping ranges unchanged from the # FMP version so downstream feature-engineering / scoring sees the @@ -298,6 +340,13 @@ def _fetch_single_ticker(ticker: str) -> dict: "gross_margin": _clip(gross_margin_raw, 0.0, 1.0), "roe": _clip(roe_raw, -1.0, 1.0), "current_ratio": _clip(current_ratio_raw / 3.0, 0.0, 3.0), + # Growth pillar quant signals + "revenue_growth_3y": _clip(revenue_growth_3y_raw, -0.5, 1.5), + "eps_growth_3y": _clip(eps_growth_3y_raw, -1.0, 2.0), + # Stewardship pillar quant signals + "payout_ratio": _clip(payout_ratio_raw, 0.0, 2.0), + "dividend_yield": _clip(dividend_yield_raw, 0.0, 0.20), + "capex_growth_5y": _clip(capex_growth_5y_raw, -1.0, 2.0), } diff --git a/features/feature_engineer.py b/features/feature_engineer.py index 4d9177b..6109916 100644 --- a/features/feature_engineer.py +++ b/features/feature_engineer.py @@ -124,6 +124,14 @@ "gross_margin", "roe", "current_ratio", + # Phase 3a of attractiveness-pillars-260520 — Growth + Stewardship + # pillar quant substrate. Surfaced from existing Finnhub metric=all + # response; no new API integrations. + "revenue_growth_3y", + "eps_growth_3y", + "payout_ratio", + "dividend_yield", + "capex_growth_5y", # v3.1 additions — longer-horizon + overnight/intraday decomposition + # reversal-native signals. Predictor ROADMAP P2: collapse FLAT + # test whether 5d is reversal or momentum regime. 2026-04-15: neutral @@ -654,6 +662,12 @@ def _safe_float(val, default: float = 0.0) -> float: df["gross_margin"] = _safe_float(fundamental_data.get("gross_margin"), 0.0) df["roe"] = _safe_float(fundamental_data.get("roe"), 0.0) df["current_ratio"] = _safe_float(fundamental_data.get("current_ratio"), 0.0) + # Phase 3a of attractiveness-pillars-260520 — Growth + Stewardship pillar substrate. + df["revenue_growth_3y"] = _safe_float(fundamental_data.get("revenue_growth_3y"), 0.0) + df["eps_growth_3y"] = _safe_float(fundamental_data.get("eps_growth_3y"), 0.0) + df["payout_ratio"] = _safe_float(fundamental_data.get("payout_ratio"), 0.0) + df["dividend_yield"] = _safe_float(fundamental_data.get("dividend_yield"), 0.0) + df["capex_growth_5y"] = _safe_float(fundamental_data.get("capex_growth_5y"), 0.0) else: df["pe_ratio"] = 0.0 df["pb_ratio"] = 0.0 @@ -663,6 +677,11 @@ def _safe_float(val, default: float = 0.0) -> float: df["gross_margin"] = 0.0 df["roe"] = 0.0 df["current_ratio"] = 0.0 + df["revenue_growth_3y"] = 0.0 + df["eps_growth_3y"] = 0.0 + df["payout_ratio"] = 0.0 + df["dividend_yield"] = 0.0 + df["capex_growth_5y"] = 0.0 # Rows with NaN features are NOT dropped — see module docstring. A # feature whose rolling-window warmup exceeds the available history diff --git a/features/registry.py b/features/registry.py index 078997e..9ae364e 100644 --- a/features/registry.py +++ b/features/registry.py @@ -93,7 +93,7 @@ class FeatureEntry: FeatureEntry("dist_from_5d_high", "technical", "(Close - 5d rolling max High) / 5d rolling max High", source="yfinance", refresh="daily"), FeatureEntry("dist_from_20d_high", "technical", "(Close - 20d rolling max High) / 20d rolling max High", source="yfinance", refresh="daily"), - # ── Fundamental (8) — quarterly financials ──────────────────────────────── + # ── Fundamental (13) — quarterly financials ─────────────────────────────── FeatureEntry("pe_ratio", "fundamental", "Trailing P/E ratio, normalized (PE / 30)", source="fmp", refresh="quarterly"), FeatureEntry("pb_ratio", "fundamental", "Price-to-book ratio, normalized (PB / 5)", source="fmp", refresh="quarterly"), FeatureEntry("debt_to_equity", "fundamental", "Total debt / total equity, normalized (D/E / 2)", source="fmp", refresh="quarterly"), @@ -102,6 +102,12 @@ class FeatureEntry: FeatureEntry("gross_margin", "fundamental", "Gross profit / revenue (0-1)", source="fmp", refresh="quarterly"), FeatureEntry("roe", "fundamental", "Return on equity (decimal)", source="fmp", refresh="quarterly"), FeatureEntry("current_ratio", "fundamental", "Current assets / current liabilities, normalized (CR / 3)", source="fmp", refresh="quarterly"), + # ── Phase 3a of attractiveness-pillars-260520 — Growth + Stewardship pillar substrate ── + FeatureEntry("revenue_growth_3y", "fundamental", "3-year revenue CAGR (decimal); Growth pillar input", source="fmp", refresh="quarterly"), + FeatureEntry("eps_growth_3y", "fundamental", "3-year EPS CAGR (decimal); Growth pillar input", source="fmp", refresh="quarterly"), + FeatureEntry("payout_ratio", "fundamental", "TTM dividends / net income (0-2 clipped); Stewardship pillar input — retention rate = 1 - payout drives reinvestment", source="fmp", refresh="quarterly"), + FeatureEntry("dividend_yield", "fundamental", "Indicated annual dividend yield (decimal, 0-0.2 clipped); Stewardship pillar input", source="fmp", refresh="quarterly"), + FeatureEntry("capex_growth_5y", "fundamental", "5-year CAPEX growth (decimal); Stewardship pillar input — reinvestment intensity proxy", source="fmp", refresh="quarterly"), ] # Quick lookup by name diff --git a/tests/test_fundamentals_finnhub.py b/tests/test_fundamentals_finnhub.py index a79a127..ed955c1 100644 --- a/tests/test_fundamentals_finnhub.py +++ b/tests/test_fundamentals_finnhub.py @@ -52,6 +52,13 @@ def _aapl_payload(**overrides): "roeRfy": 0.61, "currentRatioAnnual": 0.93, "currentRatioQuarterly": 0.95, + # Phase 3a of attractiveness-pillars-260520 — Growth + Stewardship + # pillar substrate added to NEUTRAL + _fetch_single_ticker. + "revenueGrowth3Y": 0.08, # 8% 3y CAGR + "epsGrowth3Y": 0.12, # 12% 3y EPS CAGR + "payoutRatioTTM": 0.18, # 18% of NI paid as dividends + "dividendYieldIndicatedAnnual": 0.005, # 0.5% + "capitalSpendingGrowth5Y": 0.10, # 10% CAPEX growth } metric.update(overrides) return {"metric": metric, "metricType": "all", "symbol": "AAPL"} @@ -181,6 +188,114 @@ def test_payload_with_null_metric_returns_neutral(self): data = _fetch_single_ticker("UNKNOWN") assert data == NEUTRAL + +# ── Phase 3a of attractiveness-pillars-260520 — Growth + Stewardship pillar +# substrate fields. Five new fundamental fields surfaced from existing Finnhub +# metric=all response; no new API integrations. + + +class TestPillarSubstrateFields: + """Growth + Stewardship pillar quant substrate added in Phase 3a.""" + + def test_neutral_includes_all_five_new_fields(self): + """NEUTRAL must enumerate the 5 new fields (so an empty / malformed + Finnhub payload still produces a complete fundamental record).""" + for field in ( + "revenue_growth_3y", + "eps_growth_3y", + "payout_ratio", + "dividend_yield", + "capex_growth_5y", + ): + assert field in NEUTRAL, f"NEUTRAL missing {field}" + assert NEUTRAL[field] == 0.0 + + def test_full_payload_maps_growth_3y_fields(self): + with patch.object(fundamentals, "finnhub_get", return_value=_aapl_payload()): + data = _fetch_single_ticker("AAPL") + # _aapl_payload sets revenueGrowth3Y=0.08, epsGrowth3Y=0.12 → clip + # ranges (-0.5,1.5) and (-1.0,2.0) preserve the values. + assert data["revenue_growth_3y"] == pytest.approx(0.08) + assert data["eps_growth_3y"] == pytest.approx(0.12) + + def test_full_payload_maps_stewardship_fields(self): + with patch.object(fundamentals, "finnhub_get", return_value=_aapl_payload()): + data = _fetch_single_ticker("AAPL") + assert data["payout_ratio"] == pytest.approx(0.18) + assert data["dividend_yield"] == pytest.approx(0.005) + assert data["capex_growth_5y"] == pytest.approx(0.10) + + def test_revenue_growth_3y_falls_back_to_5y(self): + """3y CAGR is preferred; 5y is the fallback for newer listings.""" + payload = _aapl_payload() + payload["metric"]["revenueGrowth3Y"] = None + payload["metric"]["revenueGrowth5Y"] = 0.05 + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["revenue_growth_3y"] == pytest.approx(0.05) + + def test_eps_growth_3y_falls_back_through_annual_5y(self): + payload = _aapl_payload() + payload["metric"]["epsGrowth3Y"] = None + payload["metric"]["epsBasicExclExtraItemsAnnual5Y"] = 0.07 + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["eps_growth_3y"] == pytest.approx(0.07) + + def test_payout_ratio_falls_back_to_annual(self): + payload = _aapl_payload() + payload["metric"]["payoutRatioTTM"] = None + payload["metric"]["payoutRatioAnnual"] = 0.25 + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["payout_ratio"] == pytest.approx(0.25) + + def test_dividend_yield_falls_back_to_ttm(self): + payload = _aapl_payload() + payload["metric"]["dividendYieldIndicatedAnnual"] = None + payload["metric"]["currentDividendYieldTTM"] = 0.012 + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["dividend_yield"] == pytest.approx(0.012) + + def test_clipping_extreme_growth_caps_at_upper_bound(self): + """A 500% YoY growth (e.g. post-spinoff base-effect) clips at the + upper bound. revenue_growth_3y cap is 1.5; eps_growth_3y cap is 2.0.""" + payload = _aapl_payload(revenueGrowth3Y=5.0, epsGrowth3Y=10.0) + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["revenue_growth_3y"] == 1.5 + assert data["eps_growth_3y"] == 2.0 + + def test_clipping_extreme_dividend_yield_caps_at_20pct(self): + """A 50% indicated yield (data error / micro-cap special dividend) + clips at 0.20 — the prior-realistic real-world ceiling for sustainable + dividend yields.""" + payload = _aapl_payload(dividendYieldIndicatedAnnual=0.50) + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["dividend_yield"] == 0.20 + + def test_payout_ratio_clipped_above_2(self): + """payout_ratio > 2.0 (paying out 2x earnings — possible briefly in + a loss year as legacy dividend, but unsustainable) clips at 2.0.""" + payload = _aapl_payload(payoutRatioTTM=5.0) + with patch.object(fundamentals, "finnhub_get", return_value=payload): + data = _fetch_single_ticker("AAPL") + assert data["payout_ratio"] == 2.0 + + def test_empty_payload_still_returns_complete_neutral(self): + """Same coverage as the existing test, but explicitly verifies the + new fields are also zeroed (regression guard against future NEUTRAL + drift dropping them).""" + with patch.object(fundamentals, "finnhub_get", return_value={}): + data = _fetch_single_ticker("UNKNOWN") + assert data["revenue_growth_3y"] == 0.0 + assert data["eps_growth_3y"] == 0.0 + assert data["payout_ratio"] == 0.0 + assert data["dividend_yield"] == 0.0 + assert data["capex_growth_5y"] == 0.0 + def test_list_response_returns_neutral(self): # Finnhub typically returns dict; defensive: list response = malformed. with patch.object(fundamentals, "finnhub_get", return_value=[]):