feat: add split-safe fundamentals research protocol

This commit is contained in:
2026-07-23 17:27:28 +02:00
parent 7f944d718f
commit 34d6dda1ab
6 changed files with 273 additions and 64 deletions
+26 -7
View File
@@ -30,6 +30,18 @@ QUALITY_FACTORS = (
"share_count_change_yoy",
)
GROWTH_FACTORS = ("revenue_growth_yoy", "eps_growth_yoy")
SPLIT_SAFE_FACTOR_POLARITY: dict[str, bool] = {
"revenue_growth_yoy": True,
"operating_margin": True,
"fcf_margin": True,
"net_debt_to_ebitda": False,
}
SPLIT_SAFE_QUALITY_FACTORS = (
"operating_margin",
"fcf_margin",
"net_debt_to_ebitda",
)
SPLIT_SAFE_GROWTH_FACTORS = ("revenue_growth_yoy",)
COMPOSITE_KEYS = ("quality", "growth", "balanced")
@@ -46,22 +58,29 @@ def cross_section_scores(
features_by_issuer: Mapping[str, Mapping[str, Any]],
*,
min_cross_section: int = MIN_CROSS_SECTION,
split_safe: bool = False,
) -> dict[str, dict[str, float | None]]:
"""Return favorable factor ranks and composites for every issuer.
Quality needs two of four inputs; growth needs one of two. Balanced requires
both sub-scores and weights them equally, so quality's four inputs do not
mechanically dominate growth's two inputs.
The default reproduces the original registered experiment. ``split_safe``
excludes diluted-EPS growth and share-count change because filing-time
values are not comparable across stock splits without point-in-time split
factors. Its quality score needs two of three remaining inputs and its
growth score is revenue growth. Balanced always weights the two sub-scores
equally.
"""
factor_polarity = SPLIT_SAFE_FACTOR_POLARITY if split_safe else FACTOR_POLARITY
quality_factors = SPLIT_SAFE_QUALITY_FACTORS if split_safe else QUALITY_FACTORS
growth_factors = SPLIT_SAFE_GROWTH_FACTORS if split_safe else GROWTH_FACTORS
result = {
str(issuer): {
**{key: None for key in FACTOR_POLARITY},
**{key: None for key in factor_polarity},
**{key: None for key in COMPOSITE_KEYS},
}
for issuer in features_by_issuer
}
for factor, higher_is_better in FACTOR_POLARITY.items():
for factor, higher_is_better in factor_polarity.items():
values = {
str(issuer): _finite_or_none(features.get(factor))
for issuer, features in features_by_issuer.items()
@@ -75,8 +94,8 @@ def cross_section_scores(
result[issuer][factor] = rank
for scores in result.values():
quality_values = _available(scores, QUALITY_FACTORS)
growth_values = _available(scores, GROWTH_FACTORS)
quality_values = _available(scores, quality_factors)
growth_values = _available(scores, growth_factors)
if len(quality_values) >= 2:
scores["quality"] = _mean(quality_values)
if growth_values: