From 611ca3b0aa67e38775646c4bdc1f976d45a305e4 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Wed, 15 Jul 2026 23:59:29 -0400 Subject: [PATCH 01/23] Design SEC quarterly actuals lineage --- ...15-sec-quarterly-actuals-lineage-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md diff --git a/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md new file mode 100644 index 000000000..b6e58af62 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md @@ -0,0 +1,269 @@ +# SEC Quarterly Actuals Lineage Design + +## Status + +Approved direction: **Option 2, versioned fiscal-quarter lineage plus explicit Q4 earnings-release ingestion**. + +This design extends the Earnings Nowcast evidence workflow. It does not make a forecast, create consensus data, apply imports, or change readiness by itself. + +## Problem + +SEC Companyfacts contains useful Revenue and diluted-EPS facts, but a naive `fy` / `fp` extraction is not safe for quarterly history: + +- A 10-Q contains current and prior-year comparative facts carrying the filing's current `fy` and `fp` labels. +- Q2 and Q3 filings contain both single-quarter and cumulative year-to-date facts. +- Companyfacts frequently omits `frame` on older valid quarter facts. +- 10-Q filings do not provide a standalone Q4 result. +- Later filings can restate or split-adjust prior-period EPS. +- Missing Q4 rows make a Q3-to-next-Q1 pair look sequential when it is not. + +The five-company pilot must therefore build period identity from source lineage, preserve revisions, reject cumulative facts, and fail closed when quarter continuity or EPS comparability cannot be proven. + +## Goals + +1. Stage source-backed quarterly Revenue and diluted GAAP EPS actuals for NVDA, AMD, AVGO, MU, and QCOM. +2. Assign each fact to the correct company fiscal quarter without using SEC calendar frames as the fiscal-period label. +3. Preserve source-visible revisions and split-adjusted presentations as append-only evidence. +4. Ingest Q4 only from an explicit result table in an SEC-filed earnings release or equivalent primary filing exhibit. +5. Prevent sequential calculations across a missing fiscal quarter. +6. Produce deterministic validate/preview evidence with no automatic apply path. + +## Non-Goals + +- Historical or current analyst consensus acquisition. +- Company guidance extraction beyond preserving an explicit source record for a later reviewed lane. +- Numerical Beat/Miss probability. +- Post-earnings price prediction. +- Annual-minus-nine-month Q4 derivation. +- Text-derived or inferred Revenue, EPS, peers, adjustments, or recommendations. +- Broad-universe SEC extraction in the pilot. + +## Source Hierarchy + +### Q1-Q3 + +Use SEC Companyfacts records from `10-Q` or `10-Q/A` filings. + +Accepted Revenue concepts, in deterministic priority order: + +1. `RevenueFromContractWithCustomerExcludingAssessedTax` +2. `Revenues` +3. `SalesRevenueNet` + +Accepted EPS concept: + +- `EarningsPerShareDiluted` + +A candidate metric pair must share the same accession, filing date, start date, end date, fiscal year, fiscal period, and duration class. + +### Q4 + +Use an explicit quarterly Revenue and diluted GAAP EPS result table in a primary SEC-filed exhibit, normally an `8-K` exhibit for domestic issuers or an equivalent filed result document for a foreign issuer. + +The source must state the quarter or quarter-ended date and the reported Revenue/EPS values. If the source table, metric basis, or fiscal-period alignment is ambiguous, Q4 remains unavailable. The system never derives Q4 by subtracting nine-month values from annual values. + +## Architecture + +Add one focused module, `src/earnings_nowcast_sec_actuals.py`, with four boundaries: + +1. **Raw fact normalization** + - Retain taxonomy, concept, unit, value, start, end, filed date, form, accession, SEC `fy` / `fp`, and optional frame. + - Preserve the raw metadata needed for audit; do not reduce a fact to value and filing year prematurely. + +2. **Fiscal-quarter lineage** + - Identify the current-quarter fact in each 10-Q accession. + - Establish the canonical `YYYY-QN` identity from that current-quarter filing and period end. + - Map later comparative presentations back to the canonical period by period end, never by the later filing's `fy` / `fp` alone. + +3. **Revision and comparability handling** + - Emit the first valid presentation as the initial evidence row. + - Emit a later changed presentation as an append-only revision with `supersedes_source_ref`. + - Keep Revenue and EPS readiness independent when only one metric changes or remains comparable. + - Label split adjustment only when the filing explicitly supports that label. + +4. **Staging and audit output** + - Write the existing `quarterly_actuals.csv` schema to an explicitly supplied output directory. + - Write a generated audit JSON and rejected-row CSV beside it. + - Create header-only consensus/signals templates so the existing onboarding preview can truthfully report `point_in_time_consensus_missing`. + - Never write to the canonical data files or call an apply command. + +## Q1-Q3 Selection Rules + +For every accession and metric concept: + +1. Keep only `10-Q` / `10-Q/A` records with numeric values and complete start/end/filed/accession metadata. +2. Calculate duration from start and end dates. +3. A single-quarter candidate must have a duration from 60 through 120 days. +4. Reject duration records outside that range as instant, cumulative, annual, or ambiguous. +5. Within an accession and SEC `fp`, use the latest period end as the current-quarter candidate. This prevents the prior-year comparative quarter from inheriting the current filing's fiscal label. +6. Revenue and EPS must align on accession, start, end, filed date, SEC `fy`, and SEC `fp` before they form a combined row. +7. A non-empty SEC frame is supporting evidence only. Missing frame does not invalidate an otherwise uniquely aligned quarter; a conflicting frame does. +8. If multiple accepted Revenue concepts produce different values for the same lineage, reject the Revenue metric as ambiguous instead of choosing the larger or newer value. +9. If Revenue and EPS cannot be paired, retain only the individually proven metric and keep the other metric empty. + +## Fiscal-Period Identity + +The canonical period key is anchored by the current-quarter record from its original filing: + +- `fiscal_year = current filing fy` +- `fiscal_quarter = current filing fp` +- `period_end = current-quarter end date` + +Later filings may present the same `period_end` with different SEC `fy` / `fp` values because it is a comparative period. Those later records inherit the canonical identity already established for that end date. + +If two original current-quarter filings assign incompatible fiscal identities to the same period end, the period is rejected and reported as `fiscal_period_conflict`. + +## Revision Lineage + +Evidence remains point-in-time and append-only: + +- `source_ref` identifies the exact SEC accession/document presentation. +- `reported_at` is the filing publication date, not the quarter end. +- `retrieved_at` records collection time. +- A later changed presentation references the prior `source_ref` through `supersedes_source_ref`. +- Preview retains both rows and lets cutoff-aware canonicalization select only evidence published by the forecast cutoff. + +No later presentation overwrites an earlier snapshot. A revision published after a historical forecast cutoff is unavailable to that backtest event. + +## EPS Split Safety + +EPS is separate from Revenue readiness. + +1. Original SEC EPS facts are labeled `as_reported` unless the source explicitly states retrospective split adjustment. +2. A source-supported retrospective presentation is labeled `split_adjusted_`. +3. Actual history and consensus must share the same EPS, share, operations, and split-adjustment definitions. +4. If the five-quarter model window crosses incompatible split bases, EPS is withheld while Revenue may remain ready. +5. Provider-assisted split history may route review, but it cannot establish the trusted split basis without a primary source reference. + +## Q4 Ingestion + +Q4 ingestion is a separate parser boundary because Companyfacts 10-Q duration facts cannot supply it. + +The Q4 parser must: + +1. Resolve a primary SEC filing and exact earnings-release exhibit. +2. Require an explicit quarter-ended label or an unambiguous fiscal Q4 label. +3. Require explicit Revenue and/or diluted GAAP EPS labels in a structured table. +4. Preserve the filing URL, accession, filed timestamp, period end, metric labels, units, and split note. +5. Reject non-GAAP-only EPS, annual-only totals, guidance tables, prose estimates, and arithmetic derivations. +6. Emit a partial row when only one metric is explicit and source-backed. + +Issuer-specific table aliases may be configured for the five-company pilot, but numeric values and periods must still come from the filing itself. The parser must not contain hard-coded company results. + +## Quarter Continuity Gate + +The model must not treat sorted rows as consecutive by default. + +For every forecast metric: + +- Require at least five source-backed prior quarters. +- Require fiscal-quarter adjacency across the model window, including Q4-to-next-Q1. +- Require the prior-year target quarter. +- If any intervening quarter is absent, mark the metric `quarter_history_gap` and withhold its forecast. +- Revenue and EPS continuity are evaluated independently. + +This gate applies to direct pilot forecasts and walk-forward backtests. + +## Command Contract + +Add a read-only staging command: + +```text +make earnings-nowcast-sec-actuals-stage \ + TICKERS=NVDA,AMD,AVGO,MU,QCOM \ + OUTPUT_DIR=/tmp/stock-nowcast-five-company/sec-actuals \ + AS_OF= +``` + +The command: + +- requires an identifying `SEC_USER_AGENT`; +- uses existing SEC cache/network boundaries; +- accepts a narrow ticker scope; +- performs no import or apply; +- never writes credentials; +- returns nonzero only for command/schema failure, not for a truthfully withheld ticker; +- reports per-ticker accepted rows, rejected rows, missing Q4, metric gaps, and source references. + +Follow it with the existing commands: + +```text +make earnings-nowcast-validate INPUT_DIR= AS_OF= +make earnings-nowcast-preview INPUT_DIR= AS_OF= +make earnings-nowcast-readiness INPUT_DIR= TICKER= AS_OF= +``` + +Absent historical consensus must continue to yield `point_in_time_consensus_missing` and `ready_for_packet=false`. + +## Failure States + +The audit output uses explicit states: + +- `accepted_explicit_quarter` +- `accepted_revision` +- `accepted_explicit_q4` +- `metric_partial` +- `cumulative_fact_rejected` +- `comparative_period_relabelled` +- `ambiguous_concept` +- `fiscal_period_conflict` +- `quarter_history_gap` +- `split_basis_unverified` +- `q4_source_unavailable` +- `post_cutoff_rejected` +- `source_unavailable` + +A withheld state is a valid extraction result and does not stop other tickers. + +## Testing Strategy + +Tests use local fixtures and mocked SEC responses. No test depends on live SEC availability. + +Required cases: + +1. Current and prior-year comparative quarters in one 10-Q. +2. Single-quarter and cumulative Q2/Q3 facts in one accession. +3. Missing frame with uniquely aligned 90-day facts. +4. Conflicting frames or Revenue concepts. +5. Revenue-only and EPS-only partial rows. +6. Amended filing and append-only revision lineage. +7. Later split-adjusted comparative EPS with explicit split source. +8. Post-cutoff revision exclusion. +9. Explicit Q4 earnings-release table. +10. Annual-only, guidance, non-GAAP-only, and derived-Q4 rejection. +11. Missing Q4 causing `quarter_history_gap`. +12. Revenue-ready/EPS-withheld split-basis behavior. +13. ETF/fund exclusion remains unchanged. +14. Staging writes only the explicit output directory and never applies data. + +After fixture tests pass, run one live, read-only five-company smoke and inspect every accepted/rejected summary. Live output remains generated and unstaged. + +## Acceptance Criteria + +1. Every accepted Q1-Q3 fact is a uniquely aligned 60-120-day SEC filing fact. +2. Comparative facts never inherit the later filing's fiscal identity. +3. Cumulative values never appear as single-quarter actuals. +4. Q4 is explicit and source-backed or remains unavailable; it is never derived. +5. Historical revisions remain append-only and cutoff-safe. +6. Sequential model inputs are quarter-contiguous. +7. EPS is withheld across an unverified split basis while Revenue remains independently eligible. +8. The five-company staging command produces valid onboarding rows with zero fabricated values. +9. Missing consensus still blocks packet generation and numerical probability. +10. Full tests, public checks, diff hygiene, and staged hygiene pass before commit. + +## Rollout + +1. Implement raw fact normalization and Q1-Q3 lineage with fixtures. +2. Add quarter-continuity and EPS split-basis gates. +3. Add explicit Q4 filing-exhibit ingestion for the five-company cohort. +4. Run the live read-only staging smoke. +5. Validate and preview the generated evidence. +6. Keep real-company Nowcast output blocked until historical point-in-time consensus is separately sourced and validated. + +## Boundaries + +- Research-only; no investment advice or trade instruction. +- Candidate data never becomes trusted merely because parsing succeeds. +- Generated extraction artifacts remain outside default staging. +- No broker, account, order, recommendation, or auto-trading behavior. +- Numerical Beat/Miss probability remains unavailable until at least 100 valid events pass calibration gates. From 4a89ce56bc905beee052d91f43cba3e1502fd4dc Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 00:06:12 -0400 Subject: [PATCH 02/23] Plan SEC quarterly actuals lineage implementation --- ...026-07-16-sec-quarterly-actuals-lineage.md | 638 ++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md diff --git a/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md b/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md new file mode 100644 index 000000000..4f7fdb1ca --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md @@ -0,0 +1,638 @@ +# SEC Quarterly Actuals Lineage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a preview-only SEC evidence pipeline that stages correctly identified Q1-Q4 Revenue and diluted GAAP EPS actuals for the five-company semiconductor Earnings Nowcast pilot without cumulative facts, period relabeling, split leakage, quarter gaps, or inferred Q4 values. + +**Architecture:** A focused `earnings_nowcast_sec_actuals` module normalizes raw Companyfacts records, establishes fiscal-quarter lineage from original current-quarter filings, preserves revisions, discovers explicit Q4 earnings-release exhibits, and writes only onboarding-compatible temporary evidence plus audit output. Existing readiness and model modules gain metric-specific continuity and split-basis gates; existing onboarding remains the final validate/preview boundary. + +**Tech Stack:** Python 3.12, standard-library dataclasses/HTMLParser/urllib, existing SEC provider cache APIs, CSV/JSON, pytest, Make. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, or direct buy/sell instructions. +- Never infer Revenue, EPS, Q4 values, fiscal periods, consensus, probabilities, or recommendations. +- Q4 must come from an explicit result table in an SEC-filed primary-source exhibit; annual-minus-nine-month arithmetic is prohibited. +- Historical evidence is append-only and cutoff-aware; later revisions never overwrite earlier evidence. +- Revenue and EPS readiness remain independent. +- Generated SEC staging CSV/JSON/rejected-row artifacts stay outside default staging. +- No automatic apply path is added. +- Historical point-in-time consensus remains an external input and must continue to block packet generation when absent. +- Numerical Beat/Miss probability remains hidden until at least 100 valid events pass calibration gates. + +## File Map + +- Create `src/earnings_nowcast_sec_actuals.py`: normalized fact types, Q1-Q3 lineage, SEC filing-index/exhibit parsing, Q4 extraction, staging CLI, and audit payload. +- Create `tests/test_earnings_nowcast_sec_actuals.py`: fixture-driven parser, lineage, revision, Q4, cutoff, and output-boundary tests. +- Modify `src/earnings_nowcast_readiness.py`: metric-specific fiscal-quarter continuity and split-basis eligibility. +- Modify `src/earnings_nowcast_model.py`: consume only the contiguous canonical metric window. +- Modify `tests/test_earnings_nowcast_readiness.py`: missing-quarter and split-basis regression tests. +- Modify `tests/test_earnings_nowcast_model.py`: prove no Q3-to-next-Q1 sequential calculation. +- Modify `tests/test_earnings_nowcast_backtest.py`: prove continuity and revisions remain cutoff-safe in walk-forward evaluation. +- Modify `Makefile`: add the narrow, read-only `earnings-nowcast-sec-actuals-stage` launcher. +- Modify `tests/test_launchers.py`: lock launcher arguments and no-apply boundary. +- Modify `docs/EARNINGS_NOWCAST_PILOT.md`, `docs/METHODOLOGY.md`, `docs/PROVENANCE_CONTRACT.md`, and `ROADMAP.md`: document actuals lineage, Q4, split, continuity, and remaining consensus dependency. + +--- + +### Task 1: Normalize SEC facts and extract Q1-Q3 fiscal-quarter lineage + +**Files:** +- Create: `src/earnings_nowcast_sec_actuals.py` +- Create: `tests/test_earnings_nowcast_sec_actuals.py` + +**Interfaces:** +- Consumes: raw SEC Companyfacts JSON and an ISO cutoff timestamp. +- Produces: `normalize_sec_duration_facts(payload: Mapping[str, object]) -> tuple[SecDurationFact, ...]`. +- Produces: `extract_q1_q3_lineage(ticker: str, payload: Mapping[str, object], *, cutoff: str, retrieved_at: str) -> ExtractionResult`. +- `ExtractionResult.rows` contains onboarding `QuarterlyActual` values; `ExtractionResult.audit_rows` records accepted and rejected source facts. + +- [ ] **Step 1: Write failing normalization and cumulative-fact tests** + +Create fixtures directly in `tests/test_earnings_nowcast_sec_actuals.py` so one Q3 accession contains: + +```python +def _fact(*, val, start, end, filed="2026-06-25", fy=2026, fp="Q3", frame=None): + return { + "val": val, + "start": start, + "end": end, + "filed": filed, + "form": "10-Q", + "accn": "0000000000-26-000001", + "fy": fy, + "fp": fp, + "frame": frame, + } + + +def test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period(): + payload = companyfacts_fixture( + revenue=[ + _fact(val=30, start="2025-08-29", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + eps=[ + _fact(val=3.0, start="2025-08-29", end="2026-05-28"), + _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=0.8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + ) + + result = extract_q1_q3_lineage("SYN1", payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT) + + assert [(row.fiscal_period, row.revenue_actual, row.eps_actual) for row in result.rows] == [ + ("2026-Q3", 12.0, 1.2) + ] + assert {row.state for row in result.audit_rows} >= { + "accepted_explicit_quarter", + "cumulative_fact_rejected", + "comparative_period_relabelled", + } +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py::test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period -q +``` + +Expected: collection/import failure because `src.earnings_nowcast_sec_actuals` does not exist. + +- [ ] **Step 3: Implement normalized fact types and deterministic Q1-Q3 selection** + +Implement these public types and functions: + +```python +@dataclass(frozen=True) +class SecDurationFact: + taxonomy: str + concept: str + unit: str + value: float + start: str + end: str + filed: str + form: str + accession: str + fiscal_year: int + fiscal_period: str + frame: str + + @property + def duration_days(self) -> int: + return (date.fromisoformat(self.end) - date.fromisoformat(self.start)).days + + +@dataclass(frozen=True) +class ExtractionAuditRow: + ticker: str + state: str + metric: str + fiscal_period: str + source_ref: str + detail: str + + +@dataclass(frozen=True) +class ExtractionResult: + rows: tuple[QuarterlyActual, ...] + audit_rows: tuple[ExtractionAuditRow, ...] + + +def normalize_sec_duration_facts(payload: Mapping[str, object]) -> tuple[SecDurationFact, ...]: ... + + +def extract_q1_q3_lineage( + ticker: str, + payload: Mapping[str, object], + *, + cutoff: str, + retrieved_at: str, +) -> ExtractionResult: ... +``` + +Implementation rules: + +- accept only `10-Q` and `10-Q/A` numeric duration facts; +- accept Revenue concepts in the design's priority order and diluted EPS only; +- accept 60-120-day quarter durations; +- choose the latest `end` within each accession/`fy`/`fp` as the current quarter; +- pair metrics only when accession/start/end/filed/`fy`/`fp` match; +- reject conflicting frame or concept values; +- construct the SEC accession URL from CIK and accession; +- set `reported_at` to the filed date at `00:00:00Z` and reject rows after cutoff; +- preserve a metric-only partial row when just Revenue or EPS is proven. + +- [ ] **Step 4: Add missing-frame, concept-conflict, partial-row, and cutoff tests** + +Add tests proving: + +```python +assert missing_frame_result.rows[0].revenue_actual == 12.0 +assert ambiguous_result.rows[0].revenue_actual is None +assert revenue_only_result.rows[0].eps_actual is None +assert post_cutoff_result.rows == () +assert "post_cutoff_rejected" in {row.state for row in post_cutoff_result.audit_rows} +``` + +Add a two-filing lineage test where the original Q2 filing establishes +`2025-Q2` for a period end and a later Q3 filing presents that same period as +comparative data with a different SEC `fy`/`fp`. Assert that the later row keeps +the original `2025-Q2` identity and becomes a revision candidate instead of +being relabelled from the later filing's metadata. Add a fail-closed case where +no original current-quarter filing can establish the comparative period. + +- [ ] **Step 5: Run the Task 1 tests and verify GREEN** + +Run: + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py -q +``` + +Expected: all Task 1 tests pass. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add -- src/earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_sec_actuals.py +git commit -m "Extract SEC quarterly actual lineage" +``` + +--- + +### Task 2: Preserve append-only revisions and write preview-only staging artifacts + +**Files:** +- Modify: `src/earnings_nowcast_sec_actuals.py` +- Modify: `tests/test_earnings_nowcast_sec_actuals.py` + +**Interfaces:** +- Consumes: per-ticker `ExtractionResult` objects and an explicit output directory. +- Produces: `link_quarter_revisions(rows: Sequence[QuarterlyActual]) -> tuple[QuarterlyActual, ...]`. +- Produces: `write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionResult]) -> StageResult`. +- Produces: `stage_sec_quarterly_actuals(tickers: Sequence[str], *, output_dir: Path, cutoff: str, user_agent: str | None, ...) -> StageResult`. + +- [ ] **Step 1: Write failing revision-lineage tests** + +```python +def test_later_changed_presentation_is_append_only_revision(): + original = actual("2025-Q2", revenue=100, eps=1.0, source_ref="sec://original", reported_at="2025-08-01T00:00:00Z") + revised = actual("2025-Q2", revenue=100, eps=0.1, source_ref="sec://split-adjusted", reported_at="2025-11-01T00:00:00Z") + + linked = link_quarter_revisions([original, revised]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref == original.source_ref + assert linked[0].eps_actual == 1.0 +``` + +Also test unchanged later presentations are de-duplicated and an unrelated conflicting source is not silently marked as a revision. + +- [ ] **Step 2: Run the revision test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py::test_later_changed_presentation_is_append_only_revision -q +``` + +Expected: FAIL because `link_quarter_revisions` is missing. + +- [ ] **Step 3: Implement revision linking and output types** + +```python +@dataclass(frozen=True) +class StageResult: + requested_tickers: tuple[str, ...] + accepted_tickers: tuple[str, ...] + withheld_tickers: tuple[str, ...] + accepted_row_count: int + rejected_row_count: int + quarterly_actuals_path: str + audit_path: str + rejected_path: str + automatic_apply: bool = False +``` + +Only link a later row as a revision when ticker/fiscal period/source family match and the changed row is later by `reported_at`. Keep the original row. Never mutate canonical data. + +- [ ] **Step 4: Write failing output-boundary tests** + +```python +def test_stage_writes_only_explicit_output_directory(tmp_path): + result = write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + assert Path(result.quarterly_actuals_path).parent == tmp_path / "stage" + assert (tmp_path / "stage" / "quarterly_actuals.csv").exists() + assert (tmp_path / "stage" / "consensus_snapshots.csv").read_text().count("\n") == 1 + assert result.automatic_apply is False + assert not (tmp_path / "data").exists() +``` + +Verify audit JSON contains raw concept/start/end/frame/accession metadata and rejected CSV contains explicit reason codes. + +- [ ] **Step 5: Implement CSV/JSON output and stage orchestrator** + +Reuse `SCHEMAS` and `EVIDENCE_SCHEMA_VERSION` from `src.earnings_nowcast_onboarding`. Reuse `load_sec_ticker_map` and `fetch_companyfacts` from `src.providers.sec_companyfacts`. Dependency-inject ticker-map and Companyfacts fetchers for tests. Require `output_dir`; do not default to `data/imports`. + +- [ ] **Step 6: Run Task 2 tests and existing onboarding tests** + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_onboarding.py -q +``` + +Expected: all pass and no file appears outside pytest temporary directories. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add -- src/earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_sec_actuals.py +git commit -m "Stage SEC actuals as append-only evidence" +``` + +--- + +### Task 3: Enforce metric-specific quarter continuity and split safety + +**Files:** +- Modify: `src/earnings_nowcast_readiness.py` +- Modify: `src/earnings_nowcast_model.py` +- Modify: `tests/test_earnings_nowcast_readiness.py` +- Modify: `tests/test_earnings_nowcast_model.py` +- Modify: `tests/test_earnings_nowcast_backtest.py` + +**Interfaces:** +- Produces: `contiguous_metric_window(rows: Sequence[QuarterlyActual], target_period: str, metric: str, minimum_quarters: int) -> tuple[QuarterlyActual, ...]`. +- `assess_nowcast_readiness` adds `quarter_history_gap` and `incompatible_eps_definition` without changing existing public state names. +- `build_baseline_nowcast` uses the contiguous windows returned by the readiness helper. + +- [ ] **Step 1: Write failing continuity tests** + +```python +def test_missing_q4_withholds_both_metrics_instead_of_treating_q3_to_q1_as_sequential(): + rows = [row for row in _actuals() if row.fiscal_period != "2024-Q4"] + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[_consensus()], + ) + + assert result.revenue_ready is False + assert result.eps_ready is False + assert "quarter_history_gap" in result.missing_evidence +``` + +Add a model regression test that monkeypatches or directly checks the selected input periods and proves Q3-to-Q1 is never passed to `_sequential_growth`. + +- [ ] **Step 2: Run the continuity tests and verify RED** + +```bash +python3 -m pytest tests/test_earnings_nowcast_readiness.py::test_missing_q4_withholds_both_metrics_instead_of_treating_q3_to_q1_as_sequential -q +``` + +Expected: FAIL because current readiness counts five rows without checking adjacency. + +- [ ] **Step 3: Implement fiscal-period adjacency and contiguous windows** + +```python +def _next_period(period: str) -> str: + year, quarter = period.split("-Q") + return f"{int(year) + 1}-Q1" if quarter == "4" else f"{year}-Q{int(quarter) + 1}" + + +def contiguous_metric_window(rows, target_period, metric, minimum_quarters): + eligible = [row for row in rows if getattr(row, f"{metric}_actual") is not None] + # Walk backward from the period immediately before target_period. + # Return an empty tuple when any required period is missing. +``` + +Use the contiguous window for history sufficiency, stability checks, model inputs, and source IDs. Keep Revenue and EPS windows separate. + +- [ ] **Step 4: Write failing split-basis tests** + +```python +def test_split_basis_change_withholds_eps_but_keeps_revenue_ready(): + rows = _actuals() + rows[0] = replace(rows[0], split_adjustment_basis="pre_split") + consensus = replace(_consensus(), split_adjustment_basis="post_split_2024_06_10") + + result = assess_nowcast_readiness(...) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence +``` + +Also prove a source-backed append-only split-adjusted revision restores a consistent EPS window before the cutoff, while a post-cutoff revision does not. + +- [ ] **Step 5: Implement split-basis filtering and cutoff-safe revision selection** + +Reuse `_metric_definition` and `canonicalize_actuals`; do not add ticker-specific split constants to model code. The evidence row and consensus row carry the basis. If no five-quarter contiguous compatible EPS window exists, withhold EPS. + +- [ ] **Step 6: Run readiness, model, and backtest suites** + +```bash +python3 -m pytest \ + tests/test_earnings_nowcast_readiness.py \ + tests/test_earnings_nowcast_model.py \ + tests/test_earnings_nowcast_backtest.py -q +``` + +Expected: all pass; existing leakage and probability gates remain unchanged. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add -- src/earnings_nowcast_readiness.py src/earnings_nowcast_model.py \ + tests/test_earnings_nowcast_readiness.py tests/test_earnings_nowcast_model.py \ + tests/test_earnings_nowcast_backtest.py +git commit -m "Require contiguous nowcast quarter history" +``` + +--- + +### Task 4: Ingest explicit Q4 results from SEC-filed earnings-release exhibits + +**Files:** +- Modify: `src/earnings_nowcast_sec_actuals.py` +- Modify: `src/providers/sec_submissions.py` +- Modify: `tests/test_earnings_nowcast_sec_actuals.py` +- Modify: `tests/test_sec_submissions.py` + +**Interfaces:** +- Produces: `sec_filing_index_url(cik: str, accession: str) -> str` in `src.providers.sec_submissions`. +- Produces: `extract_filing_exhibits(index_html: str, *, cik: str, accession: str) -> tuple[FiledExhibit, ...]`. +- Produces: `extract_explicit_q4_actual(ticker: str, exhibit: FiledExhibit, document_text: str, *, fiscal_period: str, filed_at: str, retrieved_at: str) -> ExtractionResult`. +- `stage_sec_quarterly_actuals` combines Q1-Q3 lineage and explicit Q4 rows before revision linking. + +- [ ] **Step 1: Write failing filing-index and Q4 table tests** + +Use a fixture index containing an `EX-99.1` link and an earnings-release fixture containing: + +```html +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+

All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.

