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:
+49 -11
View File
@@ -1,6 +1,7 @@
# Point-in-time fundamentals weight backtest
Status: pre-registered local research. Running it does not change production.
Status: initial experiment completed; split-safe follow-up registered locally.
Running either protocol does not change production.
## Question
@@ -10,7 +11,7 @@ position sizing, capacity, costs, ATR trail, and post-stop re-entry policy remai
unchanged. This isolates the incremental value of fundamentals as a ranking
overlay.
## Registered experiment
## Completed initial experiment
The control is the production 80/20 residual-momentum / volatility rank. The
runner tests three fundamental composites at weights 10%, 20%, 30%, and 40%:
@@ -28,11 +29,38 @@ tracked universe. Missing composite scores are neutral at 50. The formula is:
There are 13 registered portfolio trials including the control. That complete
count is used by the Deflated Sharpe calculation.
Historical P/E and FCF yield are excluded. Stored Alpaca bars are split-adjusted,
while filing-time EPS and shares are not guaranteed to use today's split basis;
mixing them without point-in-time split factors can manufacture valuation moves.
Earnings surprise is also excluded because the completed Dolt SUE study already
failed its promotion bar for this strategy.
No overlay passed the registered train and validation requirements. Review also
found that filing-time diluted EPS and shares are not guaranteed to use the same
split basis across periods. That makes EPS growth and share-count change unsafe
for historical ranking without point-in-time split factors. The initial result
remains an auditable rejection of its registered arms, but it is not evidence
that split-safe fundamentals have no value.
## Registered split-safe follow-up
Run with `--protocol split-safe`. This is a smaller sensitivity experiment:
- Quality: operating margin, FCF margin, and low net-debt/EBITDA. At least two
inputs must exist.
- Growth: revenue growth only.
- Balanced: equal weight to the quality and growth sub-scores. Both must exist.
- Overlay weights: 5%, 10%, and 15%.
Diluted-EPS growth and share-count change are excluded completely: they are not
ranked, do not enter composites, and do not appear in factor-IC output. Historical
P/E and FCF yield remain excluded for the same split-basis reason. Earnings
surprise remains excluded because the completed Dolt SUE study failed its
promotion bar for this strategy.
There are 10 registered portfolio trials including the control. The split-safe
report uses 10 in its Deflated Sharpe calculation. It has a separate score cache
and `fundamentals-splitsafe-*` output prefix, so it cannot be confused with or
silently reuse the initial experiment's scores.
The test window has already been inspected during the initial experiment. Keep
the original date boundaries and development selection discipline, but treat the
follow-up as sensitivity evidence. Any promotion still requires forward paper
evidence.
## Point-in-time rule
@@ -93,6 +121,8 @@ different path to the launcher.
## 3. Run it
The launcher now defaults to the registered `split-safe` follow-up:
```bash
./scripts/run_fundamentals_macbook.sh \
backtest_snapshots/fundamentals-backtest.sqlite
@@ -106,16 +136,24 @@ WORKERS=8 ./scripts/run_fundamentals_macbook.sh \
backtest_snapshots/fundamentals-backtest.sqlite
```
To reproduce the completed initial matrix instead, opt in explicitly:
```bash
PROTOCOL=original ./scripts/run_fundamentals_macbook.sh \
backtest_snapshots/fundamentals-backtest.sqlite
```
The first run builds two caches under `reports/.cache`: production candidate
replay and point-in-time fundamental scores. If interrupted, rerun the same
command; valid caches are reused. Cache keys include snapshot size and mtime, so
a new snapshot triggers a rebuild.
replay and protocol-specific point-in-time fundamental scores. If interrupted,
rerun the same command; valid caches are reused. Cache keys include the protocol,
snapshot size, and mtime, so neither a protocol switch nor a new snapshot can
reuse incompatible scores.
## 4. Bring the result back
The final line names one ZIP such as:
`reports/fundamentals-overlay-20260723-180000.zip`
`reports/fundamentals-splitsafe-20260723-180000.zip`
That ZIP contains:
+20 -2
View File
@@ -5,6 +5,7 @@ ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
SNAPSHOT="${1:-backtest_snapshots/fundamentals-backtest.sqlite}"
PROTOCOL="${PROTOCOL:-split-safe}"
WORKERS="${WORKERS:-$(sysctl -n hw.logicalcpu 2>/dev/null || echo 8)}"
if [[ "$WORKERS" -gt 1 ]]; then
WORKERS=$((WORKERS - 1))
@@ -21,17 +22,34 @@ if [[ ! -f "$SNAPSHOT" ]]; then
exit 1
fi
case "$PROTOCOL" in
split-safe)
PREFIX="fundamentals-splitsafe"
SCORE_CACHE="reports/.cache/fundamentals-splitsafe-scores.pkl"
;;
original)
PREFIX="fundamentals-overlay"
SCORE_CACHE="reports/.cache/fundamentals-scores.pkl"
;;
*)
echo "Unsupported PROTOCOL: $PROTOCOL (use split-safe or original)" >&2
exit 1
;;
esac
STAMP="$(date -u +%Y%m%d-%H%M%S)"
OUT="reports/fundamentals-overlay-${STAMP}.json"
OUT="reports/${PREFIX}-${STAMP}.json"
echo "Snapshot: $SNAPSHOT"
echo "Workers: $WORKERS"
echo "Protocol: $PROTOCOL"
echo "Output: $OUT"
"$PYTHON" scripts/run_fundamentals_research.py "$SNAPSHOT" \
--protocol "$PROTOCOL" \
--workers "$WORKERS" \
--candidate-cache reports/.cache/fundamentals-candidates.pkl \
--fundamentals-cache reports/.cache/fundamentals-scores.pkl \
--fundamentals-cache "$SCORE_CACHE" \
--out "$OUT"
echo
+104 -30
View File
@@ -2,8 +2,9 @@
The production qualification gate is unchanged. The runner first measures
30-session factor IC, then reorders already-qualified candidates with quality,
growth, or balanced fundamental ranks at 10, 20, 30, and 40 percent weights.
It writes JSON, Markdown, CSV, and a portable ZIP bundle.
growth, or balanced fundamental ranks. It supports the original registered
matrix and a split-safe follow-up sensitivity. It writes JSON, Markdown, CSV,
and a portable ZIP bundle.
"""
from __future__ import annotations
@@ -37,8 +38,15 @@ if str(ROOT) not in sys.path:
CACHE_VERSION = "fundamentals-overlay-v1"
NY = ZoneInfo("America/New_York")
COMPOSITES = ("quality", "growth", "balanced")
ORIGINAL_PROTOCOL = "original"
SPLIT_SAFE_PROTOCOL = "split-safe"
WEIGHTS = (0.10, 0.20, 0.30, 0.40)
ARMS: tuple[dict[str, Any], ...] = (
SPLIT_SAFE_WEIGHTS = (0.05, 0.10, 0.15)
def _arm_matrix(weights: tuple[float, ...]) -> tuple[dict[str, Any], ...]:
"""Build the control plus three bounded overlay families."""
return (
{
"id": "control_w00",
"label": "Production 80/20 momentum-volatility rank",
@@ -53,10 +61,15 @@ ARMS: tuple[dict[str, Any], ...] = (
"weight": weight,
}
for composite in COMPOSITES
for weight in WEIGHTS
for weight in weights
),
)
)
ARMS = _arm_matrix(WEIGHTS)
N_TRIALS = len(ARMS)
SPLIT_SAFE_ARMS = _arm_matrix(SPLIT_SAFE_WEIGHTS)
SPLIT_SAFE_N_TRIALS = len(SPLIT_SAFE_ARMS)
def _parse_args() -> argparse.Namespace:
@@ -74,6 +87,11 @@ def _parse_args() -> argparse.Namespace:
)
parser.add_argument("--train-end", default="2024-01-01")
parser.add_argument("--test-start", default="2025-01-01")
parser.add_argument(
"--protocol",
choices=(ORIGINAL_PROTOCOL, SPLIT_SAFE_PROTOCOL),
default=ORIGINAL_PROTOCOL,
)
parser.add_argument("--allow-spawn", action="store_true")
parser.add_argument("--quiet", action="store_true")
return parser.parse_args()
@@ -83,9 +101,14 @@ def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _default_out() -> Path:
def _default_out(protocol: str = ORIGINAL_PROTOCOL) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return Path("reports") / f"fundamentals-overlay-{stamp}.json"
label = (
"fundamentals-splitsafe"
if protocol == SPLIT_SAFE_PROTOCOL
else "fundamentals-overlay"
)
return Path("reports") / f"{label}-{stamp}.json"
def _snapshot_hash(path: Path) -> str:
@@ -394,11 +417,15 @@ def _build_scores(
dates: set[date],
representatives: dict[str, str],
snapshots_by_cik: dict[str, list[Any]],
split_safe: bool,
args: argparse.Namespace,
) -> tuple[dict[str, dict[str, dict[str, float | None]]], dict[str, Any]]:
from app.services import fundamentals_derivation as derivation
from app.services import fundamentals_research as research
factor_polarity = (
research.SPLIT_SAFE_FACTOR_POLARITY if split_safe else research.FACTOR_POLARITY
)
ordered_dates = sorted(dates)
date_fingerprint = hashlib.sha256(
"|".join(value.isoformat() for value in ordered_dates).encode()
@@ -408,6 +435,7 @@ def _build_scores(
snapshot,
{
"kind": "point-in-time-scores",
"score_profile": SPLIT_SAFE_PROTOCOL if split_safe else ORIGINAL_PROTOCOL,
"date_fingerprint": date_fingerprint,
"availability": "accepted before signal-date midnight America/New_York",
},
@@ -446,12 +474,15 @@ def _build_scores(
current_features[cik] = research.raw_features(
derivation.derive(visible[cik])
)
scores = research.cross_section_scores(current_features)
scores = research.cross_section_scores(
current_features,
split_safe=split_safe,
)
scores_by_date[signal_date.isoformat()] = scores
coverage_rows.append(
{
key: sum(row.get(key) is not None for row in scores.values())
for key in (*research.FACTOR_POLARITY, *research.COMPOSITE_KEYS)
for key in (*factor_polarity, *research.COMPOSITE_KEYS)
}
)
if not args.quiet and date_index % 100 == 0:
@@ -469,13 +500,13 @@ def _factor_diagnostics(
representatives: dict[str, str],
prices: dict[str, tuple],
scores_by_date: dict[str, dict[str, dict[str, float | None]]],
factor_keys: tuple[str, ...],
train_end: date,
test_start: date,
) -> dict[str, list[dict]]:
from app.services import backtest_service as bt
from app.services import fundamentals_research as research
signal_keys = (*research.FACTOR_POLARITY, *research.COMPOSITE_KEYS)
signal_keys = (*factor_keys, *COMPOSITES)
observations: list[dict[str, Any]] = []
for cik, symbol in representatives.items():
columns = prices[symbol]
@@ -533,6 +564,7 @@ def _attach_overlay_ranks(
candidates: list[dict],
ticker_rows: list[dict],
scores_by_date: dict[str, dict[str, dict[str, float | None]]],
arms: tuple[dict[str, Any], ...],
) -> dict[str, Any]:
from app.services import backtest_service as bt
from app.services import fundamentals_research as research
@@ -550,7 +582,7 @@ def _attach_overlay_ranks(
if value is not None:
covered[composite] += 1
base = candidate.get(bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY)
for arm in ARMS:
for arm in arms:
if arm["composite"] is None:
continue
candidate[_ranking_key(arm)] = research.overlay_rank(
@@ -626,6 +658,7 @@ def _run_arm(
data: dict[str, Any],
train_end: date,
test_start: date,
n_trials: int,
) -> tuple[dict[str, Any], dict[str, set[str]]]:
from app.services import backtest_service as bt
from scripts import run_research_matrix as shared
@@ -691,7 +724,7 @@ def _run_arm(
dsr = bt.deflated_sharpe_ratio(
sim.get("sharpe"),
sim.get("sharpe_se"),
N_TRIALS,
n_trials,
n_returns=sim.get("n_returns"),
return_skew=sim.get("return_skew"),
return_kurtosis=sim.get("return_kurtosis"),
@@ -781,13 +814,20 @@ def _fmt(value: Any) -> str:
def _markdown(report: dict[str, Any]) -> str:
protocol_id = report.get("research_protocol", ORIGINAL_PROTOCOL)
title = (
"Split-safe fundamentals overlay sensitivity"
if protocol_id == SPLIT_SAFE_PROTOCOL
else "Point-in-time fundamentals overlay research"
)
lines = [
"# Point-in-time fundamentals overlay research",
f"# {title}",
"",
"Generated: {}".format(report.get("generated_at")),
"",
"## Protocol",
"",
"- Research protocol: **{}**.".format(protocol_id),
"- Train ends before **{}**.".format(report["splits"]["train_end"]),
"- Validation runs until **{}**.".format(report["splits"]["test_start"]),
"- Test starts at that date and is not used to select the arm.",
@@ -976,9 +1016,21 @@ def _write_outputs(report: dict[str, Any], out: Path, *, bundle: bool) -> None:
async def _main() -> None:
from app.services import fundamentals_research as research
args = _parse_args()
split_safe = args.protocol == SPLIT_SAFE_PROTOCOL
arms = SPLIT_SAFE_ARMS if split_safe else ARMS
n_trials = len(arms)
factor_keys = tuple(
(
research.SPLIT_SAFE_FACTOR_POLARITY
if split_safe
else research.FACTOR_POLARITY
).keys()
)
snapshot = Path(args.snapshot)
out = Path(args.out) if args.out else _default_out()
out = Path(args.out) if args.out else _default_out(args.protocol)
if not snapshot.exists():
raise SystemExit(f"snapshot not found: {snapshot}")
if args.workers < 1:
@@ -1010,21 +1062,45 @@ async def _main() -> None:
all_dates,
representatives,
data["snapshots_by_cik"],
split_safe,
args,
)
candidate_coverage = _attach_overlay_ranks(
candidates, data["ticker_rows"], scores_by_date
candidates, data["ticker_rows"], scores_by_date, arms
)
factor_ic = _factor_diagnostics(
representatives,
data["prices"],
scores_by_date,
factor_keys,
train_end,
test_start,
)
warnings = [
"Current tracked universe only: historical constituent membership and "
"delisted names are unavailable, so absolute results have survivorship bias.",
"Earnings surprise is excluded because the completed SUE study already "
"failed its promotion bar for this strategy.",
"The test window has already been observed; this follow-up is sensitivity "
"evidence and live paper performance remains the final out-of-sample check.",
]
if split_safe:
warnings.insert(
1,
"Diluted-EPS growth and share-count change are excluded because filing-time "
"values are not split-comparable without point-in-time split factors.",
)
else:
warnings.insert(
1,
"Historical valuation is excluded because split-adjusted bars cannot be "
"safely combined with filing-time EPS and shares without split factors.",
)
report: dict[str, Any] = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"research_protocol": args.protocol,
"git_commit": _git_commit(),
"snapshot": str(snapshot.resolve()),
"snapshot_sha256": _snapshot_hash(snapshot),
@@ -1039,9 +1115,10 @@ async def _main() -> None:
"test": f"entry date >= {test_start.isoformat()}",
},
},
"n_trials": N_TRIALS,
"pre_registered_arms": list(ARMS),
"n_trials": n_trials,
"pre_registered_arms": list(arms),
"protocol": {
"id": args.protocol,
"qualification": "unchanged production gate; rank overlay only",
"cadence": "daily",
"fill_mode": "close; production near-close proxy",
@@ -1051,22 +1128,17 @@ async def _main() -> None:
"conservative match for the daily pre-market SEC import"
),
"missing_fundamental_score": 50.0,
"factor_keys": list(factor_keys),
"split_sensitive_metrics_excluded": (
["eps_growth_yoy", "share_count_change_yoy"] if split_safe else []
),
"selection": (
"highest validation Sharpe among arms with train and validation "
"Sharpe not below control and validation drawdown within 2pp"
),
"production_mutation": False,
},
"warnings": [
"Current tracked universe only: historical constituent membership and "
"delisted names are unavailable, so absolute results have survivorship bias.",
"Historical valuation is excluded because split-adjusted bars cannot be "
"safely combined with filing-time EPS and shares without split factors.",
"Earnings surprise is excluded because the completed SUE study already "
"failed its promotion bar for this strategy.",
"The test window remains research evidence, not a pristine future sample; "
"live paper performance is still the final out-of-sample check.",
],
"warnings": warnings,
"data_audit": audit,
"strategy_config": {
"recommendation": data["config"],
@@ -1087,10 +1159,12 @@ async def _main() -> None:
trade_sets: dict[str, dict[str, set[str]]] = {}
control: dict[str, Any] | None = None
for arm in ARMS:
for arm in arms:
if not args.quiet:
print("running {}".format(arm["id"]), flush=True)
result, arm_trades = _run_arm(arm, candidates, data, train_end, test_start)
result, arm_trades = _run_arm(
arm, candidates, data, train_end, test_start, n_trials
)
trade_sets[str(arm["id"])] = arm_trades
if control is None:
control = result
+39
View File
@@ -51,6 +51,45 @@ def test_composites_use_equal_subgroup_weighting():
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
@@ -29,6 +29,27 @@ def test_arm_matrix_is_bounded_and_pre_registered():
}
def test_split_safe_matrix_is_smaller_and_trial_corrected():
assert runner.SPLIT_SAFE_N_TRIALS == 10
assert runner.SPLIT_SAFE_ARMS[0]["id"] == "control_w00"
assert {arm["weight"] for arm in runner.SPLIT_SAFE_ARMS[1:]} == {
0.05,
0.10,
0.15,
}
assert {arm["composite"] for arm in runner.SPLIT_SAFE_ARMS[1:]} == {
"quality",
"growth",
"balanced",
}
def test_split_safe_output_has_a_distinct_name():
assert runner._default_out(runner.SPLIT_SAFE_PROTOCOL).name.startswith(
"fundamentals-splitsafe-"
)
def test_development_grade_does_not_read_test_window():
control = {
"windows": [