feat: add split-safe fundamentals research protocol
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user