+``` + +Assertions: + +```python +assert exhibits[0].document_type == "EX-99.1" +assert result.rows[0].fiscal_period == "2025-Q4" +assert result.rows[0].revenue_actual == 39_331_000_000 +assert result.rows[0].eps_actual == 0.89 +assert result.rows[0].split_adjustment_basis == "split_adjusted_2024_06_07" +``` + +- [ ] **Step 2: Run Q4 tests and verify RED** + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py -k 'filing_index or explicit_q4' -q +``` + +Expected: FAIL because filing-index and Q4 interfaces are missing. + +- [ ] **Step 3: Implement filing-index URL and standard-library table extraction** + +Use `html.parser.HTMLParser`; do not add a new runtime dependency. The parser must preserve table row/cell text and nearby quarter/split labels. Normalize commas, currency symbols, parentheses, and explicit `million` / `billion` scale only after the metric label is matched. + +- [ ] **Step 4: Add fail-closed Q4 cases** + +Add tests proving these produce no actual row and explicit audit states: + +- annual total without a Q4 column; +- guidance table (`outlook`, `expected`, `approximately`); +- non-GAAP EPS without GAAP diluted EPS; +- Q4 derived only by subtraction; +- ambiguous period header; +- source filed after cutoff; +- missing split note labels EPS `as_reported`; the readiness split-basis gate, + rather than the parser, withholds EPS when the surrounding lineage requires + an incompatible split-adjusted basis. + +- [ ] **Step 5: Implement exhibit discovery and Q4 selection** + +Search `8-K` / `8-K/A` filing index documents for `EX-99`, `EX-99.1`, or `EX-99.2` result exhibits. Require exact source URL, accession, filed date, quarter label, and metric labels. Issuer aliases belong in a small immutable table of labels, never values. If multiple exhibits disagree, reject Q4 as `ambiguous_concept`. + +- [ ] **Step 6: Run Q4 and SEC provider tests** + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py tests/test_sec_submissions.py -q +``` + +Expected: all pass without network access. + +- [ ] **Step 7: Commit Task 4** + +```bash +git add -- src/earnings_nowcast_sec_actuals.py src/providers/sec_submissions.py \ + tests/test_earnings_nowcast_sec_actuals.py tests/test_sec_submissions.py +git commit -m "Stage explicit SEC filed Q4 actuals" +``` + +--- + +### Task 5: Add launcher, documentation, and five-company live verification + +**Files:** +- Modify: `Makefile` +- Modify: `tests/test_launchers.py` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `ROADMAP.md` +- Modify: `src/earnings_nowcast_sec_actuals.py` +- Modify: `tests/test_earnings_nowcast_sec_actuals.py` + +**Interfaces:** +- CLI: `python3 -m src.earnings_nowcast_sec_actuals --tickers ... --output-dir ... --cutoff ...`. +- Make: `make earnings-nowcast-sec-actuals-stage TICKERS=... OUTPUT_DIR=... AS_OF=...`. +- JSON summary exposes per-ticker accepted rows, rejected rows, missing Q4, continuity gaps, source refs, and `automatic_apply=false`. + +- [ ] **Step 1: Write failing launcher tests** + +Add to `tests/test_launchers.py`: + +```python +assert "earnings-nowcast-sec-actuals-stage" in makefile +assert "TICKERS is required" in makefile +assert "OUTPUT_DIR is required" in makefile +assert "--cutoff $(AS_OF)" in makefile +assert "imports-apply" not in target_body(makefile, "earnings-nowcast-sec-actuals-stage") +``` + +Add a CLI test that dependency-injects cached fixtures and confirms JSON output contains `automatic_apply: false`. + +- [ ] **Step 2: Run launcher tests and verify RED** + +```bash +python3 -m pytest tests/test_launchers.py tests/test_earnings_nowcast_sec_actuals.py -q +``` + +Expected: launcher assertion fails. + +- [ ] **Step 3: Implement CLI and Make target** + +Add to `.PHONY` and Makefile: + +```make +earnings-nowcast-sec-actuals-stage: +ifndef TICKERS + $(error TICKERS is required, for example: make earnings-nowcast-sec-actuals-stage TICKERS=NVDA OUTPUT_DIR=/tmp/sec-actuals AS_OF=2026-07-16T03:59:59Z) +endif +ifndef OUTPUT_DIR + $(error OUTPUT_DIR is required; use a generated temporary/review directory) +endif +ifndef AS_OF + $(error AS_OF is required for cutoff-safe evidence staging) +endif + @python3 -m src.earnings_nowcast_sec_actuals --tickers "$(TICKERS)" --output-dir "$(OUTPUT_DIR)" --cutoff "$(AS_OF)" +``` + +The Python CLI accepts `--no-network`, `--sec-refresh`, and `--json`. It never accepts an apply flag. + +- [ ] **Step 4: Update methodology and roadmap truth** + +Document: + +- Q1-Q3 duration and fiscal-lineage rules; +- explicit Q4 source rule and no-derivation boundary; +- revision/cutoff behavior; +- quarter-continuity and EPS split-basis withholding; +- five-company pilot scope; +- real output still `awaiting_point_in_time_consensus`; +- probability still `awaiting_calibration_evidence`. + +Do not claim real Nowcast coverage merely because actuals stage successfully. + +- [ ] **Step 5: Run focused and full deterministic verification** + +```bash +python3 -m pytest \ + tests/test_earnings_nowcast_sec_actuals.py \ + tests/test_earnings_nowcast_onboarding.py \ + tests/test_earnings_nowcast_readiness.py \ + tests/test_earnings_nowcast_model.py \ + tests/test_earnings_nowcast_backtest.py \ + tests/test_sec_submissions.py \ + tests/test_launchers.py -q +python3 -m pytest tests -q +make public-wording-check +make dashboard-smoke +make browser-qa-evidence +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: all tests and gates pass. Generated readiness/report files remain unstaged. + +- [ ] **Step 6: Run the live read-only five-company smoke** + +```bash +rm -rf /tmp/stock-nowcast-five-company/sec-lineage-stage +make earnings-nowcast-sec-actuals-stage \ + TICKERS=NVDA,AMD,AVGO,MU,QCOM \ + OUTPUT_DIR=/tmp/stock-nowcast-five-company/sec-lineage-stage \ + AS_OF=2026-07-16T03:59:59Z +make earnings-nowcast-validate \ + INPUT_DIR=/tmp/stock-nowcast-five-company/sec-lineage-stage \ + AS_OF=2026-07-16T03:59:59Z +make earnings-nowcast-preview \ + INPUT_DIR=/tmp/stock-nowcast-five-company/sec-lineage-stage \ + AS_OF=2026-07-16T03:59:59Z +``` + +Inspect per ticker: + +- at least five consecutive Revenue quarters or an explicit truthful gap; +- EPS ready only within one source-backed split basis; +- Q4 rows have exact SEC-filed exhibit URLs; +- zero post-cutoff rows; +- zero cumulative facts; +- preview remains `ready_for_packet=false` with `point_in_time_consensus_missing` when consensus is absent. + +Do not stage `/tmp` output. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add -- Makefile ROADMAP.md \ + docs/EARNINGS_NOWCAST_PILOT.md docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md \ + src/earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_sec_actuals.py \ + tests/test_launchers.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add SEC quarterly actuals staging workflow" +``` + +## Final Review Gate + +After all task commits: + +```bash +git status --short --branch +git log -8 --oneline +git diff origin/main...HEAD --stat +python3 -m pytest tests -q +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +The feature is complete only when fixture validation, full regression gates, and live read-only evidence agree. Real Earnings Nowcast remains blocked until historical point-in-time consensus snapshots are separately source-backed and validated. From 1087cf985ebb26f88ec66395a73930569d0e4450 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 00:14:53 -0400 Subject: [PATCH 03/23] Extract SEC quarterly actual lineage --- src/earnings_nowcast_sec_actuals.py | 399 +++++++++++++++++++++ tests/test_earnings_nowcast_sec_actuals.py | 197 ++++++++++ 2 files changed, 596 insertions(+) create mode 100644 src/earnings_nowcast_sec_actuals.py create mode 100644 tests/test_earnings_nowcast_sec_actuals.py diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py new file mode 100644 index 000000000..725b2542f --- /dev/null +++ b/src/earnings_nowcast_sec_actuals.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timezone +from math import isfinite +from typing import Mapping + +from src.earnings_nowcast_contract import QuarterlyActual + + +REVENUE_CONCEPTS = ( + "RevenueFromContractWithCustomerExcludingAssessedTax", + "Revenues", + "SalesRevenueNet", +) +EPS_CONCEPT = "EarningsPerShareDiluted" +SEC_QUARTERLY_FORMS = frozenset(("10-Q", "10-Q/A")) + + +@dataclass(frozen=True) +class SecDurationFact: + taxonomy: str + concept: str + unit: str + value: float + start: str + end: str + filed: str + form: str + accession: str + fiscal_year: int + fiscal_period: str + frame: str + + @property + def duration_days(self) -> int: + return (date.fromisoformat(self.end) - date.fromisoformat(self.start)).days + + +@dataclass(frozen=True) +class ExtractionAuditRow: + ticker: str + state: str + metric: str + fiscal_period: str + source_ref: str + detail: str + + +@dataclass(frozen=True) +class ExtractionResult: + rows: tuple[QuarterlyActual, ...] + audit_rows: tuple[ExtractionAuditRow, ...] + + +def _required_text(value: object) -> str | None: + text = str(value or "").strip() + return text or None + + +def _normalized_fact( + taxonomy: str, + concept: str, + unit: str, + item: object, +) -> SecDurationFact | None: + if not isinstance(item, Mapping): + return None + form = _required_text(item.get("form")) + start = _required_text(item.get("start")) + end = _required_text(item.get("end")) + filed = _required_text(item.get("filed")) + accession = _required_text(item.get("accn")) + fiscal_period = _required_text(item.get("fp")) + try: + value = float(item.get("val")) + fiscal_year = int(item.get("fy")) + if form not in SEC_QUARTERLY_FORMS or not all((start, end, filed, accession, fiscal_period)): + return None + if not isfinite(value): + return None + date.fromisoformat(start) + date.fromisoformat(end) + date.fromisoformat(filed) + except (TypeError, ValueError): + return None + return SecDurationFact( + taxonomy=taxonomy, + concept=concept, + unit=unit, + value=value, + start=start, + end=end, + filed=filed, + form=form, + accession=accession, + fiscal_year=fiscal_year, + fiscal_period=fiscal_period, + frame=str(item.get("frame") or "").strip(), + ) + + +def normalize_sec_duration_facts(payload: Mapping[str, object]) -> tuple[SecDurationFact, ...]: + facts_root = payload.get("facts") if isinstance(payload, Mapping) else None + if not isinstance(facts_root, Mapping): + return () + facts: list[SecDurationFact] = [] + for taxonomy, taxonomy_facts in facts_root.items(): + if not isinstance(taxonomy_facts, Mapping): + continue + for concept, concept_data in taxonomy_facts.items(): + if not isinstance(concept_data, Mapping): + continue + units = concept_data.get("units") + if not isinstance(units, Mapping): + continue + for unit, items in units.items(): + if not isinstance(items, list): + continue + for item in items: + fact = _normalized_fact(str(taxonomy), str(concept), str(unit), item) + if fact is not None: + facts.append(fact) + return tuple( + sorted( + facts, + key=lambda fact: ( + fact.taxonomy, + fact.concept, + fact.unit, + fact.accession, + fact.fiscal_year, + fact.fiscal_period, + fact.end, + fact.start, + fact.filed, + fact.value, + fact.frame, + ), + ) + ) + + +def _metric_for(fact: SecDurationFact) -> str | None: + if fact.taxonomy != "us-gaap": + return None + if fact.concept in REVENUE_CONCEPTS: + return "revenue" + if fact.concept == EPS_CONCEPT: + return "eps" + return None + + +def _source_ref(cik: object, accession: str) -> str: + cik_digits = "".join(character for character in str(cik or "") if character.isdigit()).lstrip("0") or "0" + accession_path = accession.replace("-", "") + return f"https://www.sec.gov/Archives/edgar/data/{cik_digits}/{accession_path}/" + + +def _reported_at(filed: str) -> str: + return datetime.combine(date.fromisoformat(filed), datetime.min.time(), tzinfo=timezone.utc).isoformat() + + +def _audit( + ticker: str, + state: str, + metric: str, + fiscal_period: str, + source_ref: str, + detail: str, +) -> ExtractionAuditRow: + return ExtractionAuditRow( + ticker=ticker, + state=state, + metric=metric, + fiscal_period=fiscal_period, + source_ref=source_ref, + detail=detail, + ) + + +def _choose_metric( + facts: list[SecDurationFact], + metric: str, + ticker: str, + fiscal_period: str, + source_ref: str, + audit_rows: list[ExtractionAuditRow], +) -> float | None: + if not facts: + return None + frames = {fact.frame for fact in facts if fact.frame} + values = {fact.value for fact in facts} + if len(frames) > 1 or len(values) > 1: + audit_rows.append( + _audit( + ticker, + "ambiguous_concept", + metric, + fiscal_period, + source_ref, + "conflicting concepts, values, or SEC frames for one quarter", + ) + ) + return None + if metric == "revenue": + concept_rank = {concept: index for index, concept in enumerate(REVENUE_CONCEPTS)} + facts.sort(key=lambda fact: (concept_rank[fact.concept], fact.unit, fact.frame)) + return facts[0].value + + +def extract_q1_q3_lineage( + ticker: str, + payload: Mapping[str, object], + *, + cutoff: str, + retrieved_at: str, +) -> ExtractionResult: + normalized_ticker = str(ticker or "").strip().upper() + cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) + if cutoff_timestamp.tzinfo is None: + raise ValueError("cutoff must be timezone-aware") + cutoff_timestamp = cutoff_timestamp.astimezone(timezone.utc) + facts = normalize_sec_duration_facts(payload) + audit_rows: list[ExtractionAuditRow] = [] + quarter_facts: list[SecDurationFact] = [] + for fact in facts: + metric = _metric_for(fact) + if metric is None: + continue + source_ref = _source_ref(payload.get("cik"), fact.accession) + fiscal_period = f"{fact.fiscal_year}-{fact.fiscal_period}" + if not 60 <= fact.duration_days <= 120: + audit_rows.append( + _audit( + normalized_ticker, + "cumulative_fact_rejected", + metric, + fiscal_period, + source_ref, + f"duration_days={fact.duration_days} is outside the 60-120 day quarter range", + ) + ) + continue + if fact.fiscal_period not in {"Q1", "Q2", "Q3"}: + continue + quarter_facts.append(fact) + + latest_end_by_filing: dict[tuple[str, int, str], str] = {} + for fact in quarter_facts: + key = (fact.accession, fact.fiscal_year, fact.fiscal_period) + latest_end_by_filing[key] = max(latest_end_by_filing.get(key, fact.end), fact.end) + + current_facts: list[SecDurationFact] = [] + comparative_facts: list[SecDurationFact] = [] + for fact in quarter_facts: + key = (fact.accession, fact.fiscal_year, fact.fiscal_period) + metric = _metric_for(fact) + assert metric is not None + source_ref = _source_ref(payload.get("cik"), fact.accession) + fiscal_period = f"{fact.fiscal_year}-{fact.fiscal_period}" + if fact.end != latest_end_by_filing[key]: + comparative_facts.append(fact) + continue + current_facts.append(fact) + + identities_by_end: dict[str, set[str]] = {} + for fact in current_facts: + identities_by_end.setdefault(fact.end, set()).add(f"{fact.fiscal_year}-{fact.fiscal_period}") + conflicting_ends = {end for end, identities in identities_by_end.items() if len(identities) > 1} + for fact in current_facts: + if fact.end not in conflicting_ends: + continue + metric = _metric_for(fact) + assert metric is not None + audit_rows.append( + _audit( + normalized_ticker, + "fiscal_period_conflict", + metric, + f"{fact.fiscal_year}-{fact.fiscal_period}", + _source_ref(payload.get("cik"), fact.accession), + "multiple current-quarter filings assign different fiscal identities to one period end", + ) + ) + + facts_by_identity: list[tuple[SecDurationFact, str]] = [] + comparative_signatures: set[tuple[str, str, str, str, int, str]] = set() + for fact in current_facts: + if fact.end not in conflicting_ends: + facts_by_identity.append((fact, f"{fact.fiscal_year}-{fact.fiscal_period}")) + for fact in comparative_facts: + metric = _metric_for(fact) + assert metric is not None + source_ref = _source_ref(payload.get("cik"), fact.accession) + identity = identities_by_end.get(fact.end, set()) + if len(identity) == 1 and fact.end not in conflicting_ends: + facts_by_identity.append((fact, next(iter(identity)))) + comparative_signatures.add( + (fact.accession, fact.start, fact.end, fact.filed, fact.fiscal_year, fact.fiscal_period) + ) + continue + audit_rows.append( + _audit( + normalized_ticker, + "comparative_period_relabelled", + metric, + f"{fact.fiscal_year}-{fact.fiscal_period}", + source_ref, + "comparative period has no uniquely established original fiscal identity", + ) + ) + + by_signature: dict[tuple[str, str, str, str, int, str], list[SecDurationFact]] = {} + identity_by_signature: dict[tuple[str, str, str, str, int, str], str] = {} + for fact, fiscal_period in facts_by_identity: + signature = (fact.accession, fact.start, fact.end, fact.filed, fact.fiscal_year, fact.fiscal_period) + by_signature.setdefault(signature, []).append(fact) + identity_by_signature[signature] = fiscal_period + + rows: list[QuarterlyActual] = [] + for signature, signature_facts in sorted(by_signature.items(), key=lambda item: (item[0][2], item[0][3], item[0][0])): + accession, start, end, filed, fiscal_year, fiscal_quarter = signature + fiscal_period = identity_by_signature[signature] + source_ref = _source_ref(payload.get("cik"), accession) + revenue = _choose_metric( + [fact for fact in signature_facts if _metric_for(fact) == "revenue"], + "revenue", + normalized_ticker, + fiscal_period, + source_ref, + audit_rows, + ) + eps = _choose_metric( + [fact for fact in signature_facts if _metric_for(fact) == "eps"], + "eps", + normalized_ticker, + fiscal_period, + source_ref, + audit_rows, + ) + if revenue is None and eps is None: + continue + reported_at = _reported_at(filed) + if datetime.fromisoformat(reported_at).astimezone(timezone.utc) > cutoff_timestamp: + audit_rows.append( + _audit( + normalized_ticker, + "post_cutoff_rejected", + "quarterly_actual", + fiscal_period, + source_ref, + f"reported_at={reported_at} is after cutoff={cutoff_timestamp.isoformat()}", + ) + ) + continue + rows.append( + QuarterlyActual( + ticker=normalized_ticker, + fiscal_period=fiscal_period, + period_end_date=end, + reported_at=reported_at, + revenue_actual=revenue, + eps_actual=eps, + source="sec_companyfacts", + source_ref=source_ref, + retrieved_at=retrieved_at, + ) + ) + accepted_state = "accepted_revision" if signature in comparative_signatures else "accepted_explicit_quarter" + accepted_detail = ( + "comparative presentation inherits a uniquely established fiscal identity" + if accepted_state == "accepted_revision" + else "is a uniquely aligned current-quarter SEC duration fact" + ) + for metric, value in (("revenue", revenue), ("eps", eps)): + if value is not None: + audit_rows.append( + _audit( + normalized_ticker, + accepted_state, + metric, + fiscal_period, + source_ref, + f"{metric} {accepted_detail}", + ) + ) + if revenue is None or eps is None: + audit_rows.append( + _audit( + normalized_ticker, + "metric_partial", + "quarterly_actual", + fiscal_period, + source_ref, + "only one source-backed metric is available for the quarter", + ) + ) + return ExtractionResult(rows=tuple(rows), audit_rows=tuple(audit_rows)) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py new file mode 100644 index 000000000..c792fa116 --- /dev/null +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from src.earnings_nowcast_sec_actuals import ( + extract_q1_q3_lineage, + normalize_sec_duration_facts, +) + + +CUTOFF = "2026-06-30T23:59:59Z" +RETRIEVED_AT = "2026-06-26T12:00:00Z" + + +def _fact( + *, + val, + start, + end, + filed="2026-06-25", + fy=2026, + fp="Q3", + frame=None, + accn="0000000000-26-000001", +): + return { + "val": val, + "start": start, + "end": end, + "filed": filed, + "form": "10-Q", + "accn": accn, + "fy": fy, + "fp": fp, + "frame": frame, + } + + +def companyfacts_fixture(*, revenue, eps): + return { + "cik": 123456, + "facts": { + "us-gaap": { + "RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": revenue}}, + "EarningsPerShareDiluted": {"units": {"USD/shares": eps}}, + } + }, + } + + +def test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period(): + payload = companyfacts_fixture( + revenue=[ + _fact(val=30, start="2025-08-29", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + eps=[ + _fact(val=3.0, start="2025-08-29", end="2026-05-28"), + _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=0.8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + ) + + facts = normalize_sec_duration_facts(payload) + result = extract_q1_q3_lineage("SYN1", payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT) + + assert len(facts) == 6 + assert sorted(fact.duration_days for fact in facts) == [90, 90, 90, 90, 272, 272] + assert [(row.fiscal_period, row.revenue_actual, row.eps_actual) for row in result.rows] == [ + ("2026-Q3", 12.0, 1.2) + ] + assert {row.state for row in result.audit_rows} >= { + "accepted_explicit_quarter", + "cumulative_fact_rejected", + "comparative_period_relabelled", + } + + +def test_later_comparative_uses_original_fiscal_identity_and_unknown_comparative_fails_closed(): + original_q2_revenue = _fact( + val=8, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + frame="CY2025Q2", + accn="0000000000-25-000001", + ) + original_q2_eps = _fact( + val=0.8, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + frame="CY2025Q2", + accn="0000000000-25-000001", + ) + comparative_revenue = _fact(val=9, start="2025-02-28", end="2025-05-29") + comparative_eps = _fact(val=0.9, start="2025-02-28", end="2025-05-29") + current_revenue = _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + current_eps = _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[original_q2_revenue, comparative_revenue, current_revenue], + eps=[original_q2_eps, comparative_eps, current_eps], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + q2_rows = [row for row in result.rows if row.fiscal_period == "2025-Q2"] + assert [(row.revenue_actual, row.eps_actual) for row in q2_rows] == [(8.0, 0.8), (9.0, 0.9)] + assert q2_rows[1].reported_at == "2026-06-25T00:00:00+00:00" + assert "accepted_revision" in {row.state for row in result.audit_rows} + + no_original = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture(revenue=[comparative_revenue, current_revenue], eps=[comparative_eps, current_eps]), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert [row.fiscal_period for row in no_original.rows] == ["2026-Q3"] + assert "comparative_period_relabelled" in {row.state for row in no_original.audit_rows} + + +def test_missing_frame_keeps_uniquely_aligned_quarter(): + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], + eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28")], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 12.0 + + +def test_conflicting_revenue_concepts_and_frames_withhold_only_revenue(): + revenue = _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + eps = _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + ambiguous_payload = companyfacts_fixture(revenue=[revenue], eps=[eps]) + ambiguous_payload["facts"]["us-gaap"]["Revenues"] = { + "units": {"USD": [_fact(val=13, start="2026-02-27", end="2026-05-28", frame="CY2026Q2")]} + } + conflicting_frame_payload = companyfacts_fixture( + revenue=[ + revenue, + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q1"), + ], + eps=[eps], + ) + + ambiguous_result = extract_q1_q3_lineage( + "SYN1", ambiguous_payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT + ) + conflicting_frame_result = extract_q1_q3_lineage( + "SYN1", conflicting_frame_payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT + ) + + assert ambiguous_result.rows[0].revenue_actual is None + assert ambiguous_result.rows[0].eps_actual == 1.2 + assert conflicting_frame_result.rows[0].revenue_actual is None + assert "ambiguous_concept" in {row.state for row in ambiguous_result.audit_rows} + + +def test_revenue_only_quarter_is_preserved_as_metric_partial(): + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture(revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], eps=[]), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 12.0 + assert result.rows[0].eps_actual is None + assert "metric_partial" in {row.state for row in result.audit_rows} + + +def test_post_cutoff_quarter_is_withheld_with_audit_state(): + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28", filed="2026-07-01")], + eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28", filed="2026-07-01")], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "post_cutoff_rejected" in {row.state for row in result.audit_rows} From 371b3d1494c2f3209d74a968124ad7f41a519733 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 19:21:59 -0400 Subject: [PATCH 04/23] Harden SEC actual scalar normalization --- src/earnings_nowcast_sec_actuals.py | 20 +++++++++++++++++--- tests/test_earnings_nowcast_sec_actuals.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 725b2542f..099c6502b 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -58,6 +58,20 @@ def _required_text(value: object) -> str | None: return text or None +def _finite_json_number(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + normalized = float(value) + return normalized if isfinite(normalized) else None + + +def _integral_fiscal_year(value: object) -> int | None: + normalized = _finite_json_number(value) + if normalized is None or not normalized.is_integer(): + return None + return int(normalized) + + def _normalized_fact( taxonomy: str, concept: str, @@ -73,11 +87,11 @@ def _normalized_fact( accession = _required_text(item.get("accn")) fiscal_period = _required_text(item.get("fp")) try: - value = float(item.get("val")) - fiscal_year = int(item.get("fy")) + value = _finite_json_number(item.get("val")) + fiscal_year = _integral_fiscal_year(item.get("fy")) if form not in SEC_QUARTERLY_FORMS or not all((start, end, filed, accession, fiscal_period)): return None - if not isfinite(value): + if value is None or fiscal_year is None: return None date.fromisoformat(start) date.fromisoformat(end) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index c792fa116..d6d5f2929 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -46,6 +46,24 @@ def companyfacts_fixture(*, revenue, eps): } +def test_normalization_rejects_non_json_numeric_scalars_and_non_integral_fiscal_years(): + payload = companyfacts_fixture( + revenue=[ + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=True, start="2026-02-27", end="2026-05-28"), + _fact(val="12", start="2026-02-27", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy=2026.9), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy=True), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy="2026"), + ], + eps=[], + ) + + facts = normalize_sec_duration_facts(payload) + + assert [(fact.value, fact.fiscal_year) for fact in facts] == [(12.0, 2026)] + + def test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period(): payload = companyfacts_fixture( revenue=[ From 8f3719086ca316d33b39c7be165cd8874f9d69bc Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 19:26:06 -0400 Subject: [PATCH 05/23] Reject oversized SEC actual scalars --- src/earnings_nowcast_sec_actuals.py | 5 ++++- tests/test_earnings_nowcast_sec_actuals.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 099c6502b..4c6fe2ab1 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -61,7 +61,10 @@ def _required_text(value: object) -> str | None: def _finite_json_number(value: object) -> float | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None - normalized = float(value) + try: + normalized = float(value) + except OverflowError: + return None return normalized if isfinite(normalized) else None diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index d6d5f2929..2a79ab2c5 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -64,6 +64,22 @@ def test_normalization_rejects_non_json_numeric_scalars_and_non_integral_fiscal_ assert [(fact.value, fact.fiscal_year) for fact in facts] == [(12.0, 2026)] +def test_normalization_rejects_oversized_integer_scalars(): + oversized_integer = 10**400 + payload = companyfacts_fixture( + revenue=[ + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=oversized_integer, start="2026-02-27", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy=oversized_integer), + ], + eps=[], + ) + + facts = normalize_sec_duration_facts(payload) + + assert [(fact.value, fact.fiscal_year) for fact in facts] == [(12.0, 2026)] + + def test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period(): payload = companyfacts_fixture( revenue=[ From 882db7e29327604921f7ab851b5dfe68b5864b2f Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 19:32:32 -0400 Subject: [PATCH 06/23] Stage SEC actuals as append-only evidence --- src/earnings_nowcast_sec_actuals.py | 238 ++++++++++++++++++++- tests/test_earnings_nowcast_sec_actuals.py | 173 +++++++++++++++ 2 files changed, 409 insertions(+), 2 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 4c6fe2ab1..47e8a122e 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -1,11 +1,20 @@ from __future__ import annotations -from dataclasses import dataclass +import csv +import json +from dataclasses import asdict, dataclass, replace from datetime import date, datetime, timezone from math import isfinite -from typing import Mapping +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence from src.earnings_nowcast_contract import QuarterlyActual +from src.earnings_nowcast_onboarding import EVIDENCE_SCHEMA_VERSION, SCHEMAS +from src.providers.sec_companyfacts import ( + fetch_companyfacts, + load_sec_ticker_map, + resolve_ticker_to_cik, +) REVENUE_CONCEPTS = ( @@ -45,6 +54,11 @@ class ExtractionAuditRow: fiscal_period: str source_ref: str detail: str + concept: str = "" + start: str = "" + end: str = "" + frame: str = "" + accession: str = "" @dataclass(frozen=True) @@ -53,6 +67,19 @@ class ExtractionResult: audit_rows: tuple[ExtractionAuditRow, ...] +@dataclass(frozen=True) +class StageResult: + requested_tickers: tuple[str, ...] + accepted_tickers: tuple[str, ...] + withheld_tickers: tuple[str, ...] + accepted_row_count: int + rejected_row_count: int + quarterly_actuals_path: str + audit_path: str + rejected_path: str + automatic_apply: bool = False + + def _required_text(value: object) -> str | None: text = str(value or "").strip() return text or None @@ -185,6 +212,7 @@ def _audit( fiscal_period: str, source_ref: str, detail: str, + fact: SecDurationFact | None = None, ) -> ExtractionAuditRow: return ExtractionAuditRow( ticker=ticker, @@ -193,6 +221,11 @@ def _audit( fiscal_period=fiscal_period, source_ref=source_ref, detail=detail, + concept=fact.concept if fact else "", + start=fact.start if fact else "", + end=fact.end if fact else "", + frame=fact.frame if fact else "", + accession=fact.accession if fact else "", ) @@ -217,6 +250,7 @@ def _choose_metric( fiscal_period, source_ref, "conflicting concepts, values, or SEC frames for one quarter", + facts[0], ) ) return None @@ -256,6 +290,7 @@ def extract_q1_q3_lineage( fiscal_period, source_ref, f"duration_days={fact.duration_days} is outside the 60-120 day quarter range", + fact, ) ) continue @@ -298,6 +333,7 @@ def extract_q1_q3_lineage( f"{fact.fiscal_year}-{fact.fiscal_period}", _source_ref(payload.get("cik"), fact.accession), "multiple current-quarter filings assign different fiscal identities to one period end", + fact, ) ) @@ -325,6 +361,7 @@ def extract_q1_q3_lineage( f"{fact.fiscal_year}-{fact.fiscal_period}", source_ref, "comparative period has no uniquely established original fiscal identity", + fact, ) ) @@ -368,6 +405,7 @@ def extract_q1_q3_lineage( fiscal_period, source_ref, f"reported_at={reported_at} is after cutoff={cutoff_timestamp.isoformat()}", + signature_facts[0], ) ) continue @@ -400,6 +438,7 @@ def extract_q1_q3_lineage( fiscal_period, source_ref, f"{metric} {accepted_detail}", + signature_facts[0], ) ) if revenue is None or eps is None: @@ -411,6 +450,201 @@ def extract_q1_q3_lineage( fiscal_period, source_ref, "only one source-backed metric is available for the quarter", + signature_facts[0], ) ) return ExtractionResult(rows=tuple(rows), audit_rows=tuple(audit_rows)) + + +def _same_actual_presentation(left: QuarterlyActual, right: QuarterlyActual) -> bool: + return ( + left.period_end_date, + left.revenue_actual, + left.eps_actual, + left.revenue_currency, + left.revenue_unit_scale, + left.revenue_basis, + left.eps_currency, + left.eps_basis, + left.eps_share_basis, + left.eps_operations_basis, + left.split_adjustment_basis, + ) == ( + right.period_end_date, + right.revenue_actual, + right.eps_actual, + right.revenue_currency, + right.revenue_unit_scale, + right.revenue_basis, + right.eps_currency, + right.eps_basis, + right.eps_share_basis, + right.eps_operations_basis, + right.split_adjustment_basis, + ) + + +def link_quarter_revisions(rows: Sequence[QuarterlyActual]) -> tuple[QuarterlyActual, ...]: + linked: list[QuarterlyActual] = [] + for row in sorted(rows, key=lambda item: (item.ticker, item.fiscal_period, item.reported_at, item.source, item.source_ref)): + family_rows = [ + prior + for prior in linked + if prior.ticker == row.ticker + and prior.fiscal_period == row.fiscal_period + and prior.source == row.source + and prior.reported_at < row.reported_at + ] + if any(_same_actual_presentation(prior, row) for prior in family_rows): + continue + if family_rows: + linked.append(replace(row, supersedes_source_ref=family_rows[-1].source_ref)) + else: + linked.append(row) + return tuple(linked) + + +def _actual_csv_row(row: QuarterlyActual) -> dict[str, object]: + return {"schema_version": EVIDENCE_SCHEMA_VERSION, **asdict(row)} + + +def _rejected_audit_rows(results: Mapping[str, ExtractionResult]) -> list[ExtractionAuditRow]: + return [ + audit_row + for ticker in sorted(results) + for audit_row in results[ticker].audit_rows + if not audit_row.state.startswith("accepted_") + ] + + +def write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionResult]) -> StageResult: + root = Path(output_dir) + root.mkdir(parents=True, exist_ok=True) + requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in results if str(ticker).strip()})) + linked_rows = link_quarter_revisions( + [row for ticker in requested_tickers for row in results[ticker].rows] + ) + accepted_tickers = tuple(sorted({row.ticker for row in linked_rows})) + withheld_tickers = tuple(ticker for ticker in requested_tickers if ticker not in accepted_tickers) + rejected_rows = _rejected_audit_rows(results) + + quarterly_actuals_path = root / "quarterly_actuals.csv" + with quarterly_actuals_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=SCHEMAS["quarterly_actuals.csv"]) + writer.writeheader() + writer.writerows(_actual_csv_row(row) for row in linked_rows) + for filename in ("consensus_snapshots.csv", "signals.csv"): + with (root / filename).open("w", newline="", encoding="utf-8") as handle: + csv.writer(handle).writerow(SCHEMAS[filename]) + + audit_path = root / "sec_actuals_audit.json" + audit_payload = { + "schema_version": EVIDENCE_SCHEMA_VERSION, + "mode": "preview_only", + "automatic_apply": False, + "requested_tickers": requested_tickers, + "accepted_tickers": accepted_tickers, + "withheld_tickers": withheld_tickers, + "accepted_row_count": len(linked_rows), + "rejected_row_count": len(rejected_rows), + "audit_rows": [asdict(row) for ticker in requested_tickers for row in results[ticker].audit_rows], + } + audit_path.write_text(json.dumps(audit_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + rejected_path = root / "sec_actuals_rejected.csv" + rejected_fields = ( + "ticker", + "state", + "reason_code", + "metric", + "fiscal_period", + "source_ref", + "detail", + "concept", + "start", + "end", + "frame", + "accession", + ) + with rejected_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=rejected_fields) + writer.writeheader() + writer.writerows({"reason_code": row.state, **asdict(row)} for row in rejected_rows) + + return StageResult( + requested_tickers=requested_tickers, + accepted_tickers=accepted_tickers, + withheld_tickers=withheld_tickers, + accepted_row_count=len(linked_rows), + rejected_row_count=len(rejected_rows), + quarterly_actuals_path=str(quarterly_actuals_path), + audit_path=str(audit_path), + rejected_path=str(rejected_path), + ) + + +def stage_sec_quarterly_actuals( + tickers: Sequence[str], + *, + output_dir: Path, + cutoff: str, + user_agent: str | None, + retrieved_at: str | None = None, + cache_dir: Path | str | None = None, + refresh: bool = False, + sleep_seconds: float = 0.2, + ticker_map: Mapping[str, Mapping[str, Any]] | None = None, + ticker_map_loader: Callable[..., Mapping[str, Mapping[str, Any]]] = load_sec_ticker_map, + ticker_map_fetcher: Callable[[str, str, float], Any] | None = None, + companyfacts_loader: Callable[..., Mapping[str, Any]] = fetch_companyfacts, + companyfacts_fetcher: Callable[[str, str, float], Any] | None = None, +) -> StageResult: + requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()})) + if ticker_map is None: + resolved_cache_dir = Path(cache_dir) if cache_dir is not None else Path(output_dir) / ".sec-cache" + resolved_ticker_map = ticker_map_loader( + cache_dir=resolved_cache_dir, + user_agent=user_agent, + refresh=refresh, + sleep_seconds=sleep_seconds, + fetcher=ticker_map_fetcher, + ) + else: + resolved_ticker_map = ticker_map + resolved_retrieved_at = retrieved_at or datetime.now(timezone.utc).isoformat() + results: dict[str, ExtractionResult] = {} + for ticker in requested_tickers: + cik = resolve_ticker_to_cik(ticker, dict(resolved_ticker_map)) + if cik is None: + results[ticker] = ExtractionResult( + rows=(), + audit_rows=( + _audit(ticker, "ticker_unresolved", "quarterly_actual", "", "", "no SEC CIK mapping was found"), + ), + ) + continue + try: + payload = companyfacts_loader( + cik, + user_agent, + cache=False, + refresh=refresh, + cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + sleep_seconds=sleep_seconds, + fetcher=companyfacts_fetcher, + ) + except RuntimeError as exc: + results[ticker] = ExtractionResult( + rows=(), + audit_rows=( + _audit(ticker, "companyfacts_fetch_failed", "quarterly_actual", "", "", str(exc)), + ), + ) + continue + results[ticker] = extract_q1_q3_lineage( + ticker, + payload, + cutoff=cutoff, + retrieved_at=resolved_retrieved_at, + ) + return write_sec_actuals_stage(output_dir, results) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 2a79ab2c5..0eaf7faac 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -1,8 +1,18 @@ from __future__ import annotations +import csv +import json +from pathlib import Path + +from src.earnings_nowcast_contract import QuarterlyActual from src.earnings_nowcast_sec_actuals import ( + ExtractionAuditRow, + ExtractionResult, extract_q1_q3_lineage, + link_quarter_revisions, normalize_sec_duration_facts, + stage_sec_quarterly_actuals, + write_sec_actuals_stage, ) @@ -46,6 +56,169 @@ def companyfacts_fixture(*, revenue, eps): } +def actual( + fiscal_period, + *, + revenue, + eps, + source_ref, + reported_at, + source="sec_companyfacts", +): + return QuarterlyActual( + ticker="SYN1", + fiscal_period=fiscal_period, + period_end_date="2025-06-30", + reported_at=reported_at, + revenue_actual=revenue, + eps_actual=eps, + source=source, + source_ref=source_ref, + retrieved_at=RETRIEVED_AT, + ) + + +def extraction_result(): + return ExtractionResult( + rows=( + actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ), + ), + audit_rows=( + ExtractionAuditRow( + ticker="SYN1", + state="cumulative_fact_rejected", + metric="revenue", + fiscal_period="2025-Q2", + source_ref="sec://original", + detail="duration is cumulative", + concept="Revenues", + start="2025-01-01", + end="2025-06-30", + frame="CY2025", + accession="0000000000-25-000001", + ), + ), + ) + + +def test_later_changed_presentation_is_append_only_revision(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ) + revised = actual( + "2025-Q2", + revenue=100, + eps=0.1, + source_ref="sec://split-adjusted", + reported_at="2025-11-01T00:00:00Z", + ) + + linked = link_quarter_revisions([original, revised]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref == original.source_ref + assert linked[0].eps_actual == 1.0 + + +def test_later_unchanged_presentation_is_deduplicated(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ) + later_same = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://later-presentation", + reported_at="2025-11-01T00:00:00Z", + ) + + linked = link_quarter_revisions([later_same, original]) + + assert linked == (original,) + + +def test_unrelated_conflicting_source_is_not_marked_as_revision(): + sec_original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ) + unrelated = actual( + "2025-Q2", + revenue=100, + eps=0.1, + source_ref="other://conflict", + reported_at="2025-11-01T00:00:00Z", + source="other_source", + ) + + linked = link_quarter_revisions([sec_original, unrelated]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref is None + + +def test_stage_writes_only_explicit_output_directory(tmp_path): + result = write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + stage_dir = tmp_path / "stage" + assert Path(result.quarterly_actuals_path).parent == stage_dir + assert (stage_dir / "quarterly_actuals.csv").exists() + assert (stage_dir / "consensus_snapshots.csv").read_text(encoding="utf-8").count("\n") == 1 + assert (stage_dir / "signals.csv").read_text(encoding="utf-8").count("\n") == 1 + assert result.automatic_apply is False + assert not (tmp_path / "data").exists() + + audit = json.loads((stage_dir / "sec_actuals_audit.json").read_text(encoding="utf-8")) + assert audit["audit_rows"][0]["concept"] == "Revenues" + assert audit["audit_rows"][0]["start"] == "2025-01-01" + assert audit["audit_rows"][0]["end"] == "2025-06-30" + assert audit["audit_rows"][0]["frame"] == "CY2025" + assert audit["audit_rows"][0]["accession"] == "0000000000-25-000001" + + with (stage_dir / "sec_actuals_rejected.csv").open(newline="", encoding="utf-8") as handle: + rejected = list(csv.DictReader(handle)) + assert rejected[0]["reason_code"] == "cumulative_fact_rejected" + assert rejected[0]["state"] == "cumulative_fact_rejected" + + +def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): + result = stage_sec_quarterly_actuals( + ["syn1", "missing"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_fetcher=lambda *_: companyfacts_fixture( + revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], + eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28")], + ), + ) + + assert result.requested_tickers == ("MISSING", "SYN1") + assert result.accepted_tickers == ("SYN1",) + assert result.withheld_tickers == ("MISSING",) + assert result.accepted_row_count == 1 + assert result.automatic_apply is False + + def test_normalization_rejects_non_json_numeric_scalars_and_non_integral_fiscal_years(): payload = companyfacts_fixture( revenue=[ From 6166c3361ded4e0da1ea2d9c17ddaadc89373c19 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 19:38:07 -0400 Subject: [PATCH 07/23] Keep SEC metric partials out of rejected stage rows --- src/earnings_nowcast_sec_actuals.py | 11 ++++++++++- tests/test_earnings_nowcast_sec_actuals.py | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 47e8a122e..1aca89b59 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -24,6 +24,15 @@ ) EPS_CONCEPT = "EarningsPerShareDiluted" SEC_QUARTERLY_FORMS = frozenset(("10-Q", "10-Q/A")) +REJECTED_AUDIT_STATES = frozenset( + ( + "ambiguous_concept", + "ambiguous_fiscal_identity", + "comparative_period_relabelled", + "cumulative_fact_rejected", + "post_cutoff_rejected", + ) +) @dataclass(frozen=True) @@ -513,7 +522,7 @@ def _rejected_audit_rows(results: Mapping[str, ExtractionResult]) -> list[Extrac audit_row for ticker in sorted(results) for audit_row in results[ticker].audit_rows - if not audit_row.state.startswith("accepted_") + if audit_row.state in REJECTED_AUDIT_STATES ] diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 0eaf7faac..54a0f6379 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -198,6 +198,28 @@ def test_stage_writes_only_explicit_output_directory(tmp_path): assert rejected[0]["state"] == "cumulative_fact_rejected" +def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): + extraction = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture(revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], eps=[]), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + result = write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction}) + + with Path(result.quarterly_actuals_path).open(newline="", encoding="utf-8") as handle: + staged_rows = list(csv.DictReader(handle)) + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + + assert len(staged_rows) == 1 + assert staged_rows[0]["revenue_actual"] == "12.0" + assert staged_rows[0]["eps_actual"] == "" + assert result.rejected_row_count == 0 + assert rejected_rows == [] + + def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): result = stage_sec_quarterly_actuals( ["syn1", "missing"], From 601e1e8cf27974a63a1135effdcd0acb205136b4 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 19:42:20 -0400 Subject: [PATCH 08/23] Include SEC staging failure states in rejected rows --- src/earnings_nowcast_sec_actuals.py | 3 ++ tests/test_earnings_nowcast_sec_actuals.py | 50 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 1aca89b59..8285cfecb 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -29,8 +29,11 @@ "ambiguous_concept", "ambiguous_fiscal_identity", "comparative_period_relabelled", + "companyfacts_fetch_failed", "cumulative_fact_rejected", + "fiscal_period_conflict", "post_cutoff_rejected", + "ticker_unresolved", ) ) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 54a0f6379..4515acf58 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -220,6 +220,56 @@ def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): assert rejected_rows == [] +def test_stage_writes_fiscal_period_conflicts_to_rejected_rows(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[ + _fact(val=12, start="2026-02-27", end="2026-05-28", fp="Q2"), + _fact(val=12, start="2026-02-27", end="2026-05-28", fp="Q3"), + ], + eps=[ + _fact(val=1.2, start="2026-02-27", end="2026-05-28", fp="Q2"), + _fact(val=1.2, start="2026-02-27", end="2026-05-28", fp="Q3"), + ], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + }, + ) + + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + + assert result.rejected_row_count == 4 + assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} + + +def test_stage_orchestrator_writes_unresolved_and_fetch_failures_to_rejected_rows(tmp_path): + result = stage_sec_quarterly_actuals( + ["missing", "broken"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"BROKEN": {"ticker": "BROKEN", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("fixture failure")), + ) + + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + + assert result.rejected_row_count == 2 + assert {(row["ticker"], row["state"]) for row in rejected_rows} == { + ("BROKEN", "companyfacts_fetch_failed"), + ("MISSING", "ticker_unresolved"), + } + + def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): result = stage_sec_quarterly_actuals( ["syn1", "missing"], From 6d5d7babaf7bbebebb7e39b97d47d50656b95d87 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 19:50:03 -0400 Subject: [PATCH 09/23] Require contiguous nowcast quarter history --- src/earnings_nowcast_model.py | 30 +++++- src/earnings_nowcast_readiness.py | 54 ++++++++++- tests/test_earnings_nowcast_model.py | 34 ++++++- tests/test_earnings_nowcast_readiness.py | 113 ++++++++++++++++++++++- 4 files changed, 218 insertions(+), 13 deletions(-) diff --git a/src/earnings_nowcast_model.py b/src/earnings_nowcast_model.py index 8b3c95176..f91bb93e7 100644 --- a/src/earnings_nowcast_model.py +++ b/src/earnings_nowcast_model.py @@ -7,7 +7,7 @@ from typing import Sequence from src.earnings_nowcast_contract import ConsensusSnapshot, ForecastSnapshot, QuarterlyActual, input_snapshot_hash, parse_utc_timestamp -from src.earnings_nowcast_readiness import assess_nowcast_readiness, canonicalize_actuals +from src.earnings_nowcast_readiness import assess_nowcast_readiness, canonicalize_actuals, contiguous_metric_window @dataclass(frozen=True) @@ -181,15 +181,35 @@ def build_baseline_nowcast( missing = ", ".join(readiness.missing_evidence) or readiness.state.value raise ValueError(f"Nowcast is blocked: {missing}") - canonical = canonicalize_actuals(rows, consensus) + canonical = canonicalize_actuals( + [ + row + for row in rows + if parse_utc_timestamp(row.reported_at) <= parse_utc_timestamp(cutoff) + and row.fiscal_period != consensus.fiscal_period + ], + consensus, + ) + revenue_window = contiguous_metric_window( + canonical.revenue_rows, + consensus.fiscal_period, + "revenue", + config.minimum_history_quarters, + ) + eps_window = contiguous_metric_window( + canonical.eps_rows, + consensus.fiscal_period, + "eps", + config.minimum_history_quarters, + ) revenue_midpoint = revenue_low = revenue_high = None if readiness.revenue_ready: - revenue_midpoint, revenue_low, revenue_high = _revenue_forecast(canonical.revenue_rows, consensus.fiscal_period, config) + revenue_midpoint, revenue_low, revenue_high = _revenue_forecast(revenue_window, consensus.fiscal_period, config) eps_midpoint = eps_low = eps_high = None if readiness.eps_ready: - eps_midpoint, eps_low, eps_high = _eps_forecast(canonical.eps_rows, consensus.fiscal_period, config) + eps_midpoint, eps_low, eps_high = _eps_forecast(eps_window, consensus.fiscal_period, config) revenue_classification = ( classify_consensus_gap( @@ -213,7 +233,7 @@ def build_baseline_nowcast( ) primary_classification = revenue_classification or eps_classification or "withheld" - canonical_rows = {row.source_ref: row for row in (*canonical.revenue_rows, *canonical.eps_rows)} + canonical_rows = {row.source_ref: row for row in (*revenue_window, *eps_window)} digest = input_snapshot_hash([*canonical_rows.values(), consensus, asdict(config)]) forecast_horizon_days = ( (date.fromisoformat(consensus.expected_report_date) - parse_utc_timestamp(cutoff).date()).days diff --git a/src/earnings_nowcast_readiness.py b/src/earnings_nowcast_readiness.py index 4726ddfa0..26a77ff8b 100644 --- a/src/earnings_nowcast_readiness.py +++ b/src/earnings_nowcast_readiness.py @@ -87,6 +87,34 @@ def _metric_definition(row: QuarterlyActual | ConsensusSnapshot, metric: str) -> ) +def _previous_period(period: str) -> str: + year, quarter = period.split("-Q", 1) + return f"{int(year) - 1}-Q4" if quarter == "1" else f"{year}-Q{int(quarter) - 1}" + + +def contiguous_metric_window( + rows: Sequence[QuarterlyActual], + target_period: str, + metric: str, + minimum_quarters: int, +) -> tuple[QuarterlyActual, ...]: + value_field = f"{metric}_actual" + rows_by_period = { + row.fiscal_period: row + for row in rows + if getattr(row, value_field) is not None + } + expected_period = _previous_period(target_period) + window: list[QuarterlyActual] = [] + for _ in range(minimum_quarters): + row = rows_by_period.get(expected_period) + if row is None: + return () + window.append(row) + expected_period = _previous_period(expected_period) + return tuple(reversed(window)) + + def _canonical_period_row(rows: Sequence[QuarterlyActual], metric: str) -> tuple[QuarterlyActual | None, tuple[str, ...]]: value_field = f"{metric}_actual" ordered = sorted(rows, key=lambda row: (parse_utc_timestamp(row.reported_at), parse_utc_timestamp(row.retrieved_at), row.source_ref)) @@ -209,10 +237,24 @@ def assess_nowcast_readiness( selected_consensus = _latest_consensus(available_consensus) canonical = canonicalize_actuals(available_actuals, selected_consensus) - revenue_history = [row.revenue_actual for row in canonical.revenue_rows] - eps_history = [row.eps_actual for row in canonical.eps_rows] - enough_revenue_history = len(revenue_history) >= minimum_history_quarters - enough_eps_history = len(eps_history) >= minimum_history_quarters + revenue_window = contiguous_metric_window( + canonical.revenue_rows, + normalized_period, + "revenue", + minimum_history_quarters, + ) + eps_window = contiguous_metric_window( + canonical.eps_rows, + normalized_period, + "eps", + minimum_history_quarters, + ) + revenue_history = [row.revenue_actual for row in revenue_window] + eps_history = [row.eps_actual for row in eps_window] + enough_revenue_history = len(revenue_window) == minimum_history_quarters + enough_eps_history = len(eps_window) == minimum_history_quarters + revenue_history_gap = bool(canonical.revenue_rows) and not enough_revenue_history + eps_history_gap = bool(canonical.eps_rows) and not enough_eps_history stable_eps_history = enough_eps_history and _sign_changes(float(value) for value in eps_history) <= 1 freshness_state = ( @@ -252,6 +294,8 @@ def assess_nowcast_readiness( missing.append("post_cutoff_evidence") if not enough_revenue_history: missing.append("quarterly_actual_history") + if revenue_history_gap or eps_history_gap: + missing.append("quarter_history_gap") if canonical.revenue_conflict_source_ids: missing.append("conflicting_quarterly_revenue") if canonical.eps_conflict_source_ids: @@ -272,7 +316,7 @@ def assess_nowcast_readiness( missing.append("current_consensus") state = NowcastState.BASELINE_READY if revenue_ready or eps_ready else NowcastState.BLOCKED - source_ids = tuple(row.source_ref for row in (*canonical.revenue_rows, *canonical.eps_rows)) + source_ids = tuple(row.source_ref for row in (*revenue_window, *eps_window)) if selected_consensus is not None: source_ids += ( selected_consensus.source_ref diff --git a/tests/test_earnings_nowcast_model.py b/tests/test_earnings_nowcast_model.py index 81245249a..2cbf3d788 100644 --- a/tests/test_earnings_nowcast_model.py +++ b/tests/test_earnings_nowcast_model.py @@ -5,6 +5,7 @@ import pytest from src.earnings_nowcast_contract import ConsensusSnapshot, NowcastState, QuarterlyActual +from src import earnings_nowcast_model from src.earnings_nowcast_model import NowcastConfig, build_baseline_nowcast, classify_consensus_gap @@ -96,13 +97,42 @@ def test_blocked_eps_never_renders_a_numeric_eps_forecast(): assert result.eps_gap_pct is None -def test_missing_prior_year_target_quarter_fails_instead_of_guessing_seasonality(): +def test_missing_prior_year_target_quarter_is_withheld_as_a_continuity_gap(): rows = [row for row in _actuals() if row.fiscal_period != "2025-Q1"] - with pytest.raises(ValueError, match="prior-year target quarter"): + with pytest.raises(ValueError, match="quarter_history_gap"): build_baseline_nowcast(rows, _consensus(), CUTOFF, NowcastConfig(minimum_history_quarters=4)) +def test_missing_q4_never_passes_q3_to_q1_into_sequential_growth(monkeypatch: pytest.MonkeyPatch): + rows = [row for row in _actuals() if row.fiscal_period != "2025-Q4"] + rows.append( + QuarterlyActual( + ticker="SYN1", + fiscal_period="2026-Q1", + period_end_date="2026-03-31", + reported_at="2026-01-16T21:00:00Z", + revenue_actual=104.0, + eps_actual=1.3, + source="synthetic_test_fixture", + source_ref="fixture://actual/2026-Q1", + retrieved_at="2026-01-16T21:01:00Z", + ) + ) + sequential_inputs: list[list[float]] = [] + + def capture_sequential_growth(values: list[float]) -> list[float]: + sequential_inputs.append(values) + return [0.01] + + monkeypatch.setattr(earnings_nowcast_model, "_sequential_growth", capture_sequential_growth) + + with pytest.raises(ValueError, match="quarter_history_gap"): + build_baseline_nowcast(rows, replace(_consensus(), fiscal_period="2026-Q2"), CUTOFF) + + assert sequential_inputs == [] + + def test_post_cutoff_actual_fails_closed(): rows = _actuals() rows.append( diff --git a/tests/test_earnings_nowcast_readiness.py b/tests/test_earnings_nowcast_readiness.py index 6731ca3bd..2593465f7 100644 --- a/tests/test_earnings_nowcast_readiness.py +++ b/tests/test_earnings_nowcast_readiness.py @@ -5,7 +5,7 @@ import pytest from src.earnings_nowcast_contract import ConsensusSnapshot, FreshnessState, NowcastState, QuarterlyActual -from src.earnings_nowcast_readiness import assess_nowcast_readiness, readiness_payload +from src.earnings_nowcast_readiness import assess_nowcast_readiness, canonicalize_actuals, readiness_payload CUTOFF = "2026-01-31T23:59:59Z" @@ -221,6 +221,43 @@ def test_duplicate_fiscal_period_does_not_satisfy_minimum_history(): assert "quarterly_actual_history" in result.missing_evidence +def test_missing_q4_withholds_both_metrics_instead_of_treating_q3_to_q1_as_sequential(): + rows = [row for row in _actuals() if row.fiscal_period != "2024-Q4"] + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[_consensus()], + ) + + assert result.revenue_ready is False + assert result.eps_ready is False + assert "quarter_history_gap" in result.missing_evidence + + +def test_source_ids_include_only_the_contiguous_metric_windows(): + oldest = replace( + _actuals()[0], + fiscal_period="2024-Q3", + period_end_date="2024-09-30", + reported_at="2024-11-01T21:00:00Z", + retrieved_at="2024-11-01T21:00:00Z", + source_ref="fixture://actual/2024-Q3", + ) + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[oldest, *_actuals()], + consensus=[_consensus()], + ) + + assert "fixture://actual/2024-Q3" not in result.source_ids + + def test_conflicting_quarterly_revenue_blocks_only_revenue(): rows = _actuals() rows.append( @@ -348,3 +385,77 @@ def test_incompatible_metric_definitions_fail_only_the_affected_metric( assert getattr(result, ready_metric) is True assert getattr(result, blocked_metric) is False assert reason in result.missing_evidence + + +def test_split_basis_change_withholds_eps_but_keeps_revenue_ready(): + rows = _actuals() + rows[0] = replace(rows[0], split_adjustment_basis="pre_split") + consensus = replace(_consensus(), split_adjustment_basis="post_split_2024_06_10") + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[consensus], + ) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence + + +def test_pre_cutoff_split_adjusted_revisions_restore_eps_but_post_cutoff_revisions_do_not(): + from src.earnings_nowcast_readiness import contiguous_metric_window + + rows = _actuals() + consensus = replace(_consensus(), split_adjustment_basis="post_split_2024_06_10") + pre_cutoff_revisions = [ + replace( + row, + revenue_actual=None, + reported_at="2026-01-16T21:00:00Z", + retrieved_at="2026-01-16T21:01:00Z", + source_ref=f"fixture://revision/{row.fiscal_period}", + split_adjustment_basis="post_split_2024_06_10", + supersedes_source_ref=row.source_ref, + ) + for row in rows + ] + + pre_cutoff = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[*rows, *pre_cutoff_revisions], + consensus=[consensus], + ) + post_cutoff = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[ + *rows, + *[ + replace( + revision, + reported_at="2026-02-01T21:00:00Z", + retrieved_at="2026-02-01T21:01:00Z", + ) + for revision in pre_cutoff_revisions + ], + ], + consensus=[consensus], + ) + + assert pre_cutoff.revenue_ready is True + assert pre_cutoff.eps_ready is True + assert any(source_id.startswith("fixture://revision/") for source_id in pre_cutoff.source_ids) + canonical = canonicalize_actuals([*rows, *pre_cutoff_revisions], consensus) + assert tuple( + row.source_ref + for row in contiguous_metric_window(canonical.eps_rows, "2026-Q1", "eps", minimum_quarters=5) + ) == tuple(revision.source_ref for revision in pre_cutoff_revisions) + assert post_cutoff.eps_ready is False + assert "post_cutoff_evidence" in post_cutoff.missing_evidence + assert "incompatible_eps_definition" in post_cutoff.missing_evidence From 3d13bc7bd283491e8f7834b2b10b646427f4e62c Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:02:21 -0400 Subject: [PATCH 10/23] Stage explicit SEC filed Q4 actuals --- src/earnings_nowcast_sec_actuals.py | 488 ++++++++++++++++++++- src/providers/sec_submissions.py | 120 +++++ tests/test_earnings_nowcast_sec_actuals.py | 176 ++++++++ tests/test_sec_submissions.py | 27 ++ 4 files changed, 810 insertions(+), 1 deletion(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 8285cfecb..0ee810633 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -2,8 +2,10 @@ import csv import json +import re from dataclasses import asdict, dataclass, replace from datetime import date, datetime, timezone +from html.parser import HTMLParser from math import isfinite from pathlib import Path from typing import Any, Callable, Mapping, Sequence @@ -15,6 +17,13 @@ load_sec_ticker_map, resolve_ticker_to_cik, ) +from src.providers.sec_submissions import ( + FiledExhibit, + extract_filing_exhibits, + fetch_sec_filing_document, + fetch_sec_filing_index, + fetch_sec_submission, +) REVENUE_CONCEPTS = ( @@ -33,6 +42,12 @@ "cumulative_fact_rejected", "fiscal_period_conflict", "post_cutoff_rejected", + "q4_source_unavailable", + "quarter_header_missing", + "ambiguous_period_header", + "guidance_or_outlook_rejected", + "gaap_eps_missing", + "derived_q4_rejected", "ticker_unresolved", ) ) @@ -92,6 +107,300 @@ class StageResult: automatic_apply: bool = False +class _StructuredTableParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.tables: list[tuple[tuple[tuple[str, ...], ...], str]] = [] + self._page_text: list[str] = [] + self._table_rows: list[list[str]] | None = None + self._row: list[str] | None = None + self._cell: list[str] | None = None + self._table_depth = 0 + + @property + def page_text(self) -> str: + return " ".join(self._page_text) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + lowered = tag.lower() + if lowered == "table": + self._table_depth += 1 + if self._table_depth == 1: + self._table_rows = [] + elif self._table_depth and lowered == "tr": + self._row = [] + elif self._table_depth and lowered in {"td", "th"} and self._row is not None: + self._cell = [] + + def handle_data(self, data: str) -> None: + self._page_text.append(data) + if self._cell is not None: + self._cell.append(data) + + def handle_endtag(self, tag: str) -> None: + lowered = tag.lower() + if lowered in {"td", "th"} and self._cell is not None and self._row is not None: + self._row.append(" ".join(self._cell).strip()) + self._cell = None + elif lowered == "tr" and self._row is not None and self._table_rows is not None: + if self._row: + self._table_rows.append(self._row) + self._row = None + elif lowered == "table" and self._table_depth: + self._table_depth -= 1 + if self._table_depth == 0 and self._table_rows is not None: + rows = tuple(tuple(cell for cell in row) for row in self._table_rows) + self.tables.append((rows, " ".join(cell for row in rows for cell in row))) + self._table_rows = None + + +def _normalized_table_text(value: str) -> str: + return " ".join(str(value or "").replace("\xa0", " ").lower().split()) + + +def _q4_audit( + ticker: str, + state: str, + metric: str, + fiscal_period: str, + exhibit: FiledExhibit, + detail: str, +) -> ExtractionAuditRow: + return ExtractionAuditRow( + ticker=str(ticker or "").strip().upper(), + state=state, + metric=metric, + fiscal_period=fiscal_period, + source_ref=exhibit.source_ref, + detail=detail, + concept=exhibit.document_type, + accession=exhibit.accession, + ) + + +def _explicit_q4_fiscal_period(document_text: str) -> str | None: + text = _normalized_table_text(document_text) + years = { + match.group(1) + for pattern in ( + r"(?:fourth quarter|q4)\s+(?:of\s+)?fiscal\s+(20\d{2})", + r"fiscal\s+(20\d{2}).{0,80}?(?:fourth quarter|q4)", + r"q4\s+fy\s*(20\d{2})", + ) + for match in re.finditer(pattern, text) + } + if len(years) != 1: + return None + return f"{next(iter(years))}-Q4" + + +def _q4_value_column(rows: tuple[tuple[str, ...], ...], fiscal_period: str) -> int | None: + fiscal_year = fiscal_period.split("-", 1)[0] + fiscal_year_short = fiscal_year[-2:] + columns = { + index + for row in rows[:2] + for index, cell in enumerate(row) + if re.search(rf"\bq4\s+(?:fy\s*)?(?:{fiscal_year}|{fiscal_year_short})\b", _normalized_table_text(cell)) + or re.search(rf"\bfiscal\s+{fiscal_year}\s+q4\b", _normalized_table_text(cell)) + } + return next(iter(columns)) if len(columns) == 1 else None + + +def _has_ambiguous_q4_header(rows: tuple[tuple[str, ...], ...]) -> bool: + headers = _normalized_table_text(" ".join(cell for row in rows[:2] for cell in row)) + return "q4" in headers + + +def _table_number(value: str, *, scale_required: bool) -> tuple[float, float] | None: + text = _normalized_table_text(value) + if any(word in text for word in ("expected", "approximately", "outlook", "guidance")): + return None + match = re.search(r"(?P\()?\s*\$?\s*(?P\d[\d,]*(?:\.\d+)?)", text) + if not match: + return None + try: + value_number = float(match.group("number").replace(",", "")) + except ValueError: + return None + if match.group("negative"): + value_number = -value_number + if "billion" in text: + return value_number, 1_000_000_000.0 + if "million" in text: + return value_number, 1_000_000.0 + return (value_number, 1.0) if not scale_required else None + + +def _q4_metric_values( + rows: tuple[tuple[str, ...], ...], + value_column: int = 1, +) -> tuple[float | None, float | None, bool, bool]: + revenue: float | None = None + eps: float | None = None + non_gaap_eps_present = False + derived = False + for row in rows: + if len(row) <= value_column: + continue + label = _normalized_table_text(row[0]) + value = row[value_column] + if any(word in label for word in ("less", "minus", "subtract", "derived", "calculated")): + derived = True + continue + if label in {"revenue", "total revenue", "net revenue"}: + parsed = _table_number(value, scale_required=False) + if parsed is not None: + raw_value, scale = parsed + revenue = raw_value * scale + elif "non-gaap" in label and "earnings per share" in label: + non_gaap_eps_present = True + elif label in { + "gaap diluted earnings per share", + "diluted gaap earnings per share", + "gaap diluted eps", + }: + parsed = _table_number(value, scale_required=False) + if parsed is not None: + eps = parsed[0] + return revenue, eps, non_gaap_eps_present, derived + + +def _split_adjustment_basis(document_text: str) -> str: + text = _normalized_table_text(document_text) + match = re.search( + r"(?:retrospectively|retroactively) adjusted[^.]{0,160}?split[^.]{0,160}?effective\s+([a-z]+\s+\d{1,2},\s+20\d{2})", + text, + ) + if not match: + return "as_reported" + try: + effective_date = datetime.strptime(match.group(1), "%B %d, %Y").date() + except ValueError: + return "as_reported" + return f"split_adjusted_{effective_date.isoformat().replace('-', '_')}" + + +def extract_explicit_q4_actual( + ticker: str, + exhibit: FiledExhibit, + document_text: str, + *, + fiscal_period: str, + filed_at: str, + retrieved_at: str, + cutoff: str | None = None, + period_end_date: str | None = None, +) -> ExtractionResult: + normalized_ticker = str(ticker or "").strip().upper() + parser = _StructuredTableParser() + parser.feed(document_text or "") + audit_rows: list[ExtractionAuditRow] = [] + if not re.fullmatch(r"20\d{2}-Q4", fiscal_period or ""): + return ExtractionResult( + rows=(), + audit_rows=( + _q4_audit(normalized_ticker, "ambiguous_period_header", "quarterly_actual", "", exhibit, "fiscal period is not an explicit Q4 identity"), + ), + ) + explicit_period = _explicit_q4_fiscal_period(parser.page_text) + if explicit_period is not None and explicit_period != fiscal_period: + return ExtractionResult( + rows=(), + audit_rows=( + _q4_audit(normalized_ticker, "ambiguous_period_header", "quarterly_actual", fiscal_period, exhibit, "document does not state one matching fiscal Q4 identity"), + ), + ) + matching_tables = [ + (rows, value_column) + for rows, _table_text in parser.tables + if (value_column := _q4_value_column(rows, fiscal_period)) is not None + ] + if not matching_tables: + state = "ambiguous_period_header" if any(_has_ambiguous_q4_header(rows) for rows, _text in parser.tables) else "quarter_header_missing" + audit_rows.append( + _q4_audit(normalized_ticker, state, "quarterly_actual", fiscal_period, exhibit, "no structured table has an unambiguous matching Q4 header") + ) + if any(_q4_metric_values(rows)[3] for rows, _text in parser.tables): + audit_rows.append( + _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table labels a metric as derived or arithmetic") + ) + return ExtractionResult( + rows=(), + audit_rows=tuple(audit_rows), + ) + candidates: list[tuple[float | None, float | None]] = [] + for rows, value_column in matching_tables: + table_text = _normalized_table_text(" ".join(cell for row in rows for cell in row)) + revenue, eps, non_gaap_eps_present, derived = _q4_metric_values(rows, value_column) + if any(word in table_text for word in ("outlook", "expected", "approximately", "guidance")): + audit_rows.append( + _q4_audit(normalized_ticker, "guidance_or_outlook_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table contains guidance or outlook language") + ) + if non_gaap_eps_present and eps is None: + audit_rows.append( + _q4_audit(normalized_ticker, "gaap_eps_missing", "eps", fiscal_period, exhibit, "non-GAAP EPS is not accepted without an explicit GAAP diluted EPS label") + ) + continue + if derived: + audit_rows.append( + _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table labels a metric as derived or arithmetic") + ) + continue + if non_gaap_eps_present and eps is None: + audit_rows.append( + _q4_audit(normalized_ticker, "gaap_eps_missing", "eps", fiscal_period, exhibit, "non-GAAP EPS is not accepted without an explicit GAAP diluted EPS label") + ) + if revenue is not None or eps is not None: + candidates.append((revenue, eps)) + presentations = set(candidates) + if len(presentations) > 1: + audit_rows.append( + _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + if not candidates: + if not audit_rows: + audit_rows.append( + _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "explicit Revenue and GAAP diluted EPS labels were not found in a Q4 table") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + reported_at = datetime.fromisoformat(filed_at.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() + if cutoff is not None: + cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) + if cutoff_timestamp.tzinfo is None: + raise ValueError("cutoff must be timezone-aware") + if datetime.fromisoformat(reported_at) > cutoff_timestamp.astimezone(timezone.utc): + audit_rows.append( + _q4_audit(normalized_ticker, "post_cutoff_rejected", "quarterly_actual", fiscal_period, exhibit, f"reported_at={reported_at} is after cutoff={cutoff_timestamp.astimezone(timezone.utc).isoformat()}") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + revenue, eps = candidates[0] + resolved_period_end = period_end_date or reported_at[:10] + row = QuarterlyActual( + ticker=normalized_ticker, + fiscal_period=fiscal_period, + period_end_date=resolved_period_end, + reported_at=reported_at, + revenue_actual=revenue, + eps_actual=eps, + source="sec_filed_exhibit", + source_ref=exhibit.source_ref, + retrieved_at=retrieved_at, + split_adjustment_basis=_split_adjustment_basis(parser.page_text), + ) + for metric, value in (("revenue", revenue), ("eps", eps)): + if value is not None: + audit_rows.append( + _q4_audit(normalized_ticker, "accepted_explicit_q4", metric, fiscal_period, exhibit, f"explicit Q4 {metric} in SEC-filed {exhibit.document_type} table") + ) + if revenue is None or eps is None: + audit_rows.append( + _q4_audit(normalized_ticker, "metric_partial", "quarterly_actual", fiscal_period, exhibit, "only one source-backed Q4 metric is available") + ) + return ExtractionResult(rows=(row,), audit_rows=tuple(audit_rows)) + + def _required_text(value: object) -> str | None: text = str(value or "").strip() return text or None @@ -595,6 +904,74 @@ def write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionRe ) +def _recent_q4_candidate_filings(payload: Mapping[str, Any]) -> tuple[tuple[str, str, str], ...]: + recent = payload.get("filings", {}).get("recent", {}) if isinstance(payload.get("filings"), Mapping) else {} + if not isinstance(recent, Mapping): + return () + forms = recent.get("form") if isinstance(recent.get("form"), list) else [] + filing_dates = recent.get("filingDate") if isinstance(recent.get("filingDate"), list) else [] + report_dates = recent.get("reportDate") if isinstance(recent.get("reportDate"), list) else [] + accessions = recent.get("accessionNumber") if isinstance(recent.get("accessionNumber"), list) else [] + candidates: list[tuple[str, str, str]] = [] + for index, form in enumerate(forms): + if str(form or "").strip().upper() not in {"8-K", "8-K/A"}: + continue + accession = str(accessions[index] if index < len(accessions) else "").strip() + filed_date = str(filing_dates[index] if index < len(filing_dates) else "").strip() + report_date = str(report_dates[index] if index < len(report_dates) else "").strip() + if accession and filed_date: + candidates.append((accession, filed_date, report_date)) + return tuple(candidates) + + +def _q4_source_unavailable( + ticker: str, + source_ref: str, + detail: str, + accession: str = "", +) -> ExtractionAuditRow: + return ExtractionAuditRow( + ticker=ticker, + state="q4_source_unavailable", + metric="quarterly_actual", + fiscal_period="", + source_ref=source_ref, + detail=detail, + accession=accession, + ) + + +def _combine_q4_results( + ticker: str, + q4_results: Sequence[ExtractionResult], +) -> ExtractionResult: + audit_rows = [audit_row for result in q4_results for audit_row in result.audit_rows] + rows = [row for result in q4_results for row in result.rows] + by_period: dict[str, list[QuarterlyActual]] = {} + for row in rows: + by_period.setdefault(row.fiscal_period, []).append(row) + accepted_rows: list[QuarterlyActual] = [] + for fiscal_period, period_rows in sorted(by_period.items()): + presentations = { + (row.revenue_actual, row.eps_actual, row.split_adjustment_basis, row.period_end_date) + for row in period_rows + } + if len(presentations) > 1: + audit_rows.append( + ExtractionAuditRow( + ticker=ticker, + state="ambiguous_concept", + metric="quarterly_actual", + fiscal_period=fiscal_period, + source_ref=period_rows[0].source_ref, + detail="multiple SEC-filed Q4 exhibits disagree", + ) + ) + continue + accepted_rows.append(sorted(period_rows, key=lambda row: row.source_ref)[0]) + return ExtractionResult(rows=tuple(accepted_rows), audit_rows=tuple(audit_rows)) + + def stage_sec_quarterly_actuals( tickers: Sequence[str], *, @@ -610,6 +987,12 @@ def stage_sec_quarterly_actuals( ticker_map_fetcher: Callable[[str, str, float], Any] | None = None, companyfacts_loader: Callable[..., Mapping[str, Any]] = fetch_companyfacts, companyfacts_fetcher: Callable[[str, str, float], Any] | None = None, + submissions_loader: Callable[..., Mapping[str, Any]] = fetch_sec_submission, + submissions_fetcher: Callable[[str, str, float], Any] | None = None, + filing_index_loader: Callable[..., str] = fetch_sec_filing_index, + filing_index_fetcher: Callable[[str, str, float], str] | None = None, + exhibit_document_loader: Callable[..., str] = fetch_sec_filing_document, + exhibit_document_fetcher: Callable[[str, str, float], str] | None = None, ) -> StageResult: requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()})) if ticker_map is None: @@ -653,10 +1036,113 @@ def stage_sec_quarterly_actuals( ), ) continue - results[ticker] = extract_q1_q3_lineage( + q1_q3_result = extract_q1_q3_lineage( ticker, payload, cutoff=cutoff, retrieved_at=resolved_retrieved_at, ) + try: + submissions_payload = submissions_loader( + cik, + user_agent, + cache=False, + refresh=refresh, + cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + sleep_seconds=sleep_seconds, + fetcher=submissions_fetcher, + ) + except RuntimeError as exc: + results[ticker] = ExtractionResult( + rows=q1_q3_result.rows, + audit_rows=q1_q3_result.audit_rows + + (_q4_source_unavailable(ticker, "", f"SEC submissions lookup failed: {exc}"),), + ) + continue + q4_results: list[ExtractionResult] = [] + for accession, filed_date, report_date in _recent_q4_candidate_filings(submissions_payload): + try: + index_html = filing_index_loader( + cik, + accession, + user_agent, + cache=False, + refresh=refresh, + cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + sleep_seconds=sleep_seconds, + fetcher=filing_index_fetcher, + ) + except RuntimeError as exc: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=(_q4_source_unavailable(ticker, "", f"filing index lookup failed: {exc}", accession),), + ) + ) + continue + exhibits = extract_filing_exhibits(index_html, cik=cik, accession=accession) + if not exhibits: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=(_q4_source_unavailable(ticker, "", "no EX-99 result exhibit was found", accession),), + ) + ) + continue + for exhibit in exhibits: + try: + document_text = exhibit_document_loader( + cik, + accession, + exhibit.document_name, + user_agent, + cache=False, + refresh=refresh, + cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + sleep_seconds=sleep_seconds, + fetcher=exhibit_document_fetcher, + ) + except RuntimeError as exc: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=(_q4_source_unavailable(ticker, exhibit.source_ref, f"exhibit lookup failed: {exc}", accession),), + ) + ) + continue + fiscal_period = _explicit_q4_fiscal_period(document_text) + if fiscal_period is None: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=( + _q4_audit( + ticker, + "ambiguous_period_header", + "quarterly_actual", + "", + exhibit, + "document does not state one explicit fiscal Q4 identity", + ), + ), + ) + ) + continue + q4_results.append( + extract_explicit_q4_actual( + ticker, + exhibit, + document_text, + fiscal_period=fiscal_period, + filed_at=f"{filed_date}T00:00:00Z", + retrieved_at=resolved_retrieved_at, + cutoff=cutoff, + period_end_date=report_date or None, + ) + ) + q4_result = _combine_q4_results(ticker, q4_results) + results[ticker] = ExtractionResult( + rows=q1_q3_result.rows + q4_result.rows, + audit_rows=q1_q3_result.audit_rows + q4_result.audit_rows, + ) return write_sec_actuals_stage(output_dir, results) diff --git a/src/providers/sec_submissions.py b/src/providers/sec_submissions.py index 0ad3045f0..0788de97d 100644 --- a/src/providers/sec_submissions.py +++ b/src/providers/sec_submissions.py @@ -4,14 +4,18 @@ import os import re import time +from dataclasses import dataclass +from html.parser import HTMLParser from pathlib import Path from typing import Any, Callable, Iterable +from urllib.parse import urljoin from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen SEC_SUBMISSIONS_URL_TEMPLATE = "https://data.sec.gov/submissions/CIK{cik}.json" SEC_FILING_DOCUMENT_URL_TEMPLATE = "https://www.sec.gov/Archives/edgar/data/{cik}/{accession}/{primary_document}" +SEC_FILING_INDEX_URL_TEMPLATE = "https://www.sec.gov/Archives/edgar/data/{cik}/{accession_path}/{accession}-index.html" SEC_SUBMISSIONS_METADATA_BOUNDARY = ( "SEC submissions metadata supports ticker/entity/SIC/filing-recency evidence only; " "it does not unlock fundamentals, share count, DCF, valuation, earnings, or analyst estimates." @@ -25,6 +29,15 @@ SHARE_COUNT_FILING_FORMS = ("10-K", "10-K/A", "10-Q", "10-Q/A", "20-F", "20-F/A", "40-F", "40-F/A") +@dataclass(frozen=True) +class FiledExhibit: + document_type: str + document_name: str + source_ref: str + cik: str = "" + accession: str = "" + + def normalize_cik(cik: str | int) -> str: text = str(cik).upper().replace("CIK", "").strip() digits = "".join(character for character in text if character.isdigit()) @@ -54,6 +67,85 @@ def sec_filing_document_url(cik: str | int, accession: str, primary_document: st ) +def sec_filing_index_url(cik: str | int, accession: str) -> str: + normalized_cik = normalize_cik(cik).lstrip("0") + accession_text = _accession_no_dashes(accession) + accession_display = str(accession or "").strip() + if not accession_text or not accession_display: + raise ValueError("SEC filing index URL requires an accession.") + return SEC_FILING_INDEX_URL_TEMPLATE.format( + cik=normalized_cik, + accession_path=accession_text, + accession=accession_display, + ) + + +class _FilingIndexParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.rows: list[list[tuple[str, str]]] = [] + self._row: list[tuple[str, str]] | None = None + self._cell_text: list[str] | None = None + self._cell_href = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.lower() == "tr": + self._row = [] + elif tag.lower() in {"td", "th"} and self._row is not None: + self._cell_text = [] + self._cell_href = "" + elif tag.lower() == "a" and self._cell_text is not None: + self._cell_href = dict(attrs).get("href") or "" + + def handle_data(self, data: str) -> None: + if self._cell_text is not None: + self._cell_text.append(data) + + def handle_endtag(self, tag: str) -> None: + lowered = tag.lower() + if lowered in {"td", "th"} and self._row is not None and self._cell_text is not None: + self._row.append((" ".join(self._cell_text).strip(), self._cell_href)) + self._cell_text = None + self._cell_href = "" + elif lowered == "tr" and self._row is not None: + if self._row: + self.rows.append(self._row) + self._row = None + + +def extract_filing_exhibits(index_html: str, *, cik: str, accession: str) -> tuple[FiledExhibit, ...]: + parser = _FilingIndexParser() + parser.feed(index_html or "") + base_url = sec_filing_index_url(cik, accession) + exhibits: list[FiledExhibit] = [] + seen: set[tuple[str, str]] = set() + for row in parser.rows: + document_type = next( + (text.upper() for text, _href in row if re.fullmatch(r"EX-99(?:\.\d+)?", text.upper())), + "", + ) + if not document_type: + continue + document_name = next((href or text for text, href in row if href or text.lower().endswith((".htm", ".html"))), "") + if not document_name: + continue + source_ref = urljoin(base_url, document_name) + key = (document_type, source_ref) + if key in seen: + continue + seen.add(key) + exhibits.append( + FiledExhibit( + document_type=document_type, + document_name=document_name, + source_ref=source_ref, + cik=normalize_cik(cik), + accession=str(accession).strip(), + ) + ) + return tuple(exhibits) + + def _require_user_agent(user_agent: str | None = None) -> str: resolved = (user_agent or os.environ.get("SEC_USER_AGENT", "")).strip() if not resolved: @@ -76,6 +168,12 @@ def _filing_document_cache_path(cache_dir: Path, cik: str, accession: str, prima return path / str(primary_document).strip() +def _filing_index_cache_path(cache_dir: Path, cik: str, accession: str) -> Path: + path = cache_dir / "filing_indexes" / f"CIK{normalize_cik(cik)}" / _accession_no_dashes(accession) + path.mkdir(parents=True, exist_ok=True) + return path / "index.html" + + def read_cached_sec_submission(cik: str | int, *, cache_dir: str | Path = "data/cache/sec") -> dict[str, Any] | None: cache_path = _submissions_cache_path(Path(cache_dir), normalize_cik(cik)) if not cache_path.exists(): @@ -308,6 +406,28 @@ def fetch_sec_filing_document( return text +def fetch_sec_filing_index( + cik: str | int, + accession: str, + user_agent: str | None = None, + *, + cache: bool = True, + refresh: bool = False, + cache_dir: str | Path = "data/cache/sec", + sleep_seconds: float = 0.2, + fetcher: Callable[[str, str, float], str] | None = None, +) -> str: + resolved_user_agent = _require_user_agent(user_agent) + normalized_cik = normalize_cik(cik) + cache_path = _filing_index_cache_path(Path(cache_dir), normalized_cik, accession) + if cache and cache_path.exists() and not refresh: + return cache_path.read_text(encoding="utf-8", errors="replace") + text = (fetcher or _fetch_text)(sec_filing_index_url(normalized_cik, accession), resolved_user_agent, sleep_seconds) + if cache: + cache_path.write_text(text, encoding="utf-8") + return text + + _IX_NON_FRACTION_RE = re.compile( r"[^>]*)>(?P.*?)", flags=re.IGNORECASE | re.DOTALL, diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 4515acf58..5803a9996 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -8,18 +8,157 @@ from src.earnings_nowcast_sec_actuals import ( ExtractionAuditRow, ExtractionResult, + extract_explicit_q4_actual, extract_q1_q3_lineage, link_quarter_revisions, normalize_sec_duration_facts, stage_sec_quarterly_actuals, write_sec_actuals_stage, ) +from src.providers.sec_submissions import FiledExhibit CUTOFF = "2026-06-30T23:59:59Z" RETRIEVED_AT = "2026-06-26T12:00:00Z" +Q4_EXHIBIT = FiledExhibit( + document_type="EX-99.1", + document_name="earnings-release.htm", + source_ref="https://www.sec.gov/Archives/edgar/data/123456/000012345626000001/earnings-release.htm", +) + + +Q4_RELEASE_HTML = """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+

