feat: add fundamentals weighting backtest research
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
@@ -38,7 +38,9 @@ _ENDS = { # period_end per (fy, quarter index 0..3)
|
||||
}
|
||||
|
||||
|
||||
def _year(fy, discretes: dict[str, list[float]], instants: dict[str, list] | None = None):
|
||||
def _year(
|
||||
fy, discretes: dict[str, list[float]], instants: dict[str, list] | None = None
|
||||
):
|
||||
"""Build 4 snapshot rows (Q1,Q2,Q3,FY) with YTD-cumulative flow fields from the
|
||||
given per-quarter discrete values; instants set as-is per quarter."""
|
||||
rows = []
|
||||
@@ -55,22 +57,38 @@ def _year(fy, discretes: dict[str, list[float]], instants: dict[str, list] | Non
|
||||
def _two_years():
|
||||
rev25 = [100, 110, 120, 130]
|
||||
rev26 = [110, 121, 132, 143] # +10% each quarter YoY
|
||||
rows = _year(2025, {
|
||||
"revenue": rev25,
|
||||
"operating_income": [x * 0.2 for x in rev25],
|
||||
"diluted_eps": [1.0, 1.1, 1.2, 1.3],
|
||||
"cfo": [x * 0.25 for x in rev25],
|
||||
"capex": [x * 0.05 for x in rev25],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev25],
|
||||
}, instants={"shares_outstanding": [1000, 1000, 1000, 1000], "cash_and_st_investments": [40] * 4, "total_debt": [140] * 4})
|
||||
rows += _year(2026, {
|
||||
"revenue": rev26,
|
||||
"operating_income": [x * 0.2 for x in rev26],
|
||||
"diluted_eps": [1.1, 1.21, 1.32, 1.43],
|
||||
"cfo": [x * 0.25 for x in rev26],
|
||||
"capex": [x * 0.05 for x in rev26],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev26],
|
||||
}, instants={"shares_outstanding": [900, 900, 900, 900], "cash_and_st_investments": [50] * 4, "total_debt": [150] * 4})
|
||||
rows = _year(
|
||||
2025,
|
||||
{
|
||||
"revenue": rev25,
|
||||
"operating_income": [x * 0.2 for x in rev25],
|
||||
"diluted_eps": [1.0, 1.1, 1.2, 1.3],
|
||||
"cfo": [x * 0.25 for x in rev25],
|
||||
"capex": [x * 0.05 for x in rev25],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev25],
|
||||
},
|
||||
instants={
|
||||
"shares_outstanding": [1000, 1000, 1000, 1000],
|
||||
"cash_and_st_investments": [40] * 4,
|
||||
"total_debt": [140] * 4,
|
||||
},
|
||||
)
|
||||
rows += _year(
|
||||
2026,
|
||||
{
|
||||
"revenue": rev26,
|
||||
"operating_income": [x * 0.2 for x in rev26],
|
||||
"diluted_eps": [1.1, 1.21, 1.32, 1.43],
|
||||
"cfo": [x * 0.25 for x in rev26],
|
||||
"capex": [x * 0.05 for x in rev26],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev26],
|
||||
},
|
||||
instants={
|
||||
"shares_outstanding": [900, 900, 900, 900],
|
||||
"cash_and_st_investments": [50] * 4,
|
||||
"total_debt": [150] * 4,
|
||||
},
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -97,9 +115,11 @@ def test_net_debt_leverage_and_share_dilution():
|
||||
# net debt = total_debt - cash = 150 - 50 = 100 (latest instant)
|
||||
assert d.metrics["net_debt"].value == pytest.approx(100.0)
|
||||
# EBITDA TTM = TTM operating_income + TTM D&A; net_debt/ebitda
|
||||
op_ttm = 506 * 0.2 # 101.2
|
||||
da_ttm = 506 * 0.05 # 25.3
|
||||
assert d.metrics["net_debt_to_ebitda"].value == pytest.approx(100.0 / (op_ttm + da_ttm), rel=1e-6)
|
||||
op_ttm = 506 * 0.2 # 101.2
|
||||
da_ttm = 506 * 0.05 # 25.3
|
||||
assert d.metrics["net_debt_to_ebitda"].value == pytest.approx(
|
||||
100.0 / (op_ttm + da_ttm), rel=1e-6
|
||||
)
|
||||
# shares 900 vs 1000 a year earlier -> -10% (buyback)
|
||||
assert d.metrics["share_count_change_yoy"].value == pytest.approx(-10.0, abs=1e-6)
|
||||
|
||||
@@ -130,7 +150,9 @@ def test_net_debt_requires_both_components():
|
||||
r.total_debt = None
|
||||
d = fd.derive(rows)
|
||||
assert d.metrics["net_debt"].value is None
|
||||
assert d.metrics["net_debt_to_ebitda"].value is None # net debt null -> leverage null
|
||||
assert (
|
||||
d.metrics["net_debt_to_ebitda"].value is None
|
||||
) # net debt null -> leverage null
|
||||
|
||||
|
||||
def test_leverage_null_when_ebitda_nonpositive():
|
||||
@@ -140,15 +162,23 @@ def test_leverage_null_when_ebitda_nonpositive():
|
||||
r.depreciation_amortization = 1
|
||||
d = fd.derive(rows)
|
||||
assert d.metrics["net_debt"].value == pytest.approx(100.0) # net debt still valid
|
||||
assert d.metrics["net_debt_to_ebitda"].value is None # but leverage nulled
|
||||
assert d.metrics["net_debt_to_ebitda"].value is None # but leverage nulled
|
||||
|
||||
|
||||
def test_tape_stops_at_a_gap():
|
||||
rows = [r for r in _two_years() if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")]
|
||||
rows = [
|
||||
r
|
||||
for r in _two_years()
|
||||
if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")
|
||||
]
|
||||
d = fd.derive(rows)
|
||||
hist = d.metrics["operating_margin"].history
|
||||
# consecutive suffix ending at FY2026: Q2, Q3, FY (not compressed across the Q1 gap)
|
||||
assert [p.period_end for p in hist] == [date(2026, 3, 31), date(2026, 6, 30), date(2026, 9, 30)]
|
||||
assert [p.period_end for p in hist] == [
|
||||
date(2026, 3, 31),
|
||||
date(2026, 6, 30),
|
||||
date(2026, 9, 30),
|
||||
]
|
||||
|
||||
|
||||
def test_yoy_growth_null_when_prior_nonpositive():
|
||||
@@ -160,14 +190,48 @@ def test_yoy_growth_null_when_prior_nonpositive():
|
||||
assert d.metrics["eps_growth_yoy"].value is None # loss->profit is not a %
|
||||
|
||||
|
||||
def test_derive_as_of_excludes_future_amendment():
|
||||
rows = _two_years()
|
||||
original = next(
|
||||
row for row in rows if row.fiscal_year == 2026 and row.fiscal_period == "FY"
|
||||
)
|
||||
amendment = replace(
|
||||
original,
|
||||
accepted_at=datetime(2027, 1, 1, tzinfo=UTC),
|
||||
revenue=999999,
|
||||
)
|
||||
before = fd.derive_as_of([*rows, amendment], datetime(2026, 12, 31, tzinfo=UTC))
|
||||
after = fd.derive_as_of([*rows, amendment], datetime(2027, 1, 2, tzinfo=UTC))
|
||||
assert before.metrics["revenue_growth_yoy"].value == pytest.approx(10.0)
|
||||
assert after.metrics["revenue_growth_yoy"].value != pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_derive_as_of_treats_sqlite_naive_acceptance_as_utc():
|
||||
rows = _two_years()
|
||||
rows[0].accepted_at = datetime(2025, 1, 1)
|
||||
result = fd.derive_as_of(rows, datetime(2027, 1, 1, tzinfo=UTC))
|
||||
assert result.latest_period_end == date(2026, 9, 30)
|
||||
|
||||
|
||||
def test_amendment_selection_newest_accepted_wins():
|
||||
rows = _two_years()
|
||||
# an amendment to FY2026 FY restates revenue YTD higher, accepted later
|
||||
amended = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
|
||||
datetime(2027, 1, 1, tzinfo=UTC), revenue=999999,
|
||||
operating_income=100, diluted_eps=1.43, cfo=100, capex=10,
|
||||
depreciation_amortization=25, shares_outstanding=900,
|
||||
cash_and_st_investments=50, total_debt=150)
|
||||
amended = Snap(
|
||||
2026,
|
||||
"FY",
|
||||
date(2026, 9, 30),
|
||||
date(2026, 11, 1),
|
||||
datetime(2027, 1, 1, tzinfo=UTC),
|
||||
revenue=999999,
|
||||
operating_income=100,
|
||||
diluted_eps=1.43,
|
||||
cfo=100,
|
||||
capex=10,
|
||||
depreciation_amortization=25,
|
||||
shares_outstanding=900,
|
||||
cash_and_st_investments=50,
|
||||
total_debt=150,
|
||||
)
|
||||
d = fd.derive(rows + [amended])
|
||||
# Q4 revenue discrete now uses the amended YTD(FY)=999999 minus YTD(Q3)=363
|
||||
# so TTM/growth reflects the amendment, proving newest accepted_at won.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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_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)
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
|
||||
from scripts import run_fundamentals_research as runner
|
||||
|
||||
|
||||
def _window(name, sharpe, drawdown):
|
||||
return {
|
||||
"window": name,
|
||||
"sharpe": sharpe,
|
||||
"sharpe_se": 0.2,
|
||||
"dsr": 0.8,
|
||||
"cagr_pct": 10.0,
|
||||
"max_drawdown_pct": drawdown,
|
||||
"calmar": 1.0,
|
||||
"trades": 20,
|
||||
}
|
||||
|
||||
|
||||
def test_arm_matrix_is_bounded_and_pre_registered():
|
||||
assert runner.N_TRIALS == 13
|
||||
assert runner.ARMS[0]["id"] == "control_w00"
|
||||
assert {arm["weight"] for arm in runner.ARMS[1:]} == {0.1, 0.2, 0.3, 0.4}
|
||||
assert {arm["composite"] for arm in runner.ARMS[1:]} == {
|
||||
"quality",
|
||||
"growth",
|
||||
"balanced",
|
||||
}
|
||||
|
||||
|
||||
def test_development_grade_does_not_read_test_window():
|
||||
control = {
|
||||
"windows": [
|
||||
_window("train", 1.0, 10.0),
|
||||
_window("validation", 1.0, 10.0),
|
||||
_window("test", 9.0, 1.0),
|
||||
]
|
||||
}
|
||||
arm = {
|
||||
"windows": [
|
||||
_window("train", 1.1, 10.0),
|
||||
_window("validation", 1.2, 11.0),
|
||||
_window("test", -9.0, 90.0),
|
||||
]
|
||||
}
|
||||
assert runner._development_grade(control, arm)["pass"] is True
|
||||
|
||||
|
||||
def test_output_bundle_is_self_contained(tmp_path):
|
||||
report = {
|
||||
"generated_at": "2026-07-23T00:00:00Z",
|
||||
"splits": {"train_end": "2024-01-01", "test_start": "2025-01-01"},
|
||||
"n_trials": 13,
|
||||
"warnings": ["survivorship bias"],
|
||||
"factor_ic": {"full": []},
|
||||
"arms": [],
|
||||
"development_selection": None,
|
||||
"final_check": None,
|
||||
}
|
||||
output = tmp_path / "result.json"
|
||||
runner._write_outputs(report, output, bundle=True)
|
||||
with zipfile.ZipFile(output.with_suffix(".zip")) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert {
|
||||
"result.json",
|
||||
"result.md",
|
||||
"result-arms.csv",
|
||||
"result-factor-ic.csv",
|
||||
"result-trades.csv",
|
||||
} <= names
|
||||
|
||||
|
||||
def test_winner_concentration_exposes_top_five_dependence():
|
||||
details = [
|
||||
{"pnl": value, "r": value / 10} for value in (100, 90, 80, 70, 60, -10, -20)
|
||||
]
|
||||
result = runner._winner_concentration(details)
|
||||
assert result["top5_pnl"] == 400
|
||||
assert result["net_pnl_ex_top5"] == -30
|
||||
assert result["avg_r_ex_top5"] == -1.5
|
||||
Reference in New Issue
Block a user