Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions collectors/fundamentals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}


Expand Down Expand Up @@ -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
Expand All @@ -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),
}


Expand Down
19 changes: 19 additions & 0 deletions features/feature_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion features/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/test_fundamentals_finnhub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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=[]):
Expand Down
Loading