All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.

+""" + + +def test_extract_explicit_q4_actual_reads_filed_result_table(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert len(result.rows) == 1 + assert result.rows[0].fiscal_period == "2025-Q4" + assert result.rows[0].revenue_actual == 39_331_000_000 + assert result.rows[0].eps_actual == 0.89 + assert result.rows[0].split_adjustment_basis == "split_adjusted_2024_06_07" + + +def test_extract_explicit_q4_actual_reads_the_column_labeled_q4(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Fiscal 2025Q4 FY25
Revenue$160 billion$39,331 million
GAAP diluted earnings per share$3.00$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 39_331_000_000 + assert result.rows[0].eps_actual == 0.89 + + +def test_extract_explicit_q4_actual_rejects_annual_only_table(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ + + +
Fiscal 2025
Revenue$100 billion
GAAP diluted earnings per share$2.00
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "quarter_header_missing" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_rejects_guidance_and_non_gaap_metrics(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Q4 FY25 outlook

+ +
Q4 FY25
Revenue expectedapproximately $40 billion
Non-GAAP diluted earnings per share$0.90
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert {row.state for row in result.audit_rows} >= {"guidance_or_outlook_rejected", "gaap_eps_missing"} + + +def test_extract_explicit_q4_actual_rejects_derived_and_ambiguous_q4_headers(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ + + +
Q4 / Fiscal 2025
Revenue (annual less nine months)$40 billion
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert {row.state for row in result.audit_rows} >= {"ambiguous_period_header", "derived_q4_rejected"} + + +def test_extract_explicit_q4_actual_rejects_post_cutoff_but_keeps_as_reported_split_basis(): + post_cutoff = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML, + fiscal_period="2025-Q4", + filed_at="2026-07-01T00:00:00Z", + retrieved_at=RETRIEVED_AT, + cutoff=CUTOFF, + ) + as_reported = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML.replace( + "All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.", + "", + ), + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert post_cutoff.rows == () + assert "post_cutoff_rejected" in {row.state for row in post_cutoff.audit_rows} + assert as_reported.rows[0].split_adjustment_basis == "as_reported" + + def _fact( *, val, @@ -282,6 +421,7 @@ def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28")], ), + submissions_loader=lambda *_args, **_kwargs: {"filings": {"recent": {}}}, ) assert result.requested_tickers == ("MISSING", "SYN1") @@ -291,6 +431,42 @@ def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): assert result.automatic_apply is False +def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + index_html = """ + +
earnings-release.htmEarnings releaseEX-99.1
+ """ + release_html = Q4_RELEASE_HTML.replace("Fiscal 2025", "Fiscal 2026").replace("FY25", "FY26") + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: index_html, + exhibit_document_loader=lambda *_args, **_kwargs: release_html, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + assert [(row["fiscal_period"], row["source"]) for row in rows] == [("2026-Q4", "sec_filed_exhibit")] + assert rows[0]["source_ref"].endswith("/earnings-release.htm") + + def test_normalization_rejects_non_json_numeric_scalars_and_non_integral_fiscal_years(): payload = companyfacts_fixture( revenue=[ diff --git a/tests/test_sec_submissions.py b/tests/test_sec_submissions.py index d238ec501..6b684336a 100644 --- a/tests/test_sec_submissions.py +++ b/tests/test_sec_submissions.py @@ -7,9 +7,11 @@ build_sec_filing_share_count_evidence, build_sec_submission_metadata_packet, build_sec_submission_metadata, + extract_filing_exhibits, extract_share_count_from_inline_xbrl, fetch_sec_submission, latest_filing_document, + sec_filing_index_url, sec_filing_document_url, sec_submission_url, ) @@ -40,6 +42,31 @@ def test_sec_submission_url_zero_pads_cik(): assert sec_submission_url("1045810") == SEC_SUBMISSIONS_URL_TEMPLATE.format(cik="0001045810") +def test_sec_filing_index_url_uses_archive_accession_path(): + assert sec_filing_index_url("0001045810", "0001045810-26-000021") == ( + "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000021/0001045810-26-000021-index.html" + ) + + +def test_extract_filing_exhibits_discovers_explicit_ex_99_links(): + index_html = """ + + + + +
DocumentDescriptionType
nvda-ex991.htmEarnings releaseEX-99.1
not-an-exhibit.htmPrimary8-K
+ """ + + exhibits = extract_filing_exhibits(index_html, cik="0001045810", accession="0001045810-26-000021") + + assert len(exhibits) == 1 + assert exhibits[0].document_type == "EX-99.1" + assert exhibits[0].document_name == "nvda-ex991.htm" + assert exhibits[0].source_ref == ( + "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000021/nvda-ex991.htm" + ) + + def test_build_sec_submission_metadata_maps_entity_industry_and_latest_filing(): metadata = build_sec_submission_metadata(_sample_submission_payload()) From 359378b2954a77a1596ab1baf67cd8e452388bba Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:13:07 -0400 Subject: [PATCH 11/23] Harden explicit Q4 exhibit extraction gates --- src/earnings_nowcast_sec_actuals.py | 155 +++++++++++++++++--- src/providers/sec_submissions.py | 6 +- tests/test_earnings_nowcast_sec_actuals.py | 162 +++++++++++++++++++++ tests/test_sec_submissions.py | 16 +- 4 files changed, 309 insertions(+), 30 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 0ee810633..1cf119b16 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -48,6 +48,7 @@ "guidance_or_outlook_rejected", "gaap_eps_missing", "derived_q4_rejected", + "revenue_scale_missing", "ticker_unresolved", ) ) @@ -115,6 +116,7 @@ def __init__(self) -> None: self._table_rows: list[list[str]] | None = None self._row: list[str] | None = None self._cell: list[str] | None = None + self._caption: list[str] | None = None self._table_depth = 0 @property @@ -127,6 +129,8 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._table_depth += 1 if self._table_depth == 1: self._table_rows = [] + elif self._table_depth and lowered == "caption": + self._caption = [] elif self._table_depth and lowered == "tr": self._row = [] elif self._table_depth and lowered in {"td", "th"} and self._row is not None: @@ -134,12 +138,19 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None def handle_data(self, data: str) -> None: self._page_text.append(data) + if self._caption is not None: + self._caption.append(data) if self._cell is not None: self._cell.append(data) def handle_endtag(self, tag: str) -> None: lowered = tag.lower() - if lowered in {"td", "th"} and self._cell is not None and self._row is not None: + if lowered == "caption" and self._caption is not None and self._table_rows is not None: + caption = " ".join(self._caption).strip() + if caption: + self._table_rows.append([caption]) + self._caption = None + elif lowered in {"td", "th"} and self._cell is not None and self._row is not None: self._row.append(" ".join(self._cell).strip()) self._cell = None elif lowered == "tr" and self._row is not None and self._table_rows is not None: @@ -232,14 +243,42 @@ def _table_number(value: str, *, scale_required: bool) -> tuple[float, float] | return (value_number, 1.0) if not scale_required else None +def _table_level_scale(rows: tuple[tuple[str, ...], ...]) -> tuple[float | None, bool]: + header_text = _normalized_table_text(" ".join(cell for row in rows[:2] for cell in row)) + scales = { + {"thousand": 1_000.0, "million": 1_000_000.0, "billion": 1_000_000_000.0}[match.group(1).rstrip("s")] + for pattern in ( + r"\b(?:dollars?|amounts?|figures?)\b.{0,30}?\b(thousands?|millions?|billions?)\b", + r"\(\s*in\s+(thousands?|millions?|billions?)\s*\)", + ) + for match in re.finditer(pattern, header_text) + } + if len(scales) != 1: + return None, bool(scales) + return next(iter(scales)), False + + +def _derived_q4_context(text: str) -> bool: + normalized = _normalized_table_text(text) + return any( + re.search(pattern, normalized) + for pattern in ( + r"\b(?:annual|full[- ]year)\s+(?:less|minus)\s+(?:the\s+)?nine[- ]month\b", + r"\b(?:calculated|derived)\s+from\s+(?:annual|full[- ]year).{0,80}\b(?:less|minus)\s+(?:the\s+)?nine[- ]month\b", + ) + ) + + def _q4_metric_values( rows: tuple[tuple[str, ...], ...], value_column: int = 1, -) -> tuple[float | None, float | None, bool, bool]: +) -> tuple[float | None, float | None, bool, bool, bool]: revenue: float | None = None eps: float | None = None non_gaap_eps_present = False derived = False + revenue_scale_missing = False + table_scale, table_scale_ambiguous = _table_level_scale(rows) for row in rows: if len(row) <= value_column: continue @@ -251,8 +290,17 @@ def _q4_metric_values( if label in {"revenue", "total revenue", "net revenue"}: parsed = _table_number(value, scale_required=False) if parsed is not None: - raw_value, scale = parsed - revenue = raw_value * scale + raw_value, value_scale = parsed + if table_scale_ambiguous: + revenue_scale_missing = True + elif value_scale != 1.0 and table_scale is not None and value_scale != table_scale: + revenue_scale_missing = True + elif value_scale != 1.0: + revenue = raw_value * value_scale + elif table_scale is not None: + revenue = raw_value * table_scale + else: + revenue_scale_missing = True elif "non-gaap" in label and "earnings per share" in label: non_gaap_eps_present = True elif label in { @@ -263,7 +311,20 @@ def _q4_metric_values( parsed = _table_number(value, scale_required=False) if parsed is not None: eps = parsed[0] - return revenue, eps, non_gaap_eps_present, derived + return revenue, eps, non_gaap_eps_present, derived, revenue_scale_missing + + +def _merge_q4_metric_values( + candidates: Sequence[tuple[float | None, float | None]], +) -> tuple[float | None, float | None] | None: + revenue_values = {revenue for revenue, _eps in candidates if revenue is not None} + eps_values = {eps for _revenue, eps in candidates if eps is not None} + if len(revenue_values) > 1 or len(eps_values) > 1: + return None + return ( + next(iter(revenue_values)) if revenue_values else None, + next(iter(eps_values)) if eps_values else None, + ) def _split_adjustment_basis(document_text: str) -> str: @@ -321,7 +382,7 @@ def extract_explicit_q4_actual( audit_rows.append( _q4_audit(normalized_ticker, state, "quarterly_actual", fiscal_period, exhibit, "no structured table has an unambiguous matching Q4 header") ) - if any(_q4_metric_values(rows)[3] for rows, _text in parser.tables): + if _derived_q4_context(parser.page_text) or any(_q4_metric_values(rows)[3] for rows, _text in parser.tables): audit_rows.append( _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table labels a metric as derived or arithmetic") ) @@ -329,10 +390,17 @@ def extract_explicit_q4_actual( rows=(), audit_rows=tuple(audit_rows), ) + if _derived_q4_context(parser.page_text): + return ExtractionResult( + rows=(), + audit_rows=( + _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 exhibit context states the selected result was derived from annual and nine-month values"), + ), + ) candidates: list[tuple[float | None, float | None]] = [] for rows, value_column in matching_tables: table_text = _normalized_table_text(" ".join(cell for row in rows for cell in row)) - revenue, eps, non_gaap_eps_present, derived = _q4_metric_values(rows, value_column) + revenue, eps, non_gaap_eps_present, derived, revenue_scale_missing = _q4_metric_values(rows, value_column) if any(word in table_text for word in ("outlook", "expected", "approximately", "guidance")): audit_rows.append( _q4_audit(normalized_ticker, "guidance_or_outlook_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table contains guidance or outlook language") @@ -347,24 +415,28 @@ def extract_explicit_q4_actual( _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table labels a metric as derived or arithmetic") ) continue + if revenue_scale_missing: + audit_rows.append( + _q4_audit(normalized_ticker, "revenue_scale_missing", "revenue", fiscal_period, exhibit, "Revenue lacks one unambiguous dollar scale in the selected Q4 table context") + ) if non_gaap_eps_present and eps is None: audit_rows.append( _q4_audit(normalized_ticker, "gaap_eps_missing", "eps", fiscal_period, exhibit, "non-GAAP EPS is not accepted without an explicit GAAP diluted EPS label") ) if revenue is not None or eps is not None: candidates.append((revenue, eps)) - presentations = set(candidates) - if len(presentations) > 1: - audit_rows.append( - _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") - ) - return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) if not candidates: if not audit_rows: audit_rows.append( _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "explicit Revenue and GAAP diluted EPS labels were not found in a Q4 table") ) return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + resolved_metrics = _merge_q4_metric_values(candidates) + if resolved_metrics is None: + audit_rows.append( + _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) reported_at = datetime.fromisoformat(filed_at.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() if cutoff is not None: cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) @@ -375,7 +447,7 @@ def extract_explicit_q4_actual( _q4_audit(normalized_ticker, "post_cutoff_rejected", "quarterly_actual", fiscal_period, exhibit, f"reported_at={reported_at} is after cutoff={cutoff_timestamp.astimezone(timezone.utc).isoformat()}") ) return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) - revenue, eps = candidates[0] + revenue, eps = resolved_metrics resolved_period_end = period_end_date or reported_at[:10] row = QuarterlyActual( ticker=normalized_ticker, @@ -952,11 +1024,10 @@ def _combine_q4_results( by_period.setdefault(row.fiscal_period, []).append(row) accepted_rows: list[QuarterlyActual] = [] for fiscal_period, period_rows in sorted(by_period.items()): - presentations = { - (row.revenue_actual, row.eps_actual, row.split_adjustment_basis, row.period_end_date) - for row in period_rows - } - if len(presentations) > 1: + resolved_metrics = _merge_q4_metric_values( + [(row.revenue_actual, row.eps_actual) for row in period_rows] + ) + if resolved_metrics is None: audit_rows.append( ExtractionAuditRow( ticker=ticker, @@ -968,10 +1039,33 @@ def _combine_q4_results( ) ) continue - accepted_rows.append(sorted(period_rows, key=lambda row: row.source_ref)[0]) + canonical_row = sorted(period_rows, key=lambda row: row.source_ref)[0] + accepted_rows.append( + replace( + canonical_row, + revenue_actual=resolved_metrics[0], + eps_actual=resolved_metrics[1], + ) + ) return ExtractionResult(rows=tuple(accepted_rows), audit_rows=tuple(audit_rows)) +def _date_only_filing_availability(filed_date: str, cutoff: str) -> tuple[str | None, str | None]: + """Treat date-only SEC metadata as available at 23:59:59 UTC and fail closed before then.""" + filing_day = date.fromisoformat(filed_date) + cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) + if cutoff_timestamp.tzinfo is None: + raise ValueError("cutoff must be timezone-aware") + conservative_available_at = datetime.fromisoformat(f"{filing_day.isoformat()}T23:59:59+00:00") + normalized_cutoff = cutoff_timestamp.astimezone(timezone.utc) + if conservative_available_at > normalized_cutoff: + return None, ( + f"date-only filed metadata={filing_day.isoformat()} is treated as available at " + f"{conservative_available_at.isoformat()}, after cutoff={normalized_cutoff.isoformat()}" + ) + return conservative_available_at.isoformat(), None + + def stage_sec_quarterly_actuals( tickers: Sequence[str], *, @@ -1061,6 +1155,25 @@ def stage_sec_quarterly_actuals( continue q4_results: list[ExtractionResult] = [] for accession, filed_date, report_date in _recent_q4_candidate_filings(submissions_payload): + filed_at, cutoff_detail = _date_only_filing_availability(filed_date, cutoff) + if filed_at is None: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=( + ExtractionAuditRow( + ticker=ticker, + state="post_cutoff_rejected", + metric="quarterly_actual", + fiscal_period="", + source_ref="", + detail=cutoff_detail or "date-only filing metadata is unavailable at the requested cutoff", + accession=accession, + ), + ), + ) + ) + continue try: index_html = filing_index_loader( cik, @@ -1134,7 +1247,7 @@ def stage_sec_quarterly_actuals( exhibit, document_text, fiscal_period=fiscal_period, - filed_at=f"{filed_date}T00:00:00Z", + filed_at=filed_at, retrieved_at=resolved_retrieved_at, cutoff=cutoff, period_end_date=report_date or None, diff --git a/src/providers/sec_submissions.py b/src/providers/sec_submissions.py index 0788de97d..5e7711fa9 100644 --- a/src/providers/sec_submissions.py +++ b/src/providers/sec_submissions.py @@ -27,6 +27,7 @@ ) SHARE_COUNT_FACT_NAME = "dei:EntityCommonStockSharesOutstanding" SHARE_COUNT_FILING_FORMS = ("10-K", "10-K/A", "10-Q", "10-Q/A", "20-F", "20-F/A", "40-F", "40-F/A") +Q4_EARNINGS_EXHIBIT_TYPES = frozenset(("EX-99", "EX-99.1", "EX-99.2")) @dataclass(frozen=True) @@ -120,10 +121,7 @@ def extract_filing_exhibits(index_html: str, *, cik: str, accession: str) -> tup exhibits: list[FiledExhibit] = [] seen: set[tuple[str, str]] = set() for row in parser.rows: - document_type = next( - (text.upper() for text, _href in row if re.fullmatch(r"EX-99(?:\.\d+)?", text.upper())), - "", - ) + document_type = next((text.upper() for text, _href in row if text.upper() in Q4_EARNINGS_EXHIBIT_TYPES), "") if not document_type: continue document_name = next((href or text for text, href in row if href or text.lower().endswith((".htm", ".html"))), "") diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 5803a9996..b174e6c71 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -78,6 +78,69 @@ def test_extract_explicit_q4_actual_reads_the_column_labeled_q4(): assert result.rows[0].eps_actual == 0.89 +def test_extract_explicit_q4_actual_uses_explicit_table_level_revenue_scale(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Dollars in millionsQ4 FY25
Revenue$39,331
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 39_331_000_000 + + +def test_extract_explicit_q4_actual_uses_caption_table_level_revenue_scale(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + + +
Dollars in millions
Q4 FY25
Revenue$39,331
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 39_331_000_000 + + +def test_extract_explicit_q4_actual_rejects_revenue_without_an_unambiguous_scale(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Q4 FY25
Revenue$39,331
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual is None + assert result.rows[0].eps_actual == 0.89 + assert "revenue_scale_missing" in {row.state for row in result.audit_rows} + + def test_extract_explicit_q4_actual_rejects_annual_only_table(): result = extract_explicit_q4_actual( "SYN1", @@ -132,6 +195,26 @@ def test_extract_explicit_q4_actual_rejects_derived_and_ambiguous_q4_headers(): assert {row.state for row in result.audit_rows} >= {"ambiguous_period_header", "derived_q4_rejected"} +def test_extract_explicit_q4_actual_rejects_nearby_annual_less_nine_month_derivation_note(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + +
Q4 FY25
Revenue$40 billion
GAAP diluted earnings per share$0.89
+

Q4 amounts were calculated from annual less nine-month results.

+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "derived_q4_rejected" in {row.state for row in result.audit_rows} + + def test_extract_explicit_q4_actual_rejects_post_cutoff_but_keeps_as_reported_split_basis(): post_cutoff = extract_explicit_q4_actual( "SYN1", @@ -467,6 +550,85 @@ def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_ assert rows[0]["source_ref"].endswith("/earnings-release.htm") +def test_stage_merges_independent_q4_metrics_from_separate_exhibits(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + index_html = """ + + + +
revenue.htmEX-99.1
eps.htmEX-99.2
+ """ + documents = { + "revenue.htm": """ +

