158 lines
4.9 KiB
Python
158 lines
4.9 KiB
Python
"""Pure scoring helpers for point-in-time fundamentals research.
|
|
|
|
The runner converts a CIK-deduplicated cross-section into favorable 0..100
|
|
factor ranks and three deliberately small composites. Historical valuation is
|
|
absent: stored bars are split-adjusted, while filing-time EPS and share counts
|
|
are not guaranteed to be on today's split basis.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
MIN_CROSS_SECTION = 5
|
|
|
|
FACTOR_POLARITY: dict[str, bool] = {
|
|
"revenue_growth_yoy": True,
|
|
"eps_growth_yoy": True,
|
|
"operating_margin": True,
|
|
"fcf_margin": True,
|
|
"net_debt_to_ebitda": False,
|
|
"share_count_change_yoy": False,
|
|
}
|
|
|
|
QUALITY_FACTORS = (
|
|
"operating_margin",
|
|
"fcf_margin",
|
|
"net_debt_to_ebitda",
|
|
"share_count_change_yoy",
|
|
)
|
|
GROWTH_FACTORS = ("revenue_growth_yoy", "eps_growth_yoy")
|
|
COMPOSITE_KEYS = ("quality", "growth", "balanced")
|
|
|
|
|
|
def raw_features(derived: Any) -> dict[str, float | None]:
|
|
"""Extract the six research-eligible values from derived fundamentals."""
|
|
metrics = getattr(derived, "metrics", {}) or {}
|
|
return {
|
|
key: _finite_or_none(getattr(metrics.get(key), "value", None))
|
|
for key in FACTOR_POLARITY
|
|
}
|
|
|
|
|
|
def cross_section_scores(
|
|
features_by_issuer: Mapping[str, Mapping[str, Any]],
|
|
*,
|
|
min_cross_section: int = MIN_CROSS_SECTION,
|
|
) -> 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.
|
|
"""
|
|
result = {
|
|
str(issuer): {
|
|
**{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():
|
|
values = {
|
|
str(issuer): _finite_or_none(features.get(factor))
|
|
for issuer, features in features_by_issuer.items()
|
|
}
|
|
ranks = favorable_percentiles(
|
|
values,
|
|
higher_is_better=higher_is_better,
|
|
min_count=min_cross_section,
|
|
)
|
|
for issuer, rank in ranks.items():
|
|
result[issuer][factor] = rank
|
|
|
|
for scores in result.values():
|
|
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:
|
|
scores["growth"] = _mean(growth_values)
|
|
if scores["quality"] is not None and scores["growth"] is not None:
|
|
scores["balanced"] = _mean(
|
|
[float(scores["quality"]), float(scores["growth"])]
|
|
)
|
|
return result
|
|
|
|
|
|
def favorable_percentiles(
|
|
values_by_issuer: Mapping[str, Any],
|
|
*,
|
|
higher_is_better: bool,
|
|
min_count: int = MIN_CROSS_SECTION,
|
|
) -> dict[str, float | None]:
|
|
"""Tie-aware favorable percentile for a deduplicated cross-section."""
|
|
valid = {
|
|
str(issuer): float(value)
|
|
for issuer, value in values_by_issuer.items()
|
|
if _finite_or_none(value) is not None
|
|
}
|
|
result: dict[str, float | None] = {str(issuer): None for issuer in values_by_issuer}
|
|
if len(valid) < min_count:
|
|
return result
|
|
|
|
for issuer, subject in valid.items():
|
|
others = [value for key, value in valid.items() if key != issuer]
|
|
if higher_is_better:
|
|
worse = sum(value < subject for value in others)
|
|
else:
|
|
worse = sum(value > subject for value in others)
|
|
tied = sum(value == subject for value in others)
|
|
result[issuer] = round(
|
|
(worse + 0.5 * tied) / len(others) * 100.0,
|
|
4,
|
|
)
|
|
return result
|
|
|
|
|
|
def overlay_rank(
|
|
strategy_rank: Any,
|
|
fundamental_score: Any,
|
|
weight: float,
|
|
*,
|
|
missing_score: float = 50.0,
|
|
) -> float | None:
|
|
"""Blend production rank with fundamentals without changing the gate."""
|
|
base = _finite_or_none(strategy_rank)
|
|
if base is None:
|
|
return None
|
|
if not 0.0 <= weight <= 1.0:
|
|
raise ValueError("weight must be between 0 and 1")
|
|
score = _finite_or_none(fundamental_score)
|
|
if score is None:
|
|
score = float(missing_score)
|
|
return round((1.0 - weight) * base + weight * score, 4)
|
|
|
|
|
|
def _available(scores: Mapping[str, Any], keys: tuple[str, ...]) -> list[float]:
|
|
return [
|
|
value for key in keys if (value := _finite_or_none(scores.get(key))) is not None
|
|
]
|
|
|
|
|
|
def _mean(values: list[float]) -> float:
|
|
return round(sum(values) / len(values), 4)
|
|
|
|
|
|
def _finite_or_none(value: Any) -> float | None:
|
|
if (
|
|
isinstance(value, (int, float))
|
|
and not isinstance(value, bool)
|
|
and math.isfinite(value)
|
|
):
|
|
return float(value)
|
|
return None
|