Compare commits
2
Commits
dba7ea739b
...
ce8c60d957
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8c60d957 | ||
|
|
361cfd7883 |
@@ -52,6 +52,10 @@ SEC_REQUEST_SPACING_SECONDS=0.2
|
||||
SEC_MAX_RETRIES=4
|
||||
SEC_REQUEST_TIMEOUT_SECONDS=30.0
|
||||
|
||||
# A5 read-only parity report archive. In production keep this outside the
|
||||
# rsync deployment tree, e.g. /var/lib/signal-platform/reports/fundamentals-parity.
|
||||
FUNDAMENTALS_PARITY_REPORT_DIR=reports/fundamentals-parity
|
||||
|
||||
# Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
||||
# Optional: without it the volatility (V1) and credit (C1) pillars show as n/a.
|
||||
FRED_API_KEY=
|
||||
|
||||
@@ -51,3 +51,6 @@ backtest_snapshots/
|
||||
reports/*.pkl
|
||||
reports/*.pk1
|
||||
reports/.cache/
|
||||
# Runtime A5 parity bundles are generated on the production server. Research
|
||||
# conclusions belong in docs/research, not as an ever-growing artifact archive.
|
||||
reports/fundamentals-parity/
|
||||
|
||||
@@ -61,6 +61,10 @@ class Settings(BaseSettings):
|
||||
sec_max_retries: int = 4
|
||||
sec_request_timeout_seconds: float = 30.0
|
||||
|
||||
# A5 read-only comparison artifacts. Production must keep this outside the
|
||||
# rsync deployment tree so the 5-7 day review window survives deploys.
|
||||
fundamentals_parity_report_dir: str = "reports/fundamentals-parity"
|
||||
|
||||
# Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it
|
||||
# the volatility (P5) and credit-spread (F2) signals are reported as n/a.
|
||||
fred_api_key: str = ""
|
||||
|
||||
@@ -453,6 +453,36 @@ async def toggle_job(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/fundamentals-parity", response_model=APIEnvelope)
|
||||
async def get_fundamentals_parity_report(
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Latest read-only A5 source/score comparison, or null before first run."""
|
||||
return APIEnvelope(
|
||||
status="success", data=admin_service.get_fundamentals_parity_report()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/fundamentals-parity/csv", response_model=APIEnvelope)
|
||||
async def get_fundamentals_parity_csv(
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Latest flattened A5 report for an authenticated browser download."""
|
||||
artifact = admin_service.get_fundamentals_parity_csv()
|
||||
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
@router.get("/admin/fundamentals-parity/json", response_model=APIEnvelope)
|
||||
async def get_fundamentals_parity_json(
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Canonical A5 JSON artifact for an authenticated browser download."""
|
||||
artifact = admin_service.get_fundamentals_parity_json()
|
||||
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System events (operational warnings / errors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.services import (
|
||||
sentiment_service,
|
||||
settings_store,
|
||||
shadow_book_service,
|
||||
fundamentals_parity_service,
|
||||
)
|
||||
from app.services.data_import import STATUS_FAILED, SourceImporter, run_import
|
||||
from app.services.dolt_earnings_importer import DoltEarningsImporter
|
||||
@@ -98,6 +99,7 @@ _JOB_NAMES = [
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"ticker_universe_sync",
|
||||
"alerts",
|
||||
@@ -981,6 +983,49 @@ async def run_sec_fundamentals_import() -> None:
|
||||
await _run_shadow_import("sec_fundamentals_import", SecFundamentalsImporter())
|
||||
|
||||
|
||||
async def run_fundamentals_parity_report() -> None:
|
||||
"""Generate the A5 comparison bundle without mutating live fundamentals/scores."""
|
||||
job_name = "fundamentals_parity_report"
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
_runtime_start(job_name, total=1)
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
if not await _is_job_enabled(db, job_name):
|
||||
_runtime_finish(
|
||||
job_name, "skipped", processed=0, total=1, message="Disabled"
|
||||
)
|
||||
return
|
||||
report, artifacts = await fundamentals_parity_service.generate_and_store(
|
||||
db, settings.fundamentals_parity_report_dir
|
||||
)
|
||||
summary = report["summary"]
|
||||
message = (
|
||||
f"{summary['universe_count']} tickers · "
|
||||
f"{summary['fundamental_score_material_changes']} material score changes"
|
||||
)
|
||||
_runtime_finish(job_name, "completed", processed=1, total=1, message=message)
|
||||
_log_event(
|
||||
logging.INFO,
|
||||
"job_complete",
|
||||
job=job_name,
|
||||
generated_at=report["generated_at"],
|
||||
json_path=artifacts["json"],
|
||||
csv_path=artifacts["csv"],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message="Cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||
_log_event(
|
||||
logging.ERROR,
|
||||
"job_error",
|
||||
job=job_name,
|
||||
error_type=type(exc).__name__,
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: R:R Scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1524,6 +1569,7 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
|
||||
# Shadow source imports. They never write legacy fundamental_data before A5.
|
||||
"schedule_dolt_earnings_cron": "30 2 * * *",
|
||||
"schedule_sec_fundamentals_cron": "0 4 * * *",
|
||||
"schedule_fundamentals_parity_cron": "30 5 * * *",
|
||||
# Fetch in-progress bars → scan → Telegram (manual MOC window).
|
||||
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
|
||||
# Fetch final bars → outcome eval (must not run on the partial near-close bar).
|
||||
@@ -1539,6 +1585,7 @@ _CRON_JOBS: dict[str, str] = {
|
||||
"daily_pipeline": "schedule_daily_pipeline_cron",
|
||||
"dolt_earnings_import": "schedule_dolt_earnings_cron",
|
||||
"sec_fundamentals_import": "schedule_sec_fundamentals_cron",
|
||||
"fundamentals_parity_report": "schedule_fundamentals_parity_cron",
|
||||
"near_close_pipeline": "schedule_near_close_pipeline_cron",
|
||||
"after_close_pipeline": "schedule_after_close_pipeline_cron",
|
||||
"intraday_pipeline": "schedule_intraday_pipeline_cron",
|
||||
@@ -1645,6 +1692,17 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
name="SEC Fundamentals Import (shadow)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_fundamentals_parity_report,
|
||||
_cron_trigger(
|
||||
cfg["schedule_fundamentals_parity_cron"],
|
||||
tz,
|
||||
"schedule_fundamentals_parity_cron",
|
||||
),
|
||||
id="fundamentals_parity_report",
|
||||
name="Fundamentals Parity Report (read-only)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_near_close_pipeline,
|
||||
_cron_trigger(
|
||||
@@ -1720,6 +1778,9 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
},
|
||||
dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]},
|
||||
sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]},
|
||||
fundamentals_parity_report={
|
||||
"cron": cfg["schedule_fundamentals_parity_cron"]
|
||||
},
|
||||
near_close_pipeline={
|
||||
"cron": cfg["schedule_near_close_pipeline_cron"],
|
||||
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
|
||||
|
||||
@@ -78,6 +78,9 @@ class ScheduleConfigUpdate(BaseModel):
|
||||
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
|
||||
schedule_timezone: str | None = Field(default=None, max_length=64)
|
||||
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_fundamentals_parity_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||
|
||||
@@ -26,6 +26,7 @@ class MetricItem(BaseModel):
|
||||
industry: MetricIndustry | None = None
|
||||
period_end: str | None = None
|
||||
filed_date: str | None = None
|
||||
caveat: str | None = None
|
||||
source: str = "sec"
|
||||
|
||||
|
||||
|
||||
@@ -614,6 +614,7 @@ VALID_JOB_NAMES = {
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"ticker_universe_sync",
|
||||
"outcome_evaluator",
|
||||
@@ -637,6 +638,7 @@ JOB_LABELS = {
|
||||
"fundamental_collector": "Fundamental Collector",
|
||||
"dolt_earnings_import": "Dolt Earnings Import (shadow)",
|
||||
"sec_fundamentals_import": "SEC Fundamentals Import (shadow)",
|
||||
"fundamentals_parity_report": "Fundamentals Parity Report (read-only)",
|
||||
"rr_scanner": "R:R Scanner",
|
||||
"ticker_universe_sync": "Ticker Universe Sync",
|
||||
"outcome_evaluator": "Outcome Evaluator",
|
||||
@@ -775,3 +777,30 @@ async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSe
|
||||
|
||||
key = f"job_{job_name}_enabled"
|
||||
return await update_setting(db, key, str(enabled).lower())
|
||||
|
||||
|
||||
def get_fundamentals_parity_report() -> dict | None:
|
||||
"""Return the latest compact A5 summary, if the job has run."""
|
||||
from app.config import settings
|
||||
from app.services.fundamentals_parity_service import load_latest
|
||||
|
||||
report = load_latest(settings.fundamentals_parity_report_dir)
|
||||
if report is not None:
|
||||
report.pop("rows", None) # full per-ticker data is download-only
|
||||
return report
|
||||
|
||||
|
||||
def get_fundamentals_parity_csv() -> tuple[str, str] | None:
|
||||
"""Return the latest A5 CSV filename and content for authenticated download."""
|
||||
from app.config import settings
|
||||
from app.services.fundamentals_parity_service import load_latest_csv
|
||||
|
||||
return load_latest_csv(settings.fundamentals_parity_report_dir)
|
||||
|
||||
|
||||
def get_fundamentals_parity_json() -> tuple[str, str] | None:
|
||||
"""Return the canonical A5 JSON artifact for authenticated download."""
|
||||
from app.config import settings
|
||||
from app.services.fundamentals_parity_service import load_latest_json
|
||||
|
||||
return load_latest_json(settings.fundamentals_parity_report_dir)
|
||||
|
||||
@@ -121,6 +121,7 @@ def _build_metrics(derived, peer_derived, two: str | None) -> list[dict[str, Any
|
||||
"industry": industry,
|
||||
"period_end": _iso(series.period_end) if series else None,
|
||||
"filed_date": _iso(series.filed_date) if series else None,
|
||||
"caveat": series.caveat if series else None,
|
||||
"source": "sec",
|
||||
})
|
||||
return out
|
||||
@@ -303,7 +304,8 @@ async def _latest_close(db, ticker_id: int) -> tuple[float, date] | None:
|
||||
|
||||
def _empty_metrics() -> list[dict[str, Any]]:
|
||||
return [{"key": k, "value": None, "history": [], "industry": None,
|
||||
"period_end": None, "filed_date": None, "source": "sec"} for k in METRIC_KEYS]
|
||||
"period_end": None, "filed_date": None, "caveat": None,
|
||||
"source": "sec"} for k in METRIC_KEYS]
|
||||
|
||||
|
||||
def _empty_earnings() -> dict[str, Any]:
|
||||
|
||||
@@ -20,22 +20,22 @@ Rules:
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date
|
||||
from typing import Any, Iterable
|
||||
|
||||
_FP_TO_Q = {"Q1": 1, "Q2": 2, "Q3": 3, "FY": 4}
|
||||
_Q_TO_FP = {1: "Q1", 2: "Q2", 3: "Q3", 4: "FY"}
|
||||
_PREV_FP = {"Q2": "Q1", "Q3": "Q2", "FY": "Q3"}
|
||||
TAPE_LEN = 4 # quarter-tape length
|
||||
SPLIT_SUSPECT_SHARE_CHANGE_PCT = 25.0
|
||||
SPLIT_SENSITIVE_CAVEAT = (
|
||||
"Not comparable: share count changed at least 25%; possible split or "
|
||||
"corporate action."
|
||||
)
|
||||
|
||||
# Duration (flow) fields differenced from YTD into discrete quarters + summed to TTM.
|
||||
_FLOW_FIELDS = (
|
||||
"revenue",
|
||||
"net_income",
|
||||
"operating_income",
|
||||
"diluted_eps",
|
||||
"cfo",
|
||||
"capex",
|
||||
"revenue", "net_income", "operating_income", "diluted_eps", "cfo", "capex",
|
||||
"depreciation_amortization",
|
||||
)
|
||||
|
||||
@@ -49,11 +49,10 @@ class MetricPoint:
|
||||
@dataclass
|
||||
class MetricSeries:
|
||||
value: float | None = None
|
||||
history: list[MetricPoint] = field(
|
||||
default_factory=list
|
||||
) # oldest -> newest, <= TAPE_LEN
|
||||
history: list[MetricPoint] = field(default_factory=list) # oldest -> newest, <= TAPE_LEN
|
||||
period_end: date | None = None
|
||||
filed_date: date | None = None
|
||||
caveat: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -89,9 +88,7 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
|
||||
result.ttm_diluted_eps = _ttm(discrete["diluted_eps"], *latest)
|
||||
ttm_cfo = _ttm(discrete["cfo"], *latest)
|
||||
ttm_capex = _ttm(discrete["capex"], *latest)
|
||||
result.ttm_fcf = (
|
||||
None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex
|
||||
)
|
||||
result.ttm_fcf = 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,
|
||||
# stopping at a gap — so trend text never compares non-adjacent periods.
|
||||
@@ -99,41 +96,21 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
|
||||
result.metrics = {
|
||||
"revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape),
|
||||
"eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape),
|
||||
"operating_margin": _margin_series(
|
||||
discrete["operating_income"], discrete["revenue"], selected, tape
|
||||
),
|
||||
"operating_margin": _margin_series(discrete["operating_income"], discrete["revenue"], selected, tape),
|
||||
"fcf_margin": _fcf_margin_series(discrete, selected, tape),
|
||||
"net_debt": _instant_series(selected, tape, _net_debt),
|
||||
"net_debt_to_ebitda": _leverage_series(selected, discrete, tape),
|
||||
"share_count_change_yoy": _share_change_series(selected, tape),
|
||||
}
|
||||
_guard_split_sensitive_metrics(result.metrics)
|
||||
for series in result.metrics.values():
|
||||
series.period_end = latest_row.period_end
|
||||
series.filed_date = latest_row.filed_date
|
||||
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 --------------------------------------------------------
|
||||
|
||||
|
||||
def _select_latest_per_period(snapshots: Iterable[Any]) -> dict[tuple[int, str], Any]:
|
||||
best: dict[tuple[int, str], Any] = {}
|
||||
for row in snapshots:
|
||||
@@ -156,9 +133,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)
|
||||
|
||||
|
||||
def _consecutive_suffix(
|
||||
quarters: list[tuple[int, int]], n: int
|
||||
) -> list[tuple[int, int]]:
|
||||
def _consecutive_suffix(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
|
||||
adjacent periods (stop at the first gap). Returned oldest -> newest."""
|
||||
if not quarters:
|
||||
@@ -178,10 +153,7 @@ def _consecutive_suffix(
|
||||
|
||||
# -- 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] = {}
|
||||
for (fy, fp), row in selected.items():
|
||||
val = _discrete_value(selected, fy, fp, field_name)
|
||||
@@ -224,7 +196,6 @@ def _pct_change(cur: float | None, prior: float | None) -> float | None:
|
||||
|
||||
# -- per-metric series (value at latest + tape history) ----------------------
|
||||
|
||||
|
||||
def _period_end(selected, fy: int, q: int) -> date | None:
|
||||
row = selected.get((fy, _Q_TO_FP[q]))
|
||||
return row.period_end if row is not None else None
|
||||
@@ -232,7 +203,7 @@ def _period_end(selected, fy: int, q: int) -> date | None:
|
||||
|
||||
def _yoy_growth_series(dq, selected, tape) -> MetricSeries:
|
||||
pts = []
|
||||
for fy, q in tape:
|
||||
for (fy, q) in tape:
|
||||
cur, prior = _ttm(dq, fy, q), _ttm(dq, fy - 1, q)
|
||||
pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
|
||||
return _series(pts)
|
||||
@@ -240,7 +211,7 @@ def _yoy_growth_series(dq, selected, tape) -> MetricSeries:
|
||||
|
||||
def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries:
|
||||
pts = []
|
||||
for fy, q in tape:
|
||||
for (fy, q) in tape:
|
||||
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
|
||||
pts.append(MetricPoint(_period_end(selected, fy, q), val))
|
||||
@@ -249,38 +220,24 @@ def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries:
|
||||
|
||||
def _fcf_margin_series(discrete, selected, tape) -> MetricSeries:
|
||||
pts = []
|
||||
for fy, q in tape:
|
||||
cfo, capex, rev = (
|
||||
_ttm(discrete["cfo"], fy, q),
|
||||
_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
|
||||
)
|
||||
for (fy, q) in tape:
|
||||
cfo, capex, rev = _ttm(discrete["cfo"], fy, q), _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))
|
||||
return _series(pts)
|
||||
|
||||
|
||||
def _instant_series(selected, tape, fn) -> MetricSeries:
|
||||
pts = [
|
||||
MetricPoint(_period_end(selected, fy, q), fn(selected.get((fy, _Q_TO_FP[q]))))
|
||||
for (fy, q) in tape
|
||||
]
|
||||
pts = [MetricPoint(_period_end(selected, fy, q), fn(selected.get((fy, _Q_TO_FP[q])))) for (fy, q) in tape]
|
||||
return _series(pts)
|
||||
|
||||
|
||||
def _leverage_series(selected, discrete, tape) -> MetricSeries:
|
||||
pts = []
|
||||
for fy, q in tape:
|
||||
for (fy, q) in tape:
|
||||
row = selected.get((fy, _Q_TO_FP[q]))
|
||||
nd = _net_debt(row)
|
||||
op, da = (
|
||||
_ttm(discrete["operating_income"], fy, q),
|
||||
_ttm(discrete["depreciation_amortization"], fy, q),
|
||||
)
|
||||
op, da = _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
|
||||
# Null when EBITDA <= 0: a negative denominator would flip polarity and a
|
||||
# "lower is better" read would rank a distressed issuer as favorable.
|
||||
@@ -291,13 +248,47 @@ def _leverage_series(selected, discrete, tape) -> MetricSeries:
|
||||
|
||||
def _share_change_series(selected, tape) -> MetricSeries:
|
||||
pts = []
|
||||
for fy, q in tape:
|
||||
for (fy, q) in tape:
|
||||
cur = _shares(selected.get((fy, _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)))
|
||||
return _series(pts)
|
||||
|
||||
|
||||
def _guard_split_sensitive_metrics(metrics: dict[str, MetricSeries]) -> None:
|
||||
"""Suppress historical comparisons likely distorted by a corporate action.
|
||||
|
||||
Company Facts has no point-in-time split factors. A large YoY share-count
|
||||
move can therefore make both the point-in-time share comparison and
|
||||
per-share EPS growth non-comparable. Keep the raw facts in snapshots, but
|
||||
expose nulls plus an explicit caveat in the user-facing derived series.
|
||||
"""
|
||||
shares = metrics.get("share_count_change_yoy")
|
||||
eps = metrics.get("eps_growth_yoy")
|
||||
if shares is None or eps is None:
|
||||
return
|
||||
|
||||
suspect_periods = {
|
||||
point.period_end
|
||||
for point in shares.history
|
||||
if point.value is not None
|
||||
and abs(point.value) >= SPLIT_SUSPECT_SHARE_CHANGE_PCT
|
||||
}
|
||||
if not suspect_periods:
|
||||
return
|
||||
|
||||
for series in (shares, eps):
|
||||
latest_guarded = bool(
|
||||
series.history and series.history[-1].period_end in suspect_periods
|
||||
)
|
||||
for point in series.history:
|
||||
if point.period_end in suspect_periods:
|
||||
point.value = None
|
||||
series.value = series.history[-1].value if series.history else None
|
||||
if latest_guarded:
|
||||
series.caveat = SPLIT_SENSITIVE_CAVEAT
|
||||
|
||||
|
||||
def _net_debt(row: Any) -> float | None:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
"""Read-only A5 comparison of legacy and SEC/Dolt fundamental inputs.
|
||||
|
||||
The report deliberately does not write ``fundamental_data`` or score tables.
|
||||
It reconstructs the current legacy and candidate fundamental scores, projects
|
||||
their composite-score/rank effect with the active weights, and archives a
|
||||
timestamped JSON + CSV bundle for explicit human approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.data_import_run import DataImportRun
|
||||
from app.models.earnings_event import EarningsEvent
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.fundamental_snapshot import FundamentalSnapshot
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import fundamentals_derivation as deriv
|
||||
|
||||
REPORT_VERSION = 1
|
||||
APPROVAL_STATUS = "pending_explicit_approval"
|
||||
FIELD_KEYS = ("pe_ratio", "revenue_growth", "earnings_surprise")
|
||||
MIN_SCORE_METRICS = 2
|
||||
|
||||
# Materiality is a review aid, never an automatic cutover verdict. Definition
|
||||
# changes remain visible even when a delta falls inside these bands.
|
||||
FIELD_TOLERANCES = {
|
||||
"pe_ratio": {"absolute": 1.0, "relative_pct": 10.0},
|
||||
"revenue_growth": {"absolute": 2.0, "relative_pct": None},
|
||||
"earnings_surprise": {"absolute": 2.0, "relative_pct": None},
|
||||
}
|
||||
DEFINITION_NOTES = {
|
||||
"pe_ratio": (
|
||||
"Legacy provider P/E convention versus latest close divided by "
|
||||
"SEC-derived TTM diluted EPS."
|
||||
),
|
||||
"revenue_growth": (
|
||||
"Legacy provider growth convention versus SEC-derived TTM revenue YoY."
|
||||
),
|
||||
"earnings_surprise": (
|
||||
"Legacy provider latest surprise versus latest completed Dolt earnings "
|
||||
"event with actual and estimate."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def fundamental_score(
|
||||
pe_ratio: float | None,
|
||||
revenue_growth: float | None,
|
||||
earnings_surprise: float | None,
|
||||
) -> float | None:
|
||||
"""Match the production fundamental-dimension formula without persistence."""
|
||||
scores: list[float] = []
|
||||
if _finite(pe_ratio) and pe_ratio > 0:
|
||||
scores.append(max(0.0, min(100.0, 100.0 - (pe_ratio - 15.0) * (100.0 / 30.0))))
|
||||
if _finite(revenue_growth):
|
||||
scores.append(max(0.0, min(100.0, 50.0 + revenue_growth * 2.5)))
|
||||
if _finite(earnings_surprise):
|
||||
scores.append(max(0.0, min(100.0, 50.0 + earnings_surprise * 5.0)))
|
||||
return sum(scores) / len(scores) if len(scores) >= MIN_SCORE_METRICS else None
|
||||
|
||||
|
||||
async def build_report(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
generated_at: datetime | None = None,
|
||||
today: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a point-in-time parity report from one database session."""
|
||||
generated_at = generated_at or datetime.now(timezone.utc)
|
||||
today = today or datetime.now(ZoneInfo("America/New_York")).date()
|
||||
|
||||
# A report must not mix rows from before and after a concurrent import
|
||||
# promotion. The scheduled job provides a fresh session, so establish the
|
||||
# production snapshot before its first query and have Postgres enforce the
|
||||
# no-write contract as well. SQLite tests retain their normal transaction.
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
connection = await db.connection(
|
||||
execution_options={"isolation_level": "REPEATABLE READ"}
|
||||
)
|
||||
await connection.execute(text("SET TRANSACTION READ ONLY"))
|
||||
|
||||
tickers = list((await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars())
|
||||
ticker_ids = [ticker.id for ticker in tickers]
|
||||
ciks = sorted({ticker.cik for ticker in tickers if ticker.cik})
|
||||
|
||||
legacy_by_ticker = await _legacy_values(db, ticker_ids)
|
||||
derived_by_cik = await _derived_by_cik(db, ciks)
|
||||
closes_by_ticker = await _latest_closes(db, ticker_ids)
|
||||
surprise_by_ticker = await _latest_surprises(db, ticker_ids, today)
|
||||
source_runs = await _source_runs(db)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for ticker in tickers:
|
||||
legacy = legacy_by_ticker.get(ticker.id)
|
||||
derived = derived_by_cik.get(ticker.cik) if ticker.cik else None
|
||||
close = closes_by_ticker.get(ticker.id)
|
||||
candidate_pe = (
|
||||
_pe(close[0], derived.ttm_diluted_eps)
|
||||
if close is not None and derived is not None
|
||||
else None
|
||||
)
|
||||
growth_series = (
|
||||
derived.metrics.get("revenue_growth_yoy") if derived is not None else None
|
||||
)
|
||||
candidate = {
|
||||
"pe_ratio": candidate_pe,
|
||||
"revenue_growth": growth_series.value if growth_series else None,
|
||||
"earnings_surprise": surprise_by_ticker.get(ticker.id),
|
||||
}
|
||||
legacy_values = {
|
||||
"pe_ratio": legacy.pe_ratio if legacy else None,
|
||||
"revenue_growth": legacy.revenue_growth if legacy else None,
|
||||
"earnings_surprise": legacy.earnings_surprise if legacy else None,
|
||||
}
|
||||
fields = {
|
||||
key: _field_comparison(key, legacy_values[key], candidate[key])
|
||||
for key in FIELD_KEYS
|
||||
}
|
||||
legacy_score = fundamental_score(**legacy_values)
|
||||
candidate_score = fundamental_score(**candidate)
|
||||
rows.append(
|
||||
{
|
||||
"symbol": ticker.symbol,
|
||||
"cik": ticker.cik,
|
||||
"legacy_fetched_at": _iso(legacy.fetched_at) if legacy else None,
|
||||
"price_date": _iso(close[1]) if close else None,
|
||||
"fields": fields,
|
||||
"scores": {
|
||||
"legacy_fundamental": _round(legacy_score),
|
||||
"candidate_fundamental": _round(candidate_score),
|
||||
"fundamental_delta": _delta(legacy_score, candidate_score),
|
||||
"legacy_fundamental_rank": None,
|
||||
"candidate_fundamental_rank": None,
|
||||
"fundamental_rank_change": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_attach_ranks(rows, "legacy_fundamental", "legacy_fundamental_rank")
|
||||
_attach_ranks(rows, "candidate_fundamental", "candidate_fundamental_rank")
|
||||
for row in rows:
|
||||
scores = row["scores"]
|
||||
scores["fundamental_rank_change"] = _rank_change(
|
||||
scores["legacy_fundamental_rank"], scores["candidate_fundamental_rank"]
|
||||
)
|
||||
|
||||
return {
|
||||
"report_version": REPORT_VERSION,
|
||||
"generated_at": generated_at.isoformat(),
|
||||
"as_of_date": today.isoformat(),
|
||||
"approval_status": APPROVAL_STATUS,
|
||||
"read_only": True,
|
||||
"fundamental_score_formula": (
|
||||
"Equal-weighted mean of 2+ available sub-scores: P/E = "
|
||||
"clamp(100-(pe-15)*(100/30)); revenue growth = "
|
||||
"clamp(50+growth*2.5); earnings surprise = "
|
||||
"clamp(50+surprise*5)."
|
||||
),
|
||||
"source_runs": source_runs,
|
||||
"definition_notes": DEFINITION_NOTES,
|
||||
"materiality_notes": {
|
||||
"fields": FIELD_TOLERANCES,
|
||||
"fundamental_score_absolute": 5.0,
|
||||
"automatic_cutover": False,
|
||||
},
|
||||
"summary": _summary(rows),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def store_report(report: dict[str, Any], report_dir: str | Path) -> dict[str, str]:
|
||||
"""Atomically archive JSON/CSV artifacts and update the latest manifest."""
|
||||
directory = Path(report_dir).expanduser().resolve()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
stamp = _artifact_stamp(report["generated_at"])
|
||||
json_name = f"fundamentals-parity-{stamp}.json"
|
||||
csv_name = f"fundamentals-parity-{stamp}.csv"
|
||||
json_path = directory / json_name
|
||||
csv_path = directory / csv_name
|
||||
|
||||
_atomic_write(json_path, json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
_atomic_write(csv_path, report_csv(report))
|
||||
manifest = {
|
||||
"generated_at": report["generated_at"],
|
||||
"json_file": json_name,
|
||||
"csv_file": csv_name,
|
||||
}
|
||||
_atomic_write(
|
||||
directory / "latest.json",
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
)
|
||||
return {
|
||||
"json": str(json_path),
|
||||
"csv": str(csv_path),
|
||||
"manifest": str(directory / "latest.json"),
|
||||
}
|
||||
|
||||
|
||||
async def generate_and_store(
|
||||
db: AsyncSession,
|
||||
report_dir: str | Path,
|
||||
*,
|
||||
generated_at: datetime | None = None,
|
||||
today: date | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
report = await build_report(db, generated_at=generated_at, today=today)
|
||||
return report, store_report(report, report_dir)
|
||||
|
||||
|
||||
def load_latest(report_dir: str | Path) -> dict[str, Any] | None:
|
||||
manifest = _load_manifest(report_dir)
|
||||
if manifest is None:
|
||||
return None
|
||||
try:
|
||||
path = _manifest_artifact(report_dir, manifest, "json_file")
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
return loaded if isinstance(loaded, dict) else None
|
||||
|
||||
|
||||
def load_latest_csv(report_dir: str | Path) -> tuple[str, str] | None:
|
||||
return _load_latest_text_artifact(report_dir, "csv_file")
|
||||
|
||||
|
||||
def load_latest_json(report_dir: str | Path) -> tuple[str, str] | None:
|
||||
return _load_latest_text_artifact(report_dir, "json_file")
|
||||
|
||||
|
||||
def _load_latest_text_artifact(
|
||||
report_dir: str | Path, manifest_key: str
|
||||
) -> tuple[str, str] | None:
|
||||
manifest = _load_manifest(report_dir)
|
||||
if manifest is None:
|
||||
return None
|
||||
try:
|
||||
path = _manifest_artifact(report_dir, manifest, manifest_key)
|
||||
return path.name, path.read_text(encoding="utf-8")
|
||||
except (OSError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def report_csv(report: dict[str, Any]) -> str:
|
||||
output = io.StringIO(newline="")
|
||||
columns = [
|
||||
"symbol",
|
||||
"cik",
|
||||
"legacy_fetched_at",
|
||||
"price_date",
|
||||
*(
|
||||
f"{field}_{suffix}"
|
||||
for field in FIELD_KEYS
|
||||
for suffix in ("legacy", "candidate", "absolute_delta", "relative_delta_pct", "material")
|
||||
),
|
||||
"legacy_fundamental",
|
||||
"candidate_fundamental",
|
||||
"fundamental_delta",
|
||||
"legacy_fundamental_rank",
|
||||
"candidate_fundamental_rank",
|
||||
"fundamental_rank_change",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=columns)
|
||||
writer.writeheader()
|
||||
for row in report.get("rows", []):
|
||||
flat = {
|
||||
"symbol": row["symbol"],
|
||||
"cik": row.get("cik"),
|
||||
"legacy_fetched_at": row.get("legacy_fetched_at"),
|
||||
"price_date": row.get("price_date"),
|
||||
**row["scores"],
|
||||
}
|
||||
for field in FIELD_KEYS:
|
||||
comparison = row["fields"][field]
|
||||
for suffix in (
|
||||
"legacy",
|
||||
"candidate",
|
||||
"absolute_delta",
|
||||
"relative_delta_pct",
|
||||
"material",
|
||||
):
|
||||
flat[f"{field}_{suffix}"] = comparison.get(suffix)
|
||||
writer.writerow(flat)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
async def _legacy_values(
|
||||
db: AsyncSession, ticker_ids: list[int]
|
||||
) -> dict[int, FundamentalData]:
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(FundamentalData).where(FundamentalData.ticker_id.in_(ticker_ids))
|
||||
)
|
||||
).scalars()
|
||||
return {row.ticker_id: row for row in rows}
|
||||
|
||||
|
||||
async def _derived_by_cik(
|
||||
db: AsyncSession, ciks: list[str]
|
||||
) -> dict[str, deriv.DerivedFundamentals]:
|
||||
if not ciks:
|
||||
return {}
|
||||
grouped: dict[str, list[FundamentalSnapshot]] = defaultdict(list)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(FundamentalSnapshot).where(FundamentalSnapshot.cik.in_(ciks))
|
||||
)
|
||||
).scalars()
|
||||
for row in rows:
|
||||
grouped[row.cik].append(row)
|
||||
return {cik: deriv.derive(grouped.get(cik, [])) for cik in ciks}
|
||||
|
||||
|
||||
async def _latest_closes(
|
||||
db: AsyncSession, ticker_ids: list[int]
|
||||
) -> dict[int, tuple[float, date]]:
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
latest = (
|
||||
select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("max_date"))
|
||||
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
|
||||
.group_by(OHLCVRecord.ticker_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(OHLCVRecord.ticker_id, OHLCVRecord.close, OHLCVRecord.date).join(
|
||||
latest,
|
||||
(OHLCVRecord.ticker_id == latest.c.ticker_id)
|
||||
& (OHLCVRecord.date == latest.c.max_date),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return {
|
||||
ticker_id: (float(close), close_date)
|
||||
for ticker_id, close, close_date in rows
|
||||
if _finite(close)
|
||||
}
|
||||
|
||||
|
||||
async def _latest_surprises(
|
||||
db: AsyncSession, ticker_ids: list[int], today: date
|
||||
) -> dict[int, float]:
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(EarningsEvent)
|
||||
.where(
|
||||
EarningsEvent.ticker_id.in_(ticker_ids),
|
||||
EarningsEvent.announce_date < today,
|
||||
)
|
||||
.order_by(EarningsEvent.ticker_id, EarningsEvent.announce_date.desc())
|
||||
)
|
||||
).scalars()
|
||||
out: dict[int, float] = {}
|
||||
for row in rows:
|
||||
if row.ticker_id in out:
|
||||
continue
|
||||
surprise = _surprise(row.eps_estimate, row.eps_actual)
|
||||
if surprise is not None:
|
||||
out[row.ticker_id] = surprise
|
||||
return out
|
||||
|
||||
|
||||
async def _source_runs(db: AsyncSession) -> dict[str, dict[str, Any] | None]:
|
||||
sources = ("sec_facts", "dolt_earnings")
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(DataImportRun)
|
||||
.where(
|
||||
DataImportRun.source.in_(sources),
|
||||
DataImportRun.status.in_(("promoted", "no_op")),
|
||||
)
|
||||
.order_by(DataImportRun.id.desc())
|
||||
)
|
||||
).scalars()
|
||||
latest: dict[str, dict[str, Any] | None] = {source: None for source in sources}
|
||||
for row in rows:
|
||||
if latest[row.source] is None:
|
||||
latest[row.source] = {
|
||||
"run_id": row.id,
|
||||
"status": row.status,
|
||||
"revision": row.revision,
|
||||
"source_max_date": _iso(row.source_max_date),
|
||||
"completed_at": _iso(row.completed_at),
|
||||
}
|
||||
return latest
|
||||
|
||||
|
||||
def _field_comparison(
|
||||
key: str, legacy: float | None, candidate: float | None
|
||||
) -> dict[str, Any]:
|
||||
legacy = float(legacy) if _finite(legacy) else None
|
||||
candidate = float(candidate) if _finite(candidate) else None
|
||||
absolute = _delta(legacy, candidate)
|
||||
relative = (
|
||||
None
|
||||
if absolute is None or legacy in (None, 0)
|
||||
else round(absolute / abs(legacy) * 100.0, 4)
|
||||
)
|
||||
tolerance = FIELD_TOLERANCES[key]
|
||||
material = False
|
||||
if absolute is not None:
|
||||
material = abs(absolute) > tolerance["absolute"]
|
||||
relative_limit = tolerance["relative_pct"]
|
||||
if relative_limit is not None:
|
||||
material = material and relative is not None and abs(relative) > relative_limit
|
||||
return {
|
||||
"legacy": _round(legacy),
|
||||
"candidate": _round(candidate),
|
||||
"absolute_delta": absolute,
|
||||
"relative_delta_pct": relative,
|
||||
"material": material,
|
||||
"definition_changed": True,
|
||||
}
|
||||
|
||||
|
||||
def _attach_ranks(rows: list[dict[str, Any]], value_key: str, rank_key: str) -> None:
|
||||
values = [
|
||||
row["scores"][value_key]
|
||||
for row in rows
|
||||
if _finite(row["scores"][value_key])
|
||||
]
|
||||
for row in rows:
|
||||
value = row["scores"][value_key]
|
||||
row["scores"][rank_key] = (
|
||||
1 + sum(other > value for other in values) if _finite(value) else None
|
||||
)
|
||||
|
||||
|
||||
def _summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
field_stats = {}
|
||||
for key in FIELD_KEYS:
|
||||
comparisons = [row["fields"][key] for row in rows]
|
||||
deltas = [
|
||||
abs(item["absolute_delta"])
|
||||
for item in comparisons
|
||||
if item["absolute_delta"] is not None
|
||||
]
|
||||
field_stats[key] = {
|
||||
"legacy_available": sum(item["legacy"] is not None for item in comparisons),
|
||||
"candidate_available": sum(
|
||||
item["candidate"] is not None for item in comparisons
|
||||
),
|
||||
"both_available": len(deltas),
|
||||
"material_differences": sum(item["material"] for item in comparisons),
|
||||
"median_absolute_delta": _round(statistics.median(deltas) if deltas else None),
|
||||
"p95_absolute_delta": _round(_percentile(deltas, 0.95)),
|
||||
"max_absolute_delta": _round(max(deltas) if deltas else None),
|
||||
}
|
||||
|
||||
fundamental_deltas = _score_deltas(rows, "fundamental_delta")
|
||||
changed_rows = sorted(
|
||||
(
|
||||
{
|
||||
"symbol": row["symbol"],
|
||||
"fundamental_delta": row["scores"]["fundamental_delta"],
|
||||
"fundamental_rank_change": row["scores"]["fundamental_rank_change"],
|
||||
}
|
||||
for row in rows
|
||||
if row["scores"]["fundamental_delta"] is not None
|
||||
),
|
||||
key=lambda item: (
|
||||
abs(item["fundamental_delta"] or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)[:20]
|
||||
return {
|
||||
"universe_count": len(rows),
|
||||
"legacy_fundamental_score_available": _count_score(
|
||||
rows, "legacy_fundamental"
|
||||
),
|
||||
"candidate_fundamental_score_available": _count_score(
|
||||
rows, "candidate_fundamental"
|
||||
),
|
||||
"fundamental_scores_compared": len(fundamental_deltas),
|
||||
"fundamental_score_material_changes": sum(
|
||||
abs(delta) > 5.0 for delta in fundamental_deltas
|
||||
),
|
||||
"fundamental_rank_changes": _rank_change_count(
|
||||
rows, "fundamental_rank_change"
|
||||
),
|
||||
"field_stats": field_stats,
|
||||
"largest_changes": changed_rows,
|
||||
}
|
||||
|
||||
|
||||
def _score_deltas(rows: Iterable[dict[str, Any]], key: str) -> list[float]:
|
||||
return [
|
||||
row["scores"][key]
|
||||
for row in rows
|
||||
if row["scores"][key] is not None
|
||||
]
|
||||
|
||||
|
||||
def _count_score(rows: Iterable[dict[str, Any]], key: str) -> int:
|
||||
return sum(row["scores"][key] is not None for row in rows)
|
||||
|
||||
|
||||
def _rank_change_count(rows: Iterable[dict[str, Any]], key: str) -> int:
|
||||
return sum(
|
||||
row["scores"][key] not in (None, 0)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def _rank_change(legacy: int | None, candidate: int | None) -> int | None:
|
||||
# Positive means the candidate improved its rank.
|
||||
return legacy - candidate if legacy is not None and candidate is not None else None
|
||||
|
||||
|
||||
def _surprise(estimate: float | None, actual: float | None) -> float | None:
|
||||
if not _finite(estimate) or not _finite(actual) or estimate == 0:
|
||||
return None
|
||||
return (actual - estimate) / abs(estimate) * 100.0
|
||||
|
||||
|
||||
def _pe(price: float | None, ttm_eps: float | None) -> float | None:
|
||||
if not _finite(price) or price <= 0 or not _finite(ttm_eps) or ttm_eps <= 0:
|
||||
return None
|
||||
return price / ttm_eps
|
||||
|
||||
|
||||
def _delta(legacy: float | None, candidate: float | None) -> float | None:
|
||||
if not _finite(legacy) or not _finite(candidate):
|
||||
return None
|
||||
return round(candidate - legacy, 4)
|
||||
|
||||
|
||||
def _round(value: float | None, digits: int = 4) -> float | None:
|
||||
return round(float(value), digits) if _finite(value) else None
|
||||
|
||||
|
||||
def _percentile(values: list[float], quantile: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
index = max(0, math.ceil(quantile * len(ordered)) - 1)
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def _finite(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _iso(value: Any) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def _artifact_stamp(raw: str) -> str:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
return parsed.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temp.write_text(content, encoding="utf-8", newline="")
|
||||
os.replace(temp, path)
|
||||
|
||||
|
||||
def _load_manifest(report_dir: str | Path) -> dict[str, Any] | None:
|
||||
path = Path(report_dir).expanduser().resolve() / "latest.json"
|
||||
try:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
return loaded if isinstance(loaded, dict) else None
|
||||
|
||||
|
||||
def _manifest_artifact(
|
||||
report_dir: str | Path, manifest: dict[str, Any], key: str
|
||||
) -> Path:
|
||||
directory = Path(report_dir).expanduser().resolve()
|
||||
name = Path(str(manifest.get(key, ""))).name
|
||||
if not name:
|
||||
raise ValueError(f"Latest parity manifest has no {key}")
|
||||
return directory / name
|
||||
@@ -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
|
||||
@@ -15,6 +15,7 @@ MIN_FREE_GB="${DOLT_MIN_FREE_DISK_GB:-5}"
|
||||
EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}"
|
||||
DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}"
|
||||
DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}"
|
||||
FUNDAMENTALS_PARITY_REPORT_DIR="${FUNDAMENTALS_PARITY_REPORT_DIR:-/var/lib/signal-platform/reports/fundamentals-parity}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
@@ -79,6 +80,8 @@ check_env() {
|
||||
|| fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE"
|
||||
grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \
|
||||
|| fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email"
|
||||
grep -Fqx "FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR" "$ENV_FILE" \
|
||||
|| fail "set FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR in $ENV_FILE"
|
||||
}
|
||||
|
||||
check_all() {
|
||||
@@ -101,6 +104,15 @@ check_all() {
|
||||
identity_email="$(repo_config_value user.email 2>/dev/null || true)"
|
||||
[[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR"
|
||||
[[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR"
|
||||
[[ -d "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|
||||
|| fail "missing parity report directory: $FUNDAMENTALS_PARITY_REPORT_DIR"
|
||||
if [[ "$(id -un)" == "$APP_USER" ]]; then
|
||||
[[ -w "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|
||||
|| fail "parity report directory is not writable by $APP_USER"
|
||||
else
|
||||
runuser -u "$APP_USER" -- test -w "$FUNDAMENTALS_PARITY_REPORT_DIR" \
|
||||
|| fail "parity report directory is not writable by $APP_USER"
|
||||
fi
|
||||
check_free_space
|
||||
check_env
|
||||
echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned"
|
||||
@@ -127,6 +139,7 @@ fi
|
||||
version_ok || fail "Dolt $DOLT_VERSION installation failed"
|
||||
|
||||
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR"
|
||||
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$FUNDAMENTALS_PARITY_REPORT_DIR"
|
||||
check_free_space
|
||||
|
||||
if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then
|
||||
|
||||
@@ -426,6 +426,8 @@ workstream B — Alpaca remains the price source throughout.
|
||||
API values across the tracked universe, report per-field deltas and resulting
|
||||
fundamental-score/ranking changes, require explicit approval. Definition
|
||||
changes (e.g. TTM vs provider convention) called out, not averaged away.
|
||||
The read-only report job and Admin summary/download are implemented; production
|
||||
observation and explicit cutover approval remain pending.
|
||||
- A6. Remove FMP/Finnhub/Alpha Vantage; keep monitoring + manual fallback.
|
||||
|
||||
**Workstream B (independent, start when wanted):**
|
||||
|
||||
@@ -8,6 +8,7 @@ approval. Do not add OS cron entries: the application scheduler owns both jobs.
|
||||
|
||||
- `Dolt Earnings Import (shadow)` runs daily at 02:30 America/New_York.
|
||||
- `SEC Fundamentals Import (shadow)` runs daily at 04:00 America/New_York.
|
||||
- `Fundamentals Parity Report (read-only)` runs daily at 05:30 America/New_York.
|
||||
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
|
||||
- Cron expressions are editable in Admin → Schedule.
|
||||
- Every attempt is recorded in `data_import_runs`; failures also create a system
|
||||
@@ -28,11 +29,14 @@ DOLT_EARNINGS_SUBDIR=earnings
|
||||
DOLT_MIN_FREE_DISK_GB=5.0
|
||||
SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com)
|
||||
SEC_REQUEST_SPACING_SECONDS=0.2
|
||||
FUNDAMENTALS_PARITY_REPORT_DIR=/var/lib/signal-platform/reports/fundamentals-parity
|
||||
```
|
||||
|
||||
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
|
||||
path; 8–10 GB gives comfortable growth headroom. The data directory must stay
|
||||
outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
|
||||
The parity-report directory is also persistent and owned by the service user;
|
||||
its small timestamped JSON/CSV bundles form the temporary A5 review trail.
|
||||
|
||||
## One-time provisioning
|
||||
|
||||
@@ -79,6 +83,28 @@ In Admin → Jobs, wait until no other job is running, then:
|
||||
5. Open several ticker pages and confirm the fundamentals panel has populated
|
||||
data and still handles partial/missing issuers cleanly.
|
||||
|
||||
## A5 parity observation window
|
||||
|
||||
After both shadow imports are healthy, trigger **Fundamentals Parity Report
|
||||
(read-only)** once in Admin → Jobs. The **A5 Fundamentals Parity** card above
|
||||
the jobs shows the latest coverage/delta summary and provides authenticated JSON
|
||||
and CSV downloads. The canonical server-side bundles are archived at:
|
||||
|
||||
```text
|
||||
/var/lib/signal-platform/reports/fundamentals-parity/
|
||||
```
|
||||
|
||||
The scheduler then generates one report daily at 05:30 New York time, after the
|
||||
02:30 Dolt and 04:00 SEC jobs. Review 5–7 consecutive reports before making the
|
||||
cutover decision. A report never writes `fundamental_data`, dimension/composite
|
||||
scores, rankings, qualification state, or an approval flag. Materiality bands
|
||||
only highlight rows for review; A5 still requires explicit approval.
|
||||
|
||||
Each bundle contains legacy and candidate P/E, revenue growth, and earnings
|
||||
surprise; definition notes; source revisions and price dates; recomputed legacy
|
||||
and candidate fundamental scores; and per-universe fundamental-rank changes.
|
||||
Definition changes remain explicit even when numeric deltas are small.
|
||||
|
||||
Optional database verification:
|
||||
|
||||
```sql
|
||||
@@ -141,3 +167,6 @@ locks. A second Admin trigger should independently report the job as busy.
|
||||
audit rows) must remain covered by the normal production database backup.
|
||||
- Do not proceed to A5 while either shadow feed is unhealthy or the parity gate
|
||||
has not received explicit approval.
|
||||
- If report generation fails, inspect Admin → System Events and verify
|
||||
`FUNDAMENTALS_PARITY_REPORT_DIR` exists and is writable by `deploy`. Existing
|
||||
reports and all live data remain untouched.
|
||||
|
||||
@@ -1,191 +1,74 @@
|
||||
# Point-in-time fundamentals weight backtest
|
||||
# Fundamentals ranking-overlay research
|
||||
|
||||
Status: initial experiment completed; split-safe follow-up registered locally.
|
||||
Running either protocol does not change production.
|
||||
Status: completed 2026-07-23. Decision: keep production scoring and qualification unchanged.
|
||||
|
||||
## Question
|
||||
|
||||
Does reordering already-qualified long setups with SEC fundamentals improve the
|
||||
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.
|
||||
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.
|
||||
|
||||
## Completed initial experiment
|
||||
## Method
|
||||
|
||||
The control is the production 80/20 residual-momentum / volatility rank. The
|
||||
runner tests three fundamental composites at weights 10%, 20%, 30%, and 40%:
|
||||
- The control was the production 80/20 residual-momentum / volatility rank.
|
||||
- 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.
|
||||
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.
|
||||
The snapshot contained 511 tracked tickers, 507 unique CIKs, 30,494 SEC snapshot rows, and prices from 2021-06-24 through 2026-07-22.
|
||||
|
||||
Each raw metric is ranked favorably from 0 to 100 across the CIK-deduplicated
|
||||
tracked universe. Missing composite scores are neutral at 50. The formula is:
|
||||
## Initial experiment
|
||||
|
||||
`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
|
||||
count is used by the Deflated Sharpe calculation.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
inputs must exist.
|
||||
The follow-up excluded diluted-EPS growth and share-count change completely. It tested:
|
||||
|
||||
- Quality: operating margin, FCF margin, and low net-debt/EBITDA, requiring at least two inputs.
|
||||
- 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%.
|
||||
|
||||
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.
|
||||
This produced 10 registered trials including control. Growth coverage among qualified candidates was 88.96%; lack of data was not the limiting factor.
|
||||
|
||||
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.
|
||||
| Window | Control Sharpe | Revenue-growth 5% | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Train | 1.26 | 1.31 | +0.05 |
|
||||
| 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
|
||||
the original date boundaries and development selection discipline, but treat the
|
||||
follow-up as sensitivity evidence. Any promotion still requires forward paper
|
||||
evidence.
|
||||
Revenue growth at 5% mechanically passed the deliberately permissive "not worse" gate, but did not demonstrate an economically meaningful edge:
|
||||
|
||||
## 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
|
||||
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.
|
||||
The complete split-safe result is recoverable from Git commit `dba7ea7`.
|
||||
|
||||
Default windows are fixed before the first run:
|
||||
## Decision
|
||||
|
||||
- Train: entry date before 2024-01-01
|
||||
- Validation: 2024-01-01 through 2024-12-31
|
||||
- Test: entry date on or after 2025-01-01
|
||||
- Do not add fundamental weight to production ranking or the automated qualification gate.
|
||||
- Do not run another historical weight sweep on the same sample; it would add data-mining rather than new evidence.
|
||||
- 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
|
||||
to check the single arm chosen from train and validation. Reports expose all
|
||||
registered rows for auditability and correct multiple-testing accounting.
|
||||
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.
|
||||
|
||||
## 1. Create the portable snapshot
|
||||
## Limitations
|
||||
|
||||
Run this wherever the production PostgreSQL connection is already configured.
|
||||
The exporter copies prices, the safe strategy settings, SEC snapshots, and Dolt
|
||||
earnings rows. It does not copy credentials or unrelated system settings.
|
||||
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.
|
||||
|
||||
Windows PowerShell:
|
||||
## Repository cleanup
|
||||
|
||||
```powershell
|
||||
.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.
|
||||
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.
|
||||
|
||||
@@ -233,6 +233,40 @@ export interface TriggerJobResponse {
|
||||
cadence?: BacktestCadence;
|
||||
}
|
||||
|
||||
export interface ParityFieldStats {
|
||||
legacy_available: number;
|
||||
candidate_available: number;
|
||||
both_available: number;
|
||||
material_differences: number;
|
||||
median_absolute_delta: number | null;
|
||||
p95_absolute_delta: number | null;
|
||||
max_absolute_delta: number | null;
|
||||
}
|
||||
|
||||
export interface FundamentalsParityReport {
|
||||
report_version: number;
|
||||
generated_at: string;
|
||||
as_of_date: string;
|
||||
approval_status: string;
|
||||
read_only: boolean;
|
||||
summary: {
|
||||
universe_count: number;
|
||||
legacy_fundamental_score_available: number;
|
||||
candidate_fundamental_score_available: number;
|
||||
fundamental_scores_compared: number;
|
||||
fundamental_score_material_changes: number;
|
||||
fundamental_rank_changes: number;
|
||||
field_stats: Record<string, ParityFieldStats>;
|
||||
};
|
||||
source_runs: Record<string, {
|
||||
run_id: number;
|
||||
status: string;
|
||||
revision: string | null;
|
||||
source_max_date: string | null;
|
||||
completed_at: string | null;
|
||||
} | null>;
|
||||
}
|
||||
|
||||
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
||||
export type BacktestCadence = 'weekly' | 'daily';
|
||||
|
||||
@@ -259,6 +293,24 @@ export function triggerJob(
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getFundamentalsParityReport() {
|
||||
return apiClient
|
||||
.get<FundamentalsParityReport | null>('admin/fundamentals-parity')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getFundamentalsParityCsv() {
|
||||
return apiClient
|
||||
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/csv')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getFundamentalsParityJson() {
|
||||
return apiClient
|
||||
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/json')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
// System events (operational warnings / errors)
|
||||
export interface SystemEvent {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
getFundamentalsParityCsv,
|
||||
getFundamentalsParityJson,
|
||||
} from '../../api/admin';
|
||||
import { useFundamentalsParityReport } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
pe_ratio: 'P/E',
|
||||
revenue_growth: 'Revenue growth',
|
||||
earnings_surprise: 'Earnings surprise',
|
||||
};
|
||||
|
||||
function downloadText(filename: string, content: string, type: string) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function FundamentalsParityPanel() {
|
||||
const { data: report, isLoading, isError, error } = useFundamentalsParityReport();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={2} cols={4} />;
|
||||
if (isError) {
|
||||
return <p className="text-sm text-red-400">{(error as Error).message}</p>;
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
No report yet. Trigger “Fundamentals Parity Report (read-only)” below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = report.summary;
|
||||
const generated = new Date(report.generated_at).toLocaleString();
|
||||
|
||||
async function downloadCsv() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const artifact = await getFundamentalsParityCsv();
|
||||
if (artifact) downloadText(artifact.filename, artifact.content, 'text/csv;charset=utf-8');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadJson() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const artifact = await getFundamentalsParityJson();
|
||||
if (artifact) downloadText(artifact.filename, artifact.content, 'application/json;charset=utf-8');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
|
||||
<span className="rounded-full border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-amber-300">
|
||||
approval pending
|
||||
</span>
|
||||
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-cyan-300">
|
||||
read-only
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Generated {generated} · as of {report.as_of_date} · {summary.universe_count} tracked tickers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white"
|
||||
onClick={downloadJson}
|
||||
disabled={downloading}
|
||||
>
|
||||
Download JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white disabled:opacity-50"
|
||||
onClick={downloadCsv}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? 'Preparing…' : 'Download CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Summary label="Candidate score coverage" value={`${summary.candidate_fundamental_score_available}/${summary.universe_count}`} />
|
||||
<Summary label="Scores compared" value={summary.fundamental_scores_compared} />
|
||||
<Summary label="Material score moves" value={summary.fundamental_score_material_changes} />
|
||||
<Summary label="Fundamental rank moves" value={summary.fundamental_rank_changes} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||
<tr>
|
||||
<th className="pb-2 pr-4 font-medium">Field</th>
|
||||
<th className="pb-2 px-3 font-medium">Legacy</th>
|
||||
<th className="pb-2 px-3 font-medium">Candidate</th>
|
||||
<th className="pb-2 px-3 font-medium">Compared</th>
|
||||
<th className="pb-2 px-3 font-medium">Material</th>
|
||||
<th className="pb-2 pl-3 font-medium">Median |Δ|</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06] text-gray-300">
|
||||
{Object.entries(summary.field_stats).map(([key, stats]) => (
|
||||
<tr key={key}>
|
||||
<td className="py-2.5 pr-4">{FIELD_LABELS[key] ?? key}</td>
|
||||
<td className="py-2.5 px-3 num">{stats.legacy_available}</td>
|
||||
<td className="py-2.5 px-3 num">{stats.candidate_available}</td>
|
||||
<td className="py-2.5 px-3 num">{stats.both_available}</td>
|
||||
<td className="py-2.5 px-3 num">{stats.material_differences}</td>
|
||||
<td className="py-2.5 pl-3 num">
|
||||
{stats.median_absolute_delta == null ? 'n/a' : stats.median_absolute_delta.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-gray-500">
|
||||
Materiality bands highlight review candidates only. They do not approve a cutover or write fundamentals,
|
||||
scores, rankings, or qualification state.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/[0.07] bg-white/[0.025] px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500">{label}</div>
|
||||
<div className="mt-1 num text-lg text-gray-200">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const DEFAULTS: ScheduleConfig = {
|
||||
schedule_daily_pipeline_cron: '0 2 * * *',
|
||||
schedule_dolt_earnings_cron: '30 2 * * *',
|
||||
schedule_sec_fundamentals_cron: '0 4 * * *',
|
||||
schedule_fundamentals_parity_cron: '30 5 * * *',
|
||||
schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
|
||||
schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
|
||||
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
|
||||
@@ -38,6 +39,12 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
|
||||
hint: 'Import tracked-universe SEC facts daily at 04:00 ET. Unchanged revisions become no-op runs.',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'schedule_fundamentals_parity_cron',
|
||||
label: 'Fundamentals parity report',
|
||||
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the shadow imports.',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'schedule_near_close_pipeline_cron',
|
||||
label: 'Near-close pipeline (scan + alert)',
|
||||
|
||||
@@ -138,7 +138,8 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
<div className="mt-2.5 space-y-3.5">
|
||||
{trendRows.map((r) => (
|
||||
<TrendRow key={r.key} label={r.label} kind={r.kind}
|
||||
metric={metrics[r.key]} read={reads[r.key]} />
|
||||
metric={metrics[r.key]} read={reads[r.key]}
|
||||
caveat={metrics[r.key]?.caveat} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,9 +189,10 @@ function Bullet({ label, value, rail, comparison }: {
|
||||
|
||||
// ---- operating-trend row (delta vs reference, favorable = right) ------------
|
||||
|
||||
function TrendRow({ label, kind, metric, read }: {
|
||||
function TrendRow({ label, kind, metric, read, caveat }: {
|
||||
label: string; kind: 'growth' | 'margin' | 'share';
|
||||
metric: MetricItem | undefined; read: string | null | undefined;
|
||||
caveat: string | null | undefined;
|
||||
}) {
|
||||
const tone = readTone(read);
|
||||
const value = finiteOrNull(metric?.value);
|
||||
@@ -213,7 +215,9 @@ function TrendRow({ label, kind, metric, read }: {
|
||||
}
|
||||
const delta = value != null && ref != null ? value - ref : null;
|
||||
|
||||
const comparison = value == null ? (
|
||||
const comparison = caveat ? (
|
||||
<span style={{ color: HZ.muted }}>{caveat}</span>
|
||||
) : value == null ? (
|
||||
<span style={{ color: HZ.track }}>n/a</span>
|
||||
) : delta == null ? (
|
||||
<span style={{ color: HZ.track }}>history n/a</span>
|
||||
|
||||
@@ -23,11 +23,13 @@ function dateFromToday(days: number): string {
|
||||
}
|
||||
|
||||
function metric(key: string, value: number | null, hist: (number | null)[],
|
||||
industry: MetricItem['industry'] = null): MetricItem {
|
||||
industry: MetricItem['industry'] = null,
|
||||
caveat: string | null = null): MetricItem {
|
||||
return {
|
||||
key: key as MetricItem['key'], value,
|
||||
history: hist.map((v, i) => h(P[i], v)),
|
||||
industry, period_end: '2026-03-28', filed_date: '2026-05-01', source: 'sec',
|
||||
industry, period_end: '2026-03-28', filed_date: '2026-05-01', caveat,
|
||||
source: 'sec',
|
||||
};
|
||||
}
|
||||
const ind = (median: number, favorable_percentile: number) =>
|
||||
@@ -78,12 +80,24 @@ const partial: FundamentalResponse = {
|
||||
earnings: { next: { date: dateFromToday(0), session: 'unknown', days_until: 0 }, recent: [] },
|
||||
metrics: [
|
||||
metric('revenue_growth_yoy', 12, [null, 8, 10, 12], null),
|
||||
metric('eps_growth_yoy', null, [null, null, null, null], null),
|
||||
metric(
|
||||
'eps_growth_yoy',
|
||||
null,
|
||||
[null, null, null, null],
|
||||
null,
|
||||
'Not comparable: share count changed at least 25%; possible split or corporate action.',
|
||||
),
|
||||
metric('operating_margin', 25, [24, 24, 25, 25], null),
|
||||
metric('fcf_margin', null, [null, null, null, null], null),
|
||||
metric('net_debt', null, [], null),
|
||||
metric('net_debt_to_ebitda', 1.9, [1.7, 1.8, 1.9, 1.9], null),
|
||||
metric('share_count_change_yoy', 2.1, [1.8, 2.0, 2.0, 2.1], null),
|
||||
metric(
|
||||
'share_count_change_yoy',
|
||||
null,
|
||||
[1.8, 2.0, 2.0, null],
|
||||
null,
|
||||
'Not comparable: share count changed at least 25%; possible split or corporate action.',
|
||||
),
|
||||
],
|
||||
valuation: {
|
||||
pe: 15.2, fcf_yield: null, market_cap_est: 5.4e8,
|
||||
|
||||
@@ -316,6 +316,14 @@ export function useJobs() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useFundamentalsParityReport() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'fundamentals-parity'],
|
||||
queryFn: () => adminApi.getFundamentalsParityReport(),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineReadiness() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'pipeline-readiness'],
|
||||
|
||||
@@ -193,6 +193,7 @@ export interface ScheduleConfig {
|
||||
schedule_daily_pipeline_cron: string;
|
||||
schedule_dolt_earnings_cron: string;
|
||||
schedule_sec_fundamentals_cron: string;
|
||||
schedule_fundamentals_parity_cron: string;
|
||||
schedule_near_close_pipeline_cron: string;
|
||||
schedule_after_close_pipeline_cron: string;
|
||||
schedule_intraday_pipeline_cron: string;
|
||||
@@ -733,6 +734,7 @@ export interface MetricItem {
|
||||
industry: MetricIndustry | null;
|
||||
period_end: string | null;
|
||||
filed_date: string | null;
|
||||
caveat: string | null;
|
||||
source: string; // 'sec' | 'legacy_api'
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertSettings } from '../components/admin/AlertSettings';
|
||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
import { JobControls } from '../components/admin/JobControls';
|
||||
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
|
||||
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
|
||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
||||
@@ -48,6 +49,7 @@ export default function AdminPage() {
|
||||
{activeTab === 'Jobs' && (
|
||||
<div className="space-y-4">
|
||||
<ScheduleSettings />
|
||||
<FundamentalsParityPanel />
|
||||
<JobControls />
|
||||
<PipelineReadinessPanel />
|
||||
</div>
|
||||
|
||||
@@ -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,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
|
||||
|
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,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
|
||||
|
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.
@@ -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 /
|
||||
paper-exit settings the run reads, immutable SEC snapshots, and Dolt earnings
|
||||
events. Other system settings are skipped to avoid copying secrets locally.
|
||||
paper-exit settings the run reads. Other system settings are intentionally
|
||||
skipped to avoid copying secrets into local snapshot files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -54,9 +54,7 @@ def _parse_args() -> argparse.Namespace:
|
||||
help="SQLite snapshot path to create.",
|
||||
)
|
||||
parser.add_argument("--batch-size", type=int, default=5000)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Overwrite an existing snapshot file."
|
||||
)
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite an existing snapshot file.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -67,7 +65,6 @@ async def _copy_table(
|
||||
*,
|
||||
batch_size: int,
|
||||
where=None,
|
||||
row_transform=None,
|
||||
) -> int:
|
||||
table = model.__table__
|
||||
columns = list(table.columns)
|
||||
@@ -90,8 +87,6 @@ async def _copy_table(
|
||||
stream = await source.stream(stmt.execution_options(yield_per=batch_size))
|
||||
async for partition in stream.partitions(batch_size):
|
||||
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:
|
||||
continue
|
||||
await dest.execute(insert(table), rows)
|
||||
@@ -110,8 +105,6 @@ async def _main() -> None:
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401 - registers all metadata tables
|
||||
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.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
@@ -130,12 +123,8 @@ async def _main() -> None:
|
||||
connect_args={"server_settings": {"default_transaction_read_only": "on"}},
|
||||
)
|
||||
dest_engine = create_async_engine(_sqlite_url(output))
|
||||
SourceSession = async_sessionmaker(
|
||||
source_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
DestSession = async_sessionmaker(
|
||||
dest_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
SourceSession = async_sessionmaker(source_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"Snapshot: {output}")
|
||||
@@ -146,9 +135,7 @@ async def _main() -> None:
|
||||
|
||||
async with SourceSession() as source, DestSession() as dest:
|
||||
counts = {
|
||||
"tickers": await _copy_table(
|
||||
source, dest, Ticker, batch_size=args.batch_size
|
||||
),
|
||||
"tickers": await _copy_table(source, dest, Ticker, batch_size=args.batch_size),
|
||||
"system_settings": await _copy_table(
|
||||
source,
|
||||
dest,
|
||||
@@ -165,30 +152,9 @@ async def _main() -> None:
|
||||
SystemSetting.key.like("paper_%"),
|
||||
),
|
||||
),
|
||||
"benchmark_prices": await _copy_table(
|
||||
source, dest, BenchmarkPrice, batch_size=args.batch_size
|
||||
),
|
||||
"ohlcv_records": await _copy_table(
|
||||
source, dest, OHLCVRecord, batch_size=args.batch_size
|
||||
),
|
||||
"benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, 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:
|
||||
await source_engine.dispose()
|
||||
await dest_engine.dispose()
|
||||
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
@@ -38,9 +38,7 @@ _ENDS = { # period_end per (fy, quarter index 0..3)
|
||||
}
|
||||
|
||||
|
||||
def _year(
|
||||
fy, discretes: dict[str, list[float]], instants: dict[str, list] | None = None
|
||||
):
|
||||
def _year(fy, discretes: dict[str, list[float]], instants: dict[str, list] | None = None):
|
||||
"""Build 4 snapshot rows (Q1,Q2,Q3,FY) with YTD-cumulative flow fields from the
|
||||
given per-quarter discrete values; instants set as-is per quarter."""
|
||||
rows = []
|
||||
@@ -57,38 +55,22 @@ def _year(
|
||||
def _two_years():
|
||||
rev25 = [100, 110, 120, 130]
|
||||
rev26 = [110, 121, 132, 143] # +10% each quarter YoY
|
||||
rows = _year(
|
||||
2025,
|
||||
{
|
||||
"revenue": rev25,
|
||||
"operating_income": [x * 0.2 for x in rev25],
|
||||
"diluted_eps": [1.0, 1.1, 1.2, 1.3],
|
||||
"cfo": [x * 0.25 for x in rev25],
|
||||
"capex": [x * 0.05 for x in rev25],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev25],
|
||||
},
|
||||
instants={
|
||||
"shares_outstanding": [1000, 1000, 1000, 1000],
|
||||
"cash_and_st_investments": [40] * 4,
|
||||
"total_debt": [140] * 4,
|
||||
},
|
||||
)
|
||||
rows += _year(
|
||||
2026,
|
||||
{
|
||||
"revenue": rev26,
|
||||
"operating_income": [x * 0.2 for x in rev26],
|
||||
"diluted_eps": [1.1, 1.21, 1.32, 1.43],
|
||||
"cfo": [x * 0.25 for x in rev26],
|
||||
"capex": [x * 0.05 for x in rev26],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev26],
|
||||
},
|
||||
instants={
|
||||
"shares_outstanding": [900, 900, 900, 900],
|
||||
"cash_and_st_investments": [50] * 4,
|
||||
"total_debt": [150] * 4,
|
||||
},
|
||||
)
|
||||
rows = _year(2025, {
|
||||
"revenue": rev25,
|
||||
"operating_income": [x * 0.2 for x in rev25],
|
||||
"diluted_eps": [1.0, 1.1, 1.2, 1.3],
|
||||
"cfo": [x * 0.25 for x in rev25],
|
||||
"capex": [x * 0.05 for x in rev25],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev25],
|
||||
}, instants={"shares_outstanding": [1000, 1000, 1000, 1000], "cash_and_st_investments": [40] * 4, "total_debt": [140] * 4})
|
||||
rows += _year(2026, {
|
||||
"revenue": rev26,
|
||||
"operating_income": [x * 0.2 for x in rev26],
|
||||
"diluted_eps": [1.1, 1.21, 1.32, 1.43],
|
||||
"cfo": [x * 0.25 for x in rev26],
|
||||
"capex": [x * 0.05 for x in rev26],
|
||||
"depreciation_amortization": [x * 0.05 for x in rev26],
|
||||
}, instants={"shares_outstanding": [900, 900, 900, 900], "cash_and_st_investments": [50] * 4, "total_debt": [150] * 4})
|
||||
return rows
|
||||
|
||||
|
||||
@@ -115,15 +97,29 @@ def test_net_debt_leverage_and_share_dilution():
|
||||
# net debt = total_debt - cash = 150 - 50 = 100 (latest instant)
|
||||
assert d.metrics["net_debt"].value == pytest.approx(100.0)
|
||||
# EBITDA TTM = TTM operating_income + TTM D&A; net_debt/ebitda
|
||||
op_ttm = 506 * 0.2 # 101.2
|
||||
da_ttm = 506 * 0.05 # 25.3
|
||||
assert d.metrics["net_debt_to_ebitda"].value == pytest.approx(
|
||||
100.0 / (op_ttm + da_ttm), rel=1e-6
|
||||
)
|
||||
op_ttm = 506 * 0.2 # 101.2
|
||||
da_ttm = 506 * 0.05 # 25.3
|
||||
assert d.metrics["net_debt_to_ebitda"].value == pytest.approx(100.0 / (op_ttm + da_ttm), rel=1e-6)
|
||||
# shares 900 vs 1000 a year earlier -> -10% (buyback)
|
||||
assert d.metrics["share_count_change_yoy"].value == pytest.approx(-10.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_split_suspect_share_move_suppresses_share_and_eps_comparisons():
|
||||
rows = _two_years()
|
||||
for row in rows:
|
||||
if row.fiscal_year == 2026:
|
||||
row.shares_outstanding = 2000 # +100% resembles an unadjusted 2-for-1 split
|
||||
|
||||
d = fd.derive(rows)
|
||||
|
||||
for key in ("share_count_change_yoy", "eps_growth_yoy"):
|
||||
series = d.metrics[key]
|
||||
assert series.value is None
|
||||
assert series.history[-1].value is None
|
||||
assert "possible split" in series.caveat
|
||||
assert d.metrics["revenue_growth_yoy"].value == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_valuation_inputs():
|
||||
d = fd.derive(_two_years())
|
||||
# TTM diluted EPS FY2026 = 1.1+1.21+1.32+1.43 = 5.06
|
||||
@@ -150,9 +146,7 @@ def test_net_debt_requires_both_components():
|
||||
r.total_debt = None
|
||||
d = fd.derive(rows)
|
||||
assert d.metrics["net_debt"].value is None
|
||||
assert (
|
||||
d.metrics["net_debt_to_ebitda"].value is None
|
||||
) # net debt null -> leverage null
|
||||
assert d.metrics["net_debt_to_ebitda"].value is None # net debt null -> leverage null
|
||||
|
||||
|
||||
def test_leverage_null_when_ebitda_nonpositive():
|
||||
@@ -162,23 +156,15 @@ def test_leverage_null_when_ebitda_nonpositive():
|
||||
r.depreciation_amortization = 1
|
||||
d = fd.derive(rows)
|
||||
assert d.metrics["net_debt"].value == pytest.approx(100.0) # net debt still valid
|
||||
assert d.metrics["net_debt_to_ebitda"].value is None # but leverage nulled
|
||||
assert d.metrics["net_debt_to_ebitda"].value is None # but leverage nulled
|
||||
|
||||
|
||||
def test_tape_stops_at_a_gap():
|
||||
rows = [
|
||||
r
|
||||
for r in _two_years()
|
||||
if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")
|
||||
]
|
||||
rows = [r for r in _two_years() if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")]
|
||||
d = fd.derive(rows)
|
||||
hist = d.metrics["operating_margin"].history
|
||||
# consecutive suffix ending at FY2026: Q2, Q3, FY (not compressed across the Q1 gap)
|
||||
assert [p.period_end for p in hist] == [
|
||||
date(2026, 3, 31),
|
||||
date(2026, 6, 30),
|
||||
date(2026, 9, 30),
|
||||
]
|
||||
assert [p.period_end for p in hist] == [date(2026, 3, 31), date(2026, 6, 30), date(2026, 9, 30)]
|
||||
|
||||
|
||||
def test_yoy_growth_null_when_prior_nonpositive():
|
||||
@@ -190,48 +176,14 @@ def test_yoy_growth_null_when_prior_nonpositive():
|
||||
assert d.metrics["eps_growth_yoy"].value is None # loss->profit is not a %
|
||||
|
||||
|
||||
def test_derive_as_of_excludes_future_amendment():
|
||||
rows = _two_years()
|
||||
original = next(
|
||||
row for row in rows if row.fiscal_year == 2026 and row.fiscal_period == "FY"
|
||||
)
|
||||
amendment = replace(
|
||||
original,
|
||||
accepted_at=datetime(2027, 1, 1, tzinfo=UTC),
|
||||
revenue=999999,
|
||||
)
|
||||
before = fd.derive_as_of([*rows, amendment], datetime(2026, 12, 31, tzinfo=UTC))
|
||||
after = fd.derive_as_of([*rows, amendment], datetime(2027, 1, 2, tzinfo=UTC))
|
||||
assert before.metrics["revenue_growth_yoy"].value == pytest.approx(10.0)
|
||||
assert after.metrics["revenue_growth_yoy"].value != pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_derive_as_of_treats_sqlite_naive_acceptance_as_utc():
|
||||
rows = _two_years()
|
||||
rows[0].accepted_at = datetime(2025, 1, 1)
|
||||
result = fd.derive_as_of(rows, datetime(2027, 1, 1, tzinfo=UTC))
|
||||
assert result.latest_period_end == date(2026, 9, 30)
|
||||
|
||||
|
||||
def test_amendment_selection_newest_accepted_wins():
|
||||
rows = _two_years()
|
||||
# an amendment to FY2026 FY restates revenue YTD higher, accepted later
|
||||
amended = Snap(
|
||||
2026,
|
||||
"FY",
|
||||
date(2026, 9, 30),
|
||||
date(2026, 11, 1),
|
||||
datetime(2027, 1, 1, tzinfo=UTC),
|
||||
revenue=999999,
|
||||
operating_income=100,
|
||||
diluted_eps=1.43,
|
||||
cfo=100,
|
||||
capex=10,
|
||||
depreciation_amortization=25,
|
||||
shares_outstanding=900,
|
||||
cash_and_st_investments=50,
|
||||
total_debt=150,
|
||||
)
|
||||
amended = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
|
||||
datetime(2027, 1, 1, tzinfo=UTC), revenue=999999,
|
||||
operating_income=100, diluted_eps=1.43, cfo=100, capex=10,
|
||||
depreciation_amortization=25, shares_outstanding=900,
|
||||
cash_and_st_investments=50, total_debt=150)
|
||||
d = fd.derive(rows + [amended])
|
||||
# Q4 revenue discrete now uses the amended YTD(FY)=999999 minus YTD(Q3)=363
|
||||
# so TTM/growth reflects the amendment, proving newest accepted_at won.
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""A5 fundamentals parity report: read-only comparison + artifact archive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.data_import_run import DataImportRun
|
||||
from app.models.earnings_event import EarningsEvent
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.fundamental_snapshot import FundamentalSnapshot
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.fundamentals_parity_service import (
|
||||
build_report,
|
||||
fundamental_score,
|
||||
load_latest,
|
||||
load_latest_csv,
|
||||
load_latest_json,
|
||||
store_report,
|
||||
)
|
||||
|
||||
UTC = timezone.utc
|
||||
GENERATED = datetime(2026, 7, 23, 10, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
|
||||
rows = []
|
||||
periods = ("Q1", "Q2", "Q3", "FY")
|
||||
months = (3, 6, 9, 12)
|
||||
for fy, multiplier in ((2025, 1.0), (2026, 1.1)):
|
||||
revenues = [100 * multiplier, 110 * multiplier, 120 * multiplier, 130 * multiplier]
|
||||
eps = [1.0 * multiplier, 1.1 * multiplier, 1.2 * multiplier, 1.3 * multiplier]
|
||||
for index, period in enumerate(periods):
|
||||
period_end = date(fy, months[index], 28)
|
||||
rows.append(
|
||||
FundamentalSnapshot(
|
||||
cik=cik,
|
||||
accession=f"{cik}-{fy}-{period}",
|
||||
form="10-K" if period == "FY" else "10-Q",
|
||||
filed_date=period_end,
|
||||
accepted_at=datetime(fy, months[index], 28, tzinfo=UTC),
|
||||
period_end=period_end,
|
||||
fiscal_year=fy,
|
||||
fiscal_period=period,
|
||||
revenue=sum(revenues[: index + 1]),
|
||||
operating_income=sum(revenues[: index + 1]) * 0.2,
|
||||
diluted_eps=sum(eps[: index + 1]),
|
||||
cfo=sum(revenues[: index + 1]) * 0.25,
|
||||
capex=sum(revenues[: index + 1]) * 0.05,
|
||||
depreciation_amortization=sum(revenues[: index + 1]) * 0.05,
|
||||
cash_and_st_investments=40,
|
||||
total_debt=100,
|
||||
shares_outstanding=1000,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def _seed(db_session):
|
||||
first = Ticker(symbol="AAA", cik="0000000001", sic="3571")
|
||||
second = Ticker(symbol="BBB", cik=None, sic=None)
|
||||
db_session.add_all([first, second])
|
||||
await db_session.flush()
|
||||
db_session.add_all(_snapshot_rows(first.cik))
|
||||
db_session.add_all(
|
||||
[
|
||||
FundamentalData(
|
||||
ticker_id=first.id,
|
||||
pe_ratio=25,
|
||||
revenue_growth=5,
|
||||
earnings_surprise=0,
|
||||
fetched_at=GENERATED,
|
||||
),
|
||||
FundamentalData(
|
||||
ticker_id=second.id,
|
||||
pe_ratio=12,
|
||||
revenue_growth=3,
|
||||
earnings_surprise=None,
|
||||
fetched_at=GENERATED,
|
||||
),
|
||||
OHLCVRecord(
|
||||
ticker_id=first.id,
|
||||
date=date(2026, 7, 22),
|
||||
open=100,
|
||||
high=100,
|
||||
low=100,
|
||||
close=100,
|
||||
volume=100,
|
||||
),
|
||||
EarningsEvent(
|
||||
ticker_id=first.id,
|
||||
announce_date=date(2026, 7, 1),
|
||||
session="amc",
|
||||
eps_estimate=2,
|
||||
eps_actual=2.2,
|
||||
source="dolt_earnings",
|
||||
),
|
||||
DataImportRun(
|
||||
source="sec_facts",
|
||||
revision="sec-rev",
|
||||
status="promoted",
|
||||
source_max_date=date(2026, 7, 22),
|
||||
started_at=GENERATED,
|
||||
completed_at=GENERATED,
|
||||
),
|
||||
DataImportRun(
|
||||
source="dolt_earnings",
|
||||
revision="dolt-rev",
|
||||
status="no_op",
|
||||
source_max_date=date(2026, 7, 22),
|
||||
started_at=GENERATED,
|
||||
completed_at=GENERATED,
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
def test_score_formula_matches_production_rules():
|
||||
score = fundamental_score(pe_ratio=15, revenue_growth=0, earnings_surprise=0)
|
||||
assert score == pytest.approx((100 + 50 + 50) / 3)
|
||||
assert fundamental_score(pe_ratio=15, revenue_growth=None, earnings_surprise=None) is None
|
||||
|
||||
async def test_report_compares_sources_and_leaves_database_untouched(db_session):
|
||||
await _seed(db_session)
|
||||
before = await db_session.scalar(select(func.count()).select_from(FundamentalData))
|
||||
|
||||
report = await build_report(
|
||||
db_session,
|
||||
generated_at=GENERATED,
|
||||
today=date(2026, 7, 23),
|
||||
)
|
||||
|
||||
after = await db_session.scalar(select(func.count()).select_from(FundamentalData))
|
||||
assert before == after == 2
|
||||
assert not db_session.new and not db_session.dirty and not db_session.deleted
|
||||
assert report["read_only"] is True
|
||||
assert report["approval_status"] == "pending_explicit_approval"
|
||||
assert report["source_runs"]["sec_facts"]["revision"] == "sec-rev"
|
||||
assert report["source_runs"]["dolt_earnings"]["revision"] == "dolt-rev"
|
||||
|
||||
first = next(row for row in report["rows"] if row["symbol"] == "AAA")
|
||||
assert first["fields"]["pe_ratio"]["candidate"] == pytest.approx(
|
||||
100 / 5.06, abs=1e-4
|
||||
)
|
||||
assert first["fields"]["revenue_growth"]["candidate"] == pytest.approx(10)
|
||||
assert first["fields"]["earnings_surprise"]["candidate"] == pytest.approx(10)
|
||||
assert first["scores"]["candidate_fundamental"] is not None
|
||||
assert report["summary"]["universe_count"] == 2
|
||||
assert report["summary"]["field_stats"]["pe_ratio"]["both_available"] == 1
|
||||
|
||||
|
||||
async def test_artifacts_archive_and_latest_manifest(db_session, tmp_path):
|
||||
await _seed(db_session)
|
||||
report = await build_report(
|
||||
db_session,
|
||||
generated_at=GENERATED,
|
||||
today=date(2026, 7, 23),
|
||||
)
|
||||
|
||||
paths = store_report(report, tmp_path)
|
||||
|
||||
assert tmp_path.joinpath("latest.json").exists()
|
||||
assert paths["json"].endswith(".json") and paths["csv"].endswith(".csv")
|
||||
assert load_latest(tmp_path)["generated_at"] == GENERATED.isoformat()
|
||||
csv_artifact = load_latest_csv(tmp_path)
|
||||
assert csv_artifact is not None
|
||||
assert csv_artifact[0].endswith(".csv")
|
||||
assert "legacy_fundamental,candidate_fundamental" in csv_artifact[1]
|
||||
assert "AAA" in csv_artifact[1]
|
||||
json_artifact = load_latest_json(tmp_path)
|
||||
assert json_artifact is not None and '"rows"' in json_artifact[1]
|
||||
|
||||
|
||||
async def test_admin_endpoints_return_compact_summary_and_downloads(
|
||||
client, db_session, tmp_path, monkeypatch
|
||||
):
|
||||
from app.config import settings
|
||||
from app.dependencies import require_admin
|
||||
from app.main import app
|
||||
|
||||
await _seed(db_session)
|
||||
report = await build_report(
|
||||
db_session,
|
||||
generated_at=GENERATED,
|
||||
today=date(2026, 7, 23),
|
||||
)
|
||||
store_report(report, tmp_path)
|
||||
monkeypatch.setattr(settings, "fundamentals_parity_report_dir", str(tmp_path))
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
try:
|
||||
summary_response = await client.get("/api/v1/admin/fundamentals-parity")
|
||||
assert summary_response.status_code == 200
|
||||
summary = summary_response.json()["data"]
|
||||
assert summary["summary"]["universe_count"] == 2
|
||||
assert "rows" not in summary
|
||||
|
||||
csv_response = await client.get("/api/v1/admin/fundamentals-parity/csv")
|
||||
assert csv_response.status_code == 200
|
||||
assert "AAA" in csv_response.json()["data"]["content"]
|
||||
|
||||
json_response = await client.get("/api/v1/admin/fundamentals-parity/json")
|
||||
assert json_response.status_code == 200
|
||||
assert '"rows"' in json_response.json()["data"]["content"]
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_admin, None)
|
||||
@@ -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
|
||||
@@ -85,6 +85,7 @@ class TestTradingDayCrons:
|
||||
(
|
||||
("schedule_dolt_earnings_cron", 2, 30),
|
||||
("schedule_sec_fundamentals_cron", 4, 0),
|
||||
("schedule_fundamentals_parity_cron", 5, 30),
|
||||
),
|
||||
)
|
||||
def test_shadow_imports_run_daily_at_expected_et_time(
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.scheduler import (
|
||||
_resume_tickers,
|
||||
_last_successful,
|
||||
_run_shadow_import,
|
||||
run_fundamentals_parity_report,
|
||||
configure_scheduler,
|
||||
get_job_runtime_snapshot,
|
||||
queue_backtest_options,
|
||||
@@ -112,6 +113,7 @@ class TestConfigureScheduler:
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"shadow_book",
|
||||
"ticker_universe_sync",
|
||||
@@ -145,6 +147,7 @@ class TestConfigureScheduler:
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"market_regime",
|
||||
"near_close_pipeline",
|
||||
"regime_monitor",
|
||||
@@ -243,3 +246,32 @@ class TestShadowImportJobs:
|
||||
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
||||
assert runtime["status"] == "skipped"
|
||||
assert runtime["message"] == "Disabled"
|
||||
|
||||
|
||||
async def test_fundamentals_parity_job_surfaces_report_summary(monkeypatch):
|
||||
async def enabled(db, job_name):
|
||||
return True
|
||||
|
||||
async def generated(db, report_dir):
|
||||
return (
|
||||
{
|
||||
"generated_at": "2026-07-23T10:30:00+00:00",
|
||||
"summary": {
|
||||
"universe_count": 511,
|
||||
"fundamental_score_material_changes": 12,
|
||||
},
|
||||
},
|
||||
{"json": "report.json", "csv": "report.csv"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", TestShadowImportJobs._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamentals_parity_service.generate_and_store", generated
|
||||
)
|
||||
|
||||
await run_fundamentals_parity_report()
|
||||
|
||||
runtime = get_job_runtime_snapshot("fundamentals_parity_report")
|
||||
assert runtime["status"] == "completed"
|
||||
assert runtime["message"] == "511 tickers · 12 material score changes"
|
||||
|
||||
Reference in New Issue
Block a user