Fourth Quarter Fiscal 2026 Summary

+ +
Q4 FY26
Revenue$40 billion
+ """, + "eps.htm": """ +

Fourth Quarter Fiscal 2026 Summary

+ +
Q4 FY26
GAAP diluted earnings per share$0.89
+ """, + } + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: index_html, + exhibit_document_loader=lambda _cik, _accession, document_name, *_args, **_kwargs: documents[document_name], + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + assert [(row["revenue_actual"], row["eps_actual"]) for row in rows] == [("40000000000.0", "0.89")] + + +def test_stage_rejects_same_day_date_only_filing_before_end_of_day_cutoff(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff="2026-02-25T12:00:00Z", + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("same-day filing must fail closed before exhibit lookup")), + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert {(row["state"], row["accession"]) for row in rejected_rows} == {("post_cutoff_rejected", "0000123456-26-000001")} + + def test_normalization_rejects_non_json_numeric_scalars_and_non_integral_fiscal_years(): payload = companyfacts_fixture( revenue=[ diff --git a/tests/test_sec_submissions.py b/tests/test_sec_submissions.py index 6b684336a..a107cb061 100644 --- a/tests/test_sec_submissions.py +++ b/tests/test_sec_submissions.py @@ -48,21 +48,27 @@ def test_sec_filing_index_url_uses_archive_accession_path(): ) -def test_extract_filing_exhibits_discovers_explicit_ex_99_links(): +def test_extract_filing_exhibits_discovers_only_allowlisted_ex_99_links(): index_html = """ + + + +
DocumentDescriptionType
nvda-ex99.htmReleaseEX-99
nvda-ex991.htmEarnings releaseEX-99.1
nvda-ex992.htmSupplementEX-99.2
nvda-ex993.htmOther exhibitEX-99.3
nvda-ex9917.htmOther exhibitEX-99.17
not-an-exhibit.htmPrimary8-K
""" exhibits = extract_filing_exhibits(index_html, cik="0001045810", accession="0001045810-26-000021") - assert len(exhibits) == 1 - assert exhibits[0].document_type == "EX-99.1" - assert exhibits[0].document_name == "nvda-ex991.htm" - assert exhibits[0].source_ref == ( + assert [(exhibit.document_type, exhibit.document_name) for exhibit in exhibits] == [ + ("EX-99", "nvda-ex99.htm"), + ("EX-99.1", "nvda-ex991.htm"), + ("EX-99.2", "nvda-ex992.htm"), + ] + assert exhibits[1].source_ref == ( "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000021/nvda-ex991.htm" ) From d86b5a685d9aad130db8397d768d5cb0657630a6 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:19:29 -0400 Subject: [PATCH 12/23] Reject derived and cross-exhibit Q4 evidence --- src/earnings_nowcast_sec_actuals.py | 17 +++++++-- tests/test_earnings_nowcast_sec_actuals.py | 40 ++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 1cf119b16..f630ba393 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -263,8 +263,8 @@ def _derived_q4_context(text: str) -> bool: return any( re.search(pattern, normalized) for pattern in ( - r"\b(?:annual|full[- ]year)\s+(?:less|minus)\s+(?:the\s+)?nine[- ]month\b", - r"\b(?:calculated|derived)\s+from\s+(?:annual|full[- ]year).{0,80}\b(?:less|minus)\s+(?:the\s+)?nine[- ]month\b", + r"\b(?:annual|full[- ]year)\s+(?:less|minus)\s+(?:the\s+)?(?:nine|9)[- ]months?\b", + r"\b(?:calculated|derived)\s+from\s+(?:annual|full[- ]year).{0,80}\b(?:less|minus)\s+(?:the\s+)?(?:nine|9)[- ]months?\b", ) ) @@ -1024,6 +1024,19 @@ def _combine_q4_results( by_period.setdefault(row.fiscal_period, []).append(row) accepted_rows: list[QuarterlyActual] = [] for fiscal_period, period_rows in sorted(by_period.items()): + source_refs = {row.source_ref for row in period_rows} + if len(source_refs) > 1: + audit_rows.append( + ExtractionAuditRow( + ticker=ticker, + state="ambiguous_concept", + metric="quarterly_actual", + fiscal_period=fiscal_period, + source_ref="", + detail="Q4 metrics span multiple SEC-filed exhibits; per-metric provenance cannot be represented", + ) + ) + continue resolved_metrics = _merge_q4_metric_values( [(row.revenue_actual, row.eps_actual) for row in period_rows] ) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index b174e6c71..8a60ed7a6 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -4,6 +4,8 @@ import json from pathlib import Path +import pytest + from src.earnings_nowcast_contract import QuarterlyActual from src.earnings_nowcast_sec_actuals import ( ExtractionAuditRow, @@ -215,6 +217,35 @@ def test_extract_explicit_q4_actual_rejects_nearby_annual_less_nine_month_deriva assert "derived_q4_rejected" in {row.state for row in result.audit_rows} +@pytest.mark.parametrize( + "derivation_note", + ( + "Q4 amounts were calculated from annual less nine months results.", + "Q4 amounts were calculated from full year minus 9 months results.", + ), +) +def test_extract_explicit_q4_actual_rejects_plural_and_numeric_annual_minus_nine_month_derivation( + derivation_note, +): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + f""" +

