98 lines
3.1 KiB
Python
98 lines
3.1 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from app.services import fundamentals_research as research
|
|
|
|
|
|
def test_favorable_percentiles_are_tie_aware():
|
|
ranks = research.favorable_percentiles(
|
|
{"a": 3, "b": 3, "c": 3, "d": 3, "e": 3},
|
|
higher_is_better=True,
|
|
)
|
|
assert set(ranks.values()) == {50.0}
|
|
|
|
|
|
def test_lower_is_better_flips_the_rank():
|
|
ranks = research.favorable_percentiles(
|
|
{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5},
|
|
higher_is_better=False,
|
|
)
|
|
assert ranks["a"] == 100.0
|
|
assert ranks["e"] == 0.0
|
|
|
|
|
|
def test_invalid_and_thin_cross_sections_stay_null():
|
|
ranks = research.favorable_percentiles(
|
|
{"a": 1, "b": 2, "c": math.nan, "d": None, "e": 5},
|
|
higher_is_better=True,
|
|
)
|
|
assert all(value is None for value in ranks.values())
|
|
|
|
|
|
def test_composites_use_equal_subgroup_weighting():
|
|
features = {
|
|
str(index): {
|
|
"operating_margin": index,
|
|
"fcf_margin": index,
|
|
"net_debt_to_ebitda": 6 - index,
|
|
"share_count_change_yoy": 6 - index,
|
|
"revenue_growth_yoy": index,
|
|
"eps_growth_yoy": index,
|
|
}
|
|
for index in range(1, 6)
|
|
}
|
|
scores = research.cross_section_scores(features)
|
|
assert scores["5"]["quality"] == 100.0
|
|
assert scores["5"]["growth"] == 100.0
|
|
assert scores["5"]["balanced"] == 100.0
|
|
assert scores["3"]["balanced"] == 50.0
|
|
|
|
|
|
def test_split_safe_composites_ignore_eps_and_share_count():
|
|
def features(unsafe_multiplier: int):
|
|
return {
|
|
str(index): {
|
|
"operating_margin": index,
|
|
"fcf_margin": index,
|
|
"net_debt_to_ebitda": 6 - index,
|
|
"revenue_growth_yoy": index,
|
|
"eps_growth_yoy": unsafe_multiplier * (6 - index),
|
|
"share_count_change_yoy": unsafe_multiplier * index,
|
|
}
|
|
for index in range(1, 6)
|
|
}
|
|
|
|
baseline = research.cross_section_scores(features(1), split_safe=True)
|
|
distorted = research.cross_section_scores(features(1_000_000), split_safe=True)
|
|
|
|
assert distorted == baseline
|
|
assert baseline["5"]["quality"] == 100.0
|
|
assert baseline["5"]["growth"] == 100.0
|
|
assert baseline["5"]["balanced"] == 100.0
|
|
assert "eps_growth_yoy" not in baseline["5"]
|
|
assert "share_count_change_yoy" not in baseline["5"]
|
|
|
|
|
|
def test_split_safe_quality_still_requires_two_comparable_inputs():
|
|
features = {
|
|
str(index): {
|
|
"operating_margin": index,
|
|
"revenue_growth_yoy": index,
|
|
}
|
|
for index in range(1, 6)
|
|
}
|
|
scores = research.cross_section_scores(features, split_safe=True)
|
|
assert all(row["quality"] is None for row in scores.values())
|
|
assert scores["5"]["growth"] == 100.0
|
|
assert scores["5"]["balanced"] is None
|
|
|
|
|
|
def test_overlay_uses_neutral_missing_score_and_validates_weight():
|
|
assert research.overlay_rank(90, None, 0.2) == 82.0
|
|
assert research.overlay_rank(90, 100, 0.2) == 92.0
|
|
with pytest.raises(ValueError, match="weight"):
|
|
research.overlay_rank(90, 50, 1.1)
|