docs: record fundamentals research decision and clean up

This commit is contained in:
2026-07-23 17:50:33 +02:00
parent dba7ea739b
commit 361cfd7883
21 changed files with 102 additions and 209972 deletions
+16 -66
View File
@@ -20,7 +20,7 @@ Rules:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, datetime, timezone from datetime import date
from typing import Any, Iterable from typing import Any, Iterable
_FP_TO_Q = {"Q1": 1, "Q2": 2, "Q3": 3, "FY": 4} _FP_TO_Q = {"Q1": 1, "Q2": 2, "Q3": 3, "FY": 4}
@@ -30,12 +30,7 @@ TAPE_LEN = 4 # quarter-tape length
# Duration (flow) fields differenced from YTD into discrete quarters + summed to TTM. # Duration (flow) fields differenced from YTD into discrete quarters + summed to TTM.
_FLOW_FIELDS = ( _FLOW_FIELDS = (
"revenue", "revenue", "net_income", "operating_income", "diluted_eps", "cfo", "capex",
"net_income",
"operating_income",
"diluted_eps",
"cfo",
"capex",
"depreciation_amortization", "depreciation_amortization",
) )
@@ -49,9 +44,7 @@ class MetricPoint:
@dataclass @dataclass
class MetricSeries: class MetricSeries:
value: float | None = None value: float | None = None
history: list[MetricPoint] = field( history: list[MetricPoint] = field(default_factory=list) # oldest -> newest, <= TAPE_LEN
default_factory=list
) # oldest -> newest, <= TAPE_LEN
period_end: date | None = None period_end: date | None = None
filed_date: date | None = None filed_date: date | None = None
@@ -89,9 +82,7 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
result.ttm_diluted_eps = _ttm(discrete["diluted_eps"], *latest) result.ttm_diluted_eps = _ttm(discrete["diluted_eps"], *latest)
ttm_cfo = _ttm(discrete["cfo"], *latest) ttm_cfo = _ttm(discrete["cfo"], *latest)
ttm_capex = _ttm(discrete["capex"], *latest) ttm_capex = _ttm(discrete["capex"], *latest)
result.ttm_fcf = ( result.ttm_fcf = None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex
None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex
)
# tape = the CONSECUTIVE run of up to TAPE_LEN quarters ending at the latest, # tape = the CONSECUTIVE run of up to TAPE_LEN quarters ending at the latest,
# stopping at a gap — so trend text never compares non-adjacent periods. # stopping at a gap — so trend text never compares non-adjacent periods.
@@ -99,9 +90,7 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
result.metrics = { result.metrics = {
"revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape), "revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape),
"eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape), "eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape),
"operating_margin": _margin_series( "operating_margin": _margin_series(discrete["operating_income"], discrete["revenue"], selected, tape),
discrete["operating_income"], discrete["revenue"], selected, tape
),
"fcf_margin": _fcf_margin_series(discrete, selected, tape), "fcf_margin": _fcf_margin_series(discrete, selected, tape),
"net_debt": _instant_series(selected, tape, _net_debt), "net_debt": _instant_series(selected, tape, _net_debt),
"net_debt_to_ebitda": _leverage_series(selected, discrete, tape), "net_debt_to_ebitda": _leverage_series(selected, discrete, tape),
@@ -113,27 +102,8 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
return result return result
def derive_as_of(snapshots: Iterable[Any], as_of: datetime) -> DerivedFundamentals:
"""Derive using only SEC filings accepted by the historical cutoff."""
cutoff = _utc_datetime(as_of)
visible = (
row
for row in snapshots
if (accepted := getattr(row, "accepted_at", None)) is not None
and _utc_datetime(accepted) <= cutoff
)
return derive(visible)
def _utc_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
# -- period selection -------------------------------------------------------- # -- period selection --------------------------------------------------------
def _select_latest_per_period(snapshots: Iterable[Any]) -> dict[tuple[int, str], Any]: def _select_latest_per_period(snapshots: Iterable[Any]) -> dict[tuple[int, str], Any]:
best: dict[tuple[int, str], Any] = {} best: dict[tuple[int, str], Any] = {}
for row in snapshots: for row in snapshots:
@@ -156,9 +126,7 @@ def _ordered_quarters(selected: dict[tuple[int, str], Any]) -> list[tuple[int, i
return sorted((fy, _FP_TO_Q[fp]) for (fy, fp) in selected) return sorted((fy, _FP_TO_Q[fp]) for (fy, fp) in selected)
def _consecutive_suffix( def _consecutive_suffix(quarters: list[tuple[int, int]], n: int) -> list[tuple[int, int]]:
quarters: list[tuple[int, int]], n: int
) -> list[tuple[int, int]]:
"""The run of up to n quarters ending at the latest, walking back only through """The run of up to n quarters ending at the latest, walking back only through
adjacent periods (stop at the first gap). Returned oldest -> newest.""" adjacent periods (stop at the first gap). Returned oldest -> newest."""
if not quarters: if not quarters:
@@ -178,10 +146,7 @@ def _consecutive_suffix(
# -- discrete + TTM ---------------------------------------------------------- # -- discrete + TTM ----------------------------------------------------------
def _discrete_quarters(selected: dict[tuple[int, str], Any], field_name: str) -> dict[tuple[int, int], float]:
def _discrete_quarters(
selected: dict[tuple[int, str], Any], field_name: str
) -> dict[tuple[int, int], float]:
out: dict[tuple[int, int], float] = {} out: dict[tuple[int, int], float] = {}
for (fy, fp), row in selected.items(): for (fy, fp), row in selected.items():
val = _discrete_value(selected, fy, fp, field_name) val = _discrete_value(selected, fy, fp, field_name)
@@ -224,7 +189,6 @@ def _pct_change(cur: float | None, prior: float | None) -> float | None:
# -- per-metric series (value at latest + tape history) ---------------------- # -- per-metric series (value at latest + tape history) ----------------------
def _period_end(selected, fy: int, q: int) -> date | None: def _period_end(selected, fy: int, q: int) -> date | None:
row = selected.get((fy, _Q_TO_FP[q])) row = selected.get((fy, _Q_TO_FP[q]))
return row.period_end if row is not None else None return row.period_end if row is not None else None
@@ -232,7 +196,7 @@ def _period_end(selected, fy: int, q: int) -> date | None:
def _yoy_growth_series(dq, selected, tape) -> MetricSeries: def _yoy_growth_series(dq, selected, tape) -> MetricSeries:
pts = [] pts = []
for fy, q in tape: for (fy, q) in tape:
cur, prior = _ttm(dq, fy, q), _ttm(dq, fy - 1, q) cur, prior = _ttm(dq, fy, q), _ttm(dq, fy - 1, q)
pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior))) pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
return _series(pts) return _series(pts)
@@ -240,7 +204,7 @@ def _yoy_growth_series(dq, selected, tape) -> MetricSeries:
def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries: def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries:
pts = [] pts = []
for fy, q in tape: for (fy, q) in tape:
num, den = _ttm(num_dq, fy, q), _ttm(den_dq, fy, q) num, den = _ttm(num_dq, fy, q), _ttm(den_dq, fy, q)
val = None if num is None or not den else num / den * 100.0 val = None if num is None or not den else num / den * 100.0
pts.append(MetricPoint(_period_end(selected, fy, q), val)) pts.append(MetricPoint(_period_end(selected, fy, q), val))
@@ -249,38 +213,24 @@ def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries:
def _fcf_margin_series(discrete, selected, tape) -> MetricSeries: def _fcf_margin_series(discrete, selected, tape) -> MetricSeries:
pts = [] pts = []
for fy, q in tape: for (fy, q) in tape:
cfo, capex, rev = ( cfo, capex, rev = _ttm(discrete["cfo"], fy, q), _ttm(discrete["capex"], fy, q), _ttm(discrete["revenue"], fy, q)
_ttm(discrete["cfo"], fy, q), val = None if cfo is None or capex is None or not rev else (cfo - capex) / rev * 100.0
_ttm(discrete["capex"], fy, q),
_ttm(discrete["revenue"], fy, q),
)
val = (
None
if cfo is None or capex is None or not rev
else (cfo - capex) / rev * 100.0
)
pts.append(MetricPoint(_period_end(selected, fy, q), val)) pts.append(MetricPoint(_period_end(selected, fy, q), val))
return _series(pts) return _series(pts)
def _instant_series(selected, tape, fn) -> MetricSeries: def _instant_series(selected, tape, fn) -> MetricSeries:
pts = [ pts = [MetricPoint(_period_end(selected, fy, q), fn(selected.get((fy, _Q_TO_FP[q])))) for (fy, q) in tape]
MetricPoint(_period_end(selected, fy, q), fn(selected.get((fy, _Q_TO_FP[q]))))
for (fy, q) in tape
]
return _series(pts) return _series(pts)
def _leverage_series(selected, discrete, tape) -> MetricSeries: def _leverage_series(selected, discrete, tape) -> MetricSeries:
pts = [] pts = []
for fy, q in tape: for (fy, q) in tape:
row = selected.get((fy, _Q_TO_FP[q])) row = selected.get((fy, _Q_TO_FP[q]))
nd = _net_debt(row) nd = _net_debt(row)
op, da = ( op, da = _ttm(discrete["operating_income"], fy, q), _ttm(discrete["depreciation_amortization"], fy, q)
_ttm(discrete["operating_income"], fy, q),
_ttm(discrete["depreciation_amortization"], fy, q),
)
ebitda = None if op is None or da is None else op + da ebitda = None if op is None or da is None else op + da
# Null when EBITDA <= 0: a negative denominator would flip polarity and a # Null when EBITDA <= 0: a negative denominator would flip polarity and a
# "lower is better" read would rank a distressed issuer as favorable. # "lower is better" read would rank a distressed issuer as favorable.
@@ -291,7 +241,7 @@ def _leverage_series(selected, discrete, tape) -> MetricSeries:
def _share_change_series(selected, tape) -> MetricSeries: def _share_change_series(selected, tape) -> MetricSeries:
pts = [] pts = []
for fy, q in tape: for (fy, q) in tape:
cur = _shares(selected.get((fy, _Q_TO_FP[q]))) cur = _shares(selected.get((fy, _Q_TO_FP[q])))
prior = _shares(selected.get((fy - 1, _Q_TO_FP[q]))) prior = _shares(selected.get((fy - 1, _Q_TO_FP[q])))
pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior))) pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
-176
View File
@@ -1,176 +0,0 @@
"""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")
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")
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,
split_safe: bool = False,
) -> dict[str, dict[str, float | None]]:
"""Return favorable factor ranks and composites for every issuer.
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 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
+46 -163
View File
@@ -1,191 +1,74 @@
# Point-in-time fundamentals weight backtest # Fundamentals ranking-overlay research
Status: initial experiment completed; split-safe follow-up registered locally. Status: completed 2026-07-23. Decision: keep production scoring and qualification unchanged.
Running either protocol does not change production.
## Question ## Question
Does reordering already-qualified long setups with SEC fundamentals improve the Does using point-in-time SEC fundamentals to reorder already-qualified long setups improve the production portfolio's risk-adjusted return? The experiments changed ranking only; qualification, execution, sizing, capacity, costs, ATR exits, and post-stop re-entry remained unchanged.
production book's risk-adjusted return? The qualification gate, execution model,
position sizing, capacity, costs, ATR trail, and post-stop re-entry policy remain
unchanged. This isolates the incremental value of fundamentals as a ranking
overlay.
## Completed initial experiment ## Method
The control is the production 80/20 residual-momentum / volatility rank. The - The control was the production 80/20 residual-momentum / volatility rank.
runner tests three fundamental composites at weights 10%, 20%, 30%, and 40%: - SEC facts became visible only after `accepted_at`, using the newest visible accession per fiscal period.
- Portfolio simulations used daily entry opportunities, close fills, the production gate-reset re-entry policy, and a 30-session horizon.
- Train contained entries before 2024-01-01, validation covered 2024, and test began 2025-01-01.
- Missing composite scores were neutral at 50.
- Deflated Sharpe used the complete registered arm count for each experiment.
- Quality: operating margin, FCF margin, low net-debt/EBITDA, and low dilution. The snapshot contained 511 tracked tickers, 507 unique CIKs, 30,494 SEC snapshot rows, and prices from 2021-06-24 through 2026-07-22.
At least two inputs must exist.
- Growth: revenue growth and diluted-EPS growth. At least one must exist.
- Balanced: equal weight to the quality and growth sub-scores. Both must exist.
Each raw metric is ranked favorably from 0 to 100 across the CIK-deduplicated ## Initial experiment
tracked universe. Missing composite scores are neutral at 50. The formula is:
`final rank = (1 - weight) * production rank + weight * fundamental rank` The first registered matrix tested quality, growth, and balanced composites at 10%, 20%, 30%, and 40% weights: 13 trials including control.
There are 13 registered portfolio trials including the control. That complete No overlay passed the train and validation requirements. The most attractive full-period result, balanced at 10%, failed validation and improved test Sharpe by only 0.06.
count is used by the Deflated Sharpe calculation.
No overlay passed the registered train and validation requirements. Review also Review also found that filing-time diluted EPS and shares are not reliably comparable across stock splits. A snapshot audit found share-count changes above 25% for 90 of 461 issuers with comparable 2021+ periods, including recognizable split ratios for AMZN, GOOG, NVDA, CMG, and GE plus some obvious unit anomalies. Consequently, EPS growth and share-count change cannot be trusted for historical ranking without point-in-time split factors.
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 The complete initial result is recoverable from Git commit `7f944d7`.
Run with `--protocol split-safe`. This is a smaller sensitivity experiment: ## Split-safe follow-up
- Quality: operating margin, FCF margin, and low net-debt/EBITDA. At least two The follow-up excluded diluted-EPS growth and share-count change completely. It tested:
inputs must exist.
- Quality: operating margin, FCF margin, and low net-debt/EBITDA, requiring at least two inputs.
- Growth: revenue growth only. - Growth: revenue growth only.
- Balanced: equal weight to the quality and growth sub-scores. Both must exist. - Balanced: equal quality and growth weights.
- Overlay weights: 5%, 10%, and 15%. - Overlay weights: 5%, 10%, and 15%.
Diluted-EPS growth and share-count change are excluded completely: they are not This produced 10 registered trials including control. Growth coverage among qualified candidates was 88.96%; lack of data was not the limiting factor.
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 | Window | Control Sharpe | Revenue-growth 5% | Delta |
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 | Train | 1.26 | 1.31 | +0.05 |
silently reuse the initial experiment's scores. | Validation | 2.52 | 2.64 | +0.12 |
| Test | 1.99 | 1.99 | 0.00 |
| Full | 1.86 | 1.87 | +0.01 |
The test window has already been inspected during the initial experiment. Keep Revenue growth at 5% mechanically passed the deliberately permissive "not worse" gate, but did not demonstrate an economically meaningful edge:
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 - Test CAGR rose from 54.4% to 56.1%, while full-period CAGR fell from 52.1% to 51.5%.
- Full-period trade overlap was 68.53%, so roughly one-third of selections changed for essentially unchanged Sharpe.
- Revenue-growth IC was 0.0006 in train, 0.0053 in test, and 0.0116 full-period with a full-period t-stat of 0.55.
- Growth weights of 10% and 15% deteriorated; quality and balanced composites failed.
- The test window had already been inspected, so this follow-up was sensitivity evidence rather than a fresh out-of-sample result.
Only SEC rows accepted before midnight America/New_York at the start of a signal The complete split-safe result is recoverable from Git commit `dba7ea7`.
date are visible. This is conservative relative to the daily pre-market SEC
import and prevents same-day filings or later amendments from leaking backward.
The derivation then selects the newest visible accession per fiscal period.
Default windows are fixed before the first run: ## Decision
- Train: entry date before 2024-01-01 - Do not add fundamental weight to production ranking or the automated qualification gate.
- Validation: 2024-01-01 through 2024-12-31 - Do not run another historical weight sweep on the same sample; it would add data-mining rather than new evidence.
- Test: entry date on or after 2025-01-01 - Keep fundamentals informational and user-facing in the UI.
- A5 source-parity and cutover work can proceed independently without changing scoring behavior.
- Treat historical EPS growth and share-count change as non-comparable across corporate actions until a split-aware solution or a conservative UI guard exists.
Do not move these boundaries after seeing results. The test window is used only Revisit automated weighting only with materially better data, such as point-in-time split factors and historical constituent/delisting coverage, followed by genuinely new forward paper evidence.
to check the single arm chosen from train and validation. Reports expose all
registered rows for auditability and correct multiple-testing accounting.
## 1. Create the portable snapshot ## Limitations
Run this wherever the production PostgreSQL connection is already configured. The snapshot uses today's tracked universe rather than historical membership and delisted securities, creating survivorship bias. Absolute CAGR and Sharpe must not be interpreted as unbiased live expectations. The relative comparison is useful, but the observed test window and short number of independent factor windows limit statistical power.
The exporter copies prices, the safe strategy settings, SEC snapshots, and Dolt
earnings rows. It does not copy credentials or unrelated system settings.
Windows PowerShell: ## Repository cleanup
```powershell The experiment-only scorer, runner, Mac launcher, caches, tests, and expanded report bundles were removed after this decision. They remain recoverable from commits `eae4d34`, `34d6dda`, `7f944d7`, and `dba7ea7`. Production fundamentals derivation and ingestion remain unchanged.
.venv\Scripts\python.exe scripts\create_backtest_snapshot.py `
--output backtest_snapshots\fundamentals-backtest.sqlite `
--force
```
Linux production host:
```bash
.venv/bin/python scripts/create_backtest_snapshot.py \
--output backtest_snapshots/fundamentals-backtest.sqlite \
--force
```
Copy only the SQLite file to the MacBook. `scp`, a local network share, or an
encrypted USB drive are all fine. Do not copy `.env`.
## 2. Prepare the MacBook
Use the same Git commit as the machine that created the report. From the repo:
```bash
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e '.[dev]'
chmod +x scripts/run_fundamentals_macbook.sh
```
Put the snapshot at `backtest_snapshots/fundamentals-backtest.sqlite`, or pass a
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
```
The launcher defaults to logical CPU count minus one. Override it if the laptop
gets too warm or memory pressure rises:
```bash
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 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-splitsafe-20260723-180000.zip`
That ZIP contains:
- the complete JSON report and reproducibility metadata;
- a readable Markdown summary;
- portfolio-arm CSV;
- factor-IC CSV;
- full-period trade CSV for every arm, allowing winner-concentration checks;
- this registered protocol.
Copy the ZIP into this workspace or attach it in the conversation. The snapshot
itself is not needed for the first evaluation unless a result looks inconsistent.
## Evaluation order
1. Data coverage and the accepted-at range.
2. Individual factor IC: sign, magnitude, consistency, and cross-section size.
3. Composite IC in train, validation, and test.
4. Development selection made without the test window.
5. Test Sharpe, CAGR, drawdown, yearly returns, trial-corrected DSR, and trade
overlap versus control.
6. Sensitivity to a few dominant winners and whether the effect is economically
large enough to justify production complexity.
The mechanical development bar requires train and validation Sharpe not below
control and validation drawdown no more than two percentage points worse. A test
pass is still research evidence, not automatic deployment.
## Known limitation
The snapshot contains today's tracked tickers, not historical constituent
membership or delisted names. This creates survivorship bias. The same biased
universe is used for control and overlays, so the local comparison is useful,
but its absolute Sharpe or CAGR must not be presented as an unbiased live
expectation. Forward paper performance remains the true out-of-sample check.
@@ -1,53 +0,0 @@
arm,composite,weight,window,sharpe,sharpe_se,dsr,cagr_pct,max_drawdown_pct,calmar,trades,overlap_pct,top5_pnl_share_pct,avg_r_ex_top5
control_w00,,0.0,train,1.26,0.764,0.4607,32.5,18.5,1.76,195,,67.83,0.2729
control_w00,,0.0,validation,2.52,0.938,0.8309,71.3,14.8,4.82,106,,69.13,0.357
control_w00,,0.0,test,1.99,0.81,0.7757,54.4,19.2,2.83,188,,62.82,0.1591
control_w00,,0.0,full,1.86,0.49,0.9807,52.1,22.3,2.34,483,,38.74,0.4132
quality_w10,quality,0.1,train,1.66,0.772,0.6629,45.4,16.9,2.68,183,57.5,63.51,0.2957
quality_w10,quality,0.1,validation,2.44,0.941,0.8077,68.8,17.3,3.98,107,65.12,70.53,0.3518
quality_w10,quality,0.1,test,1.7,0.805,0.6562,43.8,18.8,2.33,189,60.43,72.18,0.1424
quality_w10,quality,0.1,full,1.91,0.493,0.9845,53.3,21.6,2.46,470,59.1,39.96,0.4105
quality_w20,quality,0.2,train,1.64,0.77,0.6538,44.4,17.4,2.55,181,42.42,61.16,0.2545
quality_w20,quality,0.2,validation,2.28,0.953,0.7551,59.4,18.6,3.19,111,53.9,79.91,0.3052
quality_w20,quality,0.2,test,1.49,0.805,0.5562,35.9,18.6,1.93,189,44.44,80.74,0.136
quality_w20,quality,0.2,full,1.75,0.493,0.9666,46.6,20.7,2.26,478,44.73,44.1,0.3938
quality_w30,quality,0.3,train,1.69,0.767,0.6781,45.0,17.3,2.6,176,39.47,48.72,0.3464
quality_w30,quality,0.3,validation,1.83,0.948,0.5869,42.9,18.8,2.28,111,51.75,83.6,0.2918
quality_w30,quality,0.3,test,1.3,0.801,0.4621,28.9,18.6,1.56,189,41.73,94.0,0.1314
quality_w30,quality,0.3,full,1.57,0.491,0.9297,39.1,19.6,2.0,469,41.25,43.46,0.4265
quality_w40,quality,0.4,train,1.73,0.772,0.6954,46.5,18.2,2.55,190,31.85,48.84,0.3179
quality_w40,quality,0.4,validation,1.58,0.944,0.4824,36.8,19.0,1.94,110,48.97,101.05,0.2815
quality_w40,quality,0.4,test,1.18,0.812,0.4045,24.2,18.0,1.35,193,34.63,110.57,0.0845
quality_w40,quality,0.4,full,1.39,0.494,0.8643,33.0,23.8,1.39,492,34.11,47.94,0.3366
growth_w10,growth,0.1,train,1.24,0.769,0.4506,31.1,18.2,1.71,190,53.39,70.42,0.2227
growth_w10,growth,0.1,validation,2.65,0.915,0.8695,74.6,11.6,6.41,103,74.17,67.49,0.4295
growth_w10,growth,0.1,test,1.87,0.807,0.7297,52.4,18.9,2.77,197,61.09,69.9,0.1885
growth_w10,growth,0.1,full,1.83,0.49,0.9776,51.5,21.6,2.39,485,58.43,42.2,0.4164
growth_w20,growth,0.2,train,1.16,0.775,0.4105,27.3,17.8,1.53,189,42.75,83.96,0.1951
growth_w20,growth,0.2,validation,1.99,0.945,0.6516,50.1,19.2,2.61,101,56.82,71.97,0.3987
growth_w20,growth,0.2,test,1.59,0.8,0.6053,40.5,18.7,2.16,191,53.44,83.16,0.066
growth_w20,growth,0.2,full,1.55,0.493,0.9232,39.3,21.1,1.86,477,49.07,45.99,0.3356
growth_w30,growth,0.3,train,0.94,0.784,0.307,19.5,17.7,1.1,199,41.22,107.4,0.0981
growth_w30,growth,0.3,validation,1.79,0.954,0.57,42.4,19.5,2.18,101,55.64,78.91,0.2995
growth_w30,growth,0.3,test,1.32,0.8,0.472,31.5,18.3,1.72,198,47.33,92.89,0.066
growth_w30,growth,0.3,full,1.29,0.496,0.8143,29.9,20.7,1.44,500,45.63,50.27,0.2876
growth_w40,growth,0.4,train,1.08,0.785,0.3725,22.5,16.2,1.38,196,32.54,101.48,0.1686
growth_w40,growth,0.4,validation,1.96,0.956,0.6383,48.1,19.3,2.5,101,55.64,80.82,0.2926
growth_w40,growth,0.4,test,1.45,0.799,0.5368,36.3,17.8,2.03,193,50.59,84.67,0.0155
growth_w40,growth,0.4,full,1.47,0.495,0.896,35.6,26.0,1.37,487,40.99,46.88,0.2959
balanced_w10,balanced,0.1,train,1.66,0.773,0.6627,45.7,18.5,2.47,191,62.87,63.37,0.2536
balanced_w10,balanced,0.1,validation,2.42,0.931,0.8044,64.1,17.4,3.69,105,64.84,75.23,0.4326
balanced_w10,balanced,0.1,test,2.05,0.808,0.7978,57.6,18.9,3.04,186,56.49,62.34,0.2001
balanced_w10,balanced,0.1,full,2.0,0.493,0.9903,57.4,21.4,2.69,471,58.21,38.56,0.4343
balanced_w20,balanced,0.2,train,1.5,0.774,0.5842,40.3,16.2,2.49,186,48.25,69.56,0.2281
balanced_w20,balanced,0.2,validation,1.94,0.945,0.6319,45.7,18.5,2.47,113,58.7,78.45,0.3889
balanced_w20,balanced,0.2,test,1.99,0.805,0.7771,52.6,18.3,2.88,189,46.12,61.78,0.2105
balanced_w20,balanced,0.2,full,1.71,0.493,0.96,45.6,19.3,2.37,481,47.18,37.87,0.4034
balanced_w30,balanced,0.3,train,1.11,0.774,0.3854,26.5,17.1,1.55,201,37.98,82.7,0.1461
balanced_w30,balanced,0.3,validation,1.9,0.94,0.6164,47.7,17.3,2.76,104,60.31,89.47,0.3487
balanced_w30,balanced,0.3,test,1.54,0.799,0.5812,37.9,18.3,2.08,197,45.83,82.89,0.1937
balanced_w30,balanced,0.3,full,1.52,0.492,0.9144,39.4,21.3,1.85,496,43.55,47.61,0.3639
balanced_w40,balanced,0.4,train,1.16,0.782,0.4113,25.9,16.2,1.6,202,34.58,77.25,0.1727
balanced_w40,balanced,0.4,validation,2.07,0.943,0.6827,56.2,18.4,3.06,103,50.36,80.3,0.2394
balanced_w40,balanced,0.4,test,1.29,0.807,0.4574,30.0,17.8,1.68,202,46.62,91.39,0.1051
balanced_w40,balanced,0.4,full,1.34,0.496,0.8401,32.3,26.4,1.22,507,40.83,51.04,0.3014
1 arm composite weight window sharpe sharpe_se dsr cagr_pct max_drawdown_pct calmar trades overlap_pct top5_pnl_share_pct avg_r_ex_top5
2 control_w00 0.0 train 1.26 0.764 0.4607 32.5 18.5 1.76 195 67.83 0.2729
3 control_w00 0.0 validation 2.52 0.938 0.8309 71.3 14.8 4.82 106 69.13 0.357
4 control_w00 0.0 test 1.99 0.81 0.7757 54.4 19.2 2.83 188 62.82 0.1591
5 control_w00 0.0 full 1.86 0.49 0.9807 52.1 22.3 2.34 483 38.74 0.4132
6 quality_w10 quality 0.1 train 1.66 0.772 0.6629 45.4 16.9 2.68 183 57.5 63.51 0.2957
7 quality_w10 quality 0.1 validation 2.44 0.941 0.8077 68.8 17.3 3.98 107 65.12 70.53 0.3518
8 quality_w10 quality 0.1 test 1.7 0.805 0.6562 43.8 18.8 2.33 189 60.43 72.18 0.1424
9 quality_w10 quality 0.1 full 1.91 0.493 0.9845 53.3 21.6 2.46 470 59.1 39.96 0.4105
10 quality_w20 quality 0.2 train 1.64 0.77 0.6538 44.4 17.4 2.55 181 42.42 61.16 0.2545
11 quality_w20 quality 0.2 validation 2.28 0.953 0.7551 59.4 18.6 3.19 111 53.9 79.91 0.3052
12 quality_w20 quality 0.2 test 1.49 0.805 0.5562 35.9 18.6 1.93 189 44.44 80.74 0.136
13 quality_w20 quality 0.2 full 1.75 0.493 0.9666 46.6 20.7 2.26 478 44.73 44.1 0.3938
14 quality_w30 quality 0.3 train 1.69 0.767 0.6781 45.0 17.3 2.6 176 39.47 48.72 0.3464
15 quality_w30 quality 0.3 validation 1.83 0.948 0.5869 42.9 18.8 2.28 111 51.75 83.6 0.2918
16 quality_w30 quality 0.3 test 1.3 0.801 0.4621 28.9 18.6 1.56 189 41.73 94.0 0.1314
17 quality_w30 quality 0.3 full 1.57 0.491 0.9297 39.1 19.6 2.0 469 41.25 43.46 0.4265
18 quality_w40 quality 0.4 train 1.73 0.772 0.6954 46.5 18.2 2.55 190 31.85 48.84 0.3179
19 quality_w40 quality 0.4 validation 1.58 0.944 0.4824 36.8 19.0 1.94 110 48.97 101.05 0.2815
20 quality_w40 quality 0.4 test 1.18 0.812 0.4045 24.2 18.0 1.35 193 34.63 110.57 0.0845
21 quality_w40 quality 0.4 full 1.39 0.494 0.8643 33.0 23.8 1.39 492 34.11 47.94 0.3366
22 growth_w10 growth 0.1 train 1.24 0.769 0.4506 31.1 18.2 1.71 190 53.39 70.42 0.2227
23 growth_w10 growth 0.1 validation 2.65 0.915 0.8695 74.6 11.6 6.41 103 74.17 67.49 0.4295
24 growth_w10 growth 0.1 test 1.87 0.807 0.7297 52.4 18.9 2.77 197 61.09 69.9 0.1885
25 growth_w10 growth 0.1 full 1.83 0.49 0.9776 51.5 21.6 2.39 485 58.43 42.2 0.4164
26 growth_w20 growth 0.2 train 1.16 0.775 0.4105 27.3 17.8 1.53 189 42.75 83.96 0.1951
27 growth_w20 growth 0.2 validation 1.99 0.945 0.6516 50.1 19.2 2.61 101 56.82 71.97 0.3987
28 growth_w20 growth 0.2 test 1.59 0.8 0.6053 40.5 18.7 2.16 191 53.44 83.16 0.066
29 growth_w20 growth 0.2 full 1.55 0.493 0.9232 39.3 21.1 1.86 477 49.07 45.99 0.3356
30 growth_w30 growth 0.3 train 0.94 0.784 0.307 19.5 17.7 1.1 199 41.22 107.4 0.0981
31 growth_w30 growth 0.3 validation 1.79 0.954 0.57 42.4 19.5 2.18 101 55.64 78.91 0.2995
32 growth_w30 growth 0.3 test 1.32 0.8 0.472 31.5 18.3 1.72 198 47.33 92.89 0.066
33 growth_w30 growth 0.3 full 1.29 0.496 0.8143 29.9 20.7 1.44 500 45.63 50.27 0.2876
34 growth_w40 growth 0.4 train 1.08 0.785 0.3725 22.5 16.2 1.38 196 32.54 101.48 0.1686
35 growth_w40 growth 0.4 validation 1.96 0.956 0.6383 48.1 19.3 2.5 101 55.64 80.82 0.2926
36 growth_w40 growth 0.4 test 1.45 0.799 0.5368 36.3 17.8 2.03 193 50.59 84.67 0.0155
37 growth_w40 growth 0.4 full 1.47 0.495 0.896 35.6 26.0 1.37 487 40.99 46.88 0.2959
38 balanced_w10 balanced 0.1 train 1.66 0.773 0.6627 45.7 18.5 2.47 191 62.87 63.37 0.2536
39 balanced_w10 balanced 0.1 validation 2.42 0.931 0.8044 64.1 17.4 3.69 105 64.84 75.23 0.4326
40 balanced_w10 balanced 0.1 test 2.05 0.808 0.7978 57.6 18.9 3.04 186 56.49 62.34 0.2001
41 balanced_w10 balanced 0.1 full 2.0 0.493 0.9903 57.4 21.4 2.69 471 58.21 38.56 0.4343
42 balanced_w20 balanced 0.2 train 1.5 0.774 0.5842 40.3 16.2 2.49 186 48.25 69.56 0.2281
43 balanced_w20 balanced 0.2 validation 1.94 0.945 0.6319 45.7 18.5 2.47 113 58.7 78.45 0.3889
44 balanced_w20 balanced 0.2 test 1.99 0.805 0.7771 52.6 18.3 2.88 189 46.12 61.78 0.2105
45 balanced_w20 balanced 0.2 full 1.71 0.493 0.96 45.6 19.3 2.37 481 47.18 37.87 0.4034
46 balanced_w30 balanced 0.3 train 1.11 0.774 0.3854 26.5 17.1 1.55 201 37.98 82.7 0.1461
47 balanced_w30 balanced 0.3 validation 1.9 0.94 0.6164 47.7 17.3 2.76 104 60.31 89.47 0.3487
48 balanced_w30 balanced 0.3 test 1.54 0.799 0.5812 37.9 18.3 2.08 197 45.83 82.89 0.1937
49 balanced_w30 balanced 0.3 full 1.52 0.492 0.9144 39.4 21.3 1.85 496 43.55 47.61 0.3639
50 balanced_w40 balanced 0.4 train 1.16 0.782 0.4113 25.9 16.2 1.6 202 34.58 77.25 0.1727
51 balanced_w40 balanced 0.4 validation 2.07 0.943 0.6827 56.2 18.4 3.06 103 50.36 80.3 0.2394
52 balanced_w40 balanced 0.4 test 1.29 0.807 0.4574 30.0 17.8 1.68 202 46.62 91.39 0.1051
53 balanced_w40 balanced 0.4 full 1.34 0.496 0.8401 32.3 26.4 1.22 507 40.83 51.04 0.3014
@@ -1,37 +0,0 @@
window,signal,mean_ic,ic_t_stat,ic_positive_pct,mean_quintile_spread,weeks,avg_cross_section,reliable
train,share_count_change_yoy,0.0389,1.97,61.9,0.0029,21,435.5,True
train,net_debt_to_ebitda,0.0141,0.46,61.9,0.0098,21,183.9,True
train,balanced,0.0065,0.2,57.1,0.0007,21,381.2,True
train,quality,0.0038,0.19,47.6,-0.0033,21,396.6,True
train,revenue_growth_yoy,0.0006,0.02,47.6,0.0044,21,406.8,True
train,fcf_margin,-0.006,-0.29,38.1,-0.0044,21,365.3,True
train,growth,-0.0077,-0.26,47.6,0.003,21,442.5,True
train,eps_growth_yoy,-0.0152,-0.65,52.4,-0.004,21,370.2,True
train,operating_margin,-0.0288,-1.36,28.6,-0.0131,21,333.3,True
validation,growth,0.0558,1.21,55.6,0.0177,9,450.9,False
validation,revenue_growth_yoy,0.0485,0.95,55.6,0.0205,9,416.7,False
validation,balanced,0.0483,1.06,55.6,0.0126,9,387.9,False
validation,eps_growth_yoy,0.0473,1.44,55.6,0.014,9,393.8,False
validation,fcf_margin,0.0268,0.84,66.7,0.0003,9,368.0,False
validation,net_debt_to_ebitda,0.0066,0.12,55.6,0.0104,9,184.6,False
validation,quality,-0.0029,-0.14,44.4,-0.0002,9,401.0,False
validation,operating_margin,-0.0056,-0.18,44.4,-0.0058,9,338.3,False
validation,share_count_change_yoy,-0.0169,-0.52,55.6,-0.0098,9,439.9,False
test,eps_growth_yoy,0.0182,0.88,46.2,0.0071,13,415.7,True
test,share_count_change_yoy,0.0142,0.78,61.5,-0.0147,13,451.9,True
test,growth,0.011,0.32,46.2,0.0047,13,463.6,True
test,revenue_growth_yoy,0.0053,0.14,46.2,0.0046,13,429.3,True
test,net_debt_to_ebitda,-0.0082,-0.19,61.5,-0.0043,13,195.5,True
test,balanced,-0.0176,-0.47,38.5,-0.0147,13,402.2,True
test,quality,-0.0386,-1.59,30.8,-0.0257,13,419.6,True
test,operating_margin,-0.0436,-1.52,38.5,-0.0279,13,349.5,True
test,fcf_margin,-0.0535,-1.99,30.8,-0.0307,13,380.4,True
full,share_count_change_yoy,0.0161,1.16,54.8,-0.0052,42,441.3,True
full,growth,0.0123,0.65,54.8,0.0067,42,450.5,True
full,revenue_growth_yoy,0.0116,0.55,47.6,0.0069,42,415.5,True
full,eps_growth_yoy,0.008,0.58,57.1,0.003,42,388.5,True
full,balanced,0.0071,0.36,54.8,-0.0014,42,388.9,True
full,net_debt_to_ebitda,0.006,0.29,54.8,0.0037,42,187.9,True
full,quality,-0.0109,-0.85,45.2,-0.0092,42,404.5,True
full,fcf_margin,-0.0161,-1.04,35.7,-0.0123,42,370.5,True
full,operating_margin,-0.0252,-1.74,33.3,-0.017,42,339.2,True
1 window signal mean_ic ic_t_stat ic_positive_pct mean_quintile_spread weeks avg_cross_section reliable
2 train share_count_change_yoy 0.0389 1.97 61.9 0.0029 21 435.5 True
3 train net_debt_to_ebitda 0.0141 0.46 61.9 0.0098 21 183.9 True
4 train balanced 0.0065 0.2 57.1 0.0007 21 381.2 True
5 train quality 0.0038 0.19 47.6 -0.0033 21 396.6 True
6 train revenue_growth_yoy 0.0006 0.02 47.6 0.0044 21 406.8 True
7 train fcf_margin -0.006 -0.29 38.1 -0.0044 21 365.3 True
8 train growth -0.0077 -0.26 47.6 0.003 21 442.5 True
9 train eps_growth_yoy -0.0152 -0.65 52.4 -0.004 21 370.2 True
10 train operating_margin -0.0288 -1.36 28.6 -0.0131 21 333.3 True
11 validation growth 0.0558 1.21 55.6 0.0177 9 450.9 False
12 validation revenue_growth_yoy 0.0485 0.95 55.6 0.0205 9 416.7 False
13 validation balanced 0.0483 1.06 55.6 0.0126 9 387.9 False
14 validation eps_growth_yoy 0.0473 1.44 55.6 0.014 9 393.8 False
15 validation fcf_margin 0.0268 0.84 66.7 0.0003 9 368.0 False
16 validation net_debt_to_ebitda 0.0066 0.12 55.6 0.0104 9 184.6 False
17 validation quality -0.0029 -0.14 44.4 -0.0002 9 401.0 False
18 validation operating_margin -0.0056 -0.18 44.4 -0.0058 9 338.3 False
19 validation share_count_change_yoy -0.0169 -0.52 55.6 -0.0098 9 439.9 False
20 test eps_growth_yoy 0.0182 0.88 46.2 0.0071 13 415.7 True
21 test share_count_change_yoy 0.0142 0.78 61.5 -0.0147 13 451.9 True
22 test growth 0.011 0.32 46.2 0.0047 13 463.6 True
23 test revenue_growth_yoy 0.0053 0.14 46.2 0.0046 13 429.3 True
24 test net_debt_to_ebitda -0.0082 -0.19 61.5 -0.0043 13 195.5 True
25 test balanced -0.0176 -0.47 38.5 -0.0147 13 402.2 True
26 test quality -0.0386 -1.59 30.8 -0.0257 13 419.6 True
27 test operating_margin -0.0436 -1.52 38.5 -0.0279 13 349.5 True
28 test fcf_margin -0.0535 -1.99 30.8 -0.0307 13 380.4 True
29 full share_count_change_yoy 0.0161 1.16 54.8 -0.0052 42 441.3 True
30 full growth 0.0123 0.65 54.8 0.0067 42 450.5 True
31 full revenue_growth_yoy 0.0116 0.55 47.6 0.0069 42 415.5 True
32 full eps_growth_yoy 0.008 0.58 57.1 0.003 42 388.5 True
33 full balanced 0.0071 0.36 54.8 -0.0014 42 388.9 True
34 full net_debt_to_ebitda 0.006 0.29 54.8 0.0037 42 187.9 True
35 full quality -0.0109 -0.85 45.2 -0.0092 42 404.5 True
36 full fcf_margin -0.0161 -1.04 35.7 -0.0123 42 370.5 True
37 full operating_margin -0.0252 -1.74 33.3 -0.017 42 339.2 True
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,123 +0,0 @@
# Point-in-time fundamentals overlay research
Generated: 2026-07-23T14:06:10.423614+00:00
## Protocol
- Train ends before **2024-01-01**.
- Validation runs until **2025-01-01**.
- Test starts at that date and is not used to select the arm.
- Qualification is unchanged; fundamentals only reorder qualified longs.
- SEC filings become visible at midnight New York time after acceptance.
- Pre-registered portfolio trials for DSR: **13**.
## Data 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.
## Factor IC
| window | signal | IC | t | positive | quintile spread | weeks | N |
|---|---|---:|---:|---:|---:|---:|---:|
| train | share_count_change_yoy | 0.0389 | 1.97 | 61.9 | 0.0029 | 21 | 435.5 |
| train | net_debt_to_ebitda | 0.0141 | 0.46 | 61.9 | 0.0098 | 21 | 183.9 |
| train | balanced | 0.0065 | 0.2 | 57.1 | 0.0007 | 21 | 381.2 |
| train | quality | 0.0038 | 0.19 | 47.6 | -0.0033 | 21 | 396.6 |
| train | revenue_growth_yoy | 0.0006 | 0.02 | 47.6 | 0.0044 | 21 | 406.8 |
| train | fcf_margin | -0.006 | -0.29 | 38.1 | -0.0044 | 21 | 365.3 |
| train | growth | -0.0077 | -0.26 | 47.6 | 0.003 | 21 | 442.5 |
| train | eps_growth_yoy | -0.0152 | -0.65 | 52.4 | -0.004 | 21 | 370.2 |
| train | operating_margin | -0.0288 | -1.36 | 28.6 | -0.0131 | 21 | 333.3 |
| validation | growth | 0.0558 | 1.21 | 55.6 | 0.0177 | 9 | 450.9 |
| validation | revenue_growth_yoy | 0.0485 | 0.95 | 55.6 | 0.0205 | 9 | 416.7 |
| validation | balanced | 0.0483 | 1.06 | 55.6 | 0.0126 | 9 | 387.9 |
| validation | eps_growth_yoy | 0.0473 | 1.44 | 55.6 | 0.014 | 9 | 393.8 |
| validation | fcf_margin | 0.0268 | 0.84 | 66.7 | 0.0003 | 9 | 368 |
| validation | net_debt_to_ebitda | 0.0066 | 0.12 | 55.6 | 0.0104 | 9 | 184.6 |
| validation | quality | -0.0029 | -0.14 | 44.4 | -0.0002 | 9 | 401 |
| validation | operating_margin | -0.0056 | -0.18 | 44.4 | -0.0058 | 9 | 338.3 |
| validation | share_count_change_yoy | -0.0169 | -0.52 | 55.6 | -0.0098 | 9 | 439.9 |
| test | eps_growth_yoy | 0.0182 | 0.88 | 46.2 | 0.0071 | 13 | 415.7 |
| test | share_count_change_yoy | 0.0142 | 0.78 | 61.5 | -0.0147 | 13 | 451.9 |
| test | growth | 0.011 | 0.32 | 46.2 | 0.0047 | 13 | 463.6 |
| test | revenue_growth_yoy | 0.0053 | 0.14 | 46.2 | 0.0046 | 13 | 429.3 |
| test | net_debt_to_ebitda | -0.0082 | -0.19 | 61.5 | -0.0043 | 13 | 195.5 |
| test | balanced | -0.0176 | -0.47 | 38.5 | -0.0147 | 13 | 402.2 |
| test | quality | -0.0386 | -1.59 | 30.8 | -0.0257 | 13 | 419.6 |
| test | operating_margin | -0.0436 | -1.52 | 38.5 | -0.0279 | 13 | 349.5 |
| test | fcf_margin | -0.0535 | -1.99 | 30.8 | -0.0307 | 13 | 380.4 |
| full | share_count_change_yoy | 0.0161 | 1.16 | 54.8 | -0.0052 | 42 | 441.3 |
| full | growth | 0.0123 | 0.65 | 54.8 | 0.0067 | 42 | 450.5 |
| full | revenue_growth_yoy | 0.0116 | 0.55 | 47.6 | 0.0069 | 42 | 415.5 |
| full | eps_growth_yoy | 0.008 | 0.58 | 57.1 | 0.003 | 42 | 388.5 |
| full | balanced | 0.0071 | 0.36 | 54.8 | -0.0014 | 42 | 388.9 |
| full | net_debt_to_ebitda | 0.006 | 0.29 | 54.8 | 0.0037 | 42 | 187.9 |
| full | quality | -0.0109 | -0.85 | 45.2 | -0.0092 | 42 | 404.5 |
| full | fcf_margin | -0.0161 | -1.04 | 35.7 | -0.0123 | 42 | 370.5 |
| full | operating_margin | -0.0252 | -1.74 | 33.3 | -0.017 | 42 | 339.2 |
## Portfolio arms
| arm | window | Sharpe | SE | DSR | CAGR | MaxDD | Calmar | trades | overlap |
|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
| control_w00 | train | 1.26 | 0.764 | 0.4607 | 32.5 | 18.5 | 1.76 | 195 | — |
| control_w00 | validation | 2.52 | 0.938 | 0.8309 | 71.3 | 14.8 | 4.82 | 106 | — |
| control_w00 | test | 1.99 | 0.81 | 0.7757 | 54.4 | 19.2 | 2.83 | 188 | — |
| control_w00 | full | 1.86 | 0.49 | 0.9807 | 52.1 | 22.3 | 2.34 | 483 | — |
| quality_w10 | train | 1.66 | 0.772 | 0.6629 | 45.4 | 16.9 | 2.68 | 183 | 57.5 |
| quality_w10 | validation | 2.44 | 0.941 | 0.8077 | 68.8 | 17.3 | 3.98 | 107 | 65.12 |
| quality_w10 | test | 1.7 | 0.805 | 0.6562 | 43.8 | 18.8 | 2.33 | 189 | 60.43 |
| quality_w10 | full | 1.91 | 0.493 | 0.9845 | 53.3 | 21.6 | 2.46 | 470 | 59.1 |
| quality_w20 | train | 1.64 | 0.77 | 0.6538 | 44.4 | 17.4 | 2.55 | 181 | 42.42 |
| quality_w20 | validation | 2.28 | 0.953 | 0.7551 | 59.4 | 18.6 | 3.19 | 111 | 53.9 |
| quality_w20 | test | 1.49 | 0.805 | 0.5562 | 35.9 | 18.6 | 1.93 | 189 | 44.44 |
| quality_w20 | full | 1.75 | 0.493 | 0.9666 | 46.6 | 20.7 | 2.26 | 478 | 44.73 |
| quality_w30 | train | 1.69 | 0.767 | 0.6781 | 45 | 17.3 | 2.6 | 176 | 39.47 |
| quality_w30 | validation | 1.83 | 0.948 | 0.5869 | 42.9 | 18.8 | 2.28 | 111 | 51.75 |
| quality_w30 | test | 1.3 | 0.801 | 0.4621 | 28.9 | 18.6 | 1.56 | 189 | 41.73 |
| quality_w30 | full | 1.57 | 0.491 | 0.9297 | 39.1 | 19.6 | 2 | 469 | 41.25 |
| quality_w40 | train | 1.73 | 0.772 | 0.6954 | 46.5 | 18.2 | 2.55 | 190 | 31.85 |
| quality_w40 | validation | 1.58 | 0.944 | 0.4824 | 36.8 | 19 | 1.94 | 110 | 48.97 |
| quality_w40 | test | 1.18 | 0.812 | 0.4045 | 24.2 | 18 | 1.35 | 193 | 34.63 |
| quality_w40 | full | 1.39 | 0.494 | 0.8643 | 33 | 23.8 | 1.39 | 492 | 34.11 |
| growth_w10 | train | 1.24 | 0.769 | 0.4506 | 31.1 | 18.2 | 1.71 | 190 | 53.39 |
| growth_w10 | validation | 2.65 | 0.915 | 0.8695 | 74.6 | 11.6 | 6.41 | 103 | 74.17 |
| growth_w10 | test | 1.87 | 0.807 | 0.7297 | 52.4 | 18.9 | 2.77 | 197 | 61.09 |
| growth_w10 | full | 1.83 | 0.49 | 0.9776 | 51.5 | 21.6 | 2.39 | 485 | 58.43 |
| growth_w20 | train | 1.16 | 0.775 | 0.4105 | 27.3 | 17.8 | 1.53 | 189 | 42.75 |
| growth_w20 | validation | 1.99 | 0.945 | 0.6516 | 50.1 | 19.2 | 2.61 | 101 | 56.82 |
| growth_w20 | test | 1.59 | 0.8 | 0.6053 | 40.5 | 18.7 | 2.16 | 191 | 53.44 |
| growth_w20 | full | 1.55 | 0.493 | 0.9232 | 39.3 | 21.1 | 1.86 | 477 | 49.07 |
| growth_w30 | train | 0.94 | 0.784 | 0.307 | 19.5 | 17.7 | 1.1 | 199 | 41.22 |
| growth_w30 | validation | 1.79 | 0.954 | 0.57 | 42.4 | 19.5 | 2.18 | 101 | 55.64 |
| growth_w30 | test | 1.32 | 0.8 | 0.472 | 31.5 | 18.3 | 1.72 | 198 | 47.33 |
| growth_w30 | full | 1.29 | 0.496 | 0.8143 | 29.9 | 20.7 | 1.44 | 500 | 45.63 |
| growth_w40 | train | 1.08 | 0.785 | 0.3725 | 22.5 | 16.2 | 1.38 | 196 | 32.54 |
| growth_w40 | validation | 1.96 | 0.956 | 0.6383 | 48.1 | 19.3 | 2.5 | 101 | 55.64 |
| growth_w40 | test | 1.45 | 0.799 | 0.5368 | 36.3 | 17.8 | 2.03 | 193 | 50.59 |
| growth_w40 | full | 1.47 | 0.495 | 0.896 | 35.6 | 26 | 1.37 | 487 | 40.99 |
| balanced_w10 | train | 1.66 | 0.773 | 0.6627 | 45.7 | 18.5 | 2.47 | 191 | 62.87 |
| balanced_w10 | validation | 2.42 | 0.931 | 0.8044 | 64.1 | 17.4 | 3.69 | 105 | 64.84 |
| balanced_w10 | test | 2.05 | 0.808 | 0.7978 | 57.6 | 18.9 | 3.04 | 186 | 56.49 |
| balanced_w10 | full | 2 | 0.493 | 0.9903 | 57.4 | 21.4 | 2.69 | 471 | 58.21 |
| balanced_w20 | train | 1.5 | 0.774 | 0.5842 | 40.3 | 16.2 | 2.49 | 186 | 48.25 |
| balanced_w20 | validation | 1.94 | 0.945 | 0.6319 | 45.7 | 18.5 | 2.47 | 113 | 58.7 |
| balanced_w20 | test | 1.99 | 0.805 | 0.7771 | 52.6 | 18.3 | 2.88 | 189 | 46.12 |
| balanced_w20 | full | 1.71 | 0.493 | 0.96 | 45.6 | 19.3 | 2.37 | 481 | 47.18 |
| balanced_w30 | train | 1.11 | 0.774 | 0.3854 | 26.5 | 17.1 | 1.55 | 201 | 37.98 |
| balanced_w30 | validation | 1.9 | 0.94 | 0.6164 | 47.7 | 17.3 | 2.76 | 104 | 60.31 |
| balanced_w30 | test | 1.54 | 0.799 | 0.5812 | 37.9 | 18.3 | 2.08 | 197 | 45.83 |
| balanced_w30 | full | 1.52 | 0.492 | 0.9144 | 39.4 | 21.3 | 1.85 | 496 | 43.55 |
| balanced_w40 | train | 1.16 | 0.782 | 0.4113 | 25.9 | 16.2 | 1.6 | 202 | 34.58 |
| balanced_w40 | validation | 2.07 | 0.943 | 0.6827 | 56.2 | 18.4 | 3.06 | 103 | 50.36 |
| balanced_w40 | test | 1.29 | 0.807 | 0.4574 | 30 | 17.8 | 1.68 | 202 | 46.62 |
| balanced_w40 | full | 1.34 | 0.496 | 0.8401 | 32.3 | 26.4 | 1.22 | 507 | 40.83 |
## Mechanical selection
- No overlay passed the train + validation requirements.
Production remains unchanged pending human review.
Binary file not shown.
@@ -1,41 +0,0 @@
arm,composite,weight,window,sharpe,sharpe_se,dsr,cagr_pct,max_drawdown_pct,calmar,trades,overlap_pct,top5_pnl_share_pct,avg_r_ex_top5
control_w00,,0.0,train,1.26,0.764,0.5133,32.5,18.5,1.76,195,,67.83,0.2729
control_w00,,0.0,validation,2.52,0.938,0.8618,71.3,14.8,4.82,106,,69.13,0.357
control_w00,,0.0,test,1.99,0.81,0.8122,54.4,19.2,2.83,188,,62.82,0.1591
control_w00,,0.0,full,1.86,0.49,0.986,52.1,22.3,2.34,483,,38.74,0.4132
quality_w05,quality,0.05,train,1.5,0.767,0.6354,40.3,16.7,2.42,192,72.0,69.73,0.2241
quality_w05,quality,0.05,validation,2.43,0.939,0.8392,67.4,17.3,3.89,106,73.77,71.95,0.3914
quality_w05,quality,0.05,test,1.67,0.807,0.6889,43.1,19.2,2.24,191,64.78,71.88,0.1701
quality_w05,quality,0.05,full,1.81,0.492,0.9816,49.7,22.0,2.26,482,68.12,40.52,0.3944
quality_w10,quality,0.1,train,1.68,0.768,0.7191,46.1,16.7,2.75,195,64.56,63.19,0.1846
quality_w10,quality,0.1,validation,1.95,0.948,0.6828,47.9,17.8,2.69,112,61.48,74.11,0.3579
quality_w10,quality,0.1,test,1.57,0.809,0.6436,39.7,18.8,2.12,193,56.79,76.49,0.131
quality_w10,quality,0.1,full,1.72,0.493,0.9714,46.0,19.3,2.39,491,59.67,39.37,0.3583
quality_w15,quality,0.15,train,1.4,0.773,0.5848,35.8,15.3,2.33,189,47.69,67.32,0.2364
quality_w15,quality,0.15,validation,1.85,0.932,0.6467,45.0,18.4,2.44,111,57.25,79.6,0.3738
quality_w15,quality,0.15,test,1.78,0.804,0.7361,46.3,18.8,2.47,195,49.61,66.98,0.1186
quality_w15,quality,0.15,full,1.66,0.491,0.963,43.4,19.7,2.2,484,48.31,38.77,0.3822
growth_w05,growth,0.05,train,1.31,0.768,0.5392,32.7,17.9,1.83,192,72.0,69.9,0.3036
growth_w05,growth,0.05,validation,2.64,0.918,0.893,73.6,11.6,6.33,103,75.63,68.23,0.447
growth_w05,growth,0.05,test,1.99,0.8,0.8152,56.1,18.9,2.96,190,64.35,67.21,0.1646
growth_w05,growth,0.05,full,1.87,0.489,0.9869,51.5,21.3,2.41,481,68.53,42.14,0.4654
growth_w10,growth,0.1,train,1.11,0.775,0.4362,26.3,17.8,1.47,197,53.73,86.35,0.218
growth_w10,growth,0.1,validation,2.64,0.918,0.893,73.6,11.6,6.33,103,75.63,68.23,0.447
growth_w10,growth,0.1,test,1.92,0.808,0.7886,54.1,17.3,3.13,192,55.1,68.64,0.2284
growth_w10,growth,0.1,full,1.77,0.492,0.9776,48.9,20.2,2.42,490,56.94,42.63,0.4153
growth_w15,growth,0.15,train,1.07,0.772,0.4156,24.6,18.0,1.37,194,52.55,88.03,0.2015
growth_w15,growth,0.15,validation,2.02,0.93,0.7123,52.6,19.1,2.76,100,58.46,70.05,0.3237
growth_w15,growth,0.15,test,1.61,0.809,0.6618,42.3,17.3,2.45,191,55.97,78.59,0.2064
growth_w15,growth,0.15,full,1.39,0.492,0.8915,34.7,20.6,1.69,487,54.95,47.05,0.3551
balanced_w05,balanced,0.05,train,1.2,0.771,0.4822,30.2,20.9,1.45,198,73.13,74.32,0.2634
balanced_w05,balanced,0.05,validation,2.25,0.947,0.7861,62.1,20.0,3.11,106,70.97,76.48,0.3761
balanced_w05,balanced,0.05,test,2.02,0.802,0.8244,55.6,18.9,2.94,185,78.47,66.73,0.1759
balanced_w05,balanced,0.05,full,1.76,0.492,0.9765,48.3,21.9,2.2,481,73.69,42.66,0.412
balanced_w10,balanced,0.1,train,1.34,0.77,0.5545,34.1,17.0,2.01,189,68.42,66.41,0.2941
balanced_w10,balanced,0.1,validation,1.82,0.93,0.6349,43.1,19.3,2.23,110,58.82,83.18,0.4047
balanced_w10,balanced,0.1,test,2.0,0.811,0.8152,55.9,18.5,3.03,187,57.56,66.25,0.2341
balanced_w10,balanced,0.1,full,1.71,0.492,0.9703,46.0,19.9,2.31,476,59.57,41.92,0.46
balanced_w15,balanced,0.15,train,1.35,0.766,0.5599,34.3,15.5,2.21,182,56.43,65.25,0.276
balanced_w15,balanced,0.15,validation,1.89,0.931,0.6627,45.3,17.8,2.54,111,55.0,79.37,0.3649
balanced_w15,balanced,0.15,test,1.83,0.808,0.755,48.7,18.5,2.64,190,50.0,65.58,0.1869
balanced_w15,balanced,0.15,full,1.59,0.49,0.9503,41.0,19.9,2.06,474,52.88,39.94,0.4303
1 arm composite weight window sharpe sharpe_se dsr cagr_pct max_drawdown_pct calmar trades overlap_pct top5_pnl_share_pct avg_r_ex_top5
2 control_w00 0.0 train 1.26 0.764 0.5133 32.5 18.5 1.76 195 67.83 0.2729
3 control_w00 0.0 validation 2.52 0.938 0.8618 71.3 14.8 4.82 106 69.13 0.357
4 control_w00 0.0 test 1.99 0.81 0.8122 54.4 19.2 2.83 188 62.82 0.1591
5 control_w00 0.0 full 1.86 0.49 0.986 52.1 22.3 2.34 483 38.74 0.4132
6 quality_w05 quality 0.05 train 1.5 0.767 0.6354 40.3 16.7 2.42 192 72.0 69.73 0.2241
7 quality_w05 quality 0.05 validation 2.43 0.939 0.8392 67.4 17.3 3.89 106 73.77 71.95 0.3914
8 quality_w05 quality 0.05 test 1.67 0.807 0.6889 43.1 19.2 2.24 191 64.78 71.88 0.1701
9 quality_w05 quality 0.05 full 1.81 0.492 0.9816 49.7 22.0 2.26 482 68.12 40.52 0.3944
10 quality_w10 quality 0.1 train 1.68 0.768 0.7191 46.1 16.7 2.75 195 64.56 63.19 0.1846
11 quality_w10 quality 0.1 validation 1.95 0.948 0.6828 47.9 17.8 2.69 112 61.48 74.11 0.3579
12 quality_w10 quality 0.1 test 1.57 0.809 0.6436 39.7 18.8 2.12 193 56.79 76.49 0.131
13 quality_w10 quality 0.1 full 1.72 0.493 0.9714 46.0 19.3 2.39 491 59.67 39.37 0.3583
14 quality_w15 quality 0.15 train 1.4 0.773 0.5848 35.8 15.3 2.33 189 47.69 67.32 0.2364
15 quality_w15 quality 0.15 validation 1.85 0.932 0.6467 45.0 18.4 2.44 111 57.25 79.6 0.3738
16 quality_w15 quality 0.15 test 1.78 0.804 0.7361 46.3 18.8 2.47 195 49.61 66.98 0.1186
17 quality_w15 quality 0.15 full 1.66 0.491 0.963 43.4 19.7 2.2 484 48.31 38.77 0.3822
18 growth_w05 growth 0.05 train 1.31 0.768 0.5392 32.7 17.9 1.83 192 72.0 69.9 0.3036
19 growth_w05 growth 0.05 validation 2.64 0.918 0.893 73.6 11.6 6.33 103 75.63 68.23 0.447
20 growth_w05 growth 0.05 test 1.99 0.8 0.8152 56.1 18.9 2.96 190 64.35 67.21 0.1646
21 growth_w05 growth 0.05 full 1.87 0.489 0.9869 51.5 21.3 2.41 481 68.53 42.14 0.4654
22 growth_w10 growth 0.1 train 1.11 0.775 0.4362 26.3 17.8 1.47 197 53.73 86.35 0.218
23 growth_w10 growth 0.1 validation 2.64 0.918 0.893 73.6 11.6 6.33 103 75.63 68.23 0.447
24 growth_w10 growth 0.1 test 1.92 0.808 0.7886 54.1 17.3 3.13 192 55.1 68.64 0.2284
25 growth_w10 growth 0.1 full 1.77 0.492 0.9776 48.9 20.2 2.42 490 56.94 42.63 0.4153
26 growth_w15 growth 0.15 train 1.07 0.772 0.4156 24.6 18.0 1.37 194 52.55 88.03 0.2015
27 growth_w15 growth 0.15 validation 2.02 0.93 0.7123 52.6 19.1 2.76 100 58.46 70.05 0.3237
28 growth_w15 growth 0.15 test 1.61 0.809 0.6618 42.3 17.3 2.45 191 55.97 78.59 0.2064
29 growth_w15 growth 0.15 full 1.39 0.492 0.8915 34.7 20.6 1.69 487 54.95 47.05 0.3551
30 balanced_w05 balanced 0.05 train 1.2 0.771 0.4822 30.2 20.9 1.45 198 73.13 74.32 0.2634
31 balanced_w05 balanced 0.05 validation 2.25 0.947 0.7861 62.1 20.0 3.11 106 70.97 76.48 0.3761
32 balanced_w05 balanced 0.05 test 2.02 0.802 0.8244 55.6 18.9 2.94 185 78.47 66.73 0.1759
33 balanced_w05 balanced 0.05 full 1.76 0.492 0.9765 48.3 21.9 2.2 481 73.69 42.66 0.412
34 balanced_w10 balanced 0.1 train 1.34 0.77 0.5545 34.1 17.0 2.01 189 68.42 66.41 0.2941
35 balanced_w10 balanced 0.1 validation 1.82 0.93 0.6349 43.1 19.3 2.23 110 58.82 83.18 0.4047
36 balanced_w10 balanced 0.1 test 2.0 0.811 0.8152 55.9 18.5 3.03 187 57.56 66.25 0.2341
37 balanced_w10 balanced 0.1 full 1.71 0.492 0.9703 46.0 19.9 2.31 476 59.57 41.92 0.46
38 balanced_w15 balanced 0.15 train 1.35 0.766 0.5599 34.3 15.5 2.21 182 56.43 65.25 0.276
39 balanced_w15 balanced 0.15 validation 1.89 0.931 0.6627 45.3 17.8 2.54 111 55.0 79.37 0.3649
40 balanced_w15 balanced 0.15 test 1.83 0.808 0.755 48.7 18.5 2.64 190 50.0 65.58 0.1869
41 balanced_w15 balanced 0.15 full 1.59 0.49 0.9503 41.0 19.9 2.06 474 52.88 39.94 0.4303
@@ -1,29 +0,0 @@
window,signal,mean_ic,ic_t_stat,ic_positive_pct,mean_quintile_spread,weeks,avg_cross_section,reliable
train,net_debt_to_ebitda,0.0141,0.46,61.9,0.0098,21,183.9,True
train,growth,0.0006,0.02,47.6,0.0044,21,406.8,True
train,revenue_growth_yoy,0.0006,0.02,47.6,0.0044,21,406.8,True
train,fcf_margin,-0.006,-0.29,38.1,-0.0044,21,365.3,True
train,balanced,-0.0075,-0.2,47.6,-0.003,21,305.9,True
train,quality,-0.0185,-0.72,47.6,-0.0092,21,318.2,True
train,operating_margin,-0.0288,-1.36,28.6,-0.0131,21,333.3,True
validation,balanced,0.0597,1.18,55.6,0.013,9,312.9,False
validation,growth,0.0485,0.95,55.6,0.0205,9,416.7,False
validation,revenue_growth_yoy,0.0485,0.95,55.6,0.0205,9,416.7,False
validation,fcf_margin,0.0268,0.84,66.7,0.0003,9,368.0,False
validation,quality,0.0144,0.6,44.4,-0.0019,9,323.1,False
validation,net_debt_to_ebitda,0.0066,0.12,55.6,0.0104,9,184.6,False
validation,operating_margin,-0.0056,-0.18,44.4,-0.0058,9,338.3,False
test,growth,0.0053,0.14,46.2,0.0046,13,429.3,True
test,revenue_growth_yoy,0.0053,0.14,46.2,0.0046,13,429.3,True
test,net_debt_to_ebitda,-0.0082,-0.19,61.5,-0.0043,13,195.5,True
test,balanced,-0.0318,-0.67,46.2,-0.0123,13,318.9,True
test,operating_margin,-0.0436,-1.52,38.5,-0.0279,13,349.5,True
test,fcf_margin,-0.0535,-1.99,30.8,-0.0307,13,380.4,True
test,quality,-0.055,-1.63,30.8,-0.0288,13,332.2,True
full,growth,0.0116,0.55,47.6,0.0069,42,415.5,True
full,revenue_growth_yoy,0.0116,0.55,47.6,0.0069,42,415.5,True
full,net_debt_to_ebitda,0.006,0.29,54.8,0.0037,42,187.9,True
full,balanced,-0.0042,-0.18,47.6,-0.0032,42,311.2,True
full,fcf_margin,-0.0161,-1.04,35.7,-0.0123,42,370.5,True
full,quality,-0.0242,-1.45,38.1,-0.014,42,323.6,True
full,operating_margin,-0.0252,-1.74,33.3,-0.017,42,339.2,True
1 window signal mean_ic ic_t_stat ic_positive_pct mean_quintile_spread weeks avg_cross_section reliable
2 train net_debt_to_ebitda 0.0141 0.46 61.9 0.0098 21 183.9 True
3 train growth 0.0006 0.02 47.6 0.0044 21 406.8 True
4 train revenue_growth_yoy 0.0006 0.02 47.6 0.0044 21 406.8 True
5 train fcf_margin -0.006 -0.29 38.1 -0.0044 21 365.3 True
6 train balanced -0.0075 -0.2 47.6 -0.003 21 305.9 True
7 train quality -0.0185 -0.72 47.6 -0.0092 21 318.2 True
8 train operating_margin -0.0288 -1.36 28.6 -0.0131 21 333.3 True
9 validation balanced 0.0597 1.18 55.6 0.013 9 312.9 False
10 validation growth 0.0485 0.95 55.6 0.0205 9 416.7 False
11 validation revenue_growth_yoy 0.0485 0.95 55.6 0.0205 9 416.7 False
12 validation fcf_margin 0.0268 0.84 66.7 0.0003 9 368.0 False
13 validation quality 0.0144 0.6 44.4 -0.0019 9 323.1 False
14 validation net_debt_to_ebitda 0.0066 0.12 55.6 0.0104 9 184.6 False
15 validation operating_margin -0.0056 -0.18 44.4 -0.0058 9 338.3 False
16 test growth 0.0053 0.14 46.2 0.0046 13 429.3 True
17 test revenue_growth_yoy 0.0053 0.14 46.2 0.0046 13 429.3 True
18 test net_debt_to_ebitda -0.0082 -0.19 61.5 -0.0043 13 195.5 True
19 test balanced -0.0318 -0.67 46.2 -0.0123 13 318.9 True
20 test operating_margin -0.0436 -1.52 38.5 -0.0279 13 349.5 True
21 test fcf_margin -0.0535 -1.99 30.8 -0.0307 13 380.4 True
22 test quality -0.055 -1.63 30.8 -0.0288 13 332.2 True
23 full growth 0.0116 0.55 47.6 0.0069 42 415.5 True
24 full revenue_growth_yoy 0.0116 0.55 47.6 0.0069 42 415.5 True
25 full net_debt_to_ebitda 0.006 0.29 54.8 0.0037 42 187.9 True
26 full balanced -0.0042 -0.18 47.6 -0.0032 42 311.2 True
27 full fcf_margin -0.0161 -1.04 35.7 -0.0123 42 370.5 True
28 full quality -0.0242 -1.45 38.1 -0.014 42 323.6 True
29 full operating_margin -0.0252 -1.74 33.3 -0.017 42 339.2 True
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,105 +0,0 @@
# Split-safe fundamentals overlay sensitivity
Generated: 2026-07-23T15:36:07.807809+00:00
## Protocol
- Research protocol: **split-safe**.
- Train ends before **2024-01-01**.
- Validation runs until **2025-01-01**.
- Test starts at that date and is not used to select the arm.
- Qualification is unchanged; fundamentals only reorder qualified longs.
- SEC filings become visible at midnight New York time after acceptance.
- Pre-registered portfolio trials for DSR: **10**.
## Data warnings
- Current tracked universe only: historical constituent membership and delisted names are unavailable, so absolute results have survivorship bias.
- Diluted-EPS growth and share-count change are excluded because filing-time values are not split-comparable without point-in-time split factors.
- 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.
## Factor IC
| window | signal | IC | t | positive | quintile spread | weeks | N |
|---|---|---:|---:|---:|---:|---:|---:|
| train | net_debt_to_ebitda | 0.0141 | 0.46 | 61.9 | 0.0098 | 21 | 183.9 |
| train | growth | 0.0006 | 0.02 | 47.6 | 0.0044 | 21 | 406.8 |
| train | revenue_growth_yoy | 0.0006 | 0.02 | 47.6 | 0.0044 | 21 | 406.8 |
| train | fcf_margin | -0.006 | -0.29 | 38.1 | -0.0044 | 21 | 365.3 |
| train | balanced | -0.0075 | -0.2 | 47.6 | -0.003 | 21 | 305.9 |
| train | quality | -0.0185 | -0.72 | 47.6 | -0.0092 | 21 | 318.2 |
| train | operating_margin | -0.0288 | -1.36 | 28.6 | -0.0131 | 21 | 333.3 |
| validation | balanced | 0.0597 | 1.18 | 55.6 | 0.013 | 9 | 312.9 |
| validation | growth | 0.0485 | 0.95 | 55.6 | 0.0205 | 9 | 416.7 |
| validation | revenue_growth_yoy | 0.0485 | 0.95 | 55.6 | 0.0205 | 9 | 416.7 |
| validation | fcf_margin | 0.0268 | 0.84 | 66.7 | 0.0003 | 9 | 368 |
| validation | quality | 0.0144 | 0.6 | 44.4 | -0.0019 | 9 | 323.1 |
| validation | net_debt_to_ebitda | 0.0066 | 0.12 | 55.6 | 0.0104 | 9 | 184.6 |
| validation | operating_margin | -0.0056 | -0.18 | 44.4 | -0.0058 | 9 | 338.3 |
| test | growth | 0.0053 | 0.14 | 46.2 | 0.0046 | 13 | 429.3 |
| test | revenue_growth_yoy | 0.0053 | 0.14 | 46.2 | 0.0046 | 13 | 429.3 |
| test | net_debt_to_ebitda | -0.0082 | -0.19 | 61.5 | -0.0043 | 13 | 195.5 |
| test | balanced | -0.0318 | -0.67 | 46.2 | -0.0123 | 13 | 318.9 |
| test | operating_margin | -0.0436 | -1.52 | 38.5 | -0.0279 | 13 | 349.5 |
| test | fcf_margin | -0.0535 | -1.99 | 30.8 | -0.0307 | 13 | 380.4 |
| test | quality | -0.055 | -1.63 | 30.8 | -0.0288 | 13 | 332.2 |
| full | growth | 0.0116 | 0.55 | 47.6 | 0.0069 | 42 | 415.5 |
| full | revenue_growth_yoy | 0.0116 | 0.55 | 47.6 | 0.0069 | 42 | 415.5 |
| full | net_debt_to_ebitda | 0.006 | 0.29 | 54.8 | 0.0037 | 42 | 187.9 |
| full | balanced | -0.0042 | -0.18 | 47.6 | -0.0032 | 42 | 311.2 |
| full | fcf_margin | -0.0161 | -1.04 | 35.7 | -0.0123 | 42 | 370.5 |
| full | quality | -0.0242 | -1.45 | 38.1 | -0.014 | 42 | 323.6 |
| full | operating_margin | -0.0252 | -1.74 | 33.3 | -0.017 | 42 | 339.2 |
## Portfolio arms
| arm | window | Sharpe | SE | DSR | CAGR | MaxDD | Calmar | trades | overlap |
|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
| control_w00 | train | 1.26 | 0.764 | 0.5133 | 32.5 | 18.5 | 1.76 | 195 | — |
| control_w00 | validation | 2.52 | 0.938 | 0.8618 | 71.3 | 14.8 | 4.82 | 106 | — |
| control_w00 | test | 1.99 | 0.81 | 0.8122 | 54.4 | 19.2 | 2.83 | 188 | — |
| control_w00 | full | 1.86 | 0.49 | 0.986 | 52.1 | 22.3 | 2.34 | 483 | — |
| quality_w05 | train | 1.5 | 0.767 | 0.6354 | 40.3 | 16.7 | 2.42 | 192 | 72 |
| quality_w05 | validation | 2.43 | 0.939 | 0.8392 | 67.4 | 17.3 | 3.89 | 106 | 73.77 |
| quality_w05 | test | 1.67 | 0.807 | 0.6889 | 43.1 | 19.2 | 2.24 | 191 | 64.78 |
| quality_w05 | full | 1.81 | 0.492 | 0.9816 | 49.7 | 22 | 2.26 | 482 | 68.12 |
| quality_w10 | train | 1.68 | 0.768 | 0.7191 | 46.1 | 16.7 | 2.75 | 195 | 64.56 |
| quality_w10 | validation | 1.95 | 0.948 | 0.6828 | 47.9 | 17.8 | 2.69 | 112 | 61.48 |
| quality_w10 | test | 1.57 | 0.809 | 0.6436 | 39.7 | 18.8 | 2.12 | 193 | 56.79 |
| quality_w10 | full | 1.72 | 0.493 | 0.9714 | 46 | 19.3 | 2.39 | 491 | 59.67 |
| quality_w15 | train | 1.4 | 0.773 | 0.5848 | 35.8 | 15.3 | 2.33 | 189 | 47.69 |
| quality_w15 | validation | 1.85 | 0.932 | 0.6467 | 45 | 18.4 | 2.44 | 111 | 57.25 |
| quality_w15 | test | 1.78 | 0.804 | 0.7361 | 46.3 | 18.8 | 2.47 | 195 | 49.61 |
| quality_w15 | full | 1.66 | 0.491 | 0.963 | 43.4 | 19.7 | 2.2 | 484 | 48.31 |
| growth_w05 | train | 1.31 | 0.768 | 0.5392 | 32.7 | 17.9 | 1.83 | 192 | 72 |
| growth_w05 | validation | 2.64 | 0.918 | 0.893 | 73.6 | 11.6 | 6.33 | 103 | 75.63 |
| growth_w05 | test | 1.99 | 0.8 | 0.8152 | 56.1 | 18.9 | 2.96 | 190 | 64.35 |
| growth_w05 | full | 1.87 | 0.489 | 0.9869 | 51.5 | 21.3 | 2.41 | 481 | 68.53 |
| growth_w10 | train | 1.11 | 0.775 | 0.4362 | 26.3 | 17.8 | 1.47 | 197 | 53.73 |
| growth_w10 | validation | 2.64 | 0.918 | 0.893 | 73.6 | 11.6 | 6.33 | 103 | 75.63 |
| growth_w10 | test | 1.92 | 0.808 | 0.7886 | 54.1 | 17.3 | 3.13 | 192 | 55.1 |
| growth_w10 | full | 1.77 | 0.492 | 0.9776 | 48.9 | 20.2 | 2.42 | 490 | 56.94 |
| growth_w15 | train | 1.07 | 0.772 | 0.4156 | 24.6 | 18 | 1.37 | 194 | 52.55 |
| growth_w15 | validation | 2.02 | 0.93 | 0.7123 | 52.6 | 19.1 | 2.76 | 100 | 58.46 |
| growth_w15 | test | 1.61 | 0.809 | 0.6618 | 42.3 | 17.3 | 2.45 | 191 | 55.97 |
| growth_w15 | full | 1.39 | 0.492 | 0.8915 | 34.7 | 20.6 | 1.69 | 487 | 54.95 |
| balanced_w05 | train | 1.2 | 0.771 | 0.4822 | 30.2 | 20.9 | 1.45 | 198 | 73.13 |
| balanced_w05 | validation | 2.25 | 0.947 | 0.7861 | 62.1 | 20 | 3.11 | 106 | 70.97 |
| balanced_w05 | test | 2.02 | 0.802 | 0.8244 | 55.6 | 18.9 | 2.94 | 185 | 78.47 |
| balanced_w05 | full | 1.76 | 0.492 | 0.9765 | 48.3 | 21.9 | 2.2 | 481 | 73.69 |
| balanced_w10 | train | 1.34 | 0.77 | 0.5545 | 34.1 | 17 | 2.01 | 189 | 68.42 |
| balanced_w10 | validation | 1.82 | 0.93 | 0.6349 | 43.1 | 19.3 | 2.23 | 110 | 58.82 |
| balanced_w10 | test | 2 | 0.811 | 0.8152 | 55.9 | 18.5 | 3.03 | 187 | 57.56 |
| balanced_w10 | full | 1.71 | 0.492 | 0.9703 | 46 | 19.9 | 2.31 | 476 | 59.57 |
| balanced_w15 | train | 1.35 | 0.766 | 0.5599 | 34.3 | 15.5 | 2.21 | 182 | 56.43 |
| balanced_w15 | validation | 1.89 | 0.931 | 0.6627 | 45.3 | 17.8 | 2.54 | 111 | 55 |
| balanced_w15 | test | 1.83 | 0.808 | 0.755 | 48.7 | 18.5 | 2.64 | 190 | 50 |
| balanced_w15 | full | 1.59 | 0.49 | 0.9503 | 41 | 19.9 | 2.06 | 474 | 52.88 |
## Mechanical selection
- Development-selected arm: **growth_w05**. The test result is reported only as a final check.
- Final check: `{'arm_id': 'growth_w05', 'pass': True, 'checks': {'test_sharpe_not_worse': True, 'test_drawdown_within_2pp': True}, 'test_sharpe_delta': 0.0, 'note': 'Research evidence only; passing does not change production.'}`
Production remains unchanged pending human review.
Binary file not shown.
+10 -44
View File
@@ -1,9 +1,9 @@
"""Create a portable local SQLite snapshot for offline backtest research. """Create a minimal local SQLite snapshot for offline backtest research.
Copies the data required by the production backtest and fundamentals research: Copies only the data required by app.services.backtest_service.run_backtest:
tickers, OHLCV bars, SPY benchmark closes, and the activation / recommendation / tickers, OHLCV bars, SPY benchmark closes, and the activation / recommendation /
paper-exit settings the run reads, immutable SEC snapshots, and Dolt earnings paper-exit settings the run reads. Other system settings are intentionally
events. Other system settings are skipped to avoid copying secrets locally. skipped to avoid copying secrets into local snapshot files.
""" """
from __future__ import annotations from __future__ import annotations
@@ -54,9 +54,7 @@ def _parse_args() -> argparse.Namespace:
help="SQLite snapshot path to create.", help="SQLite snapshot path to create.",
) )
parser.add_argument("--batch-size", type=int, default=5000) parser.add_argument("--batch-size", type=int, default=5000)
parser.add_argument( parser.add_argument("--force", action="store_true", help="Overwrite an existing snapshot file.")
"--force", action="store_true", help="Overwrite an existing snapshot file."
)
return parser.parse_args() return parser.parse_args()
@@ -67,7 +65,6 @@ async def _copy_table(
*, *,
batch_size: int, batch_size: int,
where=None, where=None,
row_transform=None,
) -> int: ) -> int:
table = model.__table__ table = model.__table__
columns = list(table.columns) columns = list(table.columns)
@@ -90,8 +87,6 @@ async def _copy_table(
stream = await source.stream(stmt.execution_options(yield_per=batch_size)) stream = await source.stream(stmt.execution_options(yield_per=batch_size))
async for partition in stream.partitions(batch_size): async for partition in stream.partitions(batch_size):
rows = [dict(row._mapping) for row in partition] rows = [dict(row._mapping) for row in partition]
if row_transform is not None:
rows = [row_transform(row) for row in rows]
if not rows: if not rows:
continue continue
await dest.execute(insert(table), rows) await dest.execute(insert(table), rows)
@@ -110,8 +105,6 @@ async def _main() -> None:
from app.database import Base from app.database import Base
import app.models # noqa: F401 - registers all metadata tables import app.models # noqa: F401 - registers all metadata tables
from app.models.benchmark_price import BenchmarkPrice from app.models.benchmark_price import BenchmarkPrice
from app.models.earnings_event import EarningsEvent
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.settings import SystemSetting from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -130,12 +123,8 @@ async def _main() -> None:
connect_args={"server_settings": {"default_transaction_read_only": "on"}}, connect_args={"server_settings": {"default_transaction_read_only": "on"}},
) )
dest_engine = create_async_engine(_sqlite_url(output)) dest_engine = create_async_engine(_sqlite_url(output))
SourceSession = async_sessionmaker( SourceSession = async_sessionmaker(source_engine, class_=AsyncSession, expire_on_commit=False)
source_engine, class_=AsyncSession, expire_on_commit=False DestSession = async_sessionmaker(dest_engine, class_=AsyncSession, expire_on_commit=False)
)
DestSession = async_sessionmaker(
dest_engine, class_=AsyncSession, expire_on_commit=False
)
print(f"Source: {_hide_password(source_url)}") print(f"Source: {_hide_password(source_url)}")
print(f"Snapshot: {output}") print(f"Snapshot: {output}")
@@ -146,9 +135,7 @@ async def _main() -> None:
async with SourceSession() as source, DestSession() as dest: async with SourceSession() as source, DestSession() as dest:
counts = { counts = {
"tickers": await _copy_table( "tickers": await _copy_table(source, dest, Ticker, batch_size=args.batch_size),
source, dest, Ticker, batch_size=args.batch_size
),
"system_settings": await _copy_table( "system_settings": await _copy_table(
source, source,
dest, dest,
@@ -165,30 +152,9 @@ async def _main() -> None:
SystemSetting.key.like("paper_%"), SystemSetting.key.like("paper_%"),
), ),
), ),
"benchmark_prices": await _copy_table( "benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, batch_size=args.batch_size),
source, dest, BenchmarkPrice, batch_size=args.batch_size "ohlcv_records": await _copy_table(source, dest, OHLCVRecord, batch_size=args.batch_size),
),
"ohlcv_records": await _copy_table(
source, dest, OHLCVRecord, batch_size=args.batch_size
),
} }
# Import-run provenance is operational metadata, not a research input.
# Null it so the portable snapshot needs no data_import_runs rows.
async with SourceSession() as source, DestSession() as dest:
counts["fundamental_snapshots"] = await _copy_table(
source,
dest,
FundamentalSnapshot,
batch_size=args.batch_size,
row_transform=lambda row: {**row, "import_run_id": None},
)
counts["earnings_events"] = await _copy_table(
source,
dest,
EarningsEvent,
batch_size=args.batch_size,
row_transform=lambda row: {**row, "import_run_id": None},
)
finally: finally:
await source_engine.dispose() await source_engine.dispose()
await dest_engine.dispose() await dest_engine.dispose()
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
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))
fi
if [[ -x .venv/bin/python ]]; then
PYTHON=.venv/bin/python
else
PYTHON="${PYTHON:-python3}"
fi
if [[ ! -f "$SNAPSHOT" ]]; then
echo "Snapshot not found: $SNAPSHOT" >&2
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/${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 "$SCORE_CACHE" \
--out "$OUT"
echo
echo "Bring this file back for review: ${OUT%.json}.zip"
File diff suppressed because it is too large Load Diff
+30 -94
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
import pytest import pytest
@@ -38,9 +38,7 @@ _ENDS = { # period_end per (fy, quarter index 0..3)
} }
def _year( def _year(fy, discretes: dict[str, list[float]], instants: dict[str, list] | None = None):
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 """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.""" given per-quarter discrete values; instants set as-is per quarter."""
rows = [] rows = []
@@ -57,38 +55,22 @@ def _year(
def _two_years(): def _two_years():
rev25 = [100, 110, 120, 130] rev25 = [100, 110, 120, 130]
rev26 = [110, 121, 132, 143] # +10% each quarter YoY rev26 = [110, 121, 132, 143] # +10% each quarter YoY
rows = _year( rows = _year(2025, {
2025, "revenue": rev25,
{ "operating_income": [x * 0.2 for x in rev25],
"revenue": rev25, "diluted_eps": [1.0, 1.1, 1.2, 1.3],
"operating_income": [x * 0.2 for x in rev25], "cfo": [x * 0.25 for x in rev25],
"diluted_eps": [1.0, 1.1, 1.2, 1.3], "capex": [x * 0.05 for x in rev25],
"cfo": [x * 0.25 for x in rev25], "depreciation_amortization": [x * 0.05 for x in rev25],
"capex": [x * 0.05 for x in rev25], }, instants={"shares_outstanding": [1000, 1000, 1000, 1000], "cash_and_st_investments": [40] * 4, "total_debt": [140] * 4})
"depreciation_amortization": [x * 0.05 for x in rev25], rows += _year(2026, {
}, "revenue": rev26,
instants={ "operating_income": [x * 0.2 for x in rev26],
"shares_outstanding": [1000, 1000, 1000, 1000], "diluted_eps": [1.1, 1.21, 1.32, 1.43],
"cash_and_st_investments": [40] * 4, "cfo": [x * 0.25 for x in rev26],
"total_debt": [140] * 4, "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(
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 return rows
@@ -115,11 +97,9 @@ def test_net_debt_leverage_and_share_dilution():
# net debt = total_debt - cash = 150 - 50 = 100 (latest instant) # net debt = total_debt - cash = 150 - 50 = 100 (latest instant)
assert d.metrics["net_debt"].value == pytest.approx(100.0) assert d.metrics["net_debt"].value == pytest.approx(100.0)
# EBITDA TTM = TTM operating_income + TTM D&A; net_debt/ebitda # EBITDA TTM = TTM operating_income + TTM D&A; net_debt/ebitda
op_ttm = 506 * 0.2 # 101.2 op_ttm = 506 * 0.2 # 101.2
da_ttm = 506 * 0.05 # 25.3 da_ttm = 506 * 0.05 # 25.3
assert d.metrics["net_debt_to_ebitda"].value == pytest.approx( assert d.metrics["net_debt_to_ebitda"].value == pytest.approx(100.0 / (op_ttm + da_ttm), rel=1e-6)
100.0 / (op_ttm + da_ttm), rel=1e-6
)
# shares 900 vs 1000 a year earlier -> -10% (buyback) # shares 900 vs 1000 a year earlier -> -10% (buyback)
assert d.metrics["share_count_change_yoy"].value == pytest.approx(-10.0, abs=1e-6) assert d.metrics["share_count_change_yoy"].value == pytest.approx(-10.0, abs=1e-6)
@@ -150,9 +130,7 @@ def test_net_debt_requires_both_components():
r.total_debt = None r.total_debt = None
d = fd.derive(rows) d = fd.derive(rows)
assert d.metrics["net_debt"].value is None assert d.metrics["net_debt"].value is None
assert ( assert d.metrics["net_debt_to_ebitda"].value is None # net debt null -> leverage null
d.metrics["net_debt_to_ebitda"].value is None
) # net debt null -> leverage null
def test_leverage_null_when_ebitda_nonpositive(): def test_leverage_null_when_ebitda_nonpositive():
@@ -162,23 +140,15 @@ def test_leverage_null_when_ebitda_nonpositive():
r.depreciation_amortization = 1 r.depreciation_amortization = 1
d = fd.derive(rows) d = fd.derive(rows)
assert d.metrics["net_debt"].value == pytest.approx(100.0) # net debt still valid 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(): def test_tape_stops_at_a_gap():
rows = [ rows = [r for r in _two_years() if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")]
r
for r in _two_years()
if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")
]
d = fd.derive(rows) d = fd.derive(rows)
hist = d.metrics["operating_margin"].history hist = d.metrics["operating_margin"].history
# consecutive suffix ending at FY2026: Q2, Q3, FY (not compressed across the Q1 gap) # consecutive suffix ending at FY2026: Q2, Q3, FY (not compressed across the Q1 gap)
assert [p.period_end for p in hist] == [ assert [p.period_end for p in hist] == [date(2026, 3, 31), date(2026, 6, 30), date(2026, 9, 30)]
date(2026, 3, 31),
date(2026, 6, 30),
date(2026, 9, 30),
]
def test_yoy_growth_null_when_prior_nonpositive(): def test_yoy_growth_null_when_prior_nonpositive():
@@ -190,48 +160,14 @@ def test_yoy_growth_null_when_prior_nonpositive():
assert d.metrics["eps_growth_yoy"].value is None # loss->profit is not a % 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(): def test_amendment_selection_newest_accepted_wins():
rows = _two_years() rows = _two_years()
# an amendment to FY2026 FY restates revenue YTD higher, accepted later # an amendment to FY2026 FY restates revenue YTD higher, accepted later
amended = Snap( amended = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
2026, datetime(2027, 1, 1, tzinfo=UTC), revenue=999999,
"FY", operating_income=100, diluted_eps=1.43, cfo=100, capex=10,
date(2026, 9, 30), depreciation_amortization=25, shares_outstanding=900,
date(2026, 11, 1), cash_and_st_investments=50, total_debt=150)
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]) d = fd.derive(rows + [amended])
# Q4 revenue discrete now uses the amended YTD(FY)=999999 minus YTD(Q3)=363 # 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. # so TTM/growth reflects the amendment, proving newest accepted_at won.
-97
View File
@@ -1,97 +0,0 @@
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)
@@ -1,102 +0,0 @@
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_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": [
_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