Fourth Quarter Fiscal 2025 Summary

+ + +
Q4 FY25
Revenue$40 billion
GAAP diluted earnings per share$0.89
+

{derivation_note}

+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "derived_q4_rejected" in {row.state for row in result.audit_rows} + + def test_extract_explicit_q4_actual_rejects_post_cutoff_but_keeps_as_reported_split_basis(): post_cutoff = extract_explicit_q4_actual( "SYN1", @@ -550,7 +581,7 @@ def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_ assert rows[0]["source_ref"].endswith("/earnings-release.htm") -def test_stage_merges_independent_q4_metrics_from_separate_exhibits(tmp_path): +def test_stage_withholds_independent_q4_metrics_from_separate_exhibits(tmp_path): submissions = { "cik": "123456", "filings": { @@ -595,7 +626,12 @@ def test_stage_merges_independent_q4_metrics_from_separate_exhibits(tmp_path): ) rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) - assert [(row["revenue_actual"], row["eps_actual"]) for row in rows] == [("40000000000.0", "0.89")] + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + + assert rows == [] + assert {(row["state"], row["fiscal_period"]) for row in rejected_rows} >= { + ("ambiguous_concept", "2026-Q4"), + } def test_stage_rejects_same_day_date_only_filing_before_end_of_day_cutoff(tmp_path): From 8047fe35857838b2c13acd0e0dbbb1a713c33dc6 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:24:21 -0400 Subject: [PATCH 13/23] Record missing SEC Q4 candidate filings --- src/earnings_nowcast_sec_actuals.py | 16 +++++++++- tests/test_earnings_nowcast_sec_actuals.py | 37 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index f630ba393..897a1406c 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -1166,8 +1166,22 @@ def stage_sec_quarterly_actuals( + (_q4_source_unavailable(ticker, "", f"SEC submissions lookup failed: {exc}"),), ) continue + q4_candidates = _recent_q4_candidate_filings(submissions_payload) q4_results: list[ExtractionResult] = [] - for accession, filed_date, report_date in _recent_q4_candidate_filings(submissions_payload): + if not q4_candidates: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=( + _q4_source_unavailable( + ticker, + "", + "no eligible 8-K or 8-K/A Q4 candidate filing was found", + ), + ), + ) + ) + for accession, filed_date, report_date in q4_candidates: filed_at, cutoff_detail = _date_only_filing_availability(filed_date, cutoff) if filed_at is None: q4_results.append( diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 8a60ed7a6..d83c46ed4 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -545,6 +545,43 @@ def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): assert result.automatic_apply is False +def test_stage_records_unavailable_q4_source_when_submissions_have_no_eligible_candidate(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["10-K", "10-Q"], + "filingDate": ["2026-02-25", "2026-05-25"], + "reportDate": ["2026-01-25", "2026-04-25"], + "accessionNumber": ["0000123456-26-000001", "0000123456-26-000002"], + } + }, + } + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("no eligible Q4 candidate must not fetch a filing index") + ), + ) + + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert result.accepted_row_count == 0 + assert {(row["state"], row["detail"]) for row in rejected_rows} == { + ( + "q4_source_unavailable", + "no eligible 8-K or 8-K/A Q4 candidate filing was found", + ), + } + + def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_path): submissions = { "cik": "123456", From 68b8df8b1fec14fd9300500d510f7a8a2177401e Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:29:41 -0400 Subject: [PATCH 14/23] Broaden derived Q4 wording rejection --- src/earnings_nowcast_sec_actuals.py | 12 +++++++++++- tests/test_earnings_nowcast_sec_actuals.py | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 897a1406c..e5b10e94e 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -260,13 +260,23 @@ def _table_level_scale(rows: tuple[tuple[str, ...], ...]) -> tuple[float | None, def _derived_q4_context(text: str) -> bool: normalized = _normalized_table_text(text) - return any( + direct_arithmetic = any( re.search(pattern, normalized) for pattern in ( r"\b(?:annual|full[- ]year)\s+(?:less|minus)\s+(?:the\s+)?(?:nine|9)[- ]months?\b", r"\b(?:calculated|derived)\s+from\s+(?:annual|full[- ]year).{0,80}\b(?:less|minus)\s+(?:the\s+)?(?:nine|9)[- ]months?\b", ) ) + if direct_arithmetic: + return True + + annual_pattern = r"\b(?:annual|full[- ]year)\b" + nine_month_pattern = r"\b(?:nine|9)[- ]months?\b" + for match in re.finditer(r"\b(?:calculated|derived|subtracting|subtracted)\b", normalized): + context = normalized[max(0, match.start() - 120) : match.end() + 120] + if re.search(annual_pattern, context) and re.search(nine_month_pattern, context): + return True + return False def _q4_metric_values( diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index d83c46ed4..be7466057 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -222,6 +222,7 @@ def test_extract_explicit_q4_actual_rejects_nearby_annual_less_nine_month_deriva ( "Q4 amounts were calculated from annual less nine months results.", "Q4 amounts were calculated from full year minus 9 months results.", + "Q4 amounts were derived by subtracting nine-month results from full-year results.", ), ) def test_extract_explicit_q4_actual_rejects_plural_and_numeric_annual_minus_nine_month_derivation( From de1fa5a3310ad699c4aa4969967450624c5d9030 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:34:18 -0400 Subject: [PATCH 15/23] Reject computed Q4 derivation disclosures --- src/earnings_nowcast_sec_actuals.py | 20 ++++++++++++++------ tests/test_earnings_nowcast_sec_actuals.py | 1 + 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index e5b10e94e..c8957c73a 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -260,21 +260,29 @@ def _table_level_scale(rows: tuple[tuple[str, ...], ...]) -> tuple[float | None, def _derived_q4_context(text: str) -> bool: normalized = _normalized_table_text(text) + annual_pattern = r"\b(?:annual|full[- ]year)\b" + subtraction_pattern = r"\b(?:less|minus|subtract(?:ing|ed)?|deduct(?:ing|ed)?)\b" + short_period_pattern = r"\b(?:nine|9)[- ]months?\b|\b(?:the\s+)?first\s+(?:three|3)\s+quarters?\b" direct_arithmetic = any( re.search(pattern, normalized) for pattern in ( - r"\b(?:annual|full[- ]year)\s+(?:less|minus)\s+(?:the\s+)?(?:nine|9)[- ]months?\b", - r"\b(?:calculated|derived)\s+from\s+(?:annual|full[- ]year).{0,80}\b(?:less|minus)\s+(?:the\s+)?(?:nine|9)[- ]months?\b", + rf"{annual_pattern}.{{0,80}}{subtraction_pattern}.{{0,80}}(?:{short_period_pattern})", + rf"(?:{short_period_pattern}).{{0,80}}{subtraction_pattern}.{{0,80}}{annual_pattern}", ) ) if direct_arithmetic: return True - annual_pattern = r"\b(?:annual|full[- ]year)\b" - nine_month_pattern = r"\b(?:nine|9)[- ]months?\b" - for match in re.finditer(r"\b(?:calculated|derived|subtracting|subtracted)\b", normalized): + for match in re.finditer( + r"\b(?:comput(?:e|ed|ing)|calculat(?:e|ed|ing)|deriv(?:e|ed|ing)|subtract(?:ing|ed)|deduct(?:ing|ed))\b", + normalized, + ): context = normalized[max(0, match.start() - 120) : match.end() + 120] - if re.search(annual_pattern, context) and re.search(nine_month_pattern, context): + if ( + re.search(annual_pattern, context) + and re.search(subtraction_pattern, context) + and re.search(short_period_pattern, context) + ): return True return False diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index be7466057..94cf72a29 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -223,6 +223,7 @@ def test_extract_explicit_q4_actual_rejects_nearby_annual_less_nine_month_deriva "Q4 amounts were calculated from annual less nine months results.", "Q4 amounts were calculated from full year minus 9 months results.", "Q4 amounts were derived by subtracting nine-month results from full-year results.", + "Q4 amounts were computed from annual results less the first three quarters.", ), ) def test_extract_explicit_q4_actual_rejects_plural_and_numeric_annual_minus_nine_month_derivation( From ec867c06d7222d1e83fd43c1e598590710260c7e Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:46:44 -0400 Subject: [PATCH 16/23] Add SEC quarterly actuals staging workflow --- Makefile | 13 ++ ROADMAP.md | 6 +- docs/EARNINGS_NOWCAST_PILOT.md | 10 + docs/METHODOLOGY.md | 4 +- docs/PROVENANCE_CONTRACT.md | 4 +- src/earnings_nowcast_sec_actuals.py | 203 ++++++++++++++++++--- tests/test_earnings_nowcast_sec_actuals.py | 32 ++++ tests/test_launchers.py | 14 ++ 8 files changed, 257 insertions(+), 29 deletions(-) diff --git a/Makefile b/Makefile index bd74fc9e5..bd47c3de7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: help help-full next-stage profile-context research-change-snapshot research-change-monitor research-review-queue research-event-review-record demo demo-data-build demo-data-check demo-dashboard demo-dashboard-smoke demo-dashboard-render-smoke local-profile-seed linkedin-share-check public-performance-contract public-performance-gate public-ux-review-checklist public-ux-review-checklist-json public-ux-review-notes public-ux-review-notes-check public-ux-review-note pilot-review-feedback pilot-feedback-closeout browser-qa-evidence browser-qa-capture-plan hosted-demo-readiness pilot-readiness-check pilot-readiness-packet pilot-share-brief trusted-data-pilot trusted-data-pilot-candidates trusted-data-pilot-packet trusted-data-pilot-lane trusted-data-pilot-board trusted-data-pilot-evidence reviewed-data-proof reviewed-data-proof-record reviewed-batch-proof reviewed-batch-proof-record reviewed-batch-compare reviewed-batch-preflight auto-refresh-plan auto-refresh-daily auto-refresh-weekly auto-refresh-optional auto-refresh-runbook auto-refresh-status scheduler-activation-checklist auto-apply-gate lane-outcome-history price-reviewed-run fundamentals-batch-proof peer-batch-proof peer-mapping-source-review peer-mapping-writeback-guard public-demo-readiness-pack readiness-ops-center readiness-queue data-coverage-proof-queues coverage-frontier data-coverage-planner coverage-expansion-loop readiness-ops-evidence reviewed-batch decision-proof-queue metric-readiness metric-readiness-board benchmark-risk-review diff-hygiene diff-hygiene-summary diff-hygiene-files data-release-decision public-release-package public-release-handoff license-status session-source-preflight source-activation-guide provider-setup-checklist provider-smoke fmp-smoke alpha-vantage-smoke finnhub-smoke universe-scope fundamentals-source-ladder fundamentals-source-ladder-queue optional-context-source-ladder optional-context-source-ladder-queue fmp-stage alpha-vantage-stage finnhub-stage yfinance-stage sec-filing-share-stage staged-hygiene-check public-wording-check public-check status status-check test pipeline stock-report stock-report-md local-tickers monthly track-record validate-data data-sources-check data-sources research-health research-health-check risk-context action-queue action-queue-check project-status project-status-check verify validate-all daily dashboard dashboard-smoke sec-stage sec-validate sec-preview sec-apply imports-validate imports-preview imports-apply import-staging universe-preview universe-preview-summary universe-stage universe-apply universe-refresh universe-report universe-active coverage data-wizard unlock-ladder unlock-summary command-bundles command-bundle-details command-bundle-runbook bundle-prices bundle-fundamentals bundle-peers bundle-prices-broader bundle-fundamentals-broader bundle-peers-broader detail-prices detail-fundamentals detail-peers detail-prices-broader detail-fundamentals-broader detail-peers-broader runbook-prices runbook-fundamentals runbook-peers runbook-prices-broader runbook-fundamentals-broader runbook-peers-broader focus-price focus-fundamentals focus-peers onboarding templates price-status price-worklist fundamentals-peer-worklist optional-context-worklist sec-stage-queue peer-mapping-queue dcf-input-proof-queue dcf-input-proof-handoff dcf-input-source-review dcf-input-source-command-plan dcf-input-source-guard share-count-proof-queue price-history-proof-queue price-validate price-preview price-apply price-refresh price-refresh-loop price-normalize import-prices price-coverage dcf-readiness import-fundamentals optional-context-summary optional-context-readiness import-earnings import-analyst-estimates readiness readiness-snapshot research-decisions earnings-nowcast-pilot earnings-nowcast-walkthrough earnings-nowcast-templates earnings-nowcast-validate earnings-nowcast-preview earnings-nowcast-readiness earnings-nowcast-prospective-plan .PHONY: thesis-journal thesis-journal-preview thesis-journal-record +.PHONY: earnings-nowcast-sec-actuals-stage DEFAULT_TRUSTED_PILOT_TICKERS := MU,CRDO,HOOD,TSLA,META,A,APLD DEFAULT_TRUSTED_PILOT_EVIDENCE_TICKERS := MU,CRDO @@ -1282,3 +1283,15 @@ earnings-nowcast-readiness: earnings-nowcast-prospective-plan: @python3 -m src.earnings_nowcast_onboarding prospective-plan --output-dir $(or $(OUTPUT_DIR),data/imports/earnings_nowcast) + +earnings-nowcast-sec-actuals-stage: +ifndef TICKERS + $(error TICKERS is required, for example: make earnings-nowcast-sec-actuals-stage TICKERS=NVDA OUTPUT_DIR=/tmp/sec-actuals AS_OF=2026-07-16T03:59:59Z) +endif +ifndef OUTPUT_DIR + $(error OUTPUT_DIR is required; use a generated temporary/review directory) +endif +ifndef AS_OF + $(error AS_OF is required for cutoff-safe evidence staging) +endif + @python3 -m src.earnings_nowcast_sec_actuals --tickers "$(TICKERS)" --output-dir "$(OUTPUT_DIR)" --cutoff "$(AS_OF)" diff --git a/ROADMAP.md b/ROADMAP.md index 7eeae4f2d..26a11d8fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,15 +143,15 @@ The Single-Stock Report now derives profile-scoped checks for readiness review, **Goal:** move the implemented Earnings Nowcast pilot from synthetic infrastructure proof to a leakage-safe, source-backed semiconductor cohort. -Earnings Nowcast real-data safety infrastructure is implemented for deterministic Revenue/EPS ranges, consensus-relative classification, metric-specific canonical quarterly evidence, comparability checks, evidence-only directional signals, chronological walk-forward backtesting, explicit sample-sufficiency/calibration diagnostics, and a separate probability calibration gate. Versioned read-only append-only onboarding templates, validation, preview, readiness, and prospective collection planning are implemented. The committed fixture cohort is synthetic test evidence only. +Earnings Nowcast real-data safety infrastructure is implemented for deterministic Revenue/EPS ranges, consensus-relative classification, metric-specific canonical quarterly evidence, comparability checks, evidence-only directional signals, chronological walk-forward backtesting, explicit sample-sufficiency/calibration diagnostics, and a separate probability calibration gate. Versioned read-only append-only onboarding templates, validation, preview, readiness, prospective collection planning, and SEC quarterly actual staging are implemented. The SEC stage accepts Q1-Q3 only from source-backed duration/fiscal lineage, accepts Q4 only from an explicit SEC-filed result table, preserves revisions and cutoff truth, reports quarter-continuity gaps, and withholds incompatible EPS split basis. It is review-only with no automatic apply path. The committed fixture cohort is synthetic test evidence only. -1. Acquire permitted append-only historical quarterly actuals and point-in-time consensus snapshots with source references, publication/retrieval timestamps, and explicit Revenue/EPS comparability definitions for a narrow semiconductor cohort. +1. Use the five-company SEC actuals staging scope (NVDA, AMD, AVGO, MU, and QCOM) only to assemble source-backed actual evidence; acquire permitted append-only historical point-in-time consensus snapshots with source references, publication/retrieval timestamps, and explicit Revenue/EPS comparability definitions before any real-company packet. 2. Use `make earnings-nowcast-prospective-plan` for future snapshot collection, then run the implemented onboarding validate/preview/readiness gates before any real-company packet; no automatic apply path exists. 3. Keep candidate peer/news signals separate from reviewed trusted evidence; signals explain context and never mutate forecast numbers. 4. Run chronological out-of-sample evaluation against latest-consensus and prior-year benchmarks. 5. Withhold numerical Beat/Miss probability until at least 100 valid events pass Brier-score, calibration-bin, and benchmark-improvement gates. -Real semiconductor nowcast coverage remains `awaiting_point_in_time_consensus`; numerical probability remains `awaiting_calibration_evidence`. +Real semiconductor nowcast coverage remains `awaiting_point_in_time_consensus`; numerical probability remains `awaiting_calibration_evidence`. SEC staging success alone does not change either status. **Stop rule:** do not substitute current analyst estimates for historical point-in-time snapshots, use post-cutoff evidence, infer numeric adjustments from text, claim predictive accuracy from fixtures, or predict post-earnings price movement. diff --git a/docs/EARNINGS_NOWCAST_PILOT.md b/docs/EARNINGS_NOWCAST_PILOT.md index 9c6185fe7..9ab43eced 100644 --- a/docs/EARNINGS_NOWCAST_PILOT.md +++ b/docs/EARNINGS_NOWCAST_PILOT.md @@ -38,6 +38,16 @@ make earnings-nowcast-prospective-plan OUTPUT_DIR=data/imports/earnings_nowcast Actuals and consensus files are required; reviewed signals are optional. The versioned schemas require source names, direct source references, fiscal periods, publication/retrieval timestamps, comparability definitions, and at least one supported metric. Exact duplicates, new rows, explicit append-only revisions, and unresolved conflicts are reported separately; a conflict makes the preview not packet-ready. Post-cutoff evidence is rejected. The prospective plan is scheduler-ready but does not fetch data or create its output directory. These commands never apply, overwrite, stage, commit, or push rows; there is intentionally no Earnings Nowcast apply command. +## SEC Quarterly Actuals Staging + +The read-only SEC staging command is `make earnings-nowcast-sec-actuals-stage TICKERS= OUTPUT_DIR= AS_OF=`. It writes only the explicit output directory and reports `automatic_apply=false`; it neither changes canonical evidence nor provides an apply path. The five-company pilot scope is NVDA, AMD, AVGO, MU, and QCOM, but a successful staging run is not real Nowcast coverage. + +Q1-Q3 lineage accepts only source-backed SEC Companyfacts duration facts with a 60-120 day quarter duration and a uniquely aligned fiscal identity. Cumulative facts, ambiguous concepts, conflicting fiscal identities, and post-cutoff rows are rejected. Q4 requires an explicit fiscal-Q4 result table in a SEC-filed primary-source exhibit. Annual-minus-nine-month arithmetic, derived Q4 disclosures, guidance, and cross-exhibit metric combinations are prohibited. + +Historical actual evidence is append-only and cutoff-aware: later presentations remain separate revisions and never overwrite an earlier source-backed row. The stage summary surfaces accepted rows, rejected rows, missing Q4, direct source references, and detected fiscal-quarter continuity gaps; it never fabricates a missing period or metric. Revenue and EPS remain independent. EPS is withheld when its split-adjustment, share, operations, currency, or accounting basis cannot be kept within one source-backed comparable basis. + +Real pilot output remains `awaiting_point_in_time_consensus` until permitted historical consensus snapshots are available at each forecast cutoff. Numerical Beat/Miss probability remains `awaiting_calibration_evidence` until at least 100 valid out-of-sample events pass the calibration gates. + ## Signals Company news, industry indicators, macro evidence, and trusted peer earnings may provide directional explanation. Candidate peers remain `candidate_context_only`. Trusted signals require reviewed source evidence and can move the lane to `signal_context_ready`; they cannot mutate Revenue/EPS ranges or create a numeric adjustment. diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index d3d927e29..2667ec021 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -364,11 +364,13 @@ The Earnings Nowcast pilot is a separate readiness-gated lane. It does not reuse The deterministic baseline requires at least five source-backed prior quarterly actuals, the matching prior-year fiscal quarter, and an exact forecast-period point-in-time consensus snapshot available at the forecast cutoff. Fiscal-period rows are canonicalized before history is counted: exact duplicates count once, an explicit source reference can supersede a prior row, and unresolved conflicting values block only the affected metric. Revenue and EPS are evaluated independently and must use comparable currency, unit scale, accounting basis, EPS share/operations basis, and split treatment. A stable Revenue history may produce a range while incompatible or unstable EPS remains withheld. Each snapshot records the fiscal period, cutoff, expected report date, forecast horizon, model version, immutable input hash, freshness state, and source IDs. +SEC actual staging has a narrower primary-source lineage rule. Q1-Q3 accepts only 60-120 day SEC Companyfacts duration facts with a uniquely established fiscal identity; cumulative facts and comparative facts without a source-backed original identity are rejected. Q4 is accepted only from an explicit fiscal-Q4 result table in a SEC-filed exhibit. It is never derived by annual-minus-nine-month arithmetic, and guidance, ambiguous headers, or Q4 metrics spanning separate exhibits remain withheld. Evidence is append-only and cutoff-aware, so later revised presentations preserve a new row and source reference rather than replacing historical evidence. The stage reports source-backed quarter-continuity gaps without inventing a missing fiscal period or value. Revenue and EPS readiness stay separate; EPS is withheld whenever split-adjustment, share, operations, accounting, currency, or scale basis is not source-backed and comparable across its usable history. + The model combines recent sequential behavior and same-quarter year-over-year seasonality with fixed versioned weights. It emits ranges and a `higher`, `aligned`, or `lower` consensus-relative classification. It does not accept a text-generated numeric adjustment. Peer earnings, company news, industry indicators, and macro evidence are directional context only; candidate peers remain `candidate_context_only`, while a reviewed trusted source may raise the context state without changing the numerical baseline. Historical evaluation is chronological walk-forward only. The target actual and later consensus snapshots are excluded from model inputs and used only for scoring after the forecast. Fewer than 20 valid events is `backtest_insufficient`, not backtest-ready. Reports separate valid events from exclusions, group exclusion reasons, and include Revenue/EPS error, WAPE where valid, Revenue/EPS/joint interval coverage, directional accuracy, leakage failures, and latest-consensus/prior-year benchmarks. Numerical Beat/Miss probability stays unavailable until at least 100 valid out-of-sample observations pass finite-value, Brier-score, calibration-bin, and constant-rate benchmark gates. Every populated calibration bin reports its size, mean forecast probability, observed rate, and minimum-size status. -The committed `SYN1`-`SYN5` cohort is synthetic test evidence only. It proves deterministic software behavior, not real semiconductor coverage, predictive accuracy, or data freshness. The pilot does not predict post-earnings price movement and does not provide investment advice. +The committed `SYN1`-`SYN5` cohort is synthetic test evidence only. The intended five-company SEC staging scope is NVDA, AMD, AVGO, MU, and QCOM, but real pilot output remains `awaiting_point_in_time_consensus` until exact historical consensus evidence is present. This proves neither real semiconductor coverage, predictive accuracy, nor data freshness. The pilot does not predict post-earnings price movement and does not provide investment advice. ## 13. Methodology Limits diff --git a/docs/PROVENANCE_CONTRACT.md b/docs/PROVENANCE_CONTRACT.md index df09067d2..15d305428 100644 --- a/docs/PROVENANCE_CONTRACT.md +++ b/docs/PROVENANCE_CONTRACT.md @@ -37,9 +37,11 @@ Every analysis-ready record or report section should expose, directly or through Every forecast event must preserve `ticker`, `fiscal_period`, `as_of_timestamp`, expected report date and forecast horizon when known, source publication/retrieval timestamps, direct source references, Revenue/EPS metric definitions, `model_version`, `input_snapshot_hash`, readiness/freshness states, and source IDs. Historical consensus snapshots are append-only evidence. An exact duplicate is not re-added; a revised or currently visible estimate is retained as a separate revision and must not overwrite the snapshot that was knowable at a prior cutoff. Duplicate actual rows cannot inflate quarterly history, and conflicting actuals remain blocked unless an explicit `supersedes_source_ref` resolves the revision chain. +SEC quarterly actuals follow the same append-only cutoff boundary. Q1-Q3 requires a 60-120 day SEC Companyfacts duration fact with a source-backed fiscal identity. Q4 requires one explicit result table in a SEC-filed primary-source exhibit; annual-minus-nine-month derivation is forbidden. Staging may identify source-backed quarter-continuity gaps, but it must not infer the missing period, Revenue, EPS, or fiscal basis. Revenue and EPS are independently ready, and EPS is withheld if its split-adjustment, share, operations, accounting, currency, or scale basis is not comparable within the source-backed history. The stage output is review-only and always declares `automatic_apply=false`. + The target-period actual and every source published after the cutoff are evaluation evidence only and must never enter forecast inputs. Trusted peer/news signals require explicit source, publication time, excerpt hash, review state, and trusted-peer relationship evidence where applicable. They remain directional context and cannot create a numeric adjustment or numerical probability. -The synthetic fixture packet is software-test evidence only. Real output remains `awaiting_point_in_time_consensus`, and numerical probability remains `awaiting_calibration_evidence` until the documented out-of-sample gates pass. +The synthetic fixture packet is software-test evidence only. The five-company SEC staging scope (NVDA, AMD, AVGO, MU, and QCOM) is not a coverage claim. Real output remains `awaiting_point_in_time_consensus`, and numerical probability remains `awaiting_calibration_evidence` until the documented out-of-sample gates pass. ## Freshness Rules diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index c8957c73a..b0da66d0f 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -1,7 +1,9 @@ from __future__ import annotations +import argparse import csv import json +import os import re from dataclasses import asdict, dataclass, replace from datetime import date, datetime, timezone @@ -33,6 +35,7 @@ ) EPS_CONCEPT = "EarningsPerShareDiluted" SEC_QUARTERLY_FORMS = frozenset(("10-Q", "10-Q/A")) +_FISCAL_PERIOD_PATTERN = re.compile(r"^(?P\d{4})-Q(?P[1-4])$") REJECTED_AUDIT_STATES = frozenset( ( "ambiguous_concept", @@ -919,6 +922,41 @@ def _actual_csv_row(row: QuarterlyActual) -> dict[str, object]: return {"schema_version": EVIDENCE_SCHEMA_VERSION, **asdict(row)} +def _cached_sec_ticker_map(cache_dir: Path) -> dict[str, dict[str, Any]]: + """Read the standard SEC ticker cache without falling back to the network.""" + cache_path = cache_dir / "company_tickers.json" + if not cache_path.exists(): + raise RuntimeError(f"SEC network access is disabled and ticker-map cache is missing: {cache_path}") + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"SEC network access is disabled and ticker-map cache is unreadable: {cache_path}") from exc + rows = payload.values() if isinstance(payload, dict) else payload if isinstance(payload, list) else () + ticker_map: dict[str, dict[str, Any]] = {} + for row in rows: + if not isinstance(row, Mapping): + continue + ticker = str(row.get("ticker") or "").strip().upper() + cik_value = row.get("cik_str") or row.get("cik") or row.get("cikStr") + if not ticker or cik_value in (None, ""): + continue + cik_text = str(cik_value).strip() + cik = str(int(cik_text)).zfill(10) if cik_text.isdigit() else cik_text.zfill(10) + ticker_map[ticker] = { + "ticker": ticker, + "cik": cik, + "title": row.get("title") or row.get("name"), + "exchange": row.get("exchange"), + } + if not ticker_map: + raise RuntimeError(f"SEC network access is disabled and ticker-map cache has no usable rows: {cache_path}") + return ticker_map + + +def _network_disabled_fetcher(*_args: object, **_kwargs: object) -> Any: + raise RuntimeError("SEC network access is disabled; provide cached evidence or rerun without --no-network") + + def _rejected_audit_rows(results: Mapping[str, ExtractionResult]) -> list[ExtractionAuditRow]: return [ audit_row @@ -1106,6 +1144,7 @@ def stage_sec_quarterly_actuals( retrieved_at: str | None = None, cache_dir: Path | str | None = None, refresh: bool = False, + allow_network: bool = True, sleep_seconds: float = 0.2, ticker_map: Mapping[str, Mapping[str, Any]] | None = None, ticker_map_loader: Callable[..., Mapping[str, Mapping[str, Any]]] = load_sec_ticker_map, @@ -1120,15 +1159,20 @@ def stage_sec_quarterly_actuals( exhibit_document_fetcher: Callable[[str, str, float], str] | None = None, ) -> StageResult: requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()})) + resolved_cache_dir = Path(cache_dir) if cache_dir is not None else Path(output_dir) / ".sec-cache" + provider_refresh = refresh if allow_network else False + provider_cache = not allow_network if ticker_map is None: - resolved_cache_dir = Path(cache_dir) if cache_dir is not None else Path(output_dir) / ".sec-cache" - resolved_ticker_map = ticker_map_loader( - cache_dir=resolved_cache_dir, - user_agent=user_agent, - refresh=refresh, - sleep_seconds=sleep_seconds, - fetcher=ticker_map_fetcher, - ) + if allow_network: + resolved_ticker_map = ticker_map_loader( + cache_dir=resolved_cache_dir, + user_agent=user_agent, + refresh=provider_refresh, + sleep_seconds=sleep_seconds, + fetcher=ticker_map_fetcher, + ) + else: + resolved_ticker_map = _cached_sec_ticker_map(resolved_cache_dir) else: resolved_ticker_map = ticker_map resolved_retrieved_at = retrieved_at or datetime.now(timezone.utc).isoformat() @@ -1147,11 +1191,11 @@ def stage_sec_quarterly_actuals( payload = companyfacts_loader( cik, user_agent, - cache=False, - refresh=refresh, - cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, sleep_seconds=sleep_seconds, - fetcher=companyfacts_fetcher, + fetcher=companyfacts_fetcher if allow_network else _network_disabled_fetcher, ) except RuntimeError as exc: results[ticker] = ExtractionResult( @@ -1171,11 +1215,11 @@ def stage_sec_quarterly_actuals( submissions_payload = submissions_loader( cik, user_agent, - cache=False, - refresh=refresh, - cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, sleep_seconds=sleep_seconds, - fetcher=submissions_fetcher, + fetcher=submissions_fetcher if allow_network else _network_disabled_fetcher, ) except RuntimeError as exc: results[ticker] = ExtractionResult( @@ -1224,11 +1268,11 @@ def stage_sec_quarterly_actuals( cik, accession, user_agent, - cache=False, - refresh=refresh, - cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, sleep_seconds=sleep_seconds, - fetcher=filing_index_fetcher, + fetcher=filing_index_fetcher if allow_network else _network_disabled_fetcher, ) except RuntimeError as exc: q4_results.append( @@ -1254,11 +1298,11 @@ def stage_sec_quarterly_actuals( accession, exhibit.document_name, user_agent, - cache=False, - refresh=refresh, - cache_dir=cache_dir or Path(output_dir) / ".sec-cache", + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, sleep_seconds=sleep_seconds, - fetcher=exhibit_document_fetcher, + fetcher=exhibit_document_fetcher if allow_network else _network_disabled_fetcher, ) except RuntimeError as exc: q4_results.append( @@ -1304,3 +1348,114 @@ def stage_sec_quarterly_actuals( audit_rows=q1_q3_result.audit_rows + q4_result.audit_rows, ) return write_sec_actuals_stage(output_dir, results) + + +def _continuity_gaps(rows: Sequence[Mapping[str, str]]) -> list[dict[str, object]]: + periods = sorted( + { + (int(match.group("year")), int(match.group("quarter"))) + for row in rows + if (match := _FISCAL_PERIOD_PATTERN.match(str(row.get("fiscal_period") or ""))) + } + ) + gaps: list[dict[str, object]] = [] + for prior, current in zip(periods, periods[1:]): + prior_index = prior[0] * 4 + prior[1] - 1 + current_index = current[0] * 4 + current[1] - 1 + if current_index <= prior_index + 1: + continue + missing = [] + for index in range(prior_index + 1, current_index): + year, quarter_offset = divmod(index, 4) + missing.append(f"{year}-Q{quarter_offset + 1}") + gaps.append( + { + "after_fiscal_period": f"{prior[0]}-Q{prior[1]}", + "before_fiscal_period": f"{current[0]}-Q{current[1]}", + "missing_fiscal_periods": missing, + } + ) + return gaps + + +def build_sec_actuals_stage_summary(result: StageResult) -> dict[str, object]: + with Path(result.quarterly_actuals_path).open(newline="", encoding="utf-8") as handle: + accepted_rows = list(csv.DictReader(handle)) + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + ticker_summaries: dict[str, dict[str, object]] = {} + for ticker in result.requested_tickers: + ticker_accepted = [row for row in accepted_rows if row.get("ticker") == ticker] + ticker_rejected = [row for row in rejected_rows if row.get("ticker") == ticker] + source_refs = sorted( + { + str(row.get("source_ref") or "") + for row in ticker_accepted + ticker_rejected + if str(row.get("source_ref") or "") + } + ) + ticker_summaries[ticker] = { + "accepted_rows": ticker_accepted, + "rejected_rows": ticker_rejected, + "missing_q4": not any(str(row.get("fiscal_period") or "").endswith("-Q4") for row in ticker_accepted), + "continuity_gaps": _continuity_gaps(ticker_accepted), + "source_refs": source_refs, + } + return { + "automatic_apply": False, + "requested_tickers": list(result.requested_tickers), + "accepted_tickers": list(result.accepted_tickers), + "withheld_tickers": list(result.withheld_tickers), + "accepted_row_count": result.accepted_row_count, + "rejected_row_count": result.rejected_row_count, + "paths": { + "quarterly_actuals": result.quarterly_actuals_path, + "audit": result.audit_path, + "rejected_rows": result.rejected_path, + }, + "tickers": ticker_summaries, + } + + +def main( + argv: Sequence[str] | None = None, + *, + stage_runner: Callable[..., StageResult] = stage_sec_quarterly_actuals, +) -> None: + parser = argparse.ArgumentParser(description="Stage SEC quarterly actual evidence to an explicit output directory.") + parser.add_argument("--tickers", required=True, help="Comma-separated ticker list.") + parser.add_argument("--output-dir", required=True, help="Generated staging directory; no canonical data is changed.") + parser.add_argument("--cutoff", required=True, help="Timezone-aware evidence cutoff timestamp.") + parser.add_argument("--sec-user-agent", default=os.environ.get("SEC_USER_AGENT"), help="Identifying SEC User-Agent.") + parser.add_argument("--no-network", action="store_true", help="Use cached SEC evidence only.") + parser.add_argument("--sec-refresh", action="store_true", help="Refresh SEC caches before staging.") + parser.add_argument("--json", action="store_true", help="Print machine-readable staging summary.") + args = parser.parse_args(argv) + if args.no_network and args.sec_refresh: + parser.error("--sec-refresh cannot be combined with --no-network") + tickers = [ticker.strip() for ticker in args.tickers.split(",") if ticker.strip()] + result = stage_runner( + tickers, + output_dir=Path(args.output_dir), + cutoff=args.cutoff, + user_agent=args.sec_user_agent, + refresh=args.sec_refresh, + allow_network=not args.no_network, + ) + summary = build_sec_actuals_stage_summary(result) + if args.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + return + print(f"automatic_apply: {str(summary['automatic_apply']).lower()}") + print(f"accepted_row_count: {summary['accepted_row_count']}") + print(f"rejected_row_count: {summary['rejected_row_count']}") + for ticker, ticker_summary in summary["tickers"].items(): + print( + f"{ticker}: accepted_rows={len(ticker_summary['accepted_rows'])} " + f"rejected_rows={len(ticker_summary['rejected_rows'])} " + f"missing_q4={str(ticker_summary['missing_q4']).lower()}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 94cf72a29..d479b7382 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -6,6 +6,7 @@ import pytest +import src.earnings_nowcast_sec_actuals as sec_actuals from src.earnings_nowcast_contract import QuarterlyActual from src.earnings_nowcast_sec_actuals import ( ExtractionAuditRow, @@ -453,6 +454,37 @@ def test_stage_writes_only_explicit_output_directory(tmp_path): assert rejected[0]["state"] == "cumulative_fact_rejected" +def test_sec_actuals_cli_json_uses_injected_cached_fixture_stage(tmp_path, capsys): + def cached_fixture_stage(tickers, **kwargs): + assert tickers == ["SYN1"] + assert kwargs["output_dir"] == tmp_path / "stage" + assert kwargs["cutoff"] == CUTOFF + assert kwargs["allow_network"] is False + assert kwargs["refresh"] is False + return write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + sec_actuals.main( + [ + "--tickers", + "SYN1", + "--output-dir", + str(tmp_path / "stage"), + "--cutoff", + CUTOFF, + "--no-network", + "--json", + ], + stage_runner=cached_fixture_stage, + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["automatic_apply"] is False + assert payload["tickers"]["SYN1"]["accepted_rows"] + assert payload["tickers"]["SYN1"]["rejected_rows"] + assert payload["tickers"]["SYN1"]["missing_q4"] is True + assert payload["tickers"]["SYN1"]["source_refs"] + + def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): extraction = extract_q1_q3_lineage( "SYN1", diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 886d1612f..43737b553 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -2718,3 +2718,17 @@ def test_earnings_nowcast_onboarding_launchers_have_no_apply_path(): assert "src.earnings_nowcast_onboarding" in section assert "earnings-nowcast-apply" not in section assert "imports-apply" not in section + + +def test_earnings_nowcast_sec_actuals_stage_launcher_requires_scoped_output_and_cutoff(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "earnings-nowcast-sec-actuals-stage" in _makefile_targets() + target = makefile.split("earnings-nowcast-sec-actuals-stage:", 1)[1].split("\n\n", 1)[0] + assert "TICKERS is required" in target + assert "OUTPUT_DIR is required" in target + assert "AS_OF is required" in target + assert "--cutoff \"$(AS_OF)\"" in target + assert "--output-dir \"$(OUTPUT_DIR)\"" in target + assert "imports-apply" not in target + assert "--apply" not in target From 7b2e26ff9a935a38920ebc64e06cf04cb9aa9b99 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 20:53:36 -0400 Subject: [PATCH 17/23] Report SEC actuals summary by metric --- src/earnings_nowcast_sec_actuals.py | 19 +++- tests/test_earnings_nowcast_sec_actuals.py | 111 ++++++++++++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index b0da66d0f..e168b0e52 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -1394,11 +1394,23 @@ def build_sec_actuals_stage_summary(result: StageResult) -> dict[str, object]: if str(row.get("source_ref") or "") } ) + metrics = {} + for metric, value_field in (("revenue", "revenue_actual"), ("eps", "eps_actual")): + metric_accepted = [ + row for row in ticker_accepted if str(row.get(value_field) or "").strip() + ] + metrics[metric] = { + "accepted_rows": metric_accepted, + "missing_q4": not any( + str(row.get("fiscal_period") or "").endswith("-Q4") + for row in metric_accepted + ), + "continuity_gaps": _continuity_gaps(metric_accepted), + } ticker_summaries[ticker] = { "accepted_rows": ticker_accepted, "rejected_rows": ticker_rejected, - "missing_q4": not any(str(row.get("fiscal_period") or "").endswith("-Q4") for row in ticker_accepted), - "continuity_gaps": _continuity_gaps(ticker_accepted), + "metrics": metrics, "source_refs": source_refs, } return { @@ -1453,7 +1465,8 @@ def main( print( f"{ticker}: accepted_rows={len(ticker_summary['accepted_rows'])} " f"rejected_rows={len(ticker_summary['rejected_rows'])} " - f"missing_q4={str(ticker_summary['missing_q4']).lower()}" + f"revenue_missing_q4={str(ticker_summary['metrics']['revenue']['missing_q4']).lower()} " + f"eps_missing_q4={str(ticker_summary['metrics']['eps']['missing_q4']).lower()}" ) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index d479b7382..a0d3f3fb2 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -481,7 +481,8 @@ def cached_fixture_stage(tickers, **kwargs): assert payload["automatic_apply"] is False assert payload["tickers"]["SYN1"]["accepted_rows"] assert payload["tickers"]["SYN1"]["rejected_rows"] - assert payload["tickers"]["SYN1"]["missing_q4"] is True + assert payload["tickers"]["SYN1"]["metrics"]["revenue"]["missing_q4"] is True + assert payload["tickers"]["SYN1"]["metrics"]["eps"]["missing_q4"] is True assert payload["tickers"]["SYN1"]["source_refs"] @@ -507,6 +508,114 @@ def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): assert rejected_rows == [] +def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q3", + revenue=100, + eps=None, + source_ref="sec://revenue-q3", + reported_at="2025-11-01T00:00:00Z", + ), + actual( + "2025-Q4", + revenue=110, + eps=None, + source_ref="sec://revenue-q4", + reported_at="2026-02-01T00:00:00Z", + ), + actual( + "2025-Q3", + revenue=None, + eps=1.0, + source_ref="sec://eps-q3", + reported_at="2025-11-01T00:00:00Z", + ), + actual( + "2026-Q1", + revenue=None, + eps=1.1, + source_ref="sec://eps-q1", + reported_at="2026-05-01T00:00:00Z", + ), + ), + audit_rows=(), + ) + }, + ) + + summary = sec_actuals.build_sec_actuals_stage_summary(result)["tickers"]["SYN1"] + + assert summary["metrics"]["revenue"]["missing_q4"] is False + assert summary["metrics"]["revenue"]["continuity_gaps"] == [] + assert len(summary["metrics"]["revenue"]["accepted_rows"]) == 2 + assert summary["metrics"]["eps"]["missing_q4"] is True + assert summary["metrics"]["eps"]["continuity_gaps"] == [ + { + "after_fiscal_period": "2025-Q3", + "before_fiscal_period": "2026-Q1", + "missing_fiscal_periods": ["2025-Q4"], + } + ] + assert len(summary["metrics"]["eps"]["accepted_rows"]) == 2 + + inverse_result = write_sec_actuals_stage( + tmp_path / "inverse-stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q3", + revenue=100, + eps=None, + source_ref="sec://inverse-revenue-q3", + reported_at="2025-11-01T00:00:00Z", + ), + actual( + "2026-Q1", + revenue=110, + eps=None, + source_ref="sec://inverse-revenue-q1", + reported_at="2026-05-01T00:00:00Z", + ), + actual( + "2025-Q3", + revenue=None, + eps=1.0, + source_ref="sec://inverse-eps-q3", + reported_at="2025-11-01T00:00:00Z", + ), + actual( + "2025-Q4", + revenue=None, + eps=1.1, + source_ref="sec://inverse-eps-q4", + reported_at="2026-02-01T00:00:00Z", + ), + ), + audit_rows=(), + ) + }, + ) + + inverse_summary = sec_actuals.build_sec_actuals_stage_summary(inverse_result)["tickers"]["SYN1"] + + assert inverse_summary["metrics"]["revenue"]["missing_q4"] is True + assert inverse_summary["metrics"]["revenue"]["continuity_gaps"] == [ + { + "after_fiscal_period": "2025-Q3", + "before_fiscal_period": "2026-Q1", + "missing_fiscal_periods": ["2025-Q4"], + } + ] + assert inverse_summary["metrics"]["eps"]["missing_q4"] is False + assert inverse_summary["metrics"]["eps"]["continuity_gaps"] == [] + + def test_stage_writes_fiscal_period_conflicts_to_rejected_rows(tmp_path): result = write_sec_actuals_stage( tmp_path / "stage", From eb5c9ba2677db8849c4cb0d507097aadaabe9685 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 21:06:40 -0400 Subject: [PATCH 18/23] Fail closed on SEC actuals staging timeout --- Makefile | 2 +- src/earnings_nowcast_sec_actuals.py | 41 +++++++++++++++++----- tests/test_earnings_nowcast_sec_actuals.py | 28 +++++++++++++++ tests/test_launchers.py | 1 + 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index bd47c3de7..636d41871 100644 --- a/Makefile +++ b/Makefile @@ -1294,4 +1294,4 @@ endif ifndef AS_OF $(error AS_OF is required for cutoff-safe evidence staging) endif - @python3 -m src.earnings_nowcast_sec_actuals --tickers "$(TICKERS)" --output-dir "$(OUTPUT_DIR)" --cutoff "$(AS_OF)" + @python3 -m src.earnings_nowcast_sec_actuals --tickers "$(TICKERS)" --output-dir "$(OUTPUT_DIR)" --cutoff "$(AS_OF)" --max-runtime-seconds "$(or $(SEC_STAGE_MAX_RUNTIME_SECONDS),300)" diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index e168b0e52..b521fd1cc 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -5,6 +5,7 @@ import json import os import re +import signal from dataclasses import asdict, dataclass, replace from datetime import date, datetime, timezone from html.parser import HTMLParser @@ -1442,18 +1443,42 @@ def main( parser.add_argument("--no-network", action="store_true", help="Use cached SEC evidence only.") parser.add_argument("--sec-refresh", action="store_true", help="Refresh SEC caches before staging.") parser.add_argument("--json", action="store_true", help="Print machine-readable staging summary.") + parser.add_argument( + "--max-runtime-seconds", + type=float, + default=None, + help="Fail closed if staging exceeds this many seconds.", + ) args = parser.parse_args(argv) if args.no_network and args.sec_refresh: parser.error("--sec-refresh cannot be combined with --no-network") tickers = [ticker.strip() for ticker in args.tickers.split(",") if ticker.strip()] - result = stage_runner( - tickers, - output_dir=Path(args.output_dir), - cutoff=args.cutoff, - user_agent=args.sec_user_agent, - refresh=args.sec_refresh, - allow_network=not args.no_network, - ) + previous_handler = None + if args.max_runtime_seconds is not None: + if args.max_runtime_seconds <= 0: + parser.error("--max-runtime-seconds must be greater than zero") + + def _stage_timeout(_signum, _frame): + raise TimeoutError(f"SEC actuals staging exceeded max runtime of {args.max_runtime_seconds:g} seconds") + + previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, _stage_timeout) + signal.setitimer(signal.ITIMER_REAL, args.max_runtime_seconds) + try: + result = stage_runner( + tickers, + output_dir=Path(args.output_dir), + cutoff=args.cutoff, + user_agent=args.sec_user_agent, + refresh=args.sec_refresh, + allow_network=not args.no_network, + ) + except TimeoutError as exc: + parser.error(f"environment_limited: {exc}") + finally: + if args.max_runtime_seconds is not None: + signal.setitimer(signal.ITIMER_REAL, 0.0) + signal.signal(signal.SIGALRM, previous_handler) summary = build_sec_actuals_stage_summary(result) if args.json: print(json.dumps(summary, indent=2, sort_keys=True)) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index a0d3f3fb2..c581dabb3 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -2,6 +2,7 @@ import csv import json +import time from pathlib import Path import pytest @@ -486,6 +487,33 @@ def cached_fixture_stage(tickers, **kwargs): assert payload["tickers"]["SYN1"]["source_refs"] +def test_sec_actuals_cli_fails_closed_when_stage_exceeds_max_runtime(tmp_path, capsys): + def slow_stage(_tickers, **_kwargs): + time.sleep(0.05) + return write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + with pytest.raises(SystemExit) as exc: + sec_actuals.main( + [ + "--tickers", + "SYN1", + "--output-dir", + str(tmp_path / "stage"), + "--cutoff", + CUTOFF, + "--no-network", + "--max-runtime-seconds", + "0.01", + ], + stage_runner=slow_stage, + ) + + assert exc.value.code == 2 + captured = capsys.readouterr() + assert "environment_limited" in captured.err + assert "exceeded max runtime" in captured.err + + def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): extraction = extract_q1_q3_lineage( "SYN1", diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 43737b553..37dce6342 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -2730,5 +2730,6 @@ def test_earnings_nowcast_sec_actuals_stage_launcher_requires_scoped_output_and_ assert "AS_OF is required" in target assert "--cutoff \"$(AS_OF)\"" in target assert "--output-dir \"$(OUTPUT_DIR)\"" in target + assert "--max-runtime-seconds \"$(or $(SEC_STAGE_MAX_RUNTIME_SECONDS),300)\"" in target assert "imports-apply" not in target assert "--apply" not in target From 921e6708525e7bea141ed44fd6b1dbc7ec8da9cb Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 21:56:08 -0400 Subject: [PATCH 19/23] Harden SEC actuals lineage review gates --- ROADMAP.md | 2 +- docs/EARNINGS_NOWCAST_PILOT.md | 4 +- docs/METHODOLOGY.md | 2 +- docs/PROVENANCE_CONTRACT.md | 2 +- ...15-sec-quarterly-actuals-lineage-design.md | 16 +- src/earnings_nowcast_readiness.py | 25 +- src/earnings_nowcast_sec_actuals.py | 246 ++++++++++++-- tests/test_earnings_nowcast_readiness.py | 59 ++++ tests/test_earnings_nowcast_sec_actuals.py | 317 +++++++++++++++++- tests/test_launchers.py | 3 + 10 files changed, 633 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 26a11d8fa..37233b8e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,7 +143,7 @@ The Single-Stock Report now derives profile-scoped checks for readiness review, **Goal:** move the implemented Earnings Nowcast pilot from synthetic infrastructure proof to a leakage-safe, source-backed semiconductor cohort. -Earnings Nowcast real-data safety infrastructure is implemented for deterministic Revenue/EPS ranges, consensus-relative classification, metric-specific canonical quarterly evidence, comparability checks, evidence-only directional signals, chronological walk-forward backtesting, explicit sample-sufficiency/calibration diagnostics, and a separate probability calibration gate. Versioned read-only append-only onboarding templates, validation, preview, readiness, prospective collection planning, and SEC quarterly actual staging are implemented. The SEC stage accepts Q1-Q3 only from source-backed duration/fiscal lineage, accepts Q4 only from an explicit SEC-filed result table, preserves revisions and cutoff truth, reports quarter-continuity gaps, and withholds incompatible EPS split basis. It is review-only with no automatic apply path. The committed fixture cohort is synthetic test evidence only. +Earnings Nowcast real-data safety infrastructure is implemented for deterministic Revenue/EPS ranges, consensus-relative classification, metric-specific canonical quarterly evidence, comparability checks, evidence-only directional signals, chronological walk-forward backtesting, explicit sample-sufficiency/calibration diagnostics, and a separate probability calibration gate. Versioned read-only append-only onboarding templates, validation, preview, readiness, prospective collection planning, and SEC quarterly actual staging are implemented. The SEC stage accepts Q1-Q3 only from one-to-one source-backed duration/fiscal lineage, marks Companyfacts EPS split basis unverified, accepts Q4 only when an explicit SEC-filed result table supplies the selected-column period end, preserves complete revision chains and cutoff truth, reports quarter-continuity gaps, and rejects canonical or non-generated evidence destinations. It is review-only with no automatic apply path. The committed fixture cohort is synthetic test evidence only. 1. Use the five-company SEC actuals staging scope (NVDA, AMD, AVGO, MU, and QCOM) only to assemble source-backed actual evidence; acquire permitted append-only historical point-in-time consensus snapshots with source references, publication/retrieval timestamps, and explicit Revenue/EPS comparability definitions before any real-company packet. 2. Use `make earnings-nowcast-prospective-plan` for future snapshot collection, then run the implemented onboarding validate/preview/readiness gates before any real-company packet; no automatic apply path exists. diff --git a/docs/EARNINGS_NOWCAST_PILOT.md b/docs/EARNINGS_NOWCAST_PILOT.md index 9ab43eced..3432a5f97 100644 --- a/docs/EARNINGS_NOWCAST_PILOT.md +++ b/docs/EARNINGS_NOWCAST_PILOT.md @@ -40,9 +40,9 @@ Actuals and consensus files are required; reviewed signals are optional. The ver ## SEC Quarterly Actuals Staging -The read-only SEC staging command is `make earnings-nowcast-sec-actuals-stage TICKERS= OUTPUT_DIR= AS_OF=`. It writes only the explicit output directory and reports `automatic_apply=false`; it neither changes canonical evidence nor provides an apply path. The five-company pilot scope is NVDA, AMD, AVGO, MU, and QCOM, but a successful staging run is not real Nowcast coverage. +The read-only SEC staging command is `make earnings-nowcast-sec-actuals-stage TICKERS= OUTPUT_DIR= AS_OF=`. `OUTPUT_DIR` must be a new temporary/review directory or an existing directory marked by this generated stage; canonical `data/` and `data/imports/` paths and existing non-generated evidence directories are rejected. The command reports `automatic_apply=false`; it neither changes canonical evidence nor provides an apply path. The five-company pilot scope is NVDA, AMD, AVGO, MU, and QCOM, but a successful staging run is not real Nowcast coverage. -Q1-Q3 lineage accepts only source-backed SEC Companyfacts duration facts with a 60-120 day quarter duration and a uniquely aligned fiscal identity. Cumulative facts, ambiguous concepts, conflicting fiscal identities, and post-cutoff rows are rejected. Q4 requires an explicit fiscal-Q4 result table in a SEC-filed primary-source exhibit. Annual-minus-nine-month arithmetic, derived Q4 disclosures, guidance, and cross-exhibit metric combinations are prohibited. +Q1-Q3 lineage accepts only source-backed SEC Companyfacts duration facts with a 60-120 day quarter duration and a one-to-one fiscal identity/period-end mapping. Cumulative facts, ambiguous concepts, conflicting fiscal identities, and post-cutoff rows are rejected. Companyfacts EPS uses `companyfacts_split_basis_unverified`, so it cannot become EPS-ready without separate source-backed comparability. Q4 requires an explicit fiscal-Q4 result table and one period-end date in its selected value column; filing timestamps and submission report dates are not substitutes. Annual-minus-nine-month arithmetic, derived Q4 disclosures, guidance, and cross-exhibit metric combinations are prohibited. Historical actual evidence is append-only and cutoff-aware: later presentations remain separate revisions and never overwrite an earlier source-backed row. The stage summary surfaces accepted rows, rejected rows, missing Q4, direct source references, and detected fiscal-quarter continuity gaps; it never fabricates a missing period or metric. Revenue and EPS remain independent. EPS is withheld when its split-adjustment, share, operations, currency, or accounting basis cannot be kept within one source-backed comparable basis. diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index 2667ec021..e7cd08c43 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -364,7 +364,7 @@ The Earnings Nowcast pilot is a separate readiness-gated lane. It does not reuse The deterministic baseline requires at least five source-backed prior quarterly actuals, the matching prior-year fiscal quarter, and an exact forecast-period point-in-time consensus snapshot available at the forecast cutoff. Fiscal-period rows are canonicalized before history is counted: exact duplicates count once, an explicit source reference can supersede a prior row, and unresolved conflicting values block only the affected metric. Revenue and EPS are evaluated independently and must use comparable currency, unit scale, accounting basis, EPS share/operations basis, and split treatment. A stable Revenue history may produce a range while incompatible or unstable EPS remains withheld. Each snapshot records the fiscal period, cutoff, expected report date, forecast horizon, model version, immutable input hash, freshness state, and source IDs. -SEC actual staging has a narrower primary-source lineage rule. Q1-Q3 accepts only 60-120 day SEC Companyfacts duration facts with a uniquely established fiscal identity; cumulative facts and comparative facts without a source-backed original identity are rejected. Q4 is accepted only from an explicit fiscal-Q4 result table in a SEC-filed exhibit. It is never derived by annual-minus-nine-month arithmetic, and guidance, ambiguous headers, or Q4 metrics spanning separate exhibits remain withheld. Evidence is append-only and cutoff-aware, so later revised presentations preserve a new row and source reference rather than replacing historical evidence. The stage reports source-backed quarter-continuity gaps without inventing a missing fiscal period or value. Revenue and EPS readiness stay separate; EPS is withheld whenever split-adjustment, share, operations, accounting, currency, or scale basis is not source-backed and comparable across its usable history. +SEC actual staging has a narrower primary-source lineage rule. Q1-Q3 accepts only 60-120 day SEC Companyfacts duration facts with a one-to-one fiscal identity/period-end mapping; cumulative facts and comparative facts without a source-backed original identity are rejected. Companyfacts EPS is explicitly marked `companyfacts_split_basis_unverified`, which cannot match an `as_reported` or split-adjusted consensus definition. Q4 is accepted only from an explicit fiscal-Q4 result table in a SEC-filed exhibit whose selected value column states one period-end date; filing timestamps and submission report dates are not substitutes. It is never derived by annual-minus-nine-month arithmetic, and guidance, ambiguous headers, or Q4 metrics spanning separate exhibits remain withheld. Evidence is append-only and cutoff-aware, so complete revision chains select the latest source-backed presentation without replacing historical evidence. The stage reports source-backed quarter-continuity gaps without inventing a missing fiscal period or value. Revenue and EPS readiness stay separate; EPS is withheld whenever split-adjustment, share, operations, accounting, currency, or scale basis is not source-backed and comparable across its usable history. The model combines recent sequential behavior and same-quarter year-over-year seasonality with fixed versioned weights. It emits ranges and a `higher`, `aligned`, or `lower` consensus-relative classification. It does not accept a text-generated numeric adjustment. Peer earnings, company news, industry indicators, and macro evidence are directional context only; candidate peers remain `candidate_context_only`, while a reviewed trusted source may raise the context state without changing the numerical baseline. diff --git a/docs/PROVENANCE_CONTRACT.md b/docs/PROVENANCE_CONTRACT.md index 15d305428..930fbfcc8 100644 --- a/docs/PROVENANCE_CONTRACT.md +++ b/docs/PROVENANCE_CONTRACT.md @@ -37,7 +37,7 @@ Every analysis-ready record or report section should expose, directly or through Every forecast event must preserve `ticker`, `fiscal_period`, `as_of_timestamp`, expected report date and forecast horizon when known, source publication/retrieval timestamps, direct source references, Revenue/EPS metric definitions, `model_version`, `input_snapshot_hash`, readiness/freshness states, and source IDs. Historical consensus snapshots are append-only evidence. An exact duplicate is not re-added; a revised or currently visible estimate is retained as a separate revision and must not overwrite the snapshot that was knowable at a prior cutoff. Duplicate actual rows cannot inflate quarterly history, and conflicting actuals remain blocked unless an explicit `supersedes_source_ref` resolves the revision chain. -SEC quarterly actuals follow the same append-only cutoff boundary. Q1-Q3 requires a 60-120 day SEC Companyfacts duration fact with a source-backed fiscal identity. Q4 requires one explicit result table in a SEC-filed primary-source exhibit; annual-minus-nine-month derivation is forbidden. Staging may identify source-backed quarter-continuity gaps, but it must not infer the missing period, Revenue, EPS, or fiscal basis. Revenue and EPS are independently ready, and EPS is withheld if its split-adjustment, share, operations, accounting, currency, or scale basis is not comparable within the source-backed history. The stage output is review-only and always declares `automatic_apply=false`. +SEC quarterly actuals follow the same append-only cutoff boundary. Q1-Q3 requires a 60-120 day SEC Companyfacts duration fact with a one-to-one source-backed fiscal identity/period-end mapping. Companyfacts EPS is marked `companyfacts_split_basis_unverified` until separate primary evidence proves comparability. Q4 requires one explicit result table and one selected-column period-end date in a SEC-filed primary-source exhibit; filing metadata cannot supply that date, and annual-minus-nine-month derivation is forbidden. Staging may identify source-backed quarter-continuity gaps, but it must not infer the missing period, Revenue, EPS, or fiscal basis. Revenue and EPS are independently ready, and EPS is withheld if its split-adjustment, share, operations, accounting, currency, or scale basis is not comparable within the source-backed history. Stage output is review-only, always declares `automatic_apply=false`, and is rejected for canonical data/import paths or existing non-generated evidence directories. The target-period actual and every source published after the cutoff are evaluation evidence only and must never enter forecast inputs. Trusted peer/news signals require explicit source, publication time, excerpt hash, review state, and trusted-peer relationship evidence where applicable. They remain directional context and cannot create a numeric adjustment or numerical probability. diff --git a/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md index b6e58af62..ee239eb3e 100644 --- a/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md +++ b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md @@ -111,7 +111,7 @@ The canonical period key is anchored by the current-quarter record from its orig Later filings may present the same `period_end` with different SEC `fy` / `fp` values because it is a comparative period. Those later records inherit the canonical identity already established for that end date. -If two original current-quarter filings assign incompatible fiscal identities to the same period end, the period is rejected and reported as `fiscal_period_conflict`. +If two original current-quarter filings assign incompatible fiscal identities to the same period end, or one fiscal identity maps to multiple period ends, every colliding row is rejected and reported as `fiscal_period_conflict`. ## Revision Lineage @@ -129,8 +129,8 @@ No later presentation overwrites an earlier snapshot. A revision published after EPS is separate from Revenue readiness. -1. Original SEC EPS facts are labeled `as_reported` unless the source explicitly states retrospective split adjustment. -2. A source-supported retrospective presentation is labeled `split_adjusted_`. +1. Q1-Q3 Companyfacts EPS is labeled `companyfacts_split_basis_unverified` because Companyfacts alone does not prove split comparability; it cannot match an `as_reported` or split-adjusted consensus basis. +2. A filed result document may use `as_reported` only when that is the explicit presentation, while a source-supported retrospective presentation is labeled `split_adjusted_`. 3. Actual history and consensus must share the same EPS, share, operations, and split-adjustment definitions. 4. If the five-quarter model window crosses incompatible split bases, EPS is withheld while Revenue may remain ready. 5. Provider-assisted split history may route review, but it cannot establish the trusted split basis without a primary source reference. @@ -144,9 +144,10 @@ The Q4 parser must: 1. Resolve a primary SEC filing and exact earnings-release exhibit. 2. Require an explicit quarter-ended label or an unambiguous fiscal Q4 label. 3. Require explicit Revenue and/or diluted GAAP EPS labels in a structured table. -4. Preserve the filing URL, accession, filed timestamp, period end, metric labels, units, and split note. -5. Reject non-GAAP-only EPS, annual-only totals, guidance tables, prose estimates, and arithmetic derivations. -6. Emit a partial row when only one metric is explicit and source-backed. +4. Require the selected Q4 value column to state one explicit period-end date; filing timestamps and submission `reportDate` metadata are never substitutes. +5. Preserve the filing URL, accession, filed timestamp, period end, metric labels, units, and split note. +6. Reject non-GAAP-only EPS, annual-only totals, guidance tables, prose estimates, and arithmetic derivations. +7. Emit a partial row when only one metric is explicit and source-backed. Issuer-specific table aliases may be configured for the five-company pilot, but numeric values and periods must still come from the filing itself. The parser must not contain hard-coded company results. @@ -180,6 +181,7 @@ The command: - requires an identifying `SEC_USER_AGENT`; - uses existing SEC cache/network boundaries; - accepts a narrow ticker scope; +- rejects canonical `data/` and `data/imports/` destinations plus existing directories that are not marked generated SEC review output; - performs no import or apply; - never writes credentials; - returns nonzero only for command/schema failure, not for a truthfully withheld ticker; @@ -209,6 +211,8 @@ The audit output uses explicit states: - `fiscal_period_conflict` - `quarter_history_gap` - `split_basis_unverified` +- `period_end_missing` +- `period_end_ambiguous` - `q4_source_unavailable` - `post_cutoff_rejected` - `source_unavailable` diff --git a/src/earnings_nowcast_readiness.py b/src/earnings_nowcast_readiness.py index 26a77ff8b..943a1840a 100644 --- a/src/earnings_nowcast_readiness.py +++ b/src/earnings_nowcast_readiness.py @@ -122,16 +122,21 @@ def _canonical_period_row(rows: Sequence[QuarterlyActual], metric: str) -> tuple if len(values) == 1: return ordered[-1], () latest = ordered[-1] - earlier_by_ref = {row.source_ref: row for row in ordered[:-1]} - superseded = earlier_by_ref.get(latest.supersedes_source_ref or "") - if superseded is not None and float(getattr(superseded, value_field)) != float(getattr(latest, value_field)): - unresolved = [ - row - for row in ordered[:-1] - if row.source_ref != superseded.source_ref - and float(getattr(row, value_field)) != float(getattr(latest, value_field)) - ] - if not unresolved: + by_ref = {row.source_ref: row for row in ordered} + if len(by_ref) == len(ordered): + position_by_ref = {row.source_ref: index for index, row in enumerate(ordered)} + visited: set[str] = set() + current = latest + while current.source_ref not in visited: + visited.add(current.source_ref) + supersedes = current.supersedes_source_ref + if not supersedes: + break + prior = by_ref.get(supersedes) + if prior is None or position_by_ref[prior.source_ref] >= position_by_ref[current.source_ref]: + break + current = prior + if len(visited) == len(ordered): return latest, () return None, tuple(sorted(row.source_ref for row in ordered)) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index b521fd1cc..d95ab8f09 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -6,6 +6,7 @@ import os import re import signal +import tempfile from dataclasses import asdict, dataclass, replace from datetime import date, datetime, timezone from html.parser import HTMLParser @@ -46,6 +47,8 @@ "cumulative_fact_rejected", "fiscal_period_conflict", "post_cutoff_rejected", + "period_end_ambiguous", + "period_end_missing", "q4_source_unavailable", "quarter_header_missing", "ambiguous_period_header", @@ -53,9 +56,21 @@ "gaap_eps_missing", "derived_q4_rejected", "revenue_scale_missing", + "split_basis_unverified", "ticker_unresolved", ) ) +COMPANYFACTS_SPLIT_BASIS_UNVERIFIED = "companyfacts_split_basis_unverified" +_STAGE_OUTPUT_NAMES = frozenset( + ( + ".sec-cache", + "consensus_snapshots.csv", + "quarterly_actuals.csv", + "sec_actuals_audit.json", + "sec_actuals_rejected.csv", + "signals.csv", + ) +) @dataclass(frozen=True) @@ -227,6 +242,45 @@ def _has_ambiguous_q4_header(rows: tuple[tuple[str, ...], ...]) -> bool: return "q4" in headers +def _dates_in_text(value: str) -> set[str]: + dates: set[str] = set() + for raw_date in re.findall( + r"\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},\s+20\d{2}\b", + str(value or ""), + flags=re.IGNORECASE, + ): + try: + dates.add(datetime.strptime(raw_date.title(), "%B %d, %Y").date().isoformat()) + except ValueError: + continue + for raw_date in re.findall(r"\b20\d{2}-\d{2}-\d{2}\b", str(value or "")): + try: + dates.add(date.fromisoformat(raw_date).isoformat()) + except ValueError: + continue + return dates + + +def _q4_period_end_dates( + matching_tables: Sequence[tuple[tuple[tuple[str, ...], ...], int]], +) -> set[str]: + period_ends: set[str] = set() + for rows, value_column in matching_tables: + for row in rows: + if len(row) <= value_column: + continue + label = _normalized_table_text(row[0]) + value = row[value_column] + value_text = _normalized_table_text(value) + if not any( + marker in label or marker in value_text + for marker in ("period ended", "quarter ended", "three months ended") + ): + continue + period_ends.update(_dates_in_text(value)) + return period_ends + + def _table_number(value: str, *, scale_required: bool) -> tuple[float, float] | None: text = _normalized_table_text(value) if any(word in text for word in ("expected", "approximately", "outlook", "guidance")): @@ -373,7 +427,6 @@ def extract_explicit_q4_actual( filed_at: str, retrieved_at: str, cutoff: str | None = None, - period_end_date: str | None = None, ) -> ExtractionResult: normalized_ticker = str(ticker or "").strip().upper() parser = _StructuredTableParser() @@ -459,6 +512,25 @@ def extract_explicit_q4_actual( _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") ) return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + period_end_dates = _q4_period_end_dates(matching_tables) + if len(period_end_dates) != 1: + state = "period_end_missing" if not period_end_dates else "period_end_ambiguous" + detail = ( + "selected Q4 result table does not state an explicit period-end date" + if not period_end_dates + else "selected Q4 result table states multiple period-end dates for the Q4 value column" + ) + audit_rows.append( + _q4_audit( + normalized_ticker, + state, + "quarterly_actual", + fiscal_period, + exhibit, + detail, + ) + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) reported_at = datetime.fromisoformat(filed_at.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() if cutoff is not None: cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) @@ -470,7 +542,7 @@ def extract_explicit_q4_actual( ) return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) revenue, eps = resolved_metrics - resolved_period_end = period_end_date or reported_at[:10] + resolved_period_end = next(iter(period_end_dates)) row = QuarterlyActual( ticker=normalized_ticker, fiscal_period=fiscal_period, @@ -732,22 +804,34 @@ def extract_q1_q3_lineage( current_facts.append(fact) identities_by_end: dict[str, set[str]] = {} + ends_by_identity: dict[str, set[str]] = {} for fact in current_facts: - identities_by_end.setdefault(fact.end, set()).add(f"{fact.fiscal_year}-{fact.fiscal_period}") + identity = f"{fact.fiscal_year}-{fact.fiscal_period}" + identities_by_end.setdefault(fact.end, set()).add(identity) + ends_by_identity.setdefault(identity, set()).add(fact.end) conflicting_ends = {end for end, identities in identities_by_end.items() if len(identities) > 1} + conflicting_identities = { + identity for identity, period_ends in ends_by_identity.items() if len(period_ends) > 1 + } for fact in current_facts: - if fact.end not in conflicting_ends: + identity = f"{fact.fiscal_year}-{fact.fiscal_period}" + if fact.end not in conflicting_ends and identity not in conflicting_identities: continue metric = _metric_for(fact) assert metric is not None + detail = ( + "multiple current-quarter filings assign different fiscal identities to one period end" + if fact.end in conflicting_ends + else "one fiscal identity maps to multiple current-quarter period ends" + ) audit_rows.append( _audit( normalized_ticker, "fiscal_period_conflict", metric, - f"{fact.fiscal_year}-{fact.fiscal_period}", + identity, _source_ref(payload.get("cik"), fact.accession), - "multiple current-quarter filings assign different fiscal identities to one period end", + detail, fact, ) ) @@ -755,15 +839,21 @@ def extract_q1_q3_lineage( facts_by_identity: list[tuple[SecDurationFact, str]] = [] comparative_signatures: set[tuple[str, str, str, str, int, str]] = set() for fact in current_facts: - if fact.end not in conflicting_ends: - facts_by_identity.append((fact, f"{fact.fiscal_year}-{fact.fiscal_period}")) + identity = f"{fact.fiscal_year}-{fact.fiscal_period}" + if fact.end not in conflicting_ends and identity not in conflicting_identities: + facts_by_identity.append((fact, identity)) for fact in comparative_facts: metric = _metric_for(fact) assert metric is not None source_ref = _source_ref(payload.get("cik"), fact.accession) identity = identities_by_end.get(fact.end, set()) - if len(identity) == 1 and fact.end not in conflicting_ends: - facts_by_identity.append((fact, next(iter(identity)))) + canonical_identity = next(iter(identity)) if len(identity) == 1 else None + if ( + canonical_identity is not None + and fact.end not in conflicting_ends + and canonical_identity not in conflicting_identities + ): + facts_by_identity.append((fact, canonical_identity)) comparative_signatures.add( (fact.accession, fact.start, fact.end, fact.filed, fact.fiscal_year, fact.fiscal_period) ) @@ -835,6 +925,9 @@ def extract_q1_q3_lineage( source="sec_companyfacts", source_ref=source_ref, retrieved_at=retrieved_at, + split_adjustment_basis=( + COMPANYFACTS_SPLIT_BASIS_UNVERIFIED if eps is not None else "as_reported" + ), ) ) accepted_state = "accepted_revision" if signature in comparative_signatures else "accepted_explicit_quarter" @@ -856,6 +949,18 @@ def extract_q1_q3_lineage( signature_facts[0], ) ) + if eps is not None: + audit_rows.append( + _audit( + normalized_ticker, + "split_basis_unverified", + "eps", + fiscal_period, + source_ref, + "SEC Companyfacts does not prove whether comparative diluted EPS is on a split-comparable basis", + signature_facts[0], + ) + ) if revenue is None or eps is None: audit_rows.append( _audit( @@ -907,10 +1012,11 @@ def link_quarter_revisions(rows: Sequence[QuarterlyActual]) -> tuple[QuarterlyAc for prior in linked if prior.ticker == row.ticker and prior.fiscal_period == row.fiscal_period + and prior.period_end_date == row.period_end_date and prior.source == row.source and prior.reported_at < row.reported_at ] - if any(_same_actual_presentation(prior, row) for prior in family_rows): + if family_rows and _same_actual_presentation(family_rows[-1], row): continue if family_rows: linked.append(replace(row, supersedes_source_ref=family_rows[-1].source_ref)) @@ -967,16 +1073,112 @@ def _rejected_audit_rows(results: Mapping[str, ExtractionResult]) -> list[Extrac ] +def _is_within(path: Path, parent: Path) -> bool: + return path == parent or parent in path.parents + + +def _has_generated_stage_marker(root: Path) -> bool: + audit_path = root / "sec_actuals_audit.json" + if not audit_path.is_file(): + return False + try: + payload = json.loads(audit_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + return ( + isinstance(payload, Mapping) + and payload.get("mode") == "preview_only" + and payload.get("automatic_apply") is False + ) + + +def _validated_stage_output_dir(output_dir: Path | str) -> Path: + root = Path(output_dir).expanduser().resolve(strict=False) + project_root = Path(__file__).resolve().parents[1] + canonical_data = (project_root / "data").resolve(strict=False) + if _is_within(root, canonical_data): + raise ValueError( + "SEC actuals staging requires a generated temporary/review directory outside canonical data and imports" + ) + + temporary_root = Path(tempfile.gettempdir()).resolve(strict=False) + review_named = any( + marker in part.lower() + for part in root.parts + for marker in ("generated", "review", "sec-actuals", "sec_actuals", "stage", "staging") + ) + allowed_location = _is_within(root, temporary_root) or review_named + if not allowed_location: + raise ValueError( + "SEC actuals staging requires a generated temporary/review directory" + ) + + if root.exists(): + if not root.is_dir(): + raise ValueError("SEC actuals staging output must be a directory") + entries = {path.name for path in root.iterdir()} + generated_stage = ( + not entries + or entries <= {".sec-cache"} + or (_has_generated_stage_marker(root) and entries <= _STAGE_OUTPUT_NAMES) + ) + if not generated_stage: + raise ValueError( + "SEC actuals staging refuses an existing non-generated evidence directory; use a new generated temporary/review directory" + ) + return root + + +def _stage_identity_conflicts( + rows: Sequence[QuarterlyActual], +) -> tuple[set[tuple[str, str]], list[ExtractionAuditRow]]: + period_ends_by_identity: dict[tuple[str, str], set[str]] = {} + for row in rows: + period_ends_by_identity.setdefault((row.ticker, row.fiscal_period), set()).add( + row.period_end_date + ) + conflicts = { + identity + for identity, period_ends in period_ends_by_identity.items() + if len(period_ends) > 1 + } + audit_rows = [ + ExtractionAuditRow( + ticker=row.ticker, + state="fiscal_period_conflict", + metric="quarterly_actual", + fiscal_period=row.fiscal_period, + source_ref=row.source_ref, + detail="one fiscal identity maps to multiple period ends at the staging boundary", + end=row.period_end_date, + ) + for row in rows + if (row.ticker, row.fiscal_period) in conflicts + ] + return conflicts, audit_rows + + def write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionResult]) -> StageResult: - root = Path(output_dir) + root = _validated_stage_output_dir(output_dir) root.mkdir(parents=True, exist_ok=True) requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in results if str(ticker).strip()})) + extracted_rows = [row for ticker in requested_tickers for row in results[ticker].rows] + conflicting_identities, conflict_audit_rows = _stage_identity_conflicts(extracted_rows) linked_rows = link_quarter_revisions( - [row for ticker in requested_tickers for row in results[ticker].rows] + [ + row + for row in extracted_rows + if (row.ticker, row.fiscal_period) not in conflicting_identities + ] ) accepted_tickers = tuple(sorted({row.ticker for row in linked_rows})) withheld_tickers = tuple(ticker for ticker in requested_tickers if ticker not in accepted_tickers) - rejected_rows = _rejected_audit_rows(results) + rejected_rows = _rejected_audit_rows(results) + conflict_audit_rows + all_audit_rows = [ + audit_row + for ticker in requested_tickers + for audit_row in results[ticker].audit_rows + ] + conflict_audit_rows quarterly_actuals_path = root / "quarterly_actuals.csv" with quarterly_actuals_path.open("w", newline="", encoding="utf-8") as handle: @@ -997,7 +1199,7 @@ def write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionRe "withheld_tickers": withheld_tickers, "accepted_row_count": len(linked_rows), "rejected_row_count": len(rejected_rows), - "audit_rows": [asdict(row) for ticker in requested_tickers for row in results[ticker].audit_rows], + "audit_rows": [asdict(row) for row in all_audit_rows], } audit_path.write_text(json.dumps(audit_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") @@ -1159,8 +1361,9 @@ def stage_sec_quarterly_actuals( exhibit_document_loader: Callable[..., str] = fetch_sec_filing_document, exhibit_document_fetcher: Callable[[str, str, float], str] | None = None, ) -> StageResult: + resolved_output_dir = _validated_stage_output_dir(output_dir) requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()})) - resolved_cache_dir = Path(cache_dir) if cache_dir is not None else Path(output_dir) / ".sec-cache" + resolved_cache_dir = Path(cache_dir) if cache_dir is not None else resolved_output_dir / ".sec-cache" provider_refresh = refresh if allow_network else False provider_cache = not allow_network if ticker_map is None: @@ -1244,7 +1447,7 @@ def stage_sec_quarterly_actuals( ), ) ) - for accession, filed_date, report_date in q4_candidates: + for accession, filed_date, _report_date in q4_candidates: filed_at, cutoff_detail = _date_only_filing_availability(filed_date, cutoff) if filed_at is None: q4_results.append( @@ -1340,7 +1543,6 @@ def stage_sec_quarterly_actuals( filed_at=filed_at, retrieved_at=resolved_retrieved_at, cutoff=cutoff, - period_end_date=report_date or None, ) ) q4_result = _combine_q4_results(ticker, q4_results) @@ -1348,7 +1550,7 @@ def stage_sec_quarterly_actuals( rows=q1_q3_result.rows + q4_result.rows, audit_rows=q1_q3_result.audit_rows + q4_result.audit_rows, ) - return write_sec_actuals_stage(output_dir, results) + return write_sec_actuals_stage(resolved_output_dir, results) def _continuity_gaps(rows: Sequence[Mapping[str, str]]) -> list[dict[str, object]]: @@ -1452,6 +1654,10 @@ def main( args = parser.parse_args(argv) if args.no_network and args.sec_refresh: parser.error("--sec-refresh cannot be combined with --no-network") + try: + output_dir = _validated_stage_output_dir(args.output_dir) + except ValueError as exc: + parser.error(str(exc)) tickers = [ticker.strip() for ticker in args.tickers.split(",") if ticker.strip()] previous_handler = None if args.max_runtime_seconds is not None: @@ -1467,7 +1673,7 @@ def _stage_timeout(_signum, _frame): try: result = stage_runner( tickers, - output_dir=Path(args.output_dir), + output_dir=output_dir, cutoff=args.cutoff, user_agent=args.sec_user_agent, refresh=args.sec_refresh, diff --git a/tests/test_earnings_nowcast_readiness.py b/tests/test_earnings_nowcast_readiness.py index 2593465f7..29b9f4d1a 100644 --- a/tests/test_earnings_nowcast_readiness.py +++ b/tests/test_earnings_nowcast_readiness.py @@ -312,6 +312,42 @@ def test_explicit_pre_cutoff_revision_supersedes_prior_value(): assert "conflicting_quarterly_actuals" not in result.missing_evidence +def test_complete_multi_step_revision_chain_selects_latest_reported_row(): + rows = _actuals() + original = rows[-1] + middle = replace( + original, + reported_at="2026-01-16T21:00:00Z", + retrieved_at="2026-01-16T21:01:00Z", + revenue_actual=105.0, + eps_actual=1.25, + source_ref="fixture://revision/middle", + supersedes_source_ref=original.source_ref, + ) + latest = replace( + original, + reported_at="2026-01-17T21:00:00Z", + retrieved_at="2026-01-17T21:01:00Z", + revenue_actual=110.0, + eps_actual=1.3, + source_ref="fixture://revision/latest", + supersedes_source_ref=middle.source_ref, + ) + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[*rows, middle, latest], + consensus=[_consensus()], + ) + + assert result.revenue_ready is True + assert result.eps_ready is True + assert "fixture://revision/latest" in result.source_ids + assert "fixture://revision/middle" not in result.conflict_source_ids + + def test_revision_cannot_hide_a_second_unresolved_conflicting_source(): rows = _actuals() original = rows[-1] @@ -405,6 +441,29 @@ def test_split_basis_change_withholds_eps_but_keeps_revenue_ready(): assert "incompatible_eps_definition" in result.missing_evidence +def test_companyfacts_unverified_split_basis_withholds_only_eps(): + rows = [ + replace( + row, + eps_actual=(row.eps_actual / 10 if index == 0 else row.eps_actual), + split_adjustment_basis="companyfacts_split_basis_unverified", + ) + for index, row in enumerate(_actuals()) + ] + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[_consensus()], + ) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence + + def test_pre_cutoff_split_adjusted_revisions_restore_eps_but_post_cutoff_revisions_do_not(): from src.earnings_nowcast_readiness import contiguous_metric_window diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index c581dabb3..285abbbf3 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -37,6 +37,7 @@

Fourth Quarter Fiscal 2025 Summary

+
Q4 FY25
Period endedJanuary 26, 2025
Revenue$39,331 million
GAAP diluted earnings per share$0.89
@@ -58,6 +59,7 @@ def test_extract_explicit_q4_actual_reads_filed_result_table(): assert result.rows[0].fiscal_period == "2025-Q4" assert result.rows[0].revenue_actual == 39_331_000_000 assert result.rows[0].eps_actual == 0.89 + assert result.rows[0].period_end_date == "2025-01-26" assert result.rows[0].split_adjustment_basis == "split_adjusted_2024_06_07" @@ -69,6 +71,7 @@ def test_extract_explicit_q4_actual_reads_the_column_labeled_q4():

Fourth Quarter Fiscal 2025 Summary

+
Fiscal 2025Q4 FY25
Period endedJanuary 28, 2024January 26, 2025
Revenue$160 billion$39,331 million
GAAP diluted earnings per share$3.00$0.89
@@ -90,6 +93,7 @@ def test_extract_explicit_q4_actual_uses_explicit_table_level_revenue_scale():

Fourth Quarter Fiscal 2025 Summary

+
Dollars in millionsQ4 FY25
Period endedJanuary 26, 2025
Revenue$39,331
GAAP diluted earnings per share$0.89
@@ -111,6 +115,7 @@ def test_extract_explicit_q4_actual_uses_caption_table_level_revenue_scale(): +
Dollars in millions
Q4 FY25
Period endedJanuary 26, 2025
Revenue$39,331
GAAP diluted earnings per share$0.89
@@ -131,6 +136,7 @@ def test_extract_explicit_q4_actual_rejects_revenue_without_an_unambiguous_scale

Fourth Quarter Fiscal 2025 Summary

+
Q4 FY25
Period endedJanuary 26, 2025
Revenue$39,331
GAAP diluted earnings per share$0.89
@@ -145,6 +151,25 @@ def test_extract_explicit_q4_actual_rejects_revenue_without_an_unambiguous_scale assert "revenue_scale_missing" in {row.state for row in result.audit_rows} +def test_extract_explicit_q4_actual_requires_source_backed_period_end(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "period_end_missing" in {row.state for row in result.audit_rows} + + def test_extract_explicit_q4_actual_rejects_annual_only_table(): result = extract_explicit_q4_actual( "SYN1", @@ -321,11 +346,12 @@ def actual( source_ref, reported_at, source="sec_companyfacts", + period_end_date="2025-06-30", ): return QuarterlyActual( ticker="SYN1", fiscal_period=fiscal_period, - period_end_date="2025-06-30", + period_end_date=period_end_date, reported_at=reported_at, revenue_actual=revenue, eps_actual=eps, @@ -408,6 +434,64 @@ def test_later_unchanged_presentation_is_deduplicated(): assert linked == (original,) +def test_revision_linking_preserves_a_b_a_chain_and_latest_return_to_a(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://a-original", + reported_at="2025-08-01T00:00:00Z", + ) + revised = actual( + "2025-Q2", + revenue=110, + eps=1.1, + source_ref="sec://b-revised", + reported_at="2025-11-01T00:00:00Z", + ) + restored = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://a-restored", + reported_at="2026-02-01T00:00:00Z", + ) + + linked = link_quarter_revisions([restored, original, revised]) + + assert [row.source_ref for row in linked] == [ + "sec://a-original", + "sec://b-revised", + "sec://a-restored", + ] + assert linked[1].supersedes_source_ref == "sec://a-original" + assert linked[2].supersedes_source_ref == "sec://b-revised" + + +def test_revision_linking_does_not_cross_period_end_collisions(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://first-period-end", + reported_at="2025-08-01T00:00:00Z", + period_end_date="2025-06-29", + ) + colliding_identity = actual( + "2025-Q2", + revenue=110, + eps=1.1, + source_ref="sec://second-period-end", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-06-30", + ) + + linked = link_quarter_revisions([original, colliding_identity]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref is None + + def test_unrelated_conflicting_source_is_not_marked_as_revision(): sec_original = actual( "2025-Q2", @@ -514,6 +598,52 @@ def slow_stage(_tickers, **_kwargs): assert "exceeded max runtime" in captured.err +@pytest.mark.parametrize( + "output_dir", + ( + Path(sec_actuals.__file__).resolve().parents[1] / "data" / "earnings_nowcast", + Path(sec_actuals.__file__).resolve().parents[1] / "data" / "imports" / "earnings_nowcast", + ), +) +def test_sec_actuals_cli_rejects_canonical_output_directories(output_dir, capsys): + stage_called = False + + def forbidden_stage(*_args, **_kwargs): + nonlocal stage_called + stage_called = True + raise AssertionError("canonical output must be rejected before staging") + + with pytest.raises(SystemExit) as exc: + sec_actuals.main( + [ + "--tickers", + "SYN1", + "--output-dir", + str(output_dir), + "--cutoff", + CUTOFF, + "--no-network", + ], + stage_runner=forbidden_stage, + ) + + assert exc.value.code == 2 + assert stage_called is False + assert "generated temporary/review directory" in capsys.readouterr().err + + +def test_stage_rejects_existing_non_generated_evidence_directory(tmp_path): + existing_evidence = tmp_path / "existing-evidence" + existing_evidence.mkdir() + sentinel = existing_evidence / "quarterly_actuals.csv" + sentinel.write_text("trusted,evidence\n", encoding="utf-8") + + with pytest.raises(ValueError, match="existing non-generated evidence directory"): + write_sec_actuals_stage(existing_evidence, {"SYN1": extraction_result()}) + + assert sentinel.read_text(encoding="utf-8") == "trusted,evidence\n" + + def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): extraction = extract_q1_q3_lineage( "SYN1", @@ -673,6 +803,41 @@ def test_stage_writes_fiscal_period_conflicts_to_rejected_rows(tmp_path): assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} +def test_stage_rejects_direct_rows_with_one_identity_and_multiple_period_ends(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://first-period-end", + reported_at="2025-08-01T00:00:00Z", + period_end_date="2025-06-29", + ), + actual( + "2025-Q2", + revenue=110, + eps=1.1, + source_ref="sec://second-period-end", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-06-30", + ), + ), + audit_rows=(), + ) + }, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert result.rejected_row_count == 2 + assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} + + def test_stage_orchestrator_writes_unresolved_and_fetch_failures_to_rejected_rows(tmp_path): result = stage_sec_quarterly_actuals( ["missing", "broken"], @@ -769,7 +934,11 @@ def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_
earnings-release.htm Earnings releaseEX-99.1
""" - release_html = Q4_RELEASE_HTML.replace("Fiscal 2025", "Fiscal 2026").replace("FY25", "FY26") + release_html = ( + Q4_RELEASE_HTML.replace("Fiscal 2025", "Fiscal 2026") + .replace("FY25", "FY26") + .replace("January 26, 2025", "January 25, 2026") + ) result = stage_sec_quarterly_actuals( ["syn1"], @@ -786,9 +955,52 @@ def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_ rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) assert [(row["fiscal_period"], row["source"]) for row in rows] == [("2026-Q4", "sec_filed_exhibit")] + assert rows[0]["period_end_date"] == "2026-01-25" assert rows[0]["source_ref"].endswith("/earnings-release.htm") +def test_stage_does_not_use_submission_report_date_as_q4_period_end(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + index_html = """ + +
earnings-release.htmEarnings releaseEX-99.1
+ """ + release_without_period_end = """ +

Fourth Quarter Fiscal 2026 Summary

+ + +
Q4 FY26
Revenue$40 billion
GAAP diluted earnings per share$0.89
+ """ + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: index_html, + exhibit_document_loader=lambda *_args, **_kwargs: release_without_period_end, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert {row["state"] for row in rejected_rows} == {"period_end_missing"} + + def test_stage_withholds_independent_q4_metrics_from_separate_exhibits(tmp_path): submissions = { "cik": "123456", @@ -811,11 +1023,13 @@ def test_stage_withholds_independent_q4_metrics_from_separate_exhibits(tmp_path) "revenue.htm": """

Fourth Quarter Fiscal 2026 Summary

+
Q4 FY26
Period endedJanuary 25, 2026
Revenue$40 billion
""", "eps.htm": """

Fourth Quarter Fiscal 2026 Summary

+
Q4 FY26
Period endedJanuary 25, 2026
GAAP diluted earnings per share$0.89
""", } @@ -988,6 +1202,105 @@ def test_later_comparative_uses_original_fiscal_identity_and_unknown_comparative assert "comparative_period_relabelled" in {row.state for row in no_original.audit_rows} +def test_one_fiscal_identity_mapping_to_multiple_period_ends_fails_closed(): + payload = companyfacts_fixture( + revenue=[ + _fact( + val=12, + start="2026-02-27", + end="2026-05-28", + fy=2026, + fp="Q2", + accn="0000000000-26-000001", + ), + _fact( + val=13, + start="2026-02-28", + end="2026-05-29", + fy=2026, + fp="Q2", + accn="0000000000-26-000002", + ), + ], + eps=[ + _fact( + val=1.2, + start="2026-02-27", + end="2026-05-28", + fy=2026, + fp="Q2", + accn="0000000000-26-000001", + ), + _fact( + val=1.3, + start="2026-02-28", + end="2026-05-29", + fy=2026, + fp="Q2", + accn="0000000000-26-000002", + ), + ], + ) + + result = extract_q1_q3_lineage("SYN1", payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT) + + assert result.rows == () + conflicts = [row for row in result.audit_rows if row.state == "fiscal_period_conflict"] + assert len(conflicts) == 4 + assert {row.end for row in conflicts} == {"2026-05-28", "2026-05-29"} + + +def test_companyfacts_eps_split_basis_is_unverified_for_split_restatement(): + original_eps = _fact( + val=0.80, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + accn="0000000000-25-000001", + ) + comparative_eps = _fact( + val=0.08, + start="2025-02-28", + end="2025-05-29", + filed="2026-06-25", + fy=2026, + fp="Q3", + accn="0000000000-26-000001", + ) + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[ + _fact( + val=8, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + accn="0000000000-25-000001", + ), + _fact(val=12, start="2026-02-27", end="2026-05-28"), + ], + eps=[ + original_eps, + comparative_eps, + _fact(val=1.2, start="2026-02-27", end="2026-05-28"), + ], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + eps_rows = [row for row in result.rows if row.eps_actual is not None] + assert {row.split_adjustment_basis for row in eps_rows} == { + "companyfacts_split_basis_unverified" + } + assert "split_basis_unverified" in {row.state for row in result.audit_rows} + + def test_missing_frame_keeps_uniquely_aligned_quarter(): result = extract_q1_q3_lineage( "SYN1", diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 37dce6342..a081b5ff0 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -2731,5 +2731,8 @@ def test_earnings_nowcast_sec_actuals_stage_launcher_requires_scoped_output_and_ assert "--cutoff \"$(AS_OF)\"" in target assert "--output-dir \"$(OUTPUT_DIR)\"" in target assert "--max-runtime-seconds \"$(or $(SEC_STAGE_MAX_RUNTIME_SECONDS),300)\"" in target + assert "generated temporary/review directory" in target + assert "data/earnings_nowcast" not in target + assert "data/imports" not in target assert "imports-apply" not in target assert "--apply" not in target From 59edd17090ff741ae3dda47821452eaac4280f35 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 22:23:16 -0400 Subject: [PATCH 20/23] Close SEC actuals lineage boundary gaps --- ...026-07-16-sec-quarterly-actuals-lineage.md | 2 +- ...15-sec-quarterly-actuals-lineage-design.md | 1 + src/earnings_nowcast_readiness.py | 61 ++++++++-- src/earnings_nowcast_sec_actuals.py | 39 ++++-- tests/test_earnings_nowcast_readiness.py | 54 +++++++++ tests/test_earnings_nowcast_sec_actuals.py | 114 +++++++++++++++++- 6 files changed, 249 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md b/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md index 4f7fdb1ca..8b4b10c76 100644 --- a/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md +++ b/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md @@ -164,7 +164,7 @@ Implementation rules: - pair metrics only when accession/start/end/filed/`fy`/`fp` match; - reject conflicting frame or concept values; - construct the SEC accession URL from CIK and accession; -- set `reported_at` to the filed date at `00:00:00Z` and reject rows after cutoff; +- set date-only Companyfacts `reported_at` metadata to `23:59:59Z` on the filed date and reject rows after cutoff; - preserve a metric-only partial row when just Revenue or EPS is proven. - [ ] **Step 4: Add missing-frame, concept-conflict, partial-row, and cutoff tests** diff --git a/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md index ee239eb3e..ff82f463c 100644 --- a/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md +++ b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md @@ -119,6 +119,7 @@ Evidence remains point-in-time and append-only: - `source_ref` identifies the exact SEC accession/document presentation. - `reported_at` is the filing publication date, not the quarter end. +- When SEC Companyfacts supplies only a filing date, `reported_at` is conservatively set to `23:59:59Z` on that date so same-day historical cutoffs cannot see the filing early. - `retrieved_at` records collection time. - A later changed presentation references the prior `source_ref` through `supersedes_source_ref`. - Preview retains both rows and lets cutoff-aware canonicalization select only evidence published by the forecast cutoff. diff --git a/src/earnings_nowcast_readiness.py b/src/earnings_nowcast_readiness.py index 943a1840a..c01cfff1d 100644 --- a/src/earnings_nowcast_readiness.py +++ b/src/earnings_nowcast_readiness.py @@ -13,6 +13,7 @@ EXCLUDED_ASSET_TYPES = {"etf", "index", "fund", "mutual_fund"} +COMPANYFACTS_SPLIT_BASIS_UNVERIFIED = "companyfacts_split_basis_unverified" @dataclass(frozen=True) @@ -115,16 +116,29 @@ def contiguous_metric_window( return tuple(reversed(window)) -def _canonical_period_row(rows: Sequence[QuarterlyActual], metric: str) -> tuple[QuarterlyActual | None, tuple[str, ...]]: +def _canonical_period_row( + rows: Sequence[QuarterlyActual], + metric: str, + *, + lineage_rows: Sequence[QuarterlyActual] | None = None, +) -> tuple[QuarterlyActual | None, tuple[str, ...]]: value_field = f"{metric}_actual" ordered = sorted(rows, key=lambda row: (parse_utc_timestamp(row.reported_at), parse_utc_timestamp(row.retrieved_at), row.source_ref)) values = {float(getattr(row, value_field)) for row in ordered} if len(values) == 1: return ordered[-1], () latest = ordered[-1] - by_ref = {row.source_ref: row for row in ordered} - if len(by_ref) == len(ordered): - position_by_ref = {row.source_ref: index for index, row in enumerate(ordered)} + ordered_lineage = sorted( + lineage_rows if lineage_rows is not None else rows, + key=lambda row: ( + parse_utc_timestamp(row.reported_at), + parse_utc_timestamp(row.retrieved_at), + row.source_ref, + ), + ) + by_ref = {row.source_ref: row for row in ordered_lineage} + if len(by_ref) == len(ordered_lineage): + position_by_ref = {row.source_ref: index for index, row in enumerate(ordered_lineage)} visited: set[str] = set() current = latest while current.source_ref not in visited: @@ -136,7 +150,7 @@ def _canonical_period_row(rows: Sequence[QuarterlyActual], metric: str) -> tuple if prior is None or position_by_ref[prior.source_ref] >= position_by_ref[current.source_ref]: break current = prior - if len(visited) == len(ordered): + if {row.source_ref for row in ordered}.issubset(visited): return latest, () return None, tuple(sorted(row.source_ref for row in ordered)) @@ -151,19 +165,44 @@ def canonicalize_actuals( for metric in ("revenue", "eps"): by_period: dict[str, list[QuarterlyActual]] = {} for row in rows: - if getattr(row, f"{metric}_actual") is not None: - by_period.setdefault(row.fiscal_period, []).append(row) + by_period.setdefault(row.fiscal_period, []).append(row) expected_definition = _metric_definition(consensus, metric) if consensus is not None else None for period, period_rows in sorted(by_period.items()): - definitions = {_metric_definition(row, metric) for row in period_rows} + metric_rows = [ + row + for row in period_rows + if getattr(row, f"{metric}_actual") is not None + ] + if not metric_rows: + continue + definitions = {_metric_definition(row, metric) for row in metric_rows} if expected_definition is None: - compatible = period_rows if len(definitions) == 1 else [] + compatible = metric_rows if len(definitions) == 1 else [] else: - compatible = [row for row in period_rows if _metric_definition(row, metric) == expected_definition] + compatible = [ + row + for row in metric_rows + if _metric_definition(row, metric) == expected_definition + ] + if metric == "eps": + consensus_unverified = ( + consensus is not None + and consensus.split_adjustment_basis == COMPANYFACTS_SPLIT_BASIS_UNVERIFIED + ) + compatible = [ + row + for row in compatible + if not consensus_unverified + and row.split_adjustment_basis != COMPANYFACTS_SPLIT_BASIS_UNVERIFIED + ] if not compatible: incompatible[metric].append(period) continue - chosen, period_conflicts = _canonical_period_row(compatible, metric) + chosen, period_conflicts = _canonical_period_row( + compatible, + metric, + lineage_rows=period_rows, + ) if chosen is None: conflicts[metric].extend(period_conflicts) else: diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index d95ab8f09..9b614961c 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -8,7 +8,7 @@ import signal import tempfile from dataclasses import asdict, dataclass, replace -from datetime import date, datetime, timezone +from datetime import date, datetime, time, timezone from html.parser import HTMLParser from math import isfinite from pathlib import Path @@ -473,6 +473,7 @@ def extract_explicit_q4_actual( ), ) candidates: list[tuple[float | None, float | None]] = [] + accepted_metric_tables: list[tuple[tuple[tuple[str, ...], ...], int]] = [] for rows, value_column in matching_tables: table_text = _normalized_table_text(" ".join(cell for row in rows for cell in row)) revenue, eps, non_gaap_eps_present, derived, revenue_scale_missing = _q4_metric_values(rows, value_column) @@ -500,6 +501,7 @@ def extract_explicit_q4_actual( ) if revenue is not None or eps is not None: candidates.append((revenue, eps)) + accepted_metric_tables.append((rows, value_column)) if not candidates: if not audit_rows: audit_rows.append( @@ -512,7 +514,7 @@ def extract_explicit_q4_actual( _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") ) return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) - period_end_dates = _q4_period_end_dates(matching_tables) + period_end_dates = _q4_period_end_dates(accepted_metric_tables) if len(period_end_dates) != 1: state = "period_end_missing" if not period_end_dates else "period_end_ambiguous" detail = ( @@ -689,7 +691,7 @@ def _source_ref(cik: object, accession: str) -> str: def _reported_at(filed: str) -> str: - return datetime.combine(date.fromisoformat(filed), datetime.min.time(), tzinfo=timezone.utc).isoformat() + return datetime.combine(date.fromisoformat(filed), time(23, 59, 59), tzinfo=timezone.utc).isoformat() def _audit( @@ -1131,17 +1133,32 @@ def _validated_stage_output_dir(output_dir: Path | str) -> Path: def _stage_identity_conflicts( rows: Sequence[QuarterlyActual], -) -> tuple[set[tuple[str, str]], list[ExtractionAuditRow]]: +) -> tuple[set[tuple[str, str, str]], list[ExtractionAuditRow]]: period_ends_by_identity: dict[tuple[str, str], set[str]] = {} + identities_by_period_end: dict[tuple[str, str], set[str]] = {} for row in rows: period_ends_by_identity.setdefault((row.ticker, row.fiscal_period), set()).add( row.period_end_date ) - conflicts = { + identities_by_period_end.setdefault((row.ticker, row.period_end_date), set()).add( + row.fiscal_period + ) + conflicting_identities = { identity for identity, period_ends in period_ends_by_identity.items() if len(period_ends) > 1 } + conflicting_period_ends = { + period_end + for period_end, identities in identities_by_period_end.items() + if len(identities) > 1 + } + conflicts = { + (row.ticker, row.fiscal_period, row.period_end_date) + for row in rows + if (row.ticker, row.fiscal_period) in conflicting_identities + or (row.ticker, row.period_end_date) in conflicting_period_ends + } audit_rows = [ ExtractionAuditRow( ticker=row.ticker, @@ -1149,11 +1166,15 @@ def _stage_identity_conflicts( metric="quarterly_actual", fiscal_period=row.fiscal_period, source_ref=row.source_ref, - detail="one fiscal identity maps to multiple period ends at the staging boundary", + detail=( + "one period end maps to multiple fiscal identities at the staging boundary" + if (row.ticker, row.period_end_date) in conflicting_period_ends + else "one fiscal identity maps to multiple period ends at the staging boundary" + ), end=row.period_end_date, ) for row in rows - if (row.ticker, row.fiscal_period) in conflicts + if (row.ticker, row.fiscal_period, row.period_end_date) in conflicts ] return conflicts, audit_rows @@ -1163,12 +1184,12 @@ def write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionRe root.mkdir(parents=True, exist_ok=True) requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in results if str(ticker).strip()})) extracted_rows = [row for ticker in requested_tickers for row in results[ticker].rows] - conflicting_identities, conflict_audit_rows = _stage_identity_conflicts(extracted_rows) + conflicting_rows, conflict_audit_rows = _stage_identity_conflicts(extracted_rows) linked_rows = link_quarter_revisions( [ row for row in extracted_rows - if (row.ticker, row.fiscal_period) not in conflicting_identities + if (row.ticker, row.fiscal_period, row.period_end_date) not in conflicting_rows ] ) accepted_tickers = tuple(sorted({row.ticker for row in linked_rows})) diff --git a/tests/test_earnings_nowcast_readiness.py b/tests/test_earnings_nowcast_readiness.py index 29b9f4d1a..ffe837835 100644 --- a/tests/test_earnings_nowcast_readiness.py +++ b/tests/test_earnings_nowcast_readiness.py @@ -348,6 +348,37 @@ def test_complete_multi_step_revision_chain_selects_latest_reported_row(): assert "fixture://revision/middle" not in result.conflict_source_ids +def test_metric_empty_revision_nodes_preserve_eps_chain_traversal(): + rows = _actuals() + original = rows[-1] + revenue_only_middle = replace( + original, + reported_at="2026-01-16T21:00:00Z", + retrieved_at="2026-01-16T21:01:00Z", + revenue_actual=105.0, + eps_actual=None, + source_ref="fixture://revision/revenue-only-middle", + supersedes_source_ref=original.source_ref, + ) + eps_only_latest = replace( + original, + reported_at="2026-01-17T21:00:00Z", + retrieved_at="2026-01-17T21:01:00Z", + revenue_actual=None, + eps_actual=1.3, + source_ref="fixture://revision/eps-only-latest", + supersedes_source_ref=revenue_only_middle.source_ref, + ) + + canonical = canonicalize_actuals( + [*rows, revenue_only_middle, eps_only_latest], + _consensus(), + ) + + assert canonical.eps_conflict_source_ids == () + assert canonical.eps_rows[-1].source_ref == "fixture://revision/eps-only-latest" + + def test_revision_cannot_hide_a_second_unresolved_conflicting_source(): rows = _actuals() original = rows[-1] @@ -464,6 +495,29 @@ def test_companyfacts_unverified_split_basis_withholds_only_eps(): assert "incompatible_eps_definition" in result.missing_evidence +def test_matching_companyfacts_unverified_split_basis_still_withholds_only_eps(): + rows = [ + replace(row, split_adjustment_basis="companyfacts_split_basis_unverified") + for row in _actuals() + ] + consensus = replace( + _consensus(), + split_adjustment_basis="companyfacts_split_basis_unverified", + ) + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[consensus], + ) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence + + def test_pre_cutoff_split_adjusted_revisions_restore_eps_but_post_cutoff_revisions_do_not(): from src.earnings_nowcast_readiness import contiguous_metric_window diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 285abbbf3..6d67669e8 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -170,6 +170,32 @@ def test_extract_explicit_q4_actual_requires_source_backed_period_end(): assert "period_end_missing" in {row.state for row in result.audit_rows} +def test_extract_explicit_q4_actual_does_not_borrow_period_end_from_non_result_table(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+ + + + +
Q4 FY25
Period endedJanuary 26, 2025
Cash and cash equivalents$8,000 million
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "period_end_missing" in {row.state for row in result.audit_rows} + + def test_extract_explicit_q4_actual_rejects_annual_only_table(): result = extract_explicit_q4_actual( "SYN1", @@ -678,6 +704,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=None, source_ref="sec://revenue-q3", reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", ), actual( "2025-Q4", @@ -685,6 +712,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=None, source_ref="sec://revenue-q4", reported_at="2026-02-01T00:00:00Z", + period_end_date="2025-12-31", ), actual( "2025-Q3", @@ -692,6 +720,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=1.0, source_ref="sec://eps-q3", reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", ), actual( "2026-Q1", @@ -699,6 +728,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=1.1, source_ref="sec://eps-q1", reported_at="2026-05-01T00:00:00Z", + period_end_date="2026-03-31", ), ), audit_rows=(), @@ -732,6 +762,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=None, source_ref="sec://inverse-revenue-q3", reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", ), actual( "2026-Q1", @@ -739,6 +770,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=None, source_ref="sec://inverse-revenue-q1", reported_at="2026-05-01T00:00:00Z", + period_end_date="2026-03-31", ), actual( "2025-Q3", @@ -746,6 +778,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=1.0, source_ref="sec://inverse-eps-q3", reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", ), actual( "2025-Q4", @@ -753,6 +786,7 @@ def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): eps=1.1, source_ref="sec://inverse-eps-q4", reported_at="2026-02-01T00:00:00Z", + period_end_date="2025-12-31", ), ), audit_rows=(), @@ -838,6 +872,41 @@ def test_stage_rejects_direct_rows_with_one_identity_and_multiple_period_ends(tm assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} +def test_stage_rejects_direct_rows_with_one_period_end_and_multiple_identities(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q3", + revenue=100, + eps=1.0, + source_ref="sec://q3-collision", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", + ), + actual( + "2025-Q4", + revenue=110, + eps=1.1, + source_ref="sec://q4-collision", + reported_at="2026-02-01T00:00:00Z", + period_end_date="2025-09-30", + ), + ), + audit_rows=(), + ) + }, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert result.rejected_row_count == 2 + assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} + + def test_stage_orchestrator_writes_unresolved_and_fetch_failures_to_rejected_rows(tmp_path): result = stage_sec_quarterly_actuals( ["missing", "broken"], @@ -1188,7 +1257,7 @@ def test_later_comparative_uses_original_fiscal_identity_and_unknown_comparative q2_rows = [row for row in result.rows if row.fiscal_period == "2025-Q2"] assert [(row.revenue_actual, row.eps_actual) for row in q2_rows] == [(8.0, 0.8), (9.0, 0.9)] - assert q2_rows[1].reported_at == "2026-06-25T00:00:00+00:00" + assert q2_rows[1].reported_at == "2026-06-25T23:59:59+00:00" assert "accepted_revision" in {row.state for row in result.audit_rows} no_original = extract_q1_q3_lineage( @@ -1202,6 +1271,49 @@ def test_later_comparative_uses_original_fiscal_identity_and_unknown_comparative assert "comparative_period_relabelled" in {row.state for row in no_original.audit_rows} +def test_companyfacts_date_only_filing_is_available_at_end_of_day(): + payload = companyfacts_fixture( + revenue=[ + _fact( + val=12, + start="2026-01-20", + end="2026-04-19", + filed="2026-04-20", + fy=2026, + fp="Q1", + ) + ], + eps=[ + _fact( + val=1.2, + start="2026-01-20", + end="2026-04-19", + filed="2026-04-20", + fy=2026, + fp="Q1", + ) + ], + ) + + early = extract_q1_q3_lineage( + "SYN1", + payload, + cutoff="2026-04-20T00:00:01Z", + retrieved_at=RETRIEVED_AT, + ) + end_of_day = extract_q1_q3_lineage( + "SYN1", + payload, + cutoff="2026-04-20T23:59:59Z", + retrieved_at=RETRIEVED_AT, + ) + + assert early.rows == () + assert "post_cutoff_rejected" in {row.state for row in early.audit_rows} + assert len(end_of_day.rows) == 1 + assert end_of_day.rows[0].reported_at == "2026-04-20T23:59:59+00:00" + + def test_one_fiscal_identity_mapping_to_multiple_period_ends_fails_closed(): payload = companyfacts_fixture( revenue=[ From 513638a978215f49e578dd12b8a0f4ebe85fb0d2 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Thu, 16 Jul 2026 22:36:36 -0400 Subject: [PATCH 21/23] Require Q4 period-end proof per metric table --- src/earnings_nowcast_sec_actuals.py | 31 +++++++++++++++++++--- tests/test_earnings_nowcast_sec_actuals.py | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py index 9b614961c..ddcb21107 100644 --- a/src/earnings_nowcast_sec_actuals.py +++ b/src/earnings_nowcast_sec_actuals.py @@ -473,10 +473,11 @@ def extract_explicit_q4_actual( ), ) candidates: list[tuple[float | None, float | None]] = [] - accepted_metric_tables: list[tuple[tuple[tuple[str, ...], ...], int]] = [] + accepted_period_end_dates: set[str] = set() for rows, value_column in matching_tables: table_text = _normalized_table_text(" ".join(cell for row in rows for cell in row)) revenue, eps, non_gaap_eps_present, derived, revenue_scale_missing = _q4_metric_values(rows, value_column) + table_period_end_dates = _q4_period_end_dates(((rows, value_column),)) if any(word in table_text for word in ("outlook", "expected", "approximately", "guidance")): audit_rows.append( _q4_audit(normalized_ticker, "guidance_or_outlook_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table contains guidance or outlook language") @@ -499,9 +500,33 @@ def extract_explicit_q4_actual( audit_rows.append( _q4_audit(normalized_ticker, "gaap_eps_missing", "eps", fiscal_period, exhibit, "non-GAAP EPS is not accepted without an explicit GAAP diluted EPS label") ) + if revenue is not None and len(table_period_end_dates) != 1: + audit_rows.append( + _q4_audit( + normalized_ticker, + "period_end_missing" if not table_period_end_dates else "period_end_ambiguous", + "revenue", + fiscal_period, + exhibit, + "Revenue table does not state one explicit period-end date for the Q4 value column", + ) + ) + revenue = None + if eps is not None and len(table_period_end_dates) != 1: + audit_rows.append( + _q4_audit( + normalized_ticker, + "period_end_missing" if not table_period_end_dates else "period_end_ambiguous", + "eps", + fiscal_period, + exhibit, + "EPS table does not state one explicit period-end date for the Q4 value column", + ) + ) + eps = None if revenue is not None or eps is not None: candidates.append((revenue, eps)) - accepted_metric_tables.append((rows, value_column)) + accepted_period_end_dates.update(table_period_end_dates) if not candidates: if not audit_rows: audit_rows.append( @@ -514,7 +539,7 @@ def extract_explicit_q4_actual( _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") ) return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) - period_end_dates = _q4_period_end_dates(accepted_metric_tables) + period_end_dates = accepted_period_end_dates if len(period_end_dates) != 1: state = "period_end_missing" if not period_end_dates else "period_end_ambiguous" detail = ( diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py index 6d67669e8..8071bd7b8 100644 --- a/tests/test_earnings_nowcast_sec_actuals.py +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -196,6 +196,34 @@ def test_extract_explicit_q4_actual_does_not_borrow_period_end_from_non_result_t assert "period_end_missing" in {row.state for row in result.audit_rows} +def test_extract_explicit_q4_actual_does_not_borrow_period_end_across_metric_tables(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + +
Dollars in millionsQ4 FY25
Revenue$39,331
+ + + + +
Q4 FY25
Period endedJanuary 26, 2025
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert len(result.rows) == 1 + assert result.rows[0].revenue_actual is None + assert result.rows[0].eps_actual == 0.89 + assert result.rows[0].period_end_date == "2025-01-26" + assert "period_end_missing" in {row.state for row in result.audit_rows} + + def test_extract_explicit_q4_actual_rejects_annual_only_table(): result = extract_explicit_q4_actual( "SYN1", From e39be65217d2d72a0201725d94d9dc26ca97cab9 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Fri, 17 Jul 2026 00:20:36 -0400 Subject: [PATCH 22/23] Clarify pilot share status and fixture nowcast readiness --- Makefile | 2 +- README.md | 2 +- docs/EARNINGS_NOWCAST_PILOT.md | 3 +++ docs/LINKEDIN_PROJECT_BRIEF.md | 2 +- docs/PUBLIC_DEMO_WALKTHROUGH.md | 2 +- .../earnings_nowcast_onboarding/consensus_snapshots.csv | 2 ++ .../earnings_nowcast_onboarding/quarterly_actuals.csv | 6 ++++++ tests/fixtures/earnings_nowcast_onboarding/signals.csv | 2 ++ tests/test_launchers.py | 9 +++++++++ tests/test_public_v1_release_docs.py | 8 ++++---- 10 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv create mode 100644 tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv create mode 100644 tests/fixtures/earnings_nowcast_onboarding/signals.csv diff --git a/Makefile b/Makefile index 636d41871..4fc59d6de 100644 --- a/Makefile +++ b/Makefile @@ -1279,7 +1279,7 @@ earnings-nowcast-preview: @python3 -m src.earnings_nowcast_onboarding preview --input-dir $(or $(INPUT_DIR),data/imports/earnings_nowcast) $(if $(EXISTING_DIR),--existing-dir $(EXISTING_DIR),) $(if $(AS_OF),--cutoff $(AS_OF),) earnings-nowcast-readiness: - @python3 -m src.earnings_nowcast_onboarding readiness --input-dir $(or $(INPUT_DIR),data/imports/earnings_nowcast) --ticker $(or $(TICKER),SYN1) $(if $(AS_OF),--cutoff $(AS_OF),) + @python3 -m src.earnings_nowcast_onboarding readiness --input-dir $(if $(FIXTURE),tests/fixtures/earnings_nowcast_onboarding,$(or $(INPUT_DIR),data/imports/earnings_nowcast)) --ticker $(or $(TICKER),SYN1) $(if $(AS_OF),--cutoff $(AS_OF),) earnings-nowcast-prospective-plan: @python3 -m src.earnings_nowcast_onboarding prospective-plan --output-dir $(or $(OUTPUT_DIR),data/imports/earnings_nowcast) diff --git a/README.md b/README.md index ce479a013..77c40dd23 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Use this table to decide what to do next without reopening exhausted proof loops | Optional earnings / estimates | Locked | Use trusted provider or reviewed manual rows only; do not infer optional context. | | Earnings Nowcast evidence | Real-data safety gates ready; real evidence blocked | Use the versioned templates and prospective plan; add no real output until comparable quarterly actuals and point-in-time consensus pass conflict, cutoff, validation, and readiness gates. | | Broad proof queues | Do not retry now | Current queues are exhausted; reopen only after keyed provider rows, reviewed manual rows, or changed blockers exist. | -| Public UX polish | Review limited | Public checks and repeated local cold/warm route timings pass; external reviewer evidence is still required before upgrading the claim. | +| Public UX polish | Share-review ready locally | Public checks, browser evidence, and repeated local cold/warm route timings pass; hosted-route verification and external reviewer evidence are still required before calling it a hosted or externally validated product. | | Generated artifacts | Excluded by default | Keep local CSV/report/sample-report churn unstaged unless one exact artifact is reviewed as public evidence. | ## What You Can Analyze When trusted local data is available, the product can produce price, momentum, benchmark-relative return, drawdown, volatility, beta, Sharpe/Sortino review metrics, liquidity, market-direction context, portfolio purpose checks, thesis-review flags, DCF readiness, conservative scenario valuation, source-backed peer context, ETF/index monitor reports, and single-stock reports with reader guidance, proof checklists, blockers, read-only proof steps, and source readiness notes. Most blocked rows are not errors. They are data gaps the command center exposes instead of hiding. diff --git a/docs/EARNINGS_NOWCAST_PILOT.md b/docs/EARNINGS_NOWCAST_PILOT.md index 3432a5f97..fcb91c029 100644 --- a/docs/EARNINGS_NOWCAST_PILOT.md +++ b/docs/EARNINGS_NOWCAST_PILOT.md @@ -64,9 +64,12 @@ Run: ```bash FIXTURE=1 make earnings-nowcast-pilot TICKER=SYN1 AS_OF=2026-01-31T23:59:59Z +FIXTURE=1 make earnings-nowcast-readiness TICKER=SYN1 AS_OF=2026-01-31T23:59:59Z make earnings-nowcast-walkthrough AS_OF=2026-01-31T23:59:59Z ``` The `SYN1`-`SYN5` cohort and `SYN5-BACKTEST` walkthrough label are synthetic test evidence only. The walkthrough demonstrates baseline ready, Revenue ready with EPS withheld, candidate-peer-only context, post-cutoff blocking, non-company exclusion, and backtest-insufficient/un-calibrated behavior. It proves deterministic contracts, readiness, ranges, signal separation, CLI output, and withholding behavior. It does not prove real-company coverage, current data, predictive accuracy, or investability. +The fixture readiness command uses a schema-valid synthetic onboarding fixture and should report `baseline_ready`. The same readiness command without `FIXTURE=1` reads local real-data onboarding rows and must remain blocked until source-backed point-in-time consensus and actuals pass validation. + Real semiconductor coverage is `awaiting_point_in_time_consensus`. The next legitimate data step is a narrow append-only cohort with licensed or otherwise permitted historical consensus snapshots and source-backed actuals. Generated packets remain local and unstaged unless an exact artifact is intentionally reviewed. diff --git a/docs/LINKEDIN_PROJECT_BRIEF.md b/docs/LINKEDIN_PROJECT_BRIEF.md index 99615a3a1..1be76ab23 100644 --- a/docs/LINKEDIN_PROJECT_BRIEF.md +++ b/docs/LINKEDIN_PROJECT_BRIEF.md @@ -23,7 +23,7 @@ Two-minute external review path: Each public page now opens with one question, one short answer, one primary next action, and one stop rule. The public workflow is checked at desktop and mobile widths. The current-page shortcut is visible so visitors know where they are. -Current review status is `review_limited`: deterministic public checks and repeated local cold/warm route timings pass, while external reviewer evidence and hosted-route verification are still unavailable. This is a truthful controlled-demo status, not a hosted-product or data-freshness claim. +Current review status is `share_review_ready` for the local GitHub/demo workflow: deterministic public checks, browser evidence, and repeated local cold/warm route timings pass. External reviewer evidence and hosted-route verification are still unavailable, so this remains a controlled-demo status, not a hosted-product or data-freshness claim. The first story is the public workflow, not operator automation. Keep reviewed batch packets, provider setup, and validate / preview / apply mechanics as operator detail after the visitor understands the product. diff --git a/docs/PUBLIC_DEMO_WALKTHROUGH.md b/docs/PUBLIC_DEMO_WALKTHROUGH.md index 4c76919a7..e7f67b64e 100644 --- a/docs/PUBLIC_DEMO_WALKTHROUGH.md +++ b/docs/PUBLIC_DEMO_WALKTHROUGH.md @@ -9,7 +9,7 @@ Use this when sharing the project from GitHub or LinkedIn. The walkthrough is re - Share under the controlled portfolio/demo license; do not describe the repository as open source or reusable software. - Keep the demo research-only: no investment advice, broker action, order routing, auto-trading, or direct buy/sell instructions. - No public hosted Streamlit URL is configured yet; the shareable path is the GitHub project, curated screenshots, and tracked `make demo-dashboard` instructions. Use `docs/HOSTED_DEMO_DEPLOYMENT.md` only when preparing a verified hosted app link. -- Public UX status is `review_limited`: repeated local cold/warm evidence passes, but hosted-route verification and external reviewer workflow evidence remain pending. +- Public UX status is `share_review_ready` for the local GitHub/demo workflow: public checks, browser evidence, and repeated local cold/warm evidence pass. Hosted-route verification and external reviewer workflow evidence remain pending before calling it a hosted or externally validated product. - FMP, Alpha Vantage, and Finnhub are optional provider fallbacks and are not configured by default. Provider setup does not prove coverage until a reviewed source-backed smoke, validation, preview, and readiness rebuild pass. - Coverage is intentionally readiness-gated rather than complete. Fundamentals, share count, peer mapping, earnings, and analyst estimates can remain blocked or locked while the product is still useful. - Stop before claiming a blocked lane is ready unless source proof, validation, preview, apply, rebuilt readiness, and proof history all support it. diff --git a/tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv b/tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv new file mode 100644 index 000000000..c93f457af --- /dev/null +++ b/tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv @@ -0,0 +1,2 @@ +schema_version,ticker,fiscal_period,snapshot_at,revenue_consensus,eps_consensus,source,source_ref,retrieved_at,revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis,expected_report_date +earnings-nowcast-evidence-v2,SYN1,2026-Q1,2026-01-25T12:00:00Z,112,1.00,synthetic_test_fixture,fixture://consensus/SYN1/2026-Q1/2026-01-25,2026-01-25T12:01:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported,2026-04-20 diff --git a/tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv b/tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv new file mode 100644 index 000000000..ec39a1df0 --- /dev/null +++ b/tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv @@ -0,0 +1,6 @@ +schema_version,ticker,fiscal_period,period_end_date,reported_at,revenue_actual,eps_actual,source,source_ref,retrieved_at,revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis,supersedes_source_ref +earnings-nowcast-evidence-v2,SYN1,2024-Q4,2024-12-31,2025-01-20T21:00:00Z,96,0.80,synthetic_test_fixture,fixture://actual/SYN1/2024-Q4,2025-01-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q1,2025-03-31,2025-04-20T21:00:00Z,100,0.85,synthetic_test_fixture,fixture://actual/SYN1/2025-Q1,2025-04-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q2,2025-06-30,2025-07-20T21:00:00Z,104,0.90,synthetic_test_fixture,fixture://actual/SYN1/2025-Q2,2025-07-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q3,2025-09-30,2025-10-20T21:00:00Z,108,0.95,synthetic_test_fixture,fixture://actual/SYN1/2025-Q3,2025-10-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q4,2025-12-31,2026-01-20T21:00:00Z,112,1.00,synthetic_test_fixture,fixture://actual/SYN1/2025-Q4,2026-01-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, diff --git a/tests/fixtures/earnings_nowcast_onboarding/signals.csv b/tests/fixtures/earnings_nowcast_onboarding/signals.csv new file mode 100644 index 000000000..73bfe70fd --- /dev/null +++ b/tests/fixtures/earnings_nowcast_onboarding/signals.csv @@ -0,0 +1,2 @@ +schema_version,signal_id,target_ticker,source_ticker,fiscal_period,as_of_timestamp,signal_type,direction,affected_metric,confidence_band,evidence_source,evidence_source_ref,evidence_published_at,evidence_excerpt_hash,peer_relationship_state,review_state +earnings-nowcast-evidence-v2,onboarding-fixture-peer-1,SYN1,SYN2,2026-Q1,2026-01-31T23:59:59Z,peer_earnings_readthrough,positive,revenue,medium,synthetic_test_fixture,fixture://signal/SYN1/peer/1,2026-01-20T12:00:00Z,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,trusted,supported diff --git a/tests/test_launchers.py b/tests/test_launchers.py index a081b5ff0..c875bbf3b 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -2720,6 +2720,15 @@ def test_earnings_nowcast_onboarding_launchers_have_no_apply_path(): assert "imports-apply" not in section +def test_earnings_nowcast_readiness_launcher_has_explicit_fixture_onboarding_path(): + makefile = Path("Makefile").read_text(encoding="utf-8") + section = makefile[makefile.index("earnings-nowcast-readiness:") :] + + assert "$(if $(FIXTURE),tests/fixtures/earnings_nowcast_onboarding,$(or $(INPUT_DIR),data/imports/earnings_nowcast))" in section + assert "tests/fixtures/earnings_nowcast)" not in section + assert "imports-apply" not in section + + def test_earnings_nowcast_sec_actuals_stage_launcher_requires_scoped_output_and_cutoff(): makefile = Path("Makefile").read_text(encoding="utf-8") diff --git a/tests/test_public_v1_release_docs.py b/tests/test_public_v1_release_docs.py index 60e785570..0dee11474 100644 --- a/tests/test_public_v1_release_docs.py +++ b/tests/test_public_v1_release_docs.py @@ -128,7 +128,7 @@ def test_readme_has_compact_current_next_stages_for_external_reviewers(): assert "| Peer readiness upgrade | Source-gated | Keep candidate peers as context only until source-backed peer rows pass review. |" in readme assert "| Optional earnings / estimates | Locked | Use trusted provider or reviewed manual rows only; do not infer optional context. |" in readme assert "| Broad proof queues | Do not retry now | Current queues are exhausted; reopen only after keyed provider rows, reviewed manual rows, or changed blockers exist. |" in readme - assert "| Public UX polish | Review limited | Public checks and repeated local cold/warm route timings pass; external reviewer evidence is still required before upgrading the claim. |" in readme + assert "| Public UX polish | Share-review ready locally | Public checks, browser evidence, and repeated local cold/warm route timings pass; hosted-route verification and external reviewer evidence are still required before calling it a hosted or externally validated product. |" in readme assert "| Generated artifacts | Excluded by default | Keep local CSV/report/sample-report churn unstaged unless one exact artifact is reviewed as public evidence. |" in readme assert "The local fixed-demo performance gate has passed and remains a regression check" in readme assert "The active evidence stage is a narrow, append-only Earnings Nowcast pilot" in readme @@ -139,11 +139,11 @@ def test_readme_has_compact_current_next_stages_for_external_reviewers(): assert readme.index("## Current Next Stages") < readme.index("## What You Can Analyze") -def test_public_status_language_uses_review_limited_until_live_route_gate_passes(): +def test_public_status_language_keeps_share_review_ready_local_only(): readme = _read("README.md") - assert "| Public UX polish | Review limited |" in readme - assert "Share-review ready" not in readme + assert "| Public UX polish | Share-review ready locally |" in readme + assert "hosted-route verification and external reviewer evidence are still required" in readme assert "make public-performance-gate" in readme From 85202ff5caff24826658b51a328c352310266ae6 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Fri, 17 Jul 2026 11:30:55 -0400 Subject: [PATCH 23/23] Prioritize public page answers over advanced evidence --- src/dashboard.py | 28 ++++++++++++++++++++++++---- tests/test_dashboard_helpers.py | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/dashboard.py b/src/dashboard.py index 58f902cb5..55a50abec 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -5194,12 +5194,24 @@ def profile_advanced_details(context: ProfileContext) -> dict[str, object]: } -def render_profile_trust_strip(context: ProfileContext, *, compact: bool = False) -> None: - st.markdown(profile_trust_strip_html(context, compact=compact), unsafe_allow_html=True) +def render_profile_trust_details(context: ProfileContext) -> None: + """Keep technical profile evidence after the page's primary answer.""" + with st.expander("Advanced: profile identity and freshness evidence", expanded=False): st.json(profile_advanced_details(context)) +def render_profile_trust_strip( + context: ProfileContext, + *, + compact: bool = False, + include_advanced: bool = True, +) -> None: + st.markdown(profile_trust_strip_html(context, compact=compact), unsafe_allow_html=True) + if include_advanced: + render_profile_trust_details(context) + + def research_change_home_summary( queue: list[ResearchReviewItem] | tuple[ResearchReviewItem, ...], *, @@ -33618,7 +33630,7 @@ def main() -> None: render_public_shell_mode_styles() render_public_workflow_skip_link(selected_page, st.query_params) render_public_app_shell(selected_page) - render_profile_trust_strip(profile_context, compact=True) + render_profile_trust_strip(profile_context, compact=True, include_advanced=False) render_public_workflow_skip_target() else: render_app_header( @@ -33630,7 +33642,7 @@ def main() -> None: render_profile_trust_strip(profile_context, compact=True) render_public_workflow_skip_target() - if selected_page in PUBLIC_PATH_PAGE_TITLES: + if selected_page in PUBLIC_PATH_PAGE_TITLES and not public_demo_mode: render_research_change_route_summary( selected_page, research_change_state, @@ -33672,6 +33684,14 @@ def main() -> None: render_proof_history(public_mode=public_demo_mode) elif selected_page == "Universe Manager": render_universe_manager(universe_summary or summarize_universe_manager(BASE_DIR)) + if public_demo_mode: + render_profile_trust_details(profile_context) + if selected_page in PUBLIC_PATH_PAGE_TITLES: + render_research_change_route_summary( + selected_page, + research_change_state, + ticker=str(st.query_params.get("ticker") or ""), + ) if bootstrap_placeholder is not None: bootstrap_placeholder.empty() diff --git a/tests/test_dashboard_helpers.py b/tests/test_dashboard_helpers.py index 96683ed36..88c51304f 100644 --- a/tests/test_dashboard_helpers.py +++ b/tests/test_dashboard_helpers.py @@ -29476,6 +29476,27 @@ def test_public_workflow_skip_link_bypasses_the_shared_public_shell(): assert "Page answer begins" in target_html +def test_public_page_answer_precedes_shared_advanced_evidence(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + + main_index = source.index("def main()") + output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(selected_page)", main_index) + public_mode_index = source.index("if public_demo_mode:", output_frames_index) + public_profile_index = source.index( + "render_profile_trust_strip(profile_context, compact=True, include_advanced=False)", + public_mode_index, + ) + skip_target_index = source.index("render_public_workflow_skip_target()", public_profile_index) + dispatch_index = source.index('if selected_page == "Home":', skip_target_index) + profile_details_index = source.index("render_profile_trust_details(profile_context)", dispatch_index) + change_details_index = source.index( + "render_research_change_route_summary(", + profile_details_index, + ) + + assert public_profile_index < skip_target_index < dispatch_index < profile_details_index < change_details_index + + def test_operator_workflow_skip_link_has_a_main_content_target(): source = Path("src/dashboard.py").read_text(encoding="utf-8")