SPB Git

spb/wp39_uqo

Public

WP39: phase 0-2 scaffold — data audit, frozen design, PIT protocol, client, features, tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 2, 2026) Browse files

76 changed files +6,968 −0

76 changed files
Unified Split
added .env.example +11 −0
@@ -0,0 +1,11 @@
1+# WP39 UQO — environment-based authorization. Never commit a real .env.
2+# FinData OS (mandatory primary data source). Key file is read if FINDATA_API_KEY is unset.
3+FINDATA_BASE_URL=https://findata.spboucher.ai/api/v1
4+FINDATA_API_KEY=
5+FINDATA_API_KEY_FILE=~/findata-secrets/api-key-wp39.txt
6+# Scratch cache for licensed raw extracts (outside the repository, never committed)
7+WP39_CACHE=~/wp39-cache
8+# SEC EDGAR fair-access User-Agent (public data, no secret) for ticker->CIK->SIC crosswalk
9+SEC_USER_AGENT="UQO WP39 research (mailto:simon-pierre.boucher@uqo.ca)"
10+# Set to 1 to run the live API contract tests
11+WP39_LIVE=0
added .gitignore +35 −0
@@ -0,0 +1,35 @@
1+# Raw & processed data — never committed (see data/raw/README.md for provenance)
2+data/raw/*
3+!data/raw/README.md
4+data/processed/*
5+!data/processed/.gitkeep
6+
7+# Python
8+__pycache__/
9+*.pyc
10+.pytest_cache/
11+.venv/
12+*.egg-info/
13+
14+# LaTeX build artifacts (paper/main.pdf IS committed)
15+paper/*.aux
16+paper/*.log
17+paper/*.out
18+paper/*.toc
19+paper/*.lof
20+paper/*.lot
21+paper/*.fls
22+paper/*.fdb_latexmk
23+paper/*.bbl
24+paper/*.blg
25+paper/*.spl
26+paper/*.synctex.gz
27+paper/*.run.xml
28+paper/*.bcf
29+
30+# Logs
31+logs/
32+*.log
33+
34+# macOS
35+.DS_Store
added CLAUDE.md +43 −0
@@ -0,0 +1,43 @@
1+# CLAUDE.md — UQO Research WP39
2+
3+## THE INVISIBLE BALANCE SHEET
4+### Can Intraday Financial Markets Detect Corporate Deterioration Before Accounting Disclosures?
5+
6+**Project ID:** `wp39_uqo` · **Institution:** Université du Québec en Outaouais (UQO) · **PI:** Simon-Pierre Boucher
7+**Status:** research project built 2026-11-02 under the owner's specification (reproduced below in condensed form; the
8+owner's original brief is the authoritative text). All findings are hypotheses until reproducibly estimated.
9+
10+Owner instructions added during the build (2026-11-02): (1) the FinData **fundamentals API** is the accounting source and
11+must be used; (2) **minimise AI usage** — no LLM features, no neural/text models, no AI-generated statistics.
12+
13+## Non-negotiable execution rules
14+1. FinData OS (`https://findata.spboucher.ai/api/v1`) is the primary source of prices, volumes, earnings events and
15+ fundamentals. No silent substitution; every exception is documented in `DATA_AUDIT.md` / `docs/API_INTEGRATION.md`.
16+2. Inspect before changing; preserve all other working papers; this repository is its own git repo.
17+3. Credentials from the protected store only (`~/findata-secrets/api-key-wp39.txt` on the node); never printed, never
18+ in URLs, git, artifacts, notebooks, figures or manuscript. No destructive ingestion, no unapproved backfills.
19+4. FirstRate Data is privately licensed: no raw records in git, public endpoints, replication packages or logs.
20+5. Hypotheses, variable definitions, PIT rules, baselines, splits and significance plan are frozen in
21+ `docs/RESEARCH_DESIGN.md` and `docs/PIT_PROTOCOL.md` before interpreting results. Prediction ≠ causation.
22+6. Every claim traces to a saved table/figure/log/test. No fabricated statistics or references.
23+7. Phases with gates; `PROGRESS.md`, `DECISIONS.md`, `DATA_AUDIT.md`, `LIMITATIONS.md` are kept current.
24+8. English everywhere; LaTeX article compiled with `latexmk` (`paper/`).
25+
26+## Where things are
27+| Item | Location |
28+|---|---|
29+| Design, PIT rules, novelty | `docs/RESEARCH_DESIGN.md`, `docs/PIT_PROTOCOL.md`, `docs/NOVELTY_MEMO.md` |
30+| Data audit, limitations, decisions, progress | root `*.md` files |
31+| Code | `src/wp39/` (client, provenance, calendar, instruments, PIT fundamentals, corporate actions, ingest, features, labels, samples, splits, models, inference, portfolio, robustness, figures, pipeline) |
32+| Pipeline | `scripts/00_…09_` in order; `scripts/01a_extract_node_tables.sh` runs on M3U96a |
33+| Tests | `tests/` (`pytest`; live API tests need `WP39_LIVE=1`) |
34+| Artifacts | `artifacts/manifests` (hashes, versions), `artifacts/evaluation`, `artifacts/logs`, `results/`, `figures/` |
35+| Paper | `paper/main.tex` + `sections/` + generated `tables/`, `figures/` |
36+
37+## How to run (compute node M3U96a, API on localhost)
38+```bash
39+rsync -a --exclude .venv --exclude artifacts/logs ~/Desktop/UQO/Recherche/Recherche-UQO/finance/invisible-balance-sheet/ M3U96a:~/wp39_uqo/
40+ssh M3U96a 'cd ~/wp39_uqo && uv venv -p 3.12 .venv && uv pip install -e ".[dev]" && export FINDATA_BASE_URL=http://127.0.0.1:8600/api/v1 && \
41+ bash scripts/01a_extract_node_tables.sh && for s in 00 01 02 03 04 05 06 07 08; do .venv/bin/python scripts/${s}_*.py; done'
42+```
43+Then `rsync` `results/ figures/ artifacts/ paper/tables paper/figures` back and `cd paper && make`.
added DATA_AUDIT.md +167 −0
@@ -0,0 +1,167 @@
1+# DATA_AUDIT.md — WP39 `wp39_uqo`
2+
3+Phase 0 discovery and feasibility audit of the **FinData OS** API (`https://findata.spboucher.ai/api/v1`) as the primary
4+source for *The Invisible Balance Sheet*. All numbers below were measured on **2026-11-02** (America/Toronto) with the
5+owner's Claude Code key (admin scope, laptop) and then with a dedicated least-privilege key `wp39_uqo`
6+(scopes `read:market, read:fundamentals, query, export`; stored outside git in `~/findata-secrets/api-key-wp39.txt` on
7+M3U96a and `~/Desktop/Cluster/secrets/findata/api-key-wp39.txt` on the laptop, mode 600). No credential appears in
8+this repository, in logs or in artifacts. Probe transcripts: `artifacts/logs/phase0_probe.json` (produced by
9+`scripts/00_probe_findata.py`).
10+
11+## 0. Viability conclusion
12+
13+**YES — viable, with two structural caveats that shape the paper.**
14+
15+1. A licensed, point-in-time usable cohort exists: **5,451 US-listed entities** with quarterly fundamentals
16+ (**67 metrics**, `filing_date` 2012-01-03 → 2026-10-30, median 45 quarterly periods per firm), **373,326 earnings-release
17+ events** with release date and (for 65 %) before-open/after-close timing, and **6.70 billion raw 1-minute bars** for
18+ 16,195 stock symbols (2000-01-03 → 2026-10-30) including 8,069 `-DELISTED` symbols. Exports of 1-minute history are
19+ fast (≈ 1 s per symbol-decade, see §6).
20+2. **Caveat A — survivorship in fundamentals, not in prices.** The fundamentals/earnings tables contain **zero delisted
21+ entities** (ingestion ran for *active* tickers only). Minute bars do cover delisted names, so the price side is
22+ survivorship-free, but the accounting panel is conditioned on being listed on 2026-11-01. The vendor does serve
23+ fundamentals for at least some delisted tickers (probe: `FMD` → HTTP 200 with data; `CWZ` → 404), so a scoped
24+ delisted-fundamentals backfill is *possible* but requires owner approval and a capacity plan (not run; see §8).
25+3. **Caveat B — PIT is a proxy.** `available_at = filing_date + 1 day` is a policy, `revision_id = 1` for every fact
26+ (`observed_at` = first ingestion, 2026-11-01/02). Values are the vendor's *current* history, so restatements that
27+ happened before ingestion are invisible. The design therefore (i) uses only prior-quarter fundamentals as inputs,
28+ (ii) uses the **earnings-release date** (not the filing date) as the reveal date, and (iii) reserves a stringent
29+ specification using SEC-filing-level reconstruction for a subset (Phase 5, conditional on time).
30+
31+## 1. API contract — documented vs observed
32+
33+| Purpose | Route (observed in `openapi.json`, OpenAPI 3.1.0, API 1.0.0) | Observed behaviour |
34+|---|---|---|
35+| Datasets | `GET /catalog/datasets` | 64 datasets; 62 `active`, `fundamentals.sec_companyfacts` = `pending_configuration`, `market_ticks` = `unsupported` |
36+| Dataset detail | `GET /catalog/datasets/{id}` | version, `license_class = frd-private` |
37+| Bars | `GET /market/bars` | `interval ∈ {1m, 5m, 1d}` only (no 30m/1h on REST); `adjustment ∈ {raw, split, split_div}`; `limit ≤ 10 000`; `format ∈ {json, arrow, csv}`; `version` pin |
38+| Bars schema | `GET /catalog/schema/bars` | schema v1, `timestamp_convention = bar_open`, `timezone_storage = UTC`, 20 fields (see §3) |
39+| Fundamentals | `GET /fundamentals/{entity}` | `as_of`, `metrics`, `period_type ∈ {q,a}`, `include_superseded`, **`limit ≤ 20 000`** (default 2 000) |
40+| Fundamental periods | `GET /fundamentals/{entity}/periods` | one row per fiscal period with `filing_date`, `available_at`, `max_revision`, `restated` |
41+| SQL | `POST /query` | one read-only statement, `max_rows ≤ 100 000`, 30 s; views = dataset ids with `.`→`_` |
42+| Exports | `POST /exports` → `GET /exports/{id}` → `/download` | body `{dataset_id, symbols ≤ 500, start, end, adjustment, format}`; **range ≤ 12 years per request (HTTP 413 otherwise)**; Parquet with schema metadata and pinned `X-Dataset-Version` |
43+| Coverage | `GET /coverage`, `/coverage/symbols` (`limit ≤ 5 000`, cursor), `/coverage/heatmap` | per-session bar counts vs `expected_bars` (391 regular) |
44+| Instruments | `GET /instruments`, `/instruments/{symbol}` | status `active`/`delisted`, first/last date |
45+| Quality | `GET /quality`, `/quality/anomalies` | row-level flags (`price_jump`, `outside_session`, …) |
46+| **Earnings events** | **no route** | dataset `fundamentals.earnings` is listed (0 rows reported) but rows live in Postgres `earnings_events` → read-only extraction on the node (documented exception, §7) |
47+| Corporate actions | **no route** | Postgres `corporate_actions`: 6,131 splits (2000-01-03 → 2026-10-15), 243,209 cash dividends; applied at query time via `adjustment=` |
48+
49+Reconciliation notes: the catalog reports `row_count = 0` for Postgres-backed datasets (`fundamentals.*`, `corporate_actions`,
50+`reference.instruments`); that is a catalog limitation, not an absence of data. The REST `interval` enum is narrower than the
51+lake (30m/1h exist only as vendor-adjusted datasets); WP39 rebuilds 5m/30m/1d bars **from the raw 1m export** so that every
52+frequency shares one source and one adjustment path.
53+
54+## 2. Market bars — coverage
55+
56+Counts of distinct stock symbols with at least one bar on a mid-June session (SQL playground, `session_date =` predicate):
57+
58+| Session | 1m symbols | 1m bars | 1d symbols | of which `-DELISTED` in 1d |
59+|---|---:|---:|---:|---:|
60+| 2001-06-15 | — | — | 1,088 | 0 |
61+| 2005-06-15 | 3,498 (340,665 bars delisted) | 631,260 | 1,504 | 0 |
62+| 2010-06-15 | — | — | 2,309 | 0 |
63+| 2015-06-15 | 6,489 | 1,207,920 | 2,985 | 0 |
64+| 2020-06-15 | — | — | 4,144 | 0 |
65+| 2025-06-16 | 6,918 | 1,317,343 | 6,485 | 0 |
66+
67+Finding: the vendor **1d dataset is incomplete** relative to the 1m dataset (about half the symbols in 2005–2015 and no
68+delisted names). Decision D-003: daily bars are *derived* from the 1m export (regular session 09:30–16:00 ET) — never taken
69+from `market_bars.stock.1d.raw`. Delisted symbols are present in the 1m lake (e.g. `AA.B-DELISTED` 531 sessions
70+2014–2016; `CWZ-DELISTED` 2000–2014).
71+
72+Bar conventions verified on `XOM` and `SPY` exports: timestamps are bar **open** in UTC (`2012-01-03 09:01Z` = 04:01 ET),
73+extended hours 04:00–19:59 ET are present, zero-volume minutes are absent (bar counts per session 405–798 vs 391 expected
74+regular bars), `quality_flags` list is empty on normal rows, `revision_id = 1`.
75+
76+## 3. Bars schema (Arrow, schema v1)
77+
78+`instrument_id, symbol_as_reported, venue (null), asset_class, interval, timestamp_utc (timestamp[us, UTC], bar open),
79+session_date (date32, exchange tz), open, high, low, close, volume (double), currency, trade_count (int32?, derived only),
80+vwap (double?, derived only), adjustment_type, source_id, ingested_at, quality_flags (list<string>), revision_id (int32)`.
81+Parquet exports carry metadata `findata.schema = bars`, `findata.schema_version = 1`, `findata.timestamp_convention = bar_open`.
82+
83+## 4. Fundamentals — coverage and PIT fields
84+
85+- Entities: **5,451** (`frd:<TICKER>`, `security_id = firstrate:stock:<TICKER>`, none delisted, none with `security_id` null).
86+- Metrics observed per period: **67** (the vendor documents 76; 9 are never populated in the API output). Names:
87+ `acc_oci, assets_c, assets_nc, assets_tot, capex, cash, cash_business_acq, cash_change, cash_debt_sec, cash_div,
88+ cash_equity, cash_fin, cash_inv, cash_inv_acq, cash_op, cogs, cos_inc, debt, debt_c, debt_nc, def_rev, depr, dps, ebit,
89+ ebitda, ebt, eps, eps_d, equity, fx_impact_cash, gm, gp, intangibles, interest_exp, intr_cover, inv_c, inv_nc, inventory,
90+ investments, liab_c, liab_nc, liab_tot, net_inc, net_inc_com, net_inc_non_control, net_loss_discontinued, om, op_ex,
91+ op_inc, payables, ppe, pref_divs, receivables, research_dev, ret_earnings, rev, roa, roe, roic, sgna, share_comp, shares,
92+ shares_av, shares_av_diluted, tax_asset, tax_expense, tax_liability`.
93+- Quarterly `rev` facts by fiscal year (entities / facts): 2011: 1,798 / 1,812 · 2012: 2,461 / 8,725 · 2013: 2,623 / 9,868 ·
94+ 2014: 2,771 / 10,560 · 2015: 2,916 / 11,145 · 2016: 3,076 / 11,656 · 2017: 3,276 / 12,340 · 2018: 3,505 / 13,209 ·
95+ 2019: 3,763 / 14,034 · 2020: 4,309 / 15,488 · 2021: 4,663 / 17,458 · 2022: 4,851 / 18,289 · 2023: 5,043 / 18,904 ·
96+ 2024: 5,244 / 19,467 · 2025: 5,370 / 19,625 · 2026: 4,928 / 10,915. `filing_date` is never null (no `pit_fallback`).
97+- Firms by number of quarterly periods: ≥ 8: 5,018 · ≥ 20: 4,331 · ≥ 40: 2,891. First quarter year: 2011: 2,141 ·
98+ 2012: 317 · 2013–2019: 148–256 per year · 2020: 551 · 2021: 353 · 2022–2025: 164–215 per year.
99+- Filing lag (filing_date − fiscal_period_end, quarterly): p10 = 30 d, median = 40 d, p90 = 77 d.
100+- Q4 values are genuine quarterly flows (AAPL FY2019 revenue 260.17 bn = 84.31 + 58.02 + 53.81 + 64.04 bn).
101+- PIT fields per fact: `filing_date`, `published_at` (null), `available_at = filing_date + 1 d 00:00 ET`, `observed_at`
102+ (= 2026-11-01 18:24 ET for the first ingestion), `revision_id = 1`, `restatement_flag = false`, `superseded_at = null`.
103+ The API `as_of` semantics (`available_at ≤ as_of` and (`revision_id = 1` or `observed_at ≤ as_of`)) were exercised:
104+ AAPL `as_of = 2020-01-01` returns 2,000 facts (limit) and reports `facts_hidden_by_as_of = 2211`.
105+- Entity master: `cik`, `sector`, `industry` are **null for all 5,451 entities** → external SEC crosswalk needed (§7).
106+
107+## 5. Earnings-release events (reveal dates)
108+
109+Postgres `earnings_events` (`entity_id, event_date, eps_estimate, eps_reported, release_time, source_id, observed_at`):
110+373,326 rows, 5,451 entities, 2000-01-01 → 2027-04-29, one row per (entity, date). `release_time`: `after_close` 124,832 ·
111+`before_open` 120,586 · null 127,908. For 2012–2024 the null share is 35–36 %; `eps_estimate` is null for 24–27 % of
112+events (vendor estimate; vintage unknown → exploratory only). Row counts for 2025–2026 are inflated by forward-dated
113+placeholders (2026: 35,361 events), which are dropped at the sample end (2026-10-30).
114+
115+Matching quarterly filings to the first release event in `[period_end, filing_date + 10 d]` (213,954 filings 2012→):
116+96.3 % matched; 1,493 (0.7 %) have `filing_date < event_date`; 16,868 (7.9 %) have a lag > 30 d (likely mismatches or
117+late filers); median `filing_date − event_date` = **1 day**. Rule (PIT_PROTOCOL §3): reveal date `A_q` = matched release
118+date; a firm-quarter is **excluded** when no release is matched, when `filing_date < event_date`, or when the lag exceeds
119+30 days; the prediction cutoff always satisfies `c < min(A_q, F_q)` in trading days.
120+
121+## 6. Throughput and capacity (measured on M3U96a, API on localhost)
122+
123+| Operation | Measured |
124+|---|---|
125+| Export 1m raw, 1 symbol, 2012-01-01→2019-12-31 (`XOM`) | ready in 1.0 s, 859,354 rows, 13.6 MB Parquet, dataset version 17375 |
126+| Export 1m raw, 1 symbol, 2020-01-01→2026-10-30 | ready in 1.0 s, 884,983 rows, 15.4 MB |
127+| Full-range export (2012→2026, 14.8 years) | **HTTP 413** — limit 12 years per request → two requests per symbol |
128+| SQL cross-section (`session_date =` on 1m) | ≈ 3 s |
129+| Coverage per symbol (`/coverage/symbols`) | ms, cursor-paged |
130+| Fundamentals, one entity, all quarterly facts | one call (≤ 20 000 rows) |
131+
132+Capacity model for the main sample (≈ 4,300 firms with ≥ 8 quarters and 1m coverage): 2 exports × ~1.5 s ≈ 3.6 h of
133+export wall-time (sequential; 4 concurrent streams bring it under 1 h), ≈ 30 MB raw Parquet per firm (≈ 130 GB if kept).
134+Decision D-004: raw extracts are processed **streaming** (export → daily feature rows at 1m/5m/30m/1d → delete raw file,
135+keep SHA-256 + row count in the manifest), so steady-state scratch usage stays below 5 GB. The storage node M3U96a
136+(28 cores, 96 GB RAM, 319 GB free on the internal SSD, hard floor 60 GB for FinData) hosts the computation; the feature
137+panel (≈ 4,300 firms × ~3,700 sessions × 4 frequencies) is < 2 GB.
138+
139+## 7. External sources and exceptions
140+
141+| Need | Source | Status | Why not FinData |
142+|---|---|---|---|
143+| Reveal dates / release timing | FinData Postgres `earnings_events` (read-only `psql` on M3U96a) | available | not exposed by the API (gap logged for the FinData backlog) |
144+| CIK, SIC industry | SEC `company_tickers.json` (10,423 rows) + `submissions/CIK##########.json` (`sic`, `sicDescription`, `exchanges`, `formerNames`) | verified reachable (fair-access UA) | entity master has no CIK/sector |
145+| Daily factors (H5 only) | Kenneth French library, FF5 2×3 daily + momentum daily | verified (HTTP 200, updated 2026-10-26) | not a FinData dataset |
146+| Analyst consensus | vendor `eps_estimate` in `earnings_events` (24–27 % missing, vintage unknown) | exploratory only | no point-in-time consensus history available |
147+| TAQ / quotes / order flow | none | **not available** (vendor supplies bars only) | bar-based proxies are *never* called order flow, spreads or informed trading |
148+
149+## 8. Gaps, risks and recommendations to the owner
150+
151+1. **Delisted fundamentals backfill (recommended, not run).** A one-off `fundamentals` job for the 8,069 `-DELISTED`
152+ tickers (vendor returns data for at least part of them) would remove the survivorship conditioning of the accounting
153+ panel. Estimated cost: ≤ 8,069 vendor calls (1 req/s policy ≈ 2.3 h), a few hundred MB in Postgres. Requires: owner
154+ approval, `findata ingest fundamentals --delisted` (does not exist yet; connector change on M3U96a `~/apps/findata`).
155+2. **Earnings events API route** — expose `fundamentals.earnings` through `/api/v1/earnings/{entity}` so that WP39 (and
156+ future projects) stop reading Postgres directly.
157+3. **SEC EDGAR connector** (`fundamentals.sec_companyfacts`, pending a User-Agent setting) would give filing-level
158+ as-originally-reported values for the stringent PIT specification.
159+4. API bug found: `POST /admin/keys` returns HTTP 500 (`api_keys_user_id_fkey` violation) when the caller is an API key
160+ whose `user_id` is not in `users`; the CLI `findata create-key` works. Logged for the FinData backlog, not fixed here.
161+
162+## 9. License and privacy constraints applied
163+
164+FirstRate Data: perpetual private use, research and derived statistics allowed with attribution, **no redistribution of raw
165+records**. Consequently: no raw bars or fundamentals are committed; `data/raw` stays empty; the replication package ships
166+code, manifests (hashes, versions, counts), derived aggregate tables and figures only; the manuscript attributes FirstRate
167+Data via FinData OS. SEC and Kenneth French data are public.
added DECISIONS.md +14 −0
@@ -0,0 +1,14 @@
1+# DECISIONS.md — dated design and engineering decisions (WP39)
2+
3+| ID | Date (ET) | Decision | Reason | Consequence |
4+|---|---|---|---|---|
5+| D-001 | 2026-11-02 13:00 | Compute on **M3U96a** (FinData host) with a dedicated least-privilege key `wp39_uqo`; repository lives in the UQO Research workspace (`Recherche-UQO/finance/invisible-balance-sheet`, dir name follows the series convention; project id `wp39_uqo`) and is rsync'd to `M3U96a:~/wp39_uqo` for runs | API on localhost, lake co-located, 28 cores / 96 GB; secrets never leave `~/findata-secrets` / `~/Desktop/Cluster/secrets` | results and manifests synced back; raw cache stays on the node |
6+| D-002 | 2026-11-02 13:05 | **Earnings release dates** (`earnings_events.event_date`) define the reveal date `A_q`; the filing date `F_q` defines structured-fact availability (`available_at = F_q + 1`) | press releases precede 10-Q filings by a median of 1 day; cutoffs must precede the *first* public reveal | cutoff rule `c < min(A_q, F_q)`; release rows read from Postgres (no API route) — documented exception |
7+| D-003 | 2026-11-02 13:10 | Daily bars are **rebuilt from the 1-minute raw export** (regular session), not taken from `market_bars.stock.1d.raw` | vendor 1d dataset covers about half the symbols before 2016 and no delisted names | one source, one adjustment path for all four frequencies |
8+| D-004 | 2026-11-02 13:30 | Streaming feature extraction (export → features → delete raw Parquet; manifest keeps SHA-256) | disk budget on the FinData host (hard floor 60 GB free) | re-runs re-export from the pinned dataset version |
9+| D-005 | 2026-11-02 14:00 | Universe = all FinData fundamentals entities (survivor-conditioned); delisted-fundamentals backfill **proposed, not run** | vendor ingestion covered active tickers only; backfill needs owner approval + capacity plan (rule 3 of the brief) | survivorship caveat stated in abstract, data section, limitations; price side is survivorship-free |
10+| D-006 | 2026-11-02 14:05 | **No neural (B7) nor text/multimodal (B8) models; no LLM anywhere in the pipeline** | owner instruction received mid-session on 2026-11-02 ("limite le plus possible l'utilisation AI"); also keeps the frequency comparison about information sets rather than capacity | ladder is B0–B6; LightGBM provides the nonlinear benchmark with equal budgets |
11+| D-007 | 2026-11-02 14:10 | Primary target = adverse **operating-income** surprise vs a seasonal random walk with drift, scaled by total assets, threshold −0.5 % | no point-in-time consensus exists in FinData; operating income is less affected by one-off items than net income; Foster (1977) model is the classic honest expectation | consensus-based miss (vendor `eps_estimate`) is exploratory only |
12+| D-008 | 2026-11-02 14:15 | Frequency sets B3/B4/B5 use **identical feature families** (one column per family) computed from bars of 30 m / 5 m / 1 m | compare information sets, not dimensionality | a supplementary "all frequencies" model is run in Phase 4 |
13+| D-009 | 2026-11-02 14:20 | Literature verified with **Crossref metadata** (no AI summarisation); references without a verifiable DOI are cited without one (Grossman–Stiglitz 1980, Holm 1979) | rule 7 of the brief (no fabricated references) | `artifacts/logs/crossref_batch*.json` are the evidence |
14+| D-010 | 2026-11-02 14:30 | Phase gates that require "owner approval" are recorded as **self-approved under the autonomous mandate** with the memo attached; the owner can veto any of them before Phase 6 | the owner asked for the whole project to be built in one autonomous session and is not available mid-task | each gate entry in PROGRESS.md names the memo and the date |
added LIMITATIONS.md +31 −0
@@ -0,0 +1,31 @@
1+# LIMITATIONS.md — living list (WP39)
2+
3+## Data
4+- **Survivor-conditioned accounting panel.** Fundamentals and earnings-release rows exist only for the 5,451 tickers active
5+ on 2026-11-01. Firms that delisted during 2012–2026 (including those that deteriorated and failed) are absent from the
6+ firm-quarter sample, although their minute bars exist. Base rates of severe deterioration are therefore understated and
7+ H3/H5 effects for the most vulnerable firms are not identified. A delisted-fundamentals backfill is proposed (DATA_AUDIT §8).
8+- **PIT is a documented proxy.** `available_at = filing_date + 1 day` is a policy; all facts are `revision_id = 1` observed
9+ on 2026-11-01/02, i.e. the vendor's current (possibly restated) history. Original-as-reported values are not available.
10+ Inputs use prior quarters only, so restatements affect the *measurement of inputs and targets*, not the direction of time.
11+- **OHLCV bars, not ticks.** No quotes, no trades, no order flow. Bar-based statistics (down-bar volume share, bar
12+ autocorrelation) are descriptive proxies and are never interpreted as informed trading or spreads.
13+- Vendor merges ticker changes under the latest ticker (fully since 2022); CIK/SIC come from the SEC's *current*
14+ `company_tickers.json`, so historical ticker reuse before 2022 is only partially auditable (`formerNames` check).
15+- `release_time` is unknown for ~35 % of release events; we never use release-day bars, so this affects only the
16+ descriptive timing exhibit.
17+- The vendor `eps_estimate` has an unknown vintage and is missing for a quarter of events → exploratory only.
18+- 1-minute coverage is thinner before ~2005 and for illiquid names; the sample starts in 2012 and requires ≥ 100 regular
19+ bars per session, which excludes the most illiquid firm-days (a documented selection on liquidity).
20+
21+## Design
22+- The event-time cohort conditions on the *existence* of a release in the next 65 trading days; a hypothetical observer
23+ knows the approximate schedule but not the exact date. The fixed-calendar design is reported as the robustness check.
24+- Prediction, not causation: a positive H1 shows that bar-level information is statistically informative; it does not
25+ establish who traded on private information.
26+- No B7/B8 (neural/text) models by owner instruction; the frequency comparison is therefore within linear and
27+ gradient-boosted families only.
28+
29+## To be completed as results arrive
30+- (Phase 3) achieved sample sizes, base rates, coverage gaps by year/size.
31+- (Phase 5) which placebo/stress tests failed or were inconclusive.
added PROGRESS.md +22 −0
@@ -0,0 +1,22 @@
1+# PROGRESS.md — WP39 `wp39_uqo`
2+
3+## Phase 0 — Discovery and feasibility (2026-11-02) — GATE PASSED
4+- Inspected the UQO Research workspace (`Recherche-UQO/`, 39 papers, harmonisation standard); no `wp39` existed; created
5+ `finance/invisible-balance-sheet/` from the template; git initialised on a dedicated repository (no other paper touched).
6+- Probed FinData: `/health` ok, 64 datasets, OpenAPI 3.1 (45 routes), bars schema v1, fundamentals (67 metrics, 5,451
7+ entities, 2012→2026), earnings events (Postgres), coverage endpoints, export throughput (≈ 1 s per symbol-decade;
8+ 12-year cap per request). Created least-privilege key `wp39_uqo`.
9+- Verified external public sources: SEC `company_tickers.json` + `submissions` (CIK/SIC), Kenneth French daily factors.
10+- Verified 94 bibliography entries on Crossref; wrote `docs/NOVELTY_MEMO.md`, `literature/RELATED_WORK_MATRIX.csv`.
11+- Outputs: `DATA_AUDIT.md` (viability = YES with survivorship + PIT caveats), `configs/*.yaml`, `docs/API_INTEGRATION.md`,
12+ `docs/COMPUTE_BUDGET.md`, `docs/PIT_PROTOCOL.md`, `docs/RESEARCH_DESIGN.md` (frozen), `DECISIONS.md` D-001…D-010.
13+- Owner instructions received during the session: (1) the fundamentals API must be used — it is the accounting source;
14+ (2) minimise AI usage — no LLM features, no neural models (D-006).
15+- Blockers: none. Next safe step: Phase 1 gate memo, then code + tests (Phase 2).
16+
17+## Phase 1 — Research design and novelty — GATE (self-approved under the autonomous mandate, D-010)
18+- `docs/RESEARCH_DESIGN.md` frozen 2026-11-02 14:40 ET; hypotheses H1–H5 with null/alternative, primary endpoint (H1,
19+ h = 20, log loss, binary adverse operating-income surprise), decision rules, splits, inference, power plan.
20+- Owner review pending; nothing in Phases 2–5 depends on test-period data until the Phase 3 gate.
21+
22+## Phase 2 — Data acquisition and verification — IN PROGRESS
added configs/data_contract.yaml +40 −0
@@ -0,0 +1,40 @@
1+# Data contract — verified against the live FinData OS API on 2026-11-02 (see DATA_AUDIT.md)
2+findata:
3+ base_url: https://findata.spboucher.ai/api/v1
4+ openapi: /openapi.json
5+ datasets:
6+ bars_1m: market_bars.stock.1m.raw # raw, bar-open timestamps in UTC, 04:00-20:00 ET, zero-volume minutes absent
7+ bars_1m_etf: market_bars.etf.1m.raw # SPY used as market proxy
8+ bars_1d: market_bars.stock.1d.raw # INCOMPLETE vs 1m (fewer symbols) -> daily bars are rebuilt from 1m
9+ fundamentals: fundamentals.financials # Postgres-backed; GET /fundamentals/{entity}
10+ earnings: fundamentals.earnings # Postgres table earnings_events (no API route yet) -> documented exception
11+ limits:
12+ bars_1m_days_per_call: 31
13+ rows_per_page: 10000
14+ export_years_per_request: 12
15+ export_symbols_per_request: 500
16+ query_timeout_s: 30
17+ query_max_rows: 100000
18+ rate_limit_per_min: 600
19+ fundamentals_limit_max: 20000
20+ bars_schema_version: 1
21+ timestamp_convention: bar_open
22+ timezone_storage: UTC
23+ exchange_tz: America/New_York
24+ regular_session: ["09:30", "16:00"] # the 16:00 bar is the closing print
25+ extended_session: ["04:00", "20:00"]
26+ license_class: frd-private
27+pit:
28+ available_at_policy: "filing_date + 1 day 00:00 America/New_York (vendor proxy; fallback period_end + 91 d flagged pit_fallback)"
29+ revision_semantics: "revision_id=1 observed 2026-11-01/02; no pre-ingestion restatement history (vendor limitation)"
30+ reveal_date: "earnings_events.event_date (press release), release_time in {before_open, after_close, null}"
31+universe:
32+ entity_source: fundamentals entities (5,451 active tickers as of 2026-11-01; NO delisted entities -> survivorship caveat)
33+ min_quarters: 8
34+ first_filing_year: 2012
35+ last_session: 2026-10-30
36+external:
37+ sec_company_tickers: https://www.sec.gov/files/company_tickers.json
38+ sec_submissions: https://data.sec.gov/submissions/CIK{cik:010d}.json
39+ ff5_daily: https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_5_Factors_2x3_daily_CSV.zip
40+ mom_daily: https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Momentum_Factor_daily_CSV.zip
added configs/models.yaml +27 −0
@@ -0,0 +1,27 @@
1+# Equal tuning budget across information sets. Hyper-parameters are tuned ONLY on the validation period.
2+baselines:
3+ B0: {type: base_rate}
4+ B1: {type: logistic, inputs: [fundamentals, controls]}
5+ B2: {type: logistic, inputs: [fundamentals, controls, daily]}
6+ B3: {type: logistic, inputs: [fundamentals, controls, daily, intraday_30m]}
7+ B4: {type: logistic, inputs: [fundamentals, controls, daily, intraday_5m]}
8+ B5: {type: logistic, inputs: [fundamentals, controls, daily, intraday_1m]}
9+logistic:
10+ penalty: l2
11+ C_grid: [0.01, 0.03, 0.1, 0.3, 1.0, 3.0]
12+ standardize: train_only
13+ impute: train_median
14+lightgbm:
15+ information_sets: [B1, B2, B3, B4, B5]
16+ budget_trials: 24 # identical random-search budget for every information set
17+ seed: 20261102
18+ search_space:
19+ num_leaves: [7, 15, 31, 63]
20+ learning_rate: [0.01, 0.03, 0.05]
21+ min_child_samples: [50, 100, 200]
22+ feature_fraction: [0.5, 0.7, 0.9]
23+ bagging_fraction: [0.6, 0.8, 1.0]
24+ lambda_l2: [0.0, 1.0, 10.0]
25+ n_estimators_max: 2000
26+ early_stopping_rounds: 100
27+neural: {enabled: false, reason: "owner instruction 2026-11-02: minimise AI usage; B7/B8 not run"}
added configs/research.yaml +42 −0
@@ -0,0 +1,42 @@
1+# Frozen research design parameters (see docs/RESEARCH_DESIGN.md). Changing a value requires a DECISIONS.md entry.
2+seed: 20261102
3+sample:
4+ start: 2012-01-01
5+ end: 2026-10-30
6+ min_quarters_per_firm: 8
7+ min_price_usd: 1.0 # at cutoff, raw close
8+ min_regular_bars_per_day: 100 # sessions with fewer 1m regular-session bars are "sparse"
9+ min_valid_days_in_window: 15 # out of the 20-trading-day feature window
10+cutoffs:
11+ event_time_horizons_td: [40, 20, 10, 5, 1] # trading days before the earnings release date A_q
12+ primary_horizon_td: 20
13+ fixed_calendar: month_end # last trading day of each month
14+ embargo_rule: "cutoff c < min(A_q, F_q); bars with session_date <= c only; fundamentals with available_at <= c only"
15+feature_window:
16+ short_td: 20
17+ long_td: 60
18+frequencies: [1d, 30m, 5m, 1m]
19+targets:
20+ primary:
21+ name: adverse_opinc_surprise
22+ variable: op_inc
23+ expectation: seasonal_random_walk_with_drift # E[X_q] = X_{q-4} + mean(X_{q-1-k} - X_{q-5-k}, k=0..3), PIT
24+ scale: assets_tot (latest available)
25+ threshold: -0.005 # surprise <= -0.5% of total assets
26+ continuous_primary: scaled_opinc_surprise
27+ secondary:
28+ - {name: roa_yoy_decline, rule: "roa_q - roa_{q-4} <= -0.01"}
29+ - {name: ocf_innovation_negative, rule: "(cash_op_q - cash_op_{q-4})/assets <= -0.01"}
30+ - {name: liquidity_deterioration, rule: "current ratio falls >= 10% yoy OR interest coverage falls below 1 from >= 1"}
31+ - {name: eps_miss, rule: "eps_reported < eps_estimate (vendor estimate, exploratory)"}
32+splits:
33+ train: [2012-01-01, 2018-12-31]
34+ validation: [2019-01-01, 2020-12-31]
35+ test: [2021-01-01, 2026-10-30]
36+ walk_forward: {first_test_year: 2016, embargo_quarters: 1}
37+ firm_holdout_fraction: 0.2
38+inference:
39+ cluster: [entity_id, calendar_quarter]
40+ bootstrap_blocks: calendar_quarter
41+ n_bootstrap: 1000
42+ multiple_testing: holm
added data/README.md +17 −0
@@ -0,0 +1,17 @@
1+# data/
2+
3+No licensed raw records are stored in this repository.
4+
5+| Path | Content | Committed? |
6+|---|---|---|
7+| `data/raw/` | nothing (raw FirstRate bars live only in the ignored scratch cache `$WP39_CACHE` on the compute node) | no |
8+| `data/processed/` | derived firm-quarter panel, feature tables and labels (Parquet). Derived aggregates only; still **not** committed (size, license caution) | no |
9+| `artifacts/manifests/` | hashed dataset manifests: dataset ids, `X-Dataset-Version`, extraction dates, row counts, SHA-256 of extracts, code SHA | yes |
10+
11+Provenance rules (see `DATA_AUDIT.md`, `docs/API_INTEGRATION.md`):
12+- Market bars and fundamentals come from the owner's FinData OS API (`https://findata.spboucher.ai/api/v1`), a private
13+ lakehouse of FirstRate Data (license: private use, no redistribution of raw records).
14+- Earnings-release calendar rows are read from the FinData Postgres table `earnings_events` on the storage node because the
15+ API does not expose that dataset yet (documented exception).
16+- External public augmentations: SEC EDGAR `company_tickers.json` and `submissions` (CIK, SIC), Kenneth French data library
17+ (daily factors). Both are public, versioned by download date in the manifests.
added data/processed/.gitkeep +0 −0
added data/raw/README.md +4 −0
@@ -0,0 +1,4 @@
1+# data/raw — intentionally empty
2+Raw FirstRate Data records (via FinData OS) are licensed for private use and are never stored in this repository.
3+They live only in the ignored scratch cache on the compute node (`$WP39_CACHE`), are deleted after feature extraction,
4+and are described by hashed manifests in `artifacts/manifests/`.
added docs/API_INTEGRATION.md +41 −0
@@ -0,0 +1,41 @@
1+# API_INTEGRATION.md — how WP39 talks to FinData OS
2+
3+## Authorization
4+- Header `Authorization: Bearer <key>`; the key is read from `FINDATA_API_KEY` or the file `FINDATA_API_KEY_FILE`
5+ (default `~/findata-secrets/api-key-wp39.txt`, mode 600). The client never logs headers, never puts the key in a URL,
6+ and redacts any string starting with `fd_` in exceptions (`src/wp39/api_client.py::_redact`).
7+- Dedicated key `wp39_uqo` (scopes `read:market, read:fundamentals, query, export`; **no admin**), created 2026-11-02 with
8+ the node CLI `findata create-key` because `POST /admin/keys` returned HTTP 500 for API-key callers (bug logged in
9+ DATA_AUDIT §8).
10+- Compute runs on **M3U96a** against `http://127.0.0.1:8600/api/v1` (same host as the lake; the public URL
11+ `https://findata.spboucher.ai/api/v1` is used from the laptop for probes only).
12+
13+## Client (`src/wp39/api_client.py`)
14+- `httpx.Client` with 60 s timeout, retry with bounded exponential backoff (5 attempts, 0.5 → 8 s, jitter) on 429/5xx and
15+ network errors; idempotent GETs only are retried automatically; export creation is retried only when no export id was
16+ received.
17+- Every response's `X-Request-ID`, `X-Dataset-Id`, `X-Dataset-Version` are recorded in the provenance log
18+ (`artifacts/logs/api_calls.jsonl`, one line per call, no payloads).
19+- Cursor iteration for `/market/bars` and `/coverage/symbols`; export polling (`/exports/{id}`) every 1 s up to 10 min.
20+- Arrow/Parquet schema validation against `GET /catalog/schema/bars` (schema v1) before any file is accepted.
21+
22+## Calls used by the pipeline
23+| Step | Call | Scope | Volume |
24+|---|---|---|---|
25+| 00 probe | `/health`, `/catalog/datasets`, `/catalog/schema/bars`, `/openapi.json` | read | 4 |
26+| 01 universe | `/coverage/symbols?dataset_id=market_bars.stock.1m.raw` (cursor, 5,000/page), `/instruments/{symbol}` | read | ≈ 4 + N |
27+| 02 PIT panel | `/fundamentals/{entity}?period_type=q&limit=20000` (all facts, local as-of join), `/fundamentals/{entity}/periods` | read:fundamentals | 2 × N entities |
28+| 03 features | `POST /exports` (1m raw, ≤ 12 y, 1 symbol) ×2 per symbol, `/exports/{id}`, `/download`; SPY from `market_bars.etf.1m.raw` | export | 2 × N + 2 |
29+| checks | `POST /query` (cross-section counts, spot checks ≤ 100 k rows) | query | few |
30+
31+Exception (documented): earnings-release rows are extracted once on M3U96a with
32+`psql -d findata -c "\copy (select ... from earnings_events) to '…'"` (read-only). The extract's SHA-256, row count and
33+timestamp are in `artifacts/manifests/earnings_events.json`.
34+
35+## Idempotent partitioning and manifests
36+- Exports are cached per `(symbol, chunk)` as `$WP39_CACHE/bars/<symbol>__<start>__<end>.parquet` and deleted after the
37+ feature rows are written; `artifacts/manifests/bars_manifest.jsonl` keeps `symbol, start, end, dataset_id,
38+ dataset_version, row_count, bytes, sha256, exported_at, request_id`.
39+- Fundamentals extracts: `$WP39_CACHE/fundamentals/<entity>.parquet` with the same manifest fields.
40+- Re-running a script skips completed partitions (manifest lookup) and never overwrites a partition with a different
41+ dataset version without logging a `version_drift` warning.
added docs/COMPUTE_BUDGET.md +16 −0
@@ -0,0 +1,16 @@
1+# COMPUTE_BUDGET.md
2+
3+| Resource | Plan | Measured / status |
4+|---|---|---|
5+| Compute node | M3U96a (Mac Studio M3 Ultra, 28 cores, 96 GB RAM) — FinData API on localhost | load 1.5–1.9 before start |
6+| Scratch | `~/wp39-cache` on the internal SSD (319 GB free; FinData hard floor 60 GB) | streaming design keeps < 5 GB |
7+| API exports | ≈ 2 × 4,300 symbol-chunks, ≈ 1 s each server-side, 4 concurrent | pilot measured 1.0 s / 860 k rows |
8+| Fundamentals calls | 5,451 × 2 | ms each |
9+| Feature computation | Polars per symbol (≈ 1.7 M rows → 4 frequency tables), 8 worker processes | to be measured in the pilot |
10+| Models | logistic ridge (seconds) ; LightGBM 24 trials × 5 information sets × (1 chronological + 11 walk-forward folds) ≈ 1,440 fits, early stopping; ≈ 2–4 h on 28 cores | — |
11+| Bootstrap | 1,000 stationary-bootstrap draws over 23 test quarters per contrast — seconds | — |
12+| LaTeX | laptop (TeX Live, `latexmk`) | available |
13+| AI usage | none in the pipeline (no LLM features, no neural B7/B8); Claude Code used only as the engineer | owner instruction 2026-11-02 |
14+
15+Pilot (Phase 2 gate): 300 firms stratified by first-fundamentals year and size, full history → validates throughput,
16+feature sanity and label base rates before the full run.
added docs/NOVELTY_MEMO.md +53 −0
@@ -0,0 +1,53 @@
1+# NOVELTY_MEMO.md — the three closest papers and why WP39 is not them
2+
3+Prepared 2026-11-02 from publisher metadata verified on Crossref (`literature/RELATED_WORK_MATRIX.csv`,
4+`artifacts/logs/crossref_batch*.json`). Full texts were not machine-summarised; the points of distinction below are
5+based on the papers' stated designs as reported in their abstracts and our own reading of their published versions.
6+
7+## Closest paper 1 — Grégoire & Martineau (2022), *How Is Earnings News Transmitted to Stock Prices?*, JAR 60(1)
8+- **What it does.** Decomposes price discovery around earnings announcements using TAQ quotes *and* trades, showing that
9+ most of the reaction to after-hours announcements occurs in quotes with little trading.
10+- **Strongest objection to WP39 from this paper.** "Information arrives at the announcement; what you call anticipation is
11+ the pre-announcement drift already documented, and bars cannot even see quotes."
12+- **How WP39 differs.** WP39 does not study the announcement reaction at all: all features end strictly *before* the
13+ release and the outcome is the accounting number itself, not the return. We make no claim about quotes versus trades
14+ (our data are OHLCV bars, stated plainly) and we ask a forecasting question — whether the *resolution* of pre-release
15+ bar data carries incremental information about fundamentals — which their event-window decomposition does not address.
16+
17+## Closest paper 2 — Chen, Cho, Dou & Lev (2022), *Predicting Future Earnings Changes Using Machine Learning and Detailed Financial Data*, JAR 60(2)
18+- **What it does.** Shows that gradient-boosted trees on detailed XBRL financial-statement items predict the direction of
19+ next-year earnings changes better than parsimonious accounting models, and that the predictions are priced slowly.
20+- **Strongest objection.** "Fundamentals-based ML already predicts earnings changes; adding market data is Ou–Penman with
21+ a new model, and daily returns are known to lead earnings (Beaver–Lambert–Morse 1980; Kothari–Sloan 1992)."
22+- **How WP39 differs.** Their inputs are accounting items; ours hold the accounting information set fixed (B1) and
23+ measure the *marginal* value of market data at four sampling frequencies with equal model capacity (B2–B5, B6). The
24+ stock-prices-lead-earnings literature establishes the daily/annual lead; WP39 tests whether anything remains at the
25+ minute scale once daily information is in, with a strict PIT availability clock and a release-date reveal clock.
26+
27+## Closest paper 3 — Zhang, Zhang, Cucuringu & Qian (2023/2024), *Volatility Forecasting with Machine Learning and Intraday Commonality*, JFEC
28+- **What it does.** Pools intraday realized-volatility features across many stocks to forecast volatility with ML, showing
29+ that commonality in intraday patterns improves volatility forecasts.
30+- **Strongest objection.** "Intraday features forecast volatility, and earnings surprises are volatility events, so your
31+ deterioration signal is a volatility forecast in disguise."
32+- **How WP39 differs.** Our target is an accounting quantity with a *sign* (adverse surprise), not a variance; the design
33+ includes the volatility families explicitly in every frequency set so that the test isolates information beyond
34+ predicted volatility (placebo: a symmetric |surprise| target must show the pattern a volatility channel implies, a signed
35+ target must not). We also report directional asymmetry (negative semivariance, down-bar volume) as pre-specified
36+ mechanism variables, not as the headline.
37+
38+## Other adjacent work and the recycling check
39+- UQO WP15 (*Faster, Wider, More Fragile?*) studies intraday *shock propagation* across stocks on the same vendor data;
40+ it never links bars to accounting outcomes. WP39 shares the data vendor and the realized-measure toolkit only; no
41+ result or figure is reused.
42+- Pre-announcement informed trading (Christophe–Ferri–Angel 2004; Berkman et al. 2009; Kaniel et al. 2012;
43+ Hendershott–Livdan–Schürhoff 2015) uses trader-type data we do not have; our contribution is orthogonal: whether
44+ *anonymous* OHLCV at fine resolution is informative, and whether the information is attributable to the frequency.
45+- Patton & Verardo (2012) and Bollerslev–Li–Xue (2018) model announcement-day betas and volume-volatility; they are
46+ announcement-window studies.
47+- Earnings-prediction ML (Easton et al. 2024; Cao et al. 2024) uses accounting, analyst and text inputs, not intraday bars.
48+
49+## Verdict
50+No published paper, to our verified knowledge, measures the marginal forecasting value of 1-minute versus 5-, 30-minute
51+and daily OHLCV for *forthcoming accounting deterioration* with a point-in-time information set and matched model
52+capacity. The question is therefore open. If the empirical answer is a precise null, that is itself informative given
53+the practitioner belief that high-frequency activity "sees" fundamentals early.
added docs/PIT_PROTOCOL.md +75 −0
@@ -0,0 +1,75 @@
1+# PIT_PROTOCOL.md — point-in-time rules for WP39
2+
3+Frozen 2026-11-02 before any model was estimated. Any change is an amendment logged in `DECISIONS.md`.
4+
5+## 1. Clocks
6+
7+All instants are converted to **America/New_York** for session logic and kept in UTC in storage. Trading days follow the
8+XNYS calendar (`exchange_calendars`). For firm `i` and fiscal quarter `q`:
9+
10+| Symbol | Meaning | Source |
11+|---|---|---|
12+| `E_q` | fiscal period end | `fundamentals_facts.fiscal_period_end` |
13+| `A_q` | earnings-release date (press release) — the **reveal date** of quarter-`q` results | `earnings_events.event_date`, first event in `[E_q, F_q + 10 d]` |
14+| `τ_q` | release timing: `before_open`, `after_close`, or `unknown` | `earnings_events.release_time` |
15+| `F_q` | SEC filing date of the 10-Q/10-K | `fundamentals_facts.filing_date` |
16+| `V_q` | vendor-proxy availability of the structured facts | `available_at = F_q + 1 d 00:00 ET` |
17+| `c` | prediction cutoff (a trading day; features use information up to the **close** of `c`) | design |
18+
19+## 2. The information set at cutoff `c`
20+
21+`I(c)` contains exactly:
22+
23+1. **Market bars** with `session_date ≤ c` (regular session 09:30–16:00 ET for the main specification; the 16:00 bar is the
24+ closing print; extended hours 04:00–09:29 and 16:01–19:59 enter only the "extended" variants, never mixed).
25+2. **Fundamentals** with `available_at ≤ c 00:00 ET`, i.e. `F_j + 1 ≤ c`. The latest such quarter is `j*(c)`, and the
26+ model also receives the *age* `c − A_{j*}` in trading days. Because `V_q > F_q ≥ A_q > c` by construction (§3), quarter-`q`
27+ facts can never enter `I(c)`.
28+3. **Earnings-release history** with `event_date < c` (prior realized releases, used for the historical base-rate feature B0).
29+4. **Static crosswalk** (CIK, SIC) — time-invariant by assumption; `formerNames` are checked for ticker reuse (Phase 2 audit).
30+5. **Nothing else.** No consensus estimates for quarter `q` (vintage unknown), no index-membership backfills, no
31+ contemporaneous factors.
32+
33+## 3. Cutoff rules
34+
35+- **Event-time design** (primary, H1–H4): for horizon `h ∈ {40, 20, 10, 5, 1}` trading days, `c = A_q − h` trading days.
36+ Admissible only if `c < min(A_q, F_q)` *and* `c ≥ A_{q−1} + 1` (the previous release has already happened, so the
37+ cohort window never straddles two reveals). With `τ_q = before_open`, bars of `A_q` contain the reaction and are excluded
38+ (`c ≤ A_q − 1` guarantees it); with `τ_q = after_close` or `unknown` the same rule applies — we never use `A_q` bars.
39+- **Fixed-calendar design** (H1 robustness, H5): `c` = last trading day of each month; the target is the *next* release
40+ after `c` (first `A_q > c`), restricted to `A_q − c ≤ 65` trading days. The identity of "the next quarter" is known ex
41+ ante; its date is not needed by the forecaster.
42+- **Firm-quarter exclusions** (frozen): no matched release; `F_q < A_q`; `F_q − A_q > 30` calendar days; `E_q` not within
43+ 60–120 days of `E_{q−1}` (irregular fiscal calendars); quarter `q − 4` or `q − 1` not available at `c` (expectation
44+ undefined); raw close at `c` < 1 USD; fewer than 15 valid sessions in the 20-day feature window.
45+
46+## 4. Label construction (no price-based labels)
47+
48+Primary: `S_q = (X_q − E_c[X_q]) / TA_{j*}`, `X = op_inc` (quarterly operating income), `TA` = total assets of the latest
49+available quarter, `E_c[X_q] = X_{q−4} + (1/4) Σ_{k=0..3} (X_{q−1−k} − X_{q−5−k})` using only quarters available at `c`
50+(seasonal random walk with drift; Foster 1977). `D_q = 1[S_q ≤ −0.005]`. The secondary targets are in
51+`configs/research.yaml`. `X_q` itself is taken from the **first ingested revision** of quarter `q` (the only one available),
52+which is the vendor's current value — see LIMITATIONS §PIT.
53+
54+## 5. Leakage battery (each item is a unit test in `tests/`)
55+
56+| # | Risk | Guard | Test |
57+|---|---|---|---|
58+| L1 | quarter-`q` facts used as inputs | as-of join on `available_at ≤ c` | `test_pit_leakage.py::test_no_future_fundamentals` |
59+| L2 | selection by fiscal end instead of availability | join key is `available_at`, never `fiscal_period_end` | `test_pit_leakage.py::test_join_key_is_available_at` |
60+| L3 | post-release bars in the feature window | `session_date ≤ c < A_q` | `test_pit_leakage.py::test_no_bars_after_cutoff` |
61+| L4 | global standardisation / imputation / tuning on test data | transformers fit on train rows only; tuning on validation period only | `test_train_only_transforms.py` |
62+| L5 | DST / timezone errors | UTC → ET conversion, 09:30 and 16:00 bars identified on both DST regimes | `test_timestamp_dsts.py` |
63+| L6 | ticker reuse / identifier drift | crosswalk with effective dates; SEC `formerNames` audit | `test_instrument_ids.py` |
64+| L7 | splits/dividends creating mechanical returns | split-adjusted returns from `corporate_actions` (ex-date rule), raw bars immutable | `test_corporate_actions.py` |
65+| L8 | release timing assumed intraday | release-day bars never used; timing enters only as a descriptive variable | `test_labels.py::test_release_day_excluded` |
66+| L9 | overlapping target windows across splits | purge + 1-quarter embargo at every split boundary | `test_splits.py` |
67+| L10 | survivorship by construction | universe = all fundamentals entities (documented survivor conditioning), delisted bars kept where present | `01_audit_universe.py` report |
68+| L11 | vendor ingestion date mistaken for knowledge date | `observed_at` is never used as a knowledge date; `available_at` only | `test_pit_leakage.py::test_observed_at_not_used` |
69+| L12 | randomised placebo | labels shuffled within calendar quarter → gain must vanish; cutoffs shifted −45 td → H4 gain must vanish | `08_run_robustness.py` |
70+
71+## 6. Equivalence check of the local as-of join with the API
72+
73+For 25 random entities and 10 random `as_of` instants, the set of (metric, period) pairs returned by
74+`GET /fundamentals/{entity}?as_of=` must equal the set produced by the local filter `available_at ≤ as_of` on the full
75+extract. Implemented in `tests/test_api_contract.py::test_asof_equivalence` (live test, `WP39_LIVE=1`).
added docs/REPLICATION.md +38 −0
@@ -0,0 +1,38 @@
1+# REPLICATION.md — how to reproduce WP39
2+
3+## Requirements
4+- Python ≥ 3.12 with `uv` (`uv venv -p 3.12 .venv && uv pip install -e ".[dev]"`).
5+- Access to FinData OS (owner-issued key with scopes `read:market, read:fundamentals, query, export`) exposed as
6+ `FINDATA_API_KEY` or a mode-600 file at `FINDATA_API_KEY_FILE`. Without access, Phases 2–5 cannot run; the saved
7+ evaluation artifacts in `artifacts/evaluation/` and `results/` allow Phase 6 (tables/figures/paper) to be regenerated.
8+- Read-only `psql` access to the FinData Postgres database on the storage node for `scripts/01a_extract_node_tables.sh`
9+ (earnings events, entities, corporate actions — not yet exposed by the API).
10+- Internet access to `sec.gov` (CIK/SIC) and the Kenneth French library (daily factors).
11+- TeX Live with `latexmk` for the paper.
12+
13+## Order of execution (compute node, API on localhost)
14+```bash
15+export FINDATA_BASE_URL=http://127.0.0.1:8600/api/v1 WP39_CACHE=~/wp39-cache
16+bash scripts/01a_extract_node_tables.sh # node extracts + manifests
17+python scripts/00_probe_findata.py # contract reconciliation -> artifacts/logs/phase0_probe.json
18+python scripts/01_audit_universe.py # universe.parquet, results/universe_audit.json
19+python scripts/02_build_pit_panel.py # quarters.parquet (labels), design freeze hash
20+python scripts/03_build_features.py --workers 8 # streaming exports -> session features -> rolling windows
21+python scripts/04_build_labels.py # sample_event.parquet, sample_calendar.parquet, cascades
22+python scripts/05_train_baselines.py # B0-B5 logistic, ridge, firm hold-out, walk-forward
23+python scripts/06_train_advanced.py # LightGBM equal-budget, horizon ladder, OOF signals
24+python scripts/07_evaluate.py # metrics, paired contrasts, GW tests, Holm, power
25+python scripts/08_run_robustness.py # placebos, subgroups, panel regressions, H5
26+python scripts/09_make_manuscript.py # tables, figures, results_macros.tex
27+cd paper && make # main.pdf
28+pytest -q # 29 tests; WP39_LIVE=1 adds the live API contract tests
29+```
30+Runtime on a Mac Studio M3 Ultra (28 cores): see `PROGRESS.md` for measured wall-times of each step.
31+
32+## What is and is not in the package
33+- In: code, configs, docs, tests, manifests (hashes/versions/counts), derived evaluation tables, figures, paper.
34+- Out: raw bars and fundamentals (FirstRate Data license), the per-symbol feature cache (derived from licensed data;
35+ regenerable), model binaries.
36+- Determinism: all seeds come from `configs/research.yaml` (`seed: 20261102`); LightGBM uses `n_jobs=12` with
37+ deterministic histogram construction at the default settings, so results are reproducible to numerical precision on
38+ the same architecture.
added docs/RESEARCH_DESIGN.md +111 −0
@@ -0,0 +1,111 @@
1+# RESEARCH_DESIGN.md — preregistered specification (internal)
2+
3+Frozen **2026-11-02 14:40 ET**, before the first test-period observation was opened. Hash of this file at freeze is
4+recorded in `artifacts/manifests/design_freeze.json` by `scripts/02_build_pit_panel.py`. Later changes are amendments in
5+`DECISIONS.md` (dated, with reason) and never alter frozen primary endpoints.
6+
7+## 1. Question
8+
9+Does one-minute OHLCV activity contain **incremental** information about a firm's *forthcoming* adverse accounting
10+outcome, beyond (i) point-in-time fundamentals, (ii) daily OHLCV, (iii) coarser intraday aggregates of the same bars, and
11+(iv) calendar/industry/size controls? Anticipation (prices move before the release) is not the claim; the claim is about
12+the marginal forecasting value of **sampling frequency** holding the information set, calendar coverage, universe,
13+cutoff, horizon and training budget fixed.
14+
15+## 2. Hypotheses — null, alternative, primary endpoint, decision rule
16+
17+| | Null `H0` | Alternative `H1` | Primary endpoint | Decision rule |
18+|---|---|---|---|---|
19+| **H1** incremental information | OOS log loss of B5 (fundamentals + daily + 1m) ≥ that of B2 (fundamentals + daily) | B5 < B2 | Δ log loss (B5 − B2) on the untouched test period, 2021-01 → 2026-10, horizon h = 20 | 95 % block-bootstrap CI (blocks = calendar quarters, 1,000 draws) excludes 0 **and** Giacomini–White conditional test p < 0.05 (two-sided) |
20+| **H2** temporal resolution | Δ(B5 − B2) ≤ Δ(B4 − B2) and ≤ Δ(B3 − B2) | 1m gain strictly exceeds 5m and 30m gains | paired Δ log loss B5 − B4 and B5 − B3 | both CIs exclude 0 after Holm correction over the 3 frequency contrasts |
21+| **H3** heterogeneity | the B5 − B2 gain is equal across terciles of size, illiquidity, leverage | gain larger in small, illiquid, levered firms | interaction coefficient `β_HF×tercile` in the panel regression (§5) | Holm-corrected p < 0.05 for the pre-specified ordering |
22+| **H4** timing | the B5 − B2 gain does not increase as h shrinks (40→20→10→5→1) | monotone increase | slope of Δ log loss on −h across horizons | sign test on the five ordered horizons + placebo cutoffs (−45 td) showing no gain |
23+| **H5** pricing | the LFDS does not predict the announcement-window abnormal return nor post-cutoff factor-adjusted returns | it does | (a) regression of CAR[c+1, A_q+1] on LFDS with controls; (b) decile long-short FF5+MOM alpha with costs | CI excludes 0; economic value reported with turnover and costs regardless of significance |
24+
25+Primary endpoint for the headline sentence: **H1 at h = 20 on the binary primary target** with log loss. Brier, AUC,
26+PR-AUC, calibration slope/intercept, MAE/RMSE/OOS-R² (continuous target) are secondary metrics reported for every model.
27+The headline sentence will read: *"At a 20-trading-day horizon, on a 2012–2026 panel of U.S. listed firms with a strictly
28+point-in-time information set, one-minute OHLCV features change the out-of-sample log loss of forecasts of an adverse
29+operating-income surprise by [Δ, 95 % CI] relative to a daily-market-and-accounting benchmark; the gain is
30+[consistent/inconsistent] with [mechanisms] and has [measured/absent] economic value."*
31+
32+## 3. Sample
33+
34+Universe: all FinData fundamentals entities (5,451; survivor-conditioned, see LIMITATIONS) with ≥ 8 quarterly periods
35+and 1-minute bars; firm-quarters 2012-Q1 → 2026-Q2 with reveal dates ≤ 2026-10-30 and admissible cutoffs (PIT_PROTOCOL
36+§3). Sector = SEC SIC division (2-digit), size = log market cap at `c` (raw close × latest available `shares`).
37+
38+Expected sample (to be reported, not assumed): ~4,300 firms × ~40 quarters ≈ 150–170 k firm-quarters before exclusions.
39+
40+## 4. Variables
41+
42+**Targets** — `configs/research.yaml`. Primary binary `D_q = 1[S_q ≤ −0.5 % of assets]`, primary continuous `S_q`
43+(winsorised at 1/99 % on the training period).
44+
45+**PIT fundamentals (B1)** — latest available quarter at `c`: `rev` growth (yoy), gross margin `gp/rev`, operating margin
46+`op_inc/rev`, `roa`, `net_inc/assets_tot`, `cash_op/assets_tot`, accruals `(net_inc − cash_op)/assets_tot`,
47+`cash/assets_tot`, leverage `debt/assets_tot`, interest coverage `intr_cover` (clipped), current ratio `assets_c/liab_c`,
48+net issuance (Δ`shares` yoy), capex/assets, R&D/rev, `S_{j*}` (last surprise), last-four-quarter surprise mean, loss
49+indicator, log assets, log market cap, information age `c − A_{j*}`, fiscal-quarter dummies, sector dummies, year-quarter
50+calendar dummies (in-sample only; for prediction, calendar effects are replaced by the trailing cross-sectional base rate).
51+
52+**Daily OHLCV (B2)** — from 1m regular-session bars aggregated to daily: 20- and 60-day cumulative split-adjusted returns,
53+daily return volatility (20d), Parkinson range volatility, skewness and kurtosis of daily returns (60d), daily volume
54+relative to 60-day median (20d mean and last 5d), Amihud illiquidity (60d), share of zero-return days, overnight
55+(close-to-open) vs intraday (open-to-close) return decomposition (20d sums), max drawdown (60d), beta and residual
56+volatility against SPY (60d daily).
57+
58+**Intraday families (B3 = 30m, B4 = 5m, B5 = 1m)** — the *same* families computed on bars of the given length,
59+each aggregated per session then averaged (or summed) over the 20-day window:
60+realized variance (RV), bipower variation (BPV), relative jump `max(0, RV − BPV)/RV`, negative semivariance share
61+`RS⁻/RV`, signed-jump variation `(RS⁺ − RS⁻)/RV`, realized skewness and kurtosis, maximum absolute bar-return share
62+of RV (concentration), first-order autocorrelation of bar returns, variance ratio `RV_freq / RV_daily`, intraday drift
63+`(close − open)/open`, time-of-day volume profile (share of volume in the first and last 30 minutes), volume dispersion
64+across bars (Gini), down-bar volume share (volume on bars with negative return / total — *a bar statistic, not order
65+flow*), volume surprise relative to the firm's 60-day time-of-day seasonal profile, fraction of bars present (activity),
66+RV on market-down bars vs market-up bars (asymmetry vs SPY), realized beta to SPY at that frequency, vol-of-vol
67+(dispersion of daily RV within the window). At 30m the within-day counts are small (13 bars), so skewness/kurtosis and
68+autocorrelation are computed on the pooled window.
69+
70+Note on frequency comparison: B3/B4/B5 have the **same number of feature columns** (one per family); dimensionality does
71+not grow with frequency. A supplementary experiment (Phase 4) concatenates all three to test whether 1m dominates when
72+coarser aggregates are also present.
73+
74+## 5. Models and inference
75+
76+Baseline ladder B0–B6 (`configs/models.yaml`). Logistic ridge for B1–B5 with `C` tuned on the validation period only;
77+LightGBM with an identical random-search budget (24 trials, early stopping on validation) for every information set.
78+B7/B8 (temporal neural, text) are **not run** (owner instruction to minimise AI use; declared in DECISIONS D-006).
79+
80+Panel regression for interpretability and H3:
81+`S_{i,q} = α_i + δ_{s(i),t(q)} + β·LFDS_{i,c} + γ'X_{i,c} + ε_{i,q}`, where `LFDS` is the B5 out-of-fold predicted
82+probability (walk-forward, so it is never fitted on the same quarter), with firm fixed effects and sector×year-quarter
83+effects, two-way clustered SEs (firm, calendar quarter). H3 interactions use ex-ante terciles formed at `c`.
84+
85+Comparisons: Diebold–Mariano / Giacomini–White on per-quarter mean loss differentials with HAC; stationary block
86+bootstrap over calendar quarters for paired CIs; Holm within each hypothesis family; model confidence set over the ladder
87+as a supplementary exhibit.
88+
89+## 6. Validation protocol
90+
91+- Chronological: train 2012-01 → 2018-12, validation 2019-01 → 2020-12, test 2021-01 → 2026-10 (never opened until Phase 3
92+ gate). Purge: training observations whose `A_q` falls after the validation start minus one quarter are dropped
93+ (embargo = 1 calendar quarter) and likewise at the test boundary.
94+- Walk-forward: expanding window, annual refits, first test year 2016, 1-quarter embargo; this provides the out-of-fold
95+ LFDS for the panel regressions and the H4/H5 exercises.
96+- Firm hold-out: 20 % of firms (hash of entity id) never seen in training, evaluated separately from the time hold-out.
97+- Train-only transforms: winsorisation bounds, medians for imputation, standardisation, sector-time demeaning, tercile
98+ cut-points — all computed on the training rows of the current fold.
99+
100+## 7. Power
101+
102+With ≈ 40 k test firm-quarters, base rate ≈ 20–30 %, and quarter-level clustering (23 test quarters), the minimum
103+detectable paired Δ log loss is roughly 0.002–0.004 (to be computed from the validation-period variance of the loss
104+differential and reported in `artifacts/evaluation/power.json` before the test set is opened).
105+
106+## 8. What would make us abandon or pivot
107+
108+If, on the validation period, B2 already captures all of B5 (Δ within ±0.001 and CI covering 0), H1 is likely null; we
109+proceed to the test anyway (a precise null is a result) and devote Phase 5 to characterising *where* intraday information
110+does and does not help. If the earnings-release matching proves unreliable (> 10 % irregular lags after cleaning), the
111+event-time design is demoted and the fixed-calendar design becomes primary (amendment required).
added literature/RELATED_WORK_MATRIX.csv +25 −0
@@ -0,0 +1,25 @@
1+key,authors,year,journal,doi_or_url,data_source,frequency,sample,research_question,identification,findings,point_of_distinction_for_WP39
2+gregoire2022,Grégoire & Martineau,2022,Journal of Accounting Research,10.1111/1475-679X.12394,TAQ quotes and trades,intraday (quotes/trades),US earnings announcements,How is earnings news incorporated into prices?,event-window decomposition of price discovery,most price discovery for after-hours announcements occurs through quote updates with little trading,"WP39 uses only pre-release bars, forecasts the accounting outcome (not the return), never claims quote/trade mechanisms"
3+chen2022,Chen; Cho; Dou; Lev,2022,Journal of Accounting Research,10.1111/1475-679X.12429,XBRL detailed financial statement items,annual,US firms,Can ML on detailed financials predict earnings changes?,OOS prediction + return tests,boosted trees beat parsimonious models; slow pricing,"WP39 holds accounting inputs fixed and measures the marginal value of market data by sampling frequency"
4+zhang2023volatility,Zhang; Zhang; Cucuringu; Qian,2024,Journal of Financial Econometrics,10.1093/jjfinec/nbad005,intraday equity returns,intraday,US stocks,Does pooling intraday commonality improve ML volatility forecasts?,OOS forecasting,commonality improves volatility forecasts,"WP39 targets a signed accounting quantity, includes volatility families as controls, uses a signed vs absolute placebo"
5+beaver1980,Beaver; Lambert; Morse,1980,Journal of Accounting and Economics,10.1016/0165-4101(80)90013-0,CRSP/Compustat,annual,US,Do prices contain information about future earnings?,regression of earnings changes on returns,prices lead earnings,"establishes the daily/annual lead; WP39 asks what remains at minute resolution"
6+kothari1992,Kothari & Sloan,1992,Journal of Accounting and Economics,10.1016/0165-4101(92)90016-U,CRSP/Compustat,annual,US,Information in prices about future earnings (ERC),leading returns,ERCs rise with leading returns,"same"
7+collins1994,Collins; Kothari; Shanken; Sloan,1994,Journal of Accounting and Economics,10.1016/0165-4101(94)90024-8,CRSP/Compustat,annual,US,Why is the contemporaneous return-earnings association low?,lack of timeliness vs noise,prices anticipate earnings several years ahead,"same"
8+foster1977,Foster,1977,The Accounting Review,10.2308/tar-4493420,quarterly earnings,quarterly,US,Time-series properties of quarterly earnings,forecast evaluation,seasonal random walk with drift forecasts well,"WP39's expectation model for the surprise"
9+bernard1989,Bernard & Thomas,1989,Journal of Accounting Research,10.2307/2491062,CRSP/Compustat,daily,US,Post-earnings-announcement drift,event study,drift after SUE,"WP39 studies pre-release information, not post-announcement drift"
10+ou1989,Ou & Penman,1989,Journal of Accounting and Economics,10.1016/0165-4101(89)90017-7,Compustat,annual,US,Can financial statement analysis predict earnings and returns?,logit on ratios,yes,"WP39 B1 baseline follows this tradition"
11+sloan1996,Sloan,1996,The Accounting Review,10.2308/tar-9608042309,Compustat/CRSP,annual,US,Do prices reflect accruals information?,hedge portfolio,accrual anomaly,"accruals are a B1 control"
12+christophe2004,Christophe; Ferri; Angel,2004,Journal of Finance,10.1111/j.1540-6261.2004.00681.x,Nasdaq short sales,daily,US,Do short sellers anticipate earnings news?,pre-announcement short selling,yes,"trader-type data unavailable to WP39; WP39 tests anonymous OHLCV"
13+berkman2009,Berkman; Dimitrov; Jain; Koch; Tice,2009,Journal of Financial Economics,10.1016/j.jfineco.2008.04.009,CRSP/IBES,daily,US,Differences of opinion and returns around announcements,event study,prices fall around announcements for high-disagreement stocks,"announcement-window return study"
14+kaniel2012,Kaniel; Liu; Saar; Titman,2012,Journal of Finance,10.1111/j.1540-6261.2012.01727.x,NYSE CAUD individual trades,daily,US,Do individual investors trade informatively before earnings?,pre-event trading,yes partly,"trader-type data"
15+hendershott2015,Hendershott; Livdan; Schürhoff,2015,Journal of Financial Economics,10.1016/j.jfineco.2015.03.007,institutional order flow,daily,US,Are institutions informed about news?,order flow before news,yes,"order flow unavailable; WP39 never labels bar statistics as order flow"
16+patton2012,Patton & Verardo,2012,Review of Financial Studies,10.1093/rfs/hhs073,TAQ,intraday,US,Does beta move with news?,announcement-day realized beta,beta rises on announcement days,"announcement-day study"
17+bollerslev2018volume,Bollerslev; Li; Xue,2018,Review of Economic Studies,10.1093/restud/rdy003,TAQ,intraday,US,Volume-volatility relation around public news,structural model,announcement effects on volume-volatility elasticity,"announcement-window study"
18+weller2018,Weller,2018,Review of Financial Studies,10.1093/rfs/hhx137,TAQ + earnings,daily/intraday,US,Does algorithmic trading reduce information acquisition?,AT and pre-announcement price informativeness,AT reduces pre-announcement price informativeness,"closest mechanism paper: WP39 reports pre-release information by era as a mechanism exhibit"
19+lee2012,Lee,2012,Review of Financial Studies,10.1093/rfs/hhr084,TAQ,intraday,US,Jumps and information flow,jump tests around news,jumps cluster at news,"jump proxies are WP39 features"
20+easton2024,Easton; Kapons; Monahan; Schütt; Weisbrod,2024,The Accounting Review,10.2308/tar-2021-0478,Compustat,annual,US,Forecast earnings with k-NN,OOS forecasting,kNN competitive with analysts,"accounting inputs only"
21+cao2024,Cao; Jiang; Wang; Yang,2024,Journal of Financial Economics,10.1016/j.jfineco.2024.103910,analyst + ML,monthly,US,Man vs machine in stock analysis,OOS forecasts,AI complements analysts,"no intraday data"
22+gu2020,Gu; Kelly; Xiu,2020,Review of Financial Studies,10.1093/rfs/hhaa009,CRSP/Compustat characteristics,monthly,US,ML for return prediction,OOS R2,trees and NNs best,"methodological reference for equal-budget comparisons"
23+giacomini2006,Giacomini & White,2006,Econometrica,10.1111/j.1468-0262.2006.00718.x,—,—,—,Conditional predictive ability tests,—,—,"inference for H1/H2"
24+liu2015,Liu; Patton; Sheppard,2015,Journal of Econometrics,10.1016/j.jeconom.2015.02.008,TAQ,intraday,multi-asset,Does anything beat 5-minute RV?,forecast comparison,5-min RV hard to beat,"frequency comparison for variance measurement; WP39 asks the analogous question for fundamentals"
25+boucher2026wp15,Boucher,2026,UQO Working Paper 15,—,FirstRate Data 1-minute bars,1-minute,US 1992-2026,Intraday shock propagation,event studies,shocks propagate narrowly,"same vendor; different question; nothing reused"
added literature/references.bib +1021 −0
@@ -0,0 +1,1021 @@
1+% WP39 UQO bibliography. Journal metadata verified on Crossref 2026-11-02 (artifacts/logs/crossref_batch*.json).
2+
3+@article{gregoire2022,
4+ author = {Grégoire, Vincent and Martineau, Charles},
5+ title = {{How is Earnings News Transmitted to Stock Prices?}},
6+ journal = {Journal of Accounting Research},
7+ year = {2022},
8+ volume = {60},
9+ number = {1},
10+ pages = {261--297},
11+ doi = {10.1111/1475-679x.12394}
12+}
13+
14+@article{zhang2023volatility,
15+ author = {Zhang, Chao and Zhang, Yihuang and Cucuringu, Mihai and Qian, Zhongmin},
16+ title = {{Volatility Forecasting with Machine Learning and Intraday Commonality}},
17+ journal = {Journal of Financial Econometrics},
18+ year = {2024},
19+ volume = {22},
20+ number = {2},
21+ pages = {492--530},
22+ doi = {10.1093/jjfinec/nbad005}
23+}
24+
25+@article{ball1968,
26+ author = {Ball, Ray and Brown, Philip},
27+ title = {{An Empirical Evaluation of Accounting Income Numbers}},
28+ journal = {Journal of Accounting Research},
29+ year = {1968},
30+ volume = {6},
31+ number = {2},
32+ pages = {159},
33+ doi = {10.2307/2490232}
34+}
35+
36+@article{bernard1989,
37+ author = {Bernard, Victor L. and Thomas, Jacob K.},
38+ title = {{Post-Earnings-Announcement Drift: Delayed Price Response or Risk Premium?}},
39+ journal = {Journal of Accounting Research},
40+ year = {1989},
41+ volume = {27},
42+ pages = {1},
43+ doi = {10.2307/2491062}
44+}
45+
46+@article{bernard1990,
47+ author = {Bernard, Victor L. and Thomas, Jacob K.},
48+ title = {{Evidence that stock prices do not fully reflect the implications of current earnings for future earnings}},
49+ journal = {Journal of Accounting and Economics},
50+ year = {1990},
51+ volume = {13},
52+ number = {4},
53+ pages = {305--340},
54+ doi = {10.1016/0165-4101(90)90008-r}
55+}
56+
57+@article{sloan1996,
58+ author = {Sloan, Richard G.},
59+ title = {{Do Stock Prices Fully Reflect Information in Accruals and Cash Flows About Future Earnings?}},
60+ journal = {The Accounting Review},
61+ year = {1996},
62+ volume = {71},
63+ number = {3},
64+ pages = {289--315},
65+ doi = {10.2308/tar-9608042309}
66+}
67+
68+@article{ou1989,
69+ author = {Ou, Jane A. and Penman, Stephen H.},
70+ title = {{Financial statement analysis and the prediction of stock returns}},
71+ journal = {Journal of Accounting and Economics},
72+ year = {1989},
73+ volume = {11},
74+ number = {4},
75+ pages = {295--329},
76+ doi = {10.1016/0165-4101(89)90017-7}
77+}
78+
79+@article{kyle1985,
80+ author = {Kyle, Albert S.},
81+ title = {{Continuous Auctions and Insider Trading}},
82+ journal = {Econometrica},
83+ year = {1985},
84+ volume = {53},
85+ number = {6},
86+ pages = {1315},
87+ doi = {10.2307/1913210}
88+}
89+
90+@article{easley1987,
91+ author = {Easley, David and O'Hara, Maureen},
92+ title = {{Price, trade size, and information in securities markets}},
93+ journal = {Journal of Financial Economics},
94+ year = {1987},
95+ volume = {19},
96+ number = {1},
97+ pages = {69--90},
98+ doi = {10.1016/0304-405x(87)90029-8}
99+}
100+
101+@article{andersen2003,
102+ author = {Andersen, Torben G. and Bollerslev, Tim and Diebold, Francis X. and Labys, Paul},
103+ title = {{Modeling and Forecasting Realized Volatility}},
104+ journal = {Econometrica},
105+ year = {2003},
106+ volume = {71},
107+ number = {2},
108+ pages = {579--625},
109+ doi = {10.1111/1468-0262.00418}
110+}
111+
112+@article{barndorff2004,
113+ author = {Barndorff-Nielsen, O. E.},
114+ title = {{Power and Bipower Variation with Stochastic Volatility and Jumps}},
115+ journal = {Journal of Financial Econometrics},
116+ year = {2004},
117+ volume = {2},
118+ number = {1},
119+ pages = {1--37},
120+ doi = {10.1093/jjfinec/nbh001}
121+}
122+
123+@article{gu2020,
124+ author = {Gu, Shihao and Kelly, Bryan and Xiu, Dacheng},
125+ title = {{Empirical Asset Pricing via Machine Learning}},
126+ journal = {The Review of Financial Studies},
127+ year = {2020},
128+ volume = {33},
129+ number = {5},
130+ pages = {2223--2273},
131+ doi = {10.1093/rfs/hhaa009}
132+}
133+
134+@article{giacomini2006,
135+ author = {Giacomini, Raffaella and White, Halbert},
136+ title = {{Tests of Conditional Predictive Ability}},
137+ journal = {Econometrica},
138+ year = {2006},
139+ volume = {74},
140+ number = {6},
141+ pages = {1545--1578},
142+ doi = {10.1111/j.1468-0262.2006.00718.x}
143+}
144+
145+@article{diebold1995,
146+ author = {Diebold, Francis X. and Mariano, Roberto S.},
147+ title = {{Comparing Predictive Accuracy}},
148+ journal = {Journal of Business \& Economic Statistics},
149+ year = {1995},
150+ volume = {13},
151+ number = {3},
152+ pages = {253--263},
153+ doi = {10.1080/07350015.1995.10524599}
154+}
155+
156+@article{hansen2005,
157+ author = {Hansen, Peter Reinhard},
158+ title = {{A Test for Superior Predictive Ability}},
159+ journal = {Journal of Business \& Economic Statistics},
160+ year = {2005},
161+ volume = {23},
162+ number = {4},
163+ pages = {365--380},
164+ doi = {10.1198/073500105000000063}
165+}
166+
167+@article{fama2015,
168+ author = {Fama, Eugene F. and French, Kenneth R.},
169+ title = {{A five-factor asset pricing model}},
170+ journal = {Journal of Financial Economics},
171+ year = {2015},
172+ volume = {116},
173+ number = {1},
174+ pages = {1--22},
175+ doi = {10.1016/j.jfineco.2014.10.010}
176+}
177+
178+@article{bollerslev2020good,
179+ author = {Bollerslev, Tim and Li, Sophia Zhengzi and Zhao, Bingzhi},
180+ title = {{Good Volatility, Bad Volatility, and the Cross Section of Stock Returns}},
181+ journal = {Journal of Financial and Quantitative Analysis},
182+ year = {2020},
183+ volume = {55},
184+ number = {3},
185+ pages = {751--781},
186+ doi = {10.1017/s0022109019000097}
187+}
188+
189+@article{patton2015,
190+ author = {Patton, Andrew J. and Sheppard, Kevin},
191+ title = {{Good Volatility, Bad Volatility: Signed Jumps and The Persistence of Volatility}},
192+ journal = {Review of Economics and Statistics},
193+ year = {2015},
194+ volume = {97},
195+ number = {3},
196+ pages = {683--697},
197+ doi = {10.1162/rest_a_00503}
198+}
199+
200+@article{chen2022,
201+ author = {CHEN, XI and CHO, YANG HA (TONY) and DOU, YIWEI and LEV, BARUCH},
202+ title = {{Predicting Future Earnings Changes Using Machine Learning and Detailed Financial Data}},
203+ journal = {Journal of Accounting Research},
204+ year = {2022},
205+ volume = {60},
206+ number = {2},
207+ pages = {467--515},
208+ doi = {10.1111/1475-679x.12429}
209+}
210+
211+@article{piotroski2000,
212+ author = {Piotroski, Joseph D.},
213+ title = {{Value Investing: The Use of Historical Financial Statement Information to Separate Winners from Losers}},
214+ journal = {Journal of Accounting Research},
215+ year = {2000},
216+ volume = {38},
217+ pages = {1},
218+ doi = {10.2307/2672906}
219+}
220+
221+@article{hirshleifer2009,
222+ author = {HIRSHLEIFER, DAVID and LIM, SONYA SEONGYEON and TEOH, SIEW HONG},
223+ title = {{Driven to Distraction: Extraneous Events and Underreaction to Earnings News}},
224+ journal = {The Journal of Finance},
225+ year = {2009},
226+ volume = {64},
227+ number = {5},
228+ pages = {2289--2325},
229+ doi = {10.1111/j.1540-6261.2009.01501.x}
230+}
231+
232+@article{dellavigna2009,
233+ author = {DELLAVIGNA, STEFANO and POLLET, JOSHUA M.},
234+ title = {{Investor Inattention and Friday Earnings Announcements}},
235+ journal = {The Journal of Finance},
236+ year = {2009},
237+ volume = {64},
238+ number = {2},
239+ pages = {709--749},
240+ doi = {10.1111/j.1540-6261.2009.01447.x}
241+}
242+
243+@article{loughran2011,
244+ author = {LOUGHRAN, TIM and MCDONALD, BILL},
245+ title = {{When Is a Liability Not a Liability? Textual Analysis, Dictionaries, and 10-Ks}},
246+ journal = {The Journal of Finance},
247+ year = {2011},
248+ volume = {66},
249+ number = {1},
250+ pages = {35--65},
251+ doi = {10.1111/j.1540-6261.2010.01625.x}
252+}
253+
254+@article{bajgrowicz2016,
255+ author = {Bajgrowicz, Pierre and Scaillet, Olivier and Treccani, Adrien},
256+ title = {{Jumps in High-Frequency Data: Spurious Detections, Dynamics, and News}},
257+ journal = {Management Science},
258+ year = {2016},
259+ volume = {62},
260+ number = {8},
261+ pages = {2198--2217},
262+ doi = {10.1287/mnsc.2015.2234}
263+}
264+
265+@article{lee2008,
266+ author = {Lee, Suzanne S. and Mykland, Per A.},
267+ title = {{Jumps in Financial Markets: A New Nonparametric Test and Jump Dynamics}},
268+ journal = {Review of Financial Studies},
269+ year = {2008},
270+ volume = {21},
271+ number = {6},
272+ pages = {2535--2563},
273+ doi = {10.1093/rfs/hhm056}
274+}
275+
276+@article{bollerslev2018risk,
277+ author = {Bollerslev, Tim and Hood, Benjamin and Huss, John and Pedersen, Lasse Heje},
278+ title = {{Risk Everywhere: Modeling and Managing Volatility}},
279+ journal = {The Review of Financial Studies},
280+ year = {2018},
281+ volume = {31},
282+ number = {7},
283+ pages = {2729--2773},
284+ doi = {10.1093/rfs/hhy041}
285+}
286+
287+@article{hendershott2011,
288+ author = {HENDERSHOTT, TERRENCE and JONES, CHARLES M. and MENKVELD, ALBERT J.},
289+ title = {{Does Algorithmic Trading Improve Liquidity?}},
290+ journal = {The Journal of Finance},
291+ year = {2011},
292+ volume = {66},
293+ number = {1},
294+ pages = {1--33},
295+ doi = {10.1111/j.1540-6261.2010.01624.x}
296+}
297+
298+@article{richardson2005,
299+ author = {Richardson, Scott A. and Sloan, Richard G. and Soliman, Mark T. and Tuna, İrem},
300+ title = {{Accrual reliability, earnings persistence and stock prices}},
301+ journal = {Journal of Accounting and Economics},
302+ year = {2005},
303+ volume = {39},
304+ number = {3},
305+ pages = {437--485},
306+ doi = {10.1016/j.jacceco.2005.04.005}
307+}
308+
309+@article{beneish1999,
310+ author = {Beneish, Messod D.},
311+ title = {{The Detection of Earnings Manipulation}},
312+ journal = {Financial Analysts Journal},
313+ year = {1999},
314+ volume = {55},
315+ number = {5},
316+ pages = {24--36},
317+ doi = {10.2469/faj.v55.n5.2296}
318+}
319+
320+@article{campbell2008,
321+ author = {CAMPBELL, JOHN Y. and HILSCHER, JENS and SZILAGYI, JAN},
322+ title = {{In Search of Distress Risk}},
323+ journal = {The Journal of Finance},
324+ year = {2008},
325+ volume = {63},
326+ number = {6},
327+ pages = {2899--2939},
328+ doi = {10.1111/j.1540-6261.2008.01416.x}
329+}
330+
331+@article{fama1973,
332+ author = {Fama, Eugene F. and MacBeth, James D.},
333+ title = {{Risk, Return, and Equilibrium: Empirical Tests}},
334+ journal = {Journal of Political Economy},
335+ year = {1973},
336+ volume = {81},
337+ number = {3},
338+ pages = {607--636},
339+ doi = {10.1086/260061}
340+}
341+
342+@article{petersen2009,
343+ author = {Petersen, Mitchell A.},
344+ title = {{Estimating Standard Errors in Finance Panel Data Sets: Comparing Approaches}},
345+ journal = {Review of Financial Studies},
346+ year = {2009},
347+ volume = {22},
348+ number = {1},
349+ pages = {435--480},
350+ doi = {10.1093/rfs/hhn053}
351+}
352+
353+@article{cameron2011,
354+ author = {Cameron, A. Colin and Gelbach, Jonah B. and Miller, Douglas L.},
355+ title = {{Robust Inference With Multiway Clustering}},
356+ journal = {Journal of Business \& Economic Statistics},
357+ year = {2011},
358+ volume = {29},
359+ number = {2},
360+ pages = {238--249},
361+ doi = {10.1198/jbes.2010.07136}
362+}
363+
364+@article{novymarx2016,
365+ author = {Novy-Marx, Robert and Velikov, Mihail},
366+ title = {{A Taxonomy of Anomalies and Their Trading Costs}},
367+ journal = {Review of Financial Studies},
368+ year = {2016},
369+ volume = {29},
370+ number = {1},
371+ pages = {104--147},
372+ doi = {10.1093/rfs/hhv063}
373+}
374+
375+@article{bollerslev2016beta,
376+ author = {Bollerslev, Tim and Li, Sophia Zhengzi and Todorov, Viktor},
377+ title = {{Roughing up beta: Continuous versus discontinuous betas and the cross section of expected stock returns}},
378+ journal = {Journal of Financial Economics},
379+ year = {2016},
380+ volume = {120},
381+ number = {3},
382+ pages = {464--490},
383+ doi = {10.1016/j.jfineco.2016.02.001}
384+}
385+
386+@article{lo1988,
387+ author = {Lo, Andrew W. and MacKinlay, A. Craig},
388+ title = {{Stock Market Prices Do Not Follow Random Walks: Evidence from a Simple Specification Test}},
389+ journal = {Review of Financial Studies},
390+ year = {1988},
391+ volume = {1},
392+ number = {1},
393+ pages = {41--66},
394+ doi = {10.1093/rfs/1.1.41}
395+}
396+
397+@article{freyberger2020,
398+ author = {Freyberger, Joachim and Neuhierl, Andreas and Weber, Michael},
399+ title = {{Dissecting Characteristics Nonparametrically}},
400+ journal = {The Review of Financial Studies},
401+ year = {2020},
402+ volume = {33},
403+ number = {5},
404+ pages = {2326--2377},
405+ doi = {10.1093/rfs/hhz123}
406+}
407+
408+@article{bollerslev2016errors,
409+ author = {Bollerslev, Tim and Patton, Andrew J. and Quaedvlieg, Rogier},
410+ title = {{Exploiting the errors: A simple approach for improved volatility forecasting}},
411+ journal = {Journal of Econometrics},
412+ year = {2016},
413+ volume = {192},
414+ number = {1},
415+ pages = {1--18},
416+ doi = {10.1016/j.jeconom.2015.10.007}
417+}
418+
419+@article{liu2015,
420+ author = {Liu, Lily Y. and Patton, Andrew J. and Sheppard, Kevin},
421+ title = {{Does anything beat 5-minute RV? A comparison of realized measures across multiple asset classes}},
422+ journal = {Journal of Econometrics},
423+ year = {2015},
424+ volume = {187},
425+ number = {1},
426+ pages = {293--311},
427+ doi = {10.1016/j.jeconom.2015.02.008}
428+}
429+
430+@article{andersen1998,
431+ author = {Andersen, Torben G. and Bollerslev, Tim},
432+ title = {{Answering the Skeptics: Yes, Standard Volatility Models do Provide Accurate Forecasts}},
433+ journal = {International Economic Review},
434+ year = {1998},
435+ volume = {39},
436+ number = {4},
437+ pages = {885},
438+ doi = {10.2307/2527343}
439+}
440+
441+@article{aboody2010,
442+ author = {Aboody, David and Lehavy, Reuven and Trueman, Brett},
443+ title = {{Limited attention and the earnings announcement returns of past stock market winners}},
444+ journal = {Review of Accounting Studies},
445+ year = {2010},
446+ volume = {15},
447+ number = {2},
448+ pages = {317--344},
449+ doi = {10.1007/s11142-009-9104-9}
450+}
451+
452+@article{kothari2001,
453+ author = {Kothari, S.P},
454+ title = {{Capital markets research in accounting}},
455+ journal = {Journal of Accounting and Economics},
456+ year = {2001},
457+ volume = {31},
458+ number = {1-3},
459+ pages = {105--231},
460+ doi = {10.1016/s0165-4101(01)00030-1}
461+}
462+
463+@article{easley2012,
464+ author = {Easley, David and López de Prado, Marcos M. and O'Hara, Maureen},
465+ title = {{Flow Toxicity and Liquidity in a High-frequency World}},
466+ journal = {Review of Financial Studies},
467+ year = {2012},
468+ volume = {25},
469+ number = {5},
470+ pages = {1457--1493},
471+ doi = {10.1093/rfs/hhs053}
472+}
473+
474+@article{hou2015,
475+ author = {Hou, Kewei and Xue, Chen and Zhang, Lu},
476+ title = {{Digesting Anomalies: An Investment Approach}},
477+ journal = {Review of Financial Studies},
478+ year = {2015},
479+ volume = {28},
480+ number = {3},
481+ pages = {650--705},
482+ doi = {10.1093/rfs/hhu068}
483+}
484+
485+@article{dechow2010,
486+ author = {Dechow, Patricia and Ge, Weili and Schrand, Catherine},
487+ title = {{Understanding earnings quality: A review of the proxies, their determinants and their consequences}},
488+ journal = {Journal of Accounting and Economics},
489+ year = {2010},
490+ volume = {50},
491+ number = {2-3},
492+ pages = {344--401},
493+ doi = {10.1016/j.jacceco.2010.09.001}
494+}
495+
496+@article{fama1970,
497+ author = {Fama, Eugene F.},
498+ title = {{Efficient Capital Markets: A Review of Theory and Empirical Work}},
499+ journal = {The Journal of Finance},
500+ year = {1970},
501+ volume = {25},
502+ number = {2},
503+ pages = {383},
504+ doi = {10.2307/2325486}
505+}
506+
507+@article{bollerslev2011tails,
508+ author = {BOLLERSLEV, TIM and TODOROV, VIKTOR},
509+ title = {{Tails, Fears, and Risk Premia}},
510+ journal = {The Journal of Finance},
511+ year = {2011},
512+ volume = {66},
513+ number = {6},
514+ pages = {2165--2211},
515+ doi = {10.1111/j.1540-6261.2011.01695.x}
516+}
517+
518+@article{hirshleifer2003,
519+ author = {Hirshleifer, David and Teoh, Siew Hong},
520+ title = {{Limited attention, information disclosure, and financial reporting}},
521+ journal = {Journal of Accounting and Economics},
522+ year = {2003},
523+ volume = {36},
524+ number = {1-3},
525+ pages = {337--386},
526+ doi = {10.1016/j.jacceco.2003.10.002}
527+}
528+
529+@article{savor2016,
530+ author = {SAVOR, PAVEL and WILSON, MUNGO},
531+ title = {{Earnings Announcements and Systematic Risk}},
532+ journal = {The Journal of Finance},
533+ year = {2016},
534+ volume = {71},
535+ number = {1},
536+ pages = {83--138},
537+ doi = {10.1111/jofi.12361}
538+}
539+
540+@article{budish2015,
541+ author = {Budish, Eric and Cramton, Peter and Shim, John},
542+ title = {{The High-Frequency Trading Arms Race: Frequent Batch Auctions as a Market Design Response *}},
543+ journal = {The Quarterly Journal of Economics},
544+ year = {2015},
545+ volume = {130},
546+ number = {4},
547+ pages = {1547--1621},
548+ doi = {10.1093/qje/qjv027}
549+}
550+
551+@article{chen2024deep,
552+ author = {Chen, Luyang and Pelger, Markus and Zhu, Jason},
553+ title = {{Deep Learning in Asset Pricing}},
554+ journal = {Management Science},
555+ year = {2024},
556+ volume = {70},
557+ number = {2},
558+ pages = {714--750},
559+ doi = {10.1287/mnsc.2023.4695}
560+}
561+
562+@article{kozak2020,
563+ author = {Kozak, Serhiy and Nagel, Stefan and Santosh, Shrihari},
564+ title = {{Shrinking the cross-section}},
565+ journal = {Journal of Financial Economics},
566+ year = {2020},
567+ volume = {135},
568+ number = {2},
569+ pages = {271--292},
570+ doi = {10.1016/j.jfineco.2019.06.008}
571+}
572+
573+@article{brogaard2019,
574+ author = {BROGAARD, JONATHAN and HENDERSHOTT, TERRENCE and RIORDAN, RYAN},
575+ title = {{Price Discovery without Trading: Evidence from Limit Orders}},
576+ journal = {The Journal of Finance},
577+ year = {2019},
578+ volume = {74},
579+ number = {4},
580+ pages = {1621--1658},
581+ doi = {10.1111/jofi.12769}
582+}
583+
584+@article{ang2006,
585+ author = {ANG, ANDREW and HODRICK, ROBERT J. and XING, YUHANG and ZHANG, XIAOYAN},
586+ title = {{The Cross-Section of Volatility and Expected Returns}},
587+ journal = {The Journal of Finance},
588+ year = {2006},
589+ volume = {61},
590+ number = {1},
591+ pages = {259--299},
592+ doi = {10.1111/j.1540-6261.2006.00836.x}
593+}
594+
595+@article{skinner2002,
596+ author = {Skinner, Douglas J. and Sloan, Richard G.},
597+ title = {{Earnings Surprises, Growth Expectations, and Stock Returns or Don't Let an Earnings Torpedo Sink Your Portfolio}},
598+ journal = {Review of Accounting Studies},
599+ year = {2002},
600+ volume = {7},
601+ number = {2-3},
602+ pages = {289--312},
603+ doi = {10.1023/a:1020294523516}
604+}
605+
606+@article{levi2015,
607+ author = {Levi, Shai and Zhang, Xiao-Jun},
608+ title = {{Asymmetric decrease in liquidity trading before earnings announcements and the announcement return premium}},
609+ journal = {Journal of Financial Economics},
610+ year = {2015},
611+ volume = {118},
612+ number = {2},
613+ pages = {383--398},
614+ doi = {10.1016/j.jfineco.2015.08.003}
615+}
616+
617+@article{campbell1993,
618+ author = {Campbell, J. Y. and Grossman, S. J. and Wang, J.},
619+ title = {{Trading Volume and Serial Correlation in Stock Returns}},
620+ journal = {The Quarterly Journal of Economics},
621+ year = {1993},
622+ volume = {108},
623+ number = {4},
624+ pages = {905--939},
625+ doi = {10.2307/2118454}
626+}
627+
628+@article{fang2009,
629+ author = {FANG, LILY and PERESS, JOEL},
630+ title = {{Media Coverage and the Cross-section of Stock Returns}},
631+ journal = {The Journal of Finance},
632+ year = {2009},
633+ volume = {64},
634+ number = {5},
635+ pages = {2023--2052},
636+ doi = {10.1111/j.1540-6261.2009.01493.x}
637+}
638+
639+@article{beaver1980,
640+ author = {Beaver, William and Lambert, Richard and Morse, Dale},
641+ title = {{The information content of security prices}},
642+ journal = {Journal of Accounting and Economics},
643+ year = {1980},
644+ volume = {2},
645+ number = {1},
646+ pages = {3--28},
647+ doi = {10.1016/0165-4101(80)90013-0}
648+}
649+
650+@article{kothari1992,
651+ author = {Kothari, S.P and Sloan, Richard G},
652+ title = {{Information in prices about future earnings}},
653+ journal = {Journal of Accounting and Economics},
654+ year = {1992},
655+ volume = {15},
656+ number = {2-3},
657+ pages = {143--171},
658+ doi = {10.1016/0165-4101(92)90016-u}
659+}
660+
661+@article{collins1994,
662+ author = {Collins, Daniel W. and Kothari, S.P. and Shanken, Jay and Sloan, Richard G.},
663+ title = {{Lack of timeliness and noise as explanations for the low contemporaneuos return-earnings association}},
664+ journal = {Journal of Accounting and Economics},
665+ year = {1994},
666+ volume = {18},
667+ number = {3},
668+ pages = {289--324},
669+ doi = {10.1016/0165-4101(94)90024-8}
670+}
671+
672+@article{foster1977,
673+ author = {Foster, George},
674+ title = {{Quarterly Accounting Data: Time-Series Properties and Predicative-Ability Results.}},
675+ journal = {The Accounting Review},
676+ year = {1977},
677+ volume = {52},
678+ number = {1},
679+ pages = {1--21},
680+ doi = {10.2308/tar-4493420}
681+}
682+
683+@article{weller2018,
684+ author = {Weller, Brian M.},
685+ title = {{Does Algorithmic Trading Reduce Information Acquisition?}},
686+ journal = {The Review of Financial Studies},
687+ year = {2018},
688+ volume = {31},
689+ number = {6},
690+ pages = {2184--2226},
691+ doi = {10.1093/rfs/hhx137}
692+}
693+
694+@article{bollerslev2018volume,
695+ author = {Bollerslev, Tim and Li, Jia and Xue, Yuan},
696+ title = {{Volume, Volatility, and Public News Announcements}},
697+ journal = {The Review of Economic Studies},
698+ year = {2018},
699+ volume = {85},
700+ number = {4},
701+ pages = {2005--2041},
702+ doi = {10.1093/restud/rdy003}
703+}
704+
705+@article{patton2012,
706+ author = {Patton, Andrew J. and Verardo, Michela},
707+ title = {{Does Beta Move with News? Firm-Specific Information Flows and Learning about Profitability}},
708+ journal = {Review of Financial Studies},
709+ year = {2012},
710+ volume = {25},
711+ number = {9},
712+ pages = {2789--2839},
713+ doi = {10.1093/rfs/hhs073}
714+}
715+
716+@article{kaniel2012,
717+ author = {KANIEL, RON and LIU, SHUMING and SAAR, GIDEON and TITMAN, SHERIDAN},
718+ title = {{Individual Investor Trading and Return Patterns around Earnings Announcements}},
719+ journal = {The Journal of Finance},
720+ year = {2012},
721+ volume = {67},
722+ number = {2},
723+ pages = {639--680},
724+ doi = {10.1111/j.1540-6261.2012.01727.x}
725+}
726+
727+@article{christophe2004,
728+ author = {Christophe, Stephen E. and Ferri, Michael G. and Angel, James J.},
729+ title = {{Short-Selling Prior to Earnings Announcements}},
730+ journal = {The Journal of Finance},
731+ year = {2004},
732+ volume = {59},
733+ number = {4},
734+ pages = {1845--1876},
735+ doi = {10.1111/j.1540-6261.2004.00681.x}
736+}
737+
738+@article{berkman2009,
739+ author = {Berkman, Henk and Dimitrov, Valentin and Jain, Prem C. and Koch, Paul D. and Tice, Sheri},
740+ title = {{Sell on the news: Differences of opinion, short-sales constraints, and returns around earnings announcements☆}},
741+ journal = {Journal of Financial Economics},
742+ year = {2009},
743+ volume = {92},
744+ number = {3},
745+ pages = {376--399},
746+ doi = {10.1016/j.jfineco.2008.04.009}
747+}
748+
749+@article{hendershott2015,
750+ author = {Hendershott, Terrence and Livdan, Dmitry and Schürhoff, Norman},
751+ title = {{Are institutions informed about news?}},
752+ journal = {Journal of Financial Economics},
753+ year = {2015},
754+ volume = {117},
755+ number = {2},
756+ pages = {249--287},
757+ doi = {10.1016/j.jfineco.2015.03.007}
758+}
759+
760+@article{lee2012,
761+ author = {Lee, Suzanne S.},
762+ title = {{Jumps and Information Flow in Financial Markets}},
763+ journal = {Review of Financial Studies},
764+ year = {2012},
765+ volume = {25},
766+ number = {2},
767+ pages = {439--479},
768+ doi = {10.1093/rfs/hhr084}
769+}
770+
771+@article{cao2024,
772+ author = {Cao, Sean and Jiang, Wei and Wang, Junbo and Yang, Baozhong},
773+ title = {{From Man vs. Machine to Man + Machine: The art and AI of stock analyses}},
774+ journal = {Journal of Financial Economics},
775+ year = {2024},
776+ volume = {160},
777+ pages = {103910},
778+ doi = {10.1016/j.jfineco.2024.103910}
779+}
780+
781+@article{bali2023,
782+ author = {Bali, Turan G and Beckmeyer, Heiner and Mörke, Mathis and Weigert, Florian},
783+ title = {{Option Return Predictability with Machine Learning and Big Data}},
784+ journal = {The Review of Financial Studies},
785+ year = {2023},
786+ volume = {36},
787+ number = {9},
788+ pages = {3548--3602},
789+ doi = {10.1093/rfs/hhad017}
790+}
791+
792+@article{easton2024,
793+ author = {Easton, Peter D. and Kapons, Martin M. and Monahan, Steven J. and Schütt, Harm H. and Weisbrod, Eric H.},
794+ title = {{Forecasting Earnings Using \emph{k}-Nearest Neighbors}},
795+ journal = {The Accounting Review},
796+ year = {2024},
797+ volume = {99},
798+ number = {3},
799+ pages = {115--140},
800+ doi = {10.2308/tar-2021-0478}
801+}
802+
803+@article{so2013,
804+ author = {So, Eric C.},
805+ title = {{A new approach to predicting analyst forecast errors: Do investors overweight analyst forecasts?}},
806+ journal = {Journal of Financial Economics},
807+ year = {2013},
808+ volume = {108},
809+ number = {3},
810+ pages = {615--640},
811+ doi = {10.1016/j.jfineco.2013.02.002}
812+}
813+
814+@article{hou2012,
815+ author = {Hou, Kewei and van Dijk, Mathijs A. and Zhang, Yinglei},
816+ title = {{The implied cost of capital: A new approach}},
817+ journal = {Journal of Accounting and Economics},
818+ year = {2012},
819+ volume = {53},
820+ number = {3},
821+ pages = {504--526},
822+ doi = {10.1016/j.jacceco.2011.12.001}
823+}
824+
825+@article{hansen2006,
826+ author = {Hansen, Peter R and Lunde, Asger},
827+ title = {{Realized Variance and Market Microstructure Noise}},
828+ journal = {Journal of Business \& Economic Statistics},
829+ year = {2006},
830+ volume = {24},
831+ number = {2},
832+ pages = {127--161},
833+ doi = {10.1198/073500106000000071}
834+}
835+
836+@article{barndorff2008,
837+ author = {Barndorff-Nielsen, Ole E. and Hansen, Peter Reinhard and Lunde, Asger and Shephard, Neil},
838+ title = {{Designing Realized Kernels to Measure the ex post Variation of Equity Prices in the Presence of Noise}},
839+ journal = {Econometrica},
840+ year = {2008},
841+ volume = {76},
842+ number = {6},
843+ pages = {1481--1536},
844+ doi = {10.3982/ecta6495}
845+}
846+
847+@article{vankervel2019,
848+ author = {VAN KERVEL, VINCENT and MENKVELD, ALBERT J.},
849+ title = {{High-Frequency Trading around Large Institutional Orders}},
850+ journal = {The Journal of Finance},
851+ year = {2019},
852+ volume = {74},
853+ number = {3},
854+ pages = {1091--1137},
855+ doi = {10.1111/jofi.12759}
856+}
857+
858+@article{avramov2023,
859+ author = {Avramov, Doron and Cheng, Si and Metzker, Lior},
860+ title = {{Machine Learning vs. Economic Restrictions: Evidence from Stock Return Predictability}},
861+ journal = {Management Science},
862+ year = {2023},
863+ volume = {69},
864+ number = {5},
865+ pages = {2587--2619},
866+ doi = {10.1287/mnsc.2022.4449}
867+}
868+
869+@article{chordia2020,
870+ author = {Chordia, Tarun and Goyal, Amit and Saretto, Alessio},
871+ title = {{Anomalies and False Rejections}},
872+ journal = {The Review of Financial Studies},
873+ year = {2020},
874+ volume = {33},
875+ number = {5},
876+ pages = {2134--2179},
877+ doi = {10.1093/rfs/hhaa018}
878+}
879+
880+@article{barber2013,
881+ author = {Barber, Brad M. and De George, Emmanuel T. and Lehavy, Reuven and Trueman, Brett},
882+ title = {{The earnings announcement premium around the globe}},
883+ journal = {Journal of Financial Economics},
884+ year = {2013},
885+ volume = {108},
886+ number = {1},
887+ pages = {118--138},
888+ doi = {10.1016/j.jfineco.2012.10.006}
889+}
890+
891+@article{politis1994,
892+ author = {Politis, Dimitris N. and Romano, Joseph P.},
893+ title = {{The Stationary Bootstrap}},
894+ journal = {Journal of the American Statistical Association},
895+ year = {1994},
896+ volume = {89},
897+ number = {428},
898+ pages = {1303--1313},
899+ doi = {10.1080/01621459.1994.10476870}
900+}
901+
902+@article{hansen2011mcs,
903+ author = {Hansen, Peter R. and Lunde, Asger and Nason, James M.},
904+ title = {{The Model Confidence Set}},
905+ journal = {Econometrica},
906+ year = {2011},
907+ volume = {79},
908+ number = {2},
909+ pages = {453--497},
910+ doi = {10.3982/ecta5771}
911+}
912+
913+@article{benjamini1995,
914+ author = {Benjamini, Yoav and Hochberg, Yosef},
915+ title = {{Controlling the False Discovery Rate: A Practical and Powerful Approach to Multiple Testing}},
916+ journal = {Journal of the Royal Statistical Society Series B: Statistical Methodology},
917+ year = {1995},
918+ volume = {57},
919+ number = {1},
920+ pages = {289--300},
921+ doi = {10.1111/j.2517-6161.1995.tb02031.x}
922+}
923+
924+@article{amihud2002,
925+ author = {Amihud, Yakov},
926+ title = {{Illiquidity and stock returns: cross-section and time-series effects}},
927+ journal = {Journal of Financial Markets},
928+ year = {2002},
929+ volume = {5},
930+ number = {1},
931+ pages = {31--56},
932+ doi = {10.1016/s1386-4181(01)00024-6}
933+}
934+
935+@article{fama1993,
936+ author = {Fama, Eugene F. and French, Kenneth R.},
937+ title = {{Common risk factors in the returns on stocks and bonds}},
938+ journal = {Journal of Financial Economics},
939+ year = {1993},
940+ volume = {33},
941+ number = {1},
942+ pages = {3--56},
943+ doi = {10.1016/0304-405x(93)90023-5}
944+}
945+
946+@article{grossman1980,
947+ author = {Grossman, Sanford J. and Stiglitz, Joseph E.},
948+ title = {{On the Impossibility of Informationally Efficient Markets}},
949+ journal = {American Economic Review},
950+ year = {1980},
951+ volume = {70},
952+ number = {3},
953+ pages = {393--408}
954+}
955+@article{holm1979,
956+ author = {Holm, Sture},
957+ title = {{A Simple Sequentially Rejective Multiple Test Procedure}},
958+ journal = {Scandinavian Journal of Statistics},
959+ year = {1979},
960+ volume = {6},
961+ number = {2},
962+ pages = {65--70}
963+}
964+@article{kunsch1989,
965+ author = {K{\"u}nsch, Hans R.},
966+ title = {{The Jackknife and the Bootstrap for General Stationary Observations}},
967+ journal = {Annals of Statistics},
968+ year = {1989},
969+ volume = {17},
970+ number = {3},
971+ pages = {1217--1241},
972+ doi = {10.1214/aos/1176347265}
973+}
974+@inproceedings{ke2017lightgbm,
975+ author = {Ke, Guolin and Meng, Qi and Finley, Thomas and Wang, Taifeng and Chen, Wei and Ma, Weidong and Ye, Qiwei and Liu, Tie-Yan},
976+ title = {{LightGBM: A Highly Efficient Gradient Boosting Decision Tree}},
977+ booktitle = {Advances in Neural Information Processing Systems 30},
978+ year = {2017},
979+ pages = {3146--3154}
980+}
981+@book{lopezdeprado2018,
982+ author = {L{\'o}pez de Prado, Marcos},
983+ title = {{Advances in Financial Machine Learning}},
984+ publisher = {Wiley},
985+ address = {Hoboken, NJ},
986+ year = {2018}
987+}
988+@techreport{frazzini2018costs,
989+ author = {Frazzini, Andrea and Israel, Ronen and Moskowitz, Tobias J.},
990+ title = {{Trading Costs}},
991+ institution = {SSRN Working Paper 3229719},
992+ year = {2018},
993+ doi = {10.2139/ssrn.3229719}
994+}
995+@incollection{barndorff2010semivariance,
996+ author = {Barndorff-Nielsen, Ole E. and Kinnebrock, Silja and Shephard, Neil},
997+ title = {{Measuring Downside Risk: Realised Semivariance}},
998+ booktitle = {Volatility and Time Series Econometrics: Essays in Honor of Robert F. Engle},
999+ editor = {Bollerslev, Tim and Russell, Jeffrey and Watson, Mark},
1000+ publisher = {Oxford University Press},
1001+ year = {2010},
1002+ pages = {117--136},
1003+ doi = {10.1093/acprof:oso/9780199549498.003.0007}
1004+}
1005+@article{boucher2026wp15,
1006+ author = {Boucher, Simon-Pierre},
1007+ title = {{Faster, Wider, More Fragile? The Evolution of Intraday Shock Propagation in U.S. Equity Markets, 1992--2026}},
1008+ journal = {UQO Working Paper No. 15},
1009+ year = {2026}
1010+}
1011+
1012+@article{harvey2016,
1013+ author = {Harvey, Campbell R. and Liu, Yan and Zhu, Heqing},
1014+ title = {{\ldots and the Cross-Section of Expected Returns}},
1015+ journal = {The Review of Financial Studies},
1016+ year = {2016},
1017+ volume = {29},
1018+ number = {1},
1019+ pages = {5--68},
1020+ doi = {10.1093/rfs/hhv059}
1021+}
added paper/.latexmkrc +5 −0
@@ -0,0 +1,5 @@
1+$pdf_mode = 1;
2+$pdflatex = 'pdflatex -interaction=nonstopmode -halt-on-error -synctex=1 %O %S';
3+$bibtex_use = 2;
4+$clean_ext = 'synctex.gz run.xml bbl bcf fdb_latexmk fls log aux out toc lof lot blg';
5+@default_files = ('main.tex');
added paper/Makefile +11 −0
@@ -0,0 +1,11 @@
1+.PHONY: all clean distclean
2+
3+all:
4+ latexmk
5+
6+clean:
7+ latexmk -c
8+
9+distclean:
10+ latexmk -C
11+ rm -f *.synctex.gz *.run.xml *.bbl *.bcf
added paper/appendix/appendix.tex +54 −0
@@ -0,0 +1,54 @@
1+\newpage
2+\begin{appendices}
3+\section{Variable definitions and data lineage}
4+\label{app:variables}
5+\begin{table}[H]
6+\centering\scriptsize
7+\caption{Variable definitions and lineage}
8+\begin{tabular}{p{3.4cm}p{8.2cm}p{3.2cm}}
9+\toprule
10+Variable & Definition & Source / step \\
11+\midrule
12+$A_{i,q}$ & Earnings-release date: first \texttt{earnings\_events.event\_date} in $[E_q, F_q+10\,\mathrm{d}]$ & FinData Postgres extract (01a) \\
13+$F_{i,q}$, $V_{i,q}$ & SEC filing date; availability $=F+1$ day 00:00 ET & \texttt{/fundamentals} (02) \\
14+$S_{i,q}$ & $(X_q-\hat X_q)/TA_{q-1}$, $X=$ operating income, $\hat X_q=X_{q-4}+\frac14\sum_{k=0}^3(X_{q-1-k}-X_{q-5-k})$ & 02, \texttt{labels.py} \\
15+$D_{i,q}$ & $\mathbb 1[S_{i,q}\le-0.005]$ & 02 \\
16+Secondary targets & net-income surprise; $\Delta$ROA$_{yoy}\le-0.01$; $(CFO_q-CFO_{q-4})/TA\le-0.01$; current ratio $-10\%$ or coverage $<1$ from $\ge1$; $|S|\ge0.005$; EPS $<$ estimate & 02 \\
17+Intraday families (per bar length $f\in\{1,5,30\}$ min) & RV $=\sum r^2$; BPV $=\frac{\pi}{2}\sum|r_t||r_{t-1}|$; RJ $=\max(0,\mathrm{RV}-\mathrm{BPV})/\mathrm{RV}$; RS$^-$/RV; SJV $=(\mathrm{RS}^+-\mathrm{RS}^-)/\mathrm{RV}$; realized skewness/kurtosis; $\max r^2/\mathrm{RV}$; AC(1); drift $=\ln(C/O)$; opening/closing 30-min volume shares; volume Gini; down-bar volume share; present-bar share; RV share on SPY-down bars; $\beta=\sum r\,r^{SPY}/\sum (r^{SPY})^2$; vol-of-vol; RV/Var(daily) & 03, \texttt{features.py}, \texttt{samples.py} \\
18+Daily block & 20/60-day log returns, SD, Parkinson, skewness, kurtosis, overnight/intraday sums, volume ratios (20d, 5d vs 60d median), Amihud (60d), zero-return share, log dollar volume, max drawdown (60d), bars per day & 03 \\
19+Accounting block & gross/operating margin, NI/TA, CFO/TA, accruals, cash/TA, debt/TA, current ratio, capex/TA, R\&D/revenue, coverage, loss dummy, log assets, ROA, revenue growth, net issuance, log market cap, last surprise, mean of last four surprises, trailing adverse rate, information age & 02--04 \\
20+Returns & split- and dividend-adjusted close-to-close from raw bars and \texttt{corporate\_actions} (ex-date rule) & 03 \\
21+\bottomrule
22+\end{tabular}
23+\end{table}
24+
25+\section{Leakage battery}
26+\label{app:leakage}
27+Each guard is a unit test in \texttt{tests/}: (L1) future facts, (L2) fiscal-end joins, (L3) post-cutoff bars,
28+(L4) test-period transforms, (L5) DST/timezone, (L6) identifier drift, (L7) splits/dividends, (L8) release-day bars,
29+(L9) overlapping windows across splits, (L10) survivorship report, (L11) ingestion dates, (L12) randomised placebos.
30+The live test \texttt{test\_asof\_equivalence} verifies that the local as-of join reproduces the API's
31+\texttt{as\_of} output on random entities and instants.
32+
33+\section{Additional tables}
34+\label{app:tables}
35+\begin{table}[H]\centering\footnotesize
36+\caption{Continuous target: ridge forecasts of the winsorised surprise (test period)}
37+\input{tables/tab10b_continuous}
38+\end{table}
39+\begin{table}[H]\centering\footnotesize
40+\caption{Transfer to held-out firms (20\% of firms never seen in training)}
41+\input{tables/tab09b_firmholdout}
42+\end{table}
43+\begin{table}[H]\centering\footnotesize
44+\caption{Announcement-window abnormal returns on the ex-ante signal}
45+\input{tables/tab11b_car}
46+\begin{minipage}{\textwidth}\vspace{0.3em}\footnotesize\emph{Notes.} CAR from $c+1$ to $A_q+1$, market-adjusted, winsorised 1/99\%; firm and sector$\times$quarter fixed effects; two-way clustered SEs. $^{*}$, $^{**}$, $^{***}$: 10, 5, 1\%.\end{minipage}
47+\end{table}
48+
49+\section{Reproducibility}
50+\label{app:repro}
51+The pipeline (\texttt{scripts/00}--\texttt{09}) runs on the FinData storage node against the local API; manifests in
52+\texttt{artifacts/manifests} pin dataset versions and hashes; the design freeze hash is recorded before the first
53+model fit. Raw extracts are deleted after feature extraction and are reproducible from the pinned versions.
54+\end{appendices}
added paper/main.tex +141 −0
@@ -0,0 +1,141 @@
1+% ============================================================================
2+% UQO Working Paper Template — Economics / Finance
3+% ============================================================================
4+\documentclass[12pt,letterpaper]{article}
5+
6+% --- Encoding & Language ---
7+\usepackage[utf8]{inputenc}
8+\usepackage[T1]{fontenc}
9+\usepackage[english]{babel}
10+
11+% --- Page Layout ---
12+\usepackage[
13+ letterpaper,
14+ top=1in,
15+ bottom=1in,
16+ left=1in,
17+ right=1in
18+]{geometry}
19+
20+% --- Typography ---
21+\usepackage{mathptmx} % Times-like font (text + math)
22+\usepackage{microtype} % Improved typographic refinements
23+\usepackage{setspace}
24+\onehalfspacing % 1.5 line spacing
25+
26+% --- Math ---
27+\usepackage{amsmath,amssymb,amsthm}
28+
29+% --- Tables ---
30+\usepackage{booktabs}
31+\usepackage{tabularx}
32+\usepackage{multirow}
33+\usepackage{threeparttable}
34+\usepackage{siunitx}
35+\sisetup{
36+ input-symbols = {()},
37+ table-format = -1.3,
38+ parse-numbers = false
39+}
40+
41+% --- Figures ---
42+\usepackage{graphicx}
43+\usepackage{subcaption}
44+\usepackage{float}
45+\usepackage{longtable}
46+\usepackage{adjustbox}
47+
48+% --- Colors & Links ---
49+\usepackage[dvipsnames]{xcolor}
50+\usepackage[
51+ pdftitle={The Invisible Balance Sheet},
52+ pdfauthor={Simon-Pierre Boucher},
53+ colorlinks = true,
54+ linkcolor = NavyBlue,
55+ citecolor = NavyBlue,
56+ urlcolor = NavyBlue,
57+ breaklinks = true
58+]{hyperref}
59+
60+% --- Bibliography ---
61+\usepackage[authoryear,round,semicolon]{natbib}
62+\bibliographystyle{aer}
63+
64+% --- Captions ---
65+\usepackage[
66+ font = small,
67+ labelfont = bf,
68+ labelsep = period,
69+ justification = justified,
70+ singlelinecheck = false
71+]{caption}
72+
73+% --- Headers & Footers ---
74+\usepackage{fancyhdr}
75+\pagestyle{fancy}
76+\fancyhf{}
77+\renewcommand{\headrulewidth}{0pt}
78+\fancyfoot[C]{\thepage}
79+
80+% --- Section Formatting ---
81+\usepackage{titlesec}
82+\titleformat{\section}{\large\bfseries}{\thesection.}{0.5em}{}
83+\titleformat{\subsection}{\normalsize\bfseries}{\thesubsection.}{0.5em}{}
84+\titleformat{\subsubsection}{\normalsize\itshape}{\thesubsubsection.}{0.5em}{}
85+
86+% --- Appendix Support ---
87+\usepackage[toc,page]{appendix}
88+
89+% --- Misc ---
90+\usepackage{enumitem}
91+\setlist{nosep,leftmargin=*}
92+
93+% ============================================================================
94+% METADATA
95+% ============================================================================
96+\input{results_macros}
97+\newcommand{\WPnumber}{39}
98+\newcommand{\WPtitle}{The Invisible Balance Sheet}
99+\newcommand{\WPsubtitle}{Can Intraday Financial Markets Detect Corporate Deterioration Before Accounting Disclosures?}
100+\newcommand{\WPdate}{November 2026}
101+\newcommand{\WPversion}{1.0}
102+\newcommand{\WPabstract}{\input{sections/abstract}}
103+\newcommand{\WPkeywords}{intraday data, sampling frequency, earnings surprises, fundamental analysis, point-in-time data, machine learning, market efficiency}
104+\newcommand{\WPjel}{G14, G12, G17, M41, C53, C58}
105+
106+% --- Author ---
107+\newcommand{\WPauthor}{Simon-Pierre Boucher}
108+\newcommand{\WPaffiliation}{%
109+ Département des sciences administratives\\
110+ Université du Québec en Outaouais%
111+}
112+\newcommand{\WPemail}{simon-pierre.boucher@uqo.ca}
113+\newcommand{\WPaddress}{%
114+ Gatineau -- Pavillon Alexandre-Taché\\
115+ 283, boulevard Alexandre-Taché\\
116+ Gatineau, Québec, Canada J9A 1L8%
117+}
118+
119+% ============================================================================
120+% DOCUMENT
121+% ============================================================================
122+\begin{document}
123+
124+\input{sections/titlepage}
125+
126+\input{sections/introduction}
127+\input{sections/theory_hypotheses}
128+\input{sections/literature}
129+\input{sections/data}
130+\input{sections/methodology}
131+\input{sections/results}
132+\input{sections/mechanisms}
133+\input{sections/robustness}
134+\input{sections/conclusion}
135+
136+\newpage
137+\bibliography{references}
138+
139+\input{appendix/appendix}
140+
141+\end{document}
added paper/references.bib +1021 −0
@@ -0,0 +1,1021 @@
1+% WP39 UQO bibliography. Journal metadata verified on Crossref 2026-11-02 (artifacts/logs/crossref_batch*.json).
2+
3+@article{gregoire2022,
4+ author = {Grégoire, Vincent and Martineau, Charles},
5+ title = {{How is Earnings News Transmitted to Stock Prices?}},
6+ journal = {Journal of Accounting Research},
7+ year = {2022},
8+ volume = {60},
9+ number = {1},
10+ pages = {261--297},
11+ doi = {10.1111/1475-679x.12394}
12+}
13+
14+@article{zhang2023volatility,
15+ author = {Zhang, Chao and Zhang, Yihuang and Cucuringu, Mihai and Qian, Zhongmin},
16+ title = {{Volatility Forecasting with Machine Learning and Intraday Commonality}},
17+ journal = {Journal of Financial Econometrics},
18+ year = {2024},
19+ volume = {22},
20+ number = {2},
21+ pages = {492--530},
22+ doi = {10.1093/jjfinec/nbad005}
23+}
24+
25+@article{ball1968,
26+ author = {Ball, Ray and Brown, Philip},
27+ title = {{An Empirical Evaluation of Accounting Income Numbers}},
28+ journal = {Journal of Accounting Research},
29+ year = {1968},
30+ volume = {6},
31+ number = {2},
32+ pages = {159},
33+ doi = {10.2307/2490232}
34+}
35+
36+@article{bernard1989,
37+ author = {Bernard, Victor L. and Thomas, Jacob K.},
38+ title = {{Post-Earnings-Announcement Drift: Delayed Price Response or Risk Premium?}},
39+ journal = {Journal of Accounting Research},
40+ year = {1989},
41+ volume = {27},
42+ pages = {1},
43+ doi = {10.2307/2491062}
44+}
45+
46+@article{bernard1990,
47+ author = {Bernard, Victor L. and Thomas, Jacob K.},
48+ title = {{Evidence that stock prices do not fully reflect the implications of current earnings for future earnings}},
49+ journal = {Journal of Accounting and Economics},
50+ year = {1990},
51+ volume = {13},
52+ number = {4},
53+ pages = {305--340},
54+ doi = {10.1016/0165-4101(90)90008-r}
55+}
56+
57+@article{sloan1996,
58+ author = {Sloan, Richard G.},
59+ title = {{Do Stock Prices Fully Reflect Information in Accruals and Cash Flows About Future Earnings?}},
60+ journal = {The Accounting Review},
61+ year = {1996},
62+ volume = {71},
63+ number = {3},
64+ pages = {289--315},
65+ doi = {10.2308/tar-9608042309}
66+}
67+
68+@article{ou1989,
69+ author = {Ou, Jane A. and Penman, Stephen H.},
70+ title = {{Financial statement analysis and the prediction of stock returns}},
71+ journal = {Journal of Accounting and Economics},
72+ year = {1989},
73+ volume = {11},
74+ number = {4},
75+ pages = {295--329},
76+ doi = {10.1016/0165-4101(89)90017-7}
77+}
78+
79+@article{kyle1985,
80+ author = {Kyle, Albert S.},
81+ title = {{Continuous Auctions and Insider Trading}},
82+ journal = {Econometrica},
83+ year = {1985},
84+ volume = {53},
85+ number = {6},
86+ pages = {1315},
87+ doi = {10.2307/1913210}
88+}
89+
90+@article{easley1987,
91+ author = {Easley, David and O'Hara, Maureen},
92+ title = {{Price, trade size, and information in securities markets}},
93+ journal = {Journal of Financial Economics},
94+ year = {1987},
95+ volume = {19},
96+ number = {1},
97+ pages = {69--90},
98+ doi = {10.1016/0304-405x(87)90029-8}
99+}
100+
101+@article{andersen2003,
102+ author = {Andersen, Torben G. and Bollerslev, Tim and Diebold, Francis X. and Labys, Paul},
103+ title = {{Modeling and Forecasting Realized Volatility}},
104+ journal = {Econometrica},
105+ year = {2003},
106+ volume = {71},
107+ number = {2},
108+ pages = {579--625},
109+ doi = {10.1111/1468-0262.00418}
110+}
111+
112+@article{barndorff2004,
113+ author = {Barndorff-Nielsen, O. E.},
114+ title = {{Power and Bipower Variation with Stochastic Volatility and Jumps}},
115+ journal = {Journal of Financial Econometrics},
116+ year = {2004},
117+ volume = {2},
118+ number = {1},
119+ pages = {1--37},
120+ doi = {10.1093/jjfinec/nbh001}
121+}
122+
123+@article{gu2020,
124+ author = {Gu, Shihao and Kelly, Bryan and Xiu, Dacheng},
125+ title = {{Empirical Asset Pricing via Machine Learning}},
126+ journal = {The Review of Financial Studies},
127+ year = {2020},
128+ volume = {33},
129+ number = {5},
130+ pages = {2223--2273},
131+ doi = {10.1093/rfs/hhaa009}
132+}
133+
134+@article{giacomini2006,
135+ author = {Giacomini, Raffaella and White, Halbert},
136+ title = {{Tests of Conditional Predictive Ability}},
137+ journal = {Econometrica},
138+ year = {2006},
139+ volume = {74},
140+ number = {6},
141+ pages = {1545--1578},
142+ doi = {10.1111/j.1468-0262.2006.00718.x}
143+}
144+
145+@article{diebold1995,
146+ author = {Diebold, Francis X. and Mariano, Roberto S.},
147+ title = {{Comparing Predictive Accuracy}},
148+ journal = {Journal of Business \& Economic Statistics},
149+ year = {1995},
150+ volume = {13},
151+ number = {3},
152+ pages = {253--263},
153+ doi = {10.1080/07350015.1995.10524599}
154+}
155+
156+@article{hansen2005,
157+ author = {Hansen, Peter Reinhard},
158+ title = {{A Test for Superior Predictive Ability}},
159+ journal = {Journal of Business \& Economic Statistics},
160+ year = {2005},
161+ volume = {23},
162+ number = {4},
163+ pages = {365--380},
164+ doi = {10.1198/073500105000000063}
165+}
166+
167+@article{fama2015,
168+ author = {Fama, Eugene F. and French, Kenneth R.},
169+ title = {{A five-factor asset pricing model}},
170+ journal = {Journal of Financial Economics},
171+ year = {2015},
172+ volume = {116},
173+ number = {1},
174+ pages = {1--22},
175+ doi = {10.1016/j.jfineco.2014.10.010}
176+}
177+
178+@article{bollerslev2020good,
179+ author = {Bollerslev, Tim and Li, Sophia Zhengzi and Zhao, Bingzhi},
180+ title = {{Good Volatility, Bad Volatility, and the Cross Section of Stock Returns}},
181+ journal = {Journal of Financial and Quantitative Analysis},
182+ year = {2020},
183+ volume = {55},
184+ number = {3},
185+ pages = {751--781},
186+ doi = {10.1017/s0022109019000097}
187+}
188+
189+@article{patton2015,
190+ author = {Patton, Andrew J. and Sheppard, Kevin},
191+ title = {{Good Volatility, Bad Volatility: Signed Jumps and The Persistence of Volatility}},
192+ journal = {Review of Economics and Statistics},
193+ year = {2015},
194+ volume = {97},
195+ number = {3},
196+ pages = {683--697},
197+ doi = {10.1162/rest_a_00503}
198+}
199+
200+@article{chen2022,
201+ author = {CHEN, XI and CHO, YANG HA (TONY) and DOU, YIWEI and LEV, BARUCH},
202+ title = {{Predicting Future Earnings Changes Using Machine Learning and Detailed Financial Data}},
203+ journal = {Journal of Accounting Research},
204+ year = {2022},
205+ volume = {60},
206+ number = {2},
207+ pages = {467--515},
208+ doi = {10.1111/1475-679x.12429}
209+}
210+
211+@article{piotroski2000,
212+ author = {Piotroski, Joseph D.},
213+ title = {{Value Investing: The Use of Historical Financial Statement Information to Separate Winners from Losers}},
214+ journal = {Journal of Accounting Research},
215+ year = {2000},
216+ volume = {38},
217+ pages = {1},
218+ doi = {10.2307/2672906}
219+}
220+
221+@article{hirshleifer2009,
222+ author = {HIRSHLEIFER, DAVID and LIM, SONYA SEONGYEON and TEOH, SIEW HONG},
223+ title = {{Driven to Distraction: Extraneous Events and Underreaction to Earnings News}},
224+ journal = {The Journal of Finance},
225+ year = {2009},
226+ volume = {64},
227+ number = {5},
228+ pages = {2289--2325},
229+ doi = {10.1111/j.1540-6261.2009.01501.x}
230+}
231+
232+@article{dellavigna2009,
233+ author = {DELLAVIGNA, STEFANO and POLLET, JOSHUA M.},
234+ title = {{Investor Inattention and Friday Earnings Announcements}},
235+ journal = {The Journal of Finance},
236+ year = {2009},
237+ volume = {64},
238+ number = {2},
239+ pages = {709--749},
240+ doi = {10.1111/j.1540-6261.2009.01447.x}
241+}
242+
243+@article{loughran2011,
244+ author = {LOUGHRAN, TIM and MCDONALD, BILL},
245+ title = {{When Is a Liability Not a Liability? Textual Analysis, Dictionaries, and 10-Ks}},
246+ journal = {The Journal of Finance},
247+ year = {2011},
248+ volume = {66},
249+ number = {1},
250+ pages = {35--65},
251+ doi = {10.1111/j.1540-6261.2010.01625.x}
252+}
253+
254+@article{bajgrowicz2016,
255+ author = {Bajgrowicz, Pierre and Scaillet, Olivier and Treccani, Adrien},
256+ title = {{Jumps in High-Frequency Data: Spurious Detections, Dynamics, and News}},
257+ journal = {Management Science},
258+ year = {2016},
259+ volume = {62},
260+ number = {8},
261+ pages = {2198--2217},
262+ doi = {10.1287/mnsc.2015.2234}
263+}
264+
265+@article{lee2008,
266+ author = {Lee, Suzanne S. and Mykland, Per A.},
267+ title = {{Jumps in Financial Markets: A New Nonparametric Test and Jump Dynamics}},
268+ journal = {Review of Financial Studies},
269+ year = {2008},
270+ volume = {21},
271+ number = {6},
272+ pages = {2535--2563},
273+ doi = {10.1093/rfs/hhm056}
274+}
275+
276+@article{bollerslev2018risk,
277+ author = {Bollerslev, Tim and Hood, Benjamin and Huss, John and Pedersen, Lasse Heje},
278+ title = {{Risk Everywhere: Modeling and Managing Volatility}},
279+ journal = {The Review of Financial Studies},
280+ year = {2018},
281+ volume = {31},
282+ number = {7},
283+ pages = {2729--2773},
284+ doi = {10.1093/rfs/hhy041}
285+}
286+
287+@article{hendershott2011,
288+ author = {HENDERSHOTT, TERRENCE and JONES, CHARLES M. and MENKVELD, ALBERT J.},
289+ title = {{Does Algorithmic Trading Improve Liquidity?}},
290+ journal = {The Journal of Finance},
291+ year = {2011},
292+ volume = {66},
293+ number = {1},
294+ pages = {1--33},
295+ doi = {10.1111/j.1540-6261.2010.01624.x}
296+}
297+
298+@article{richardson2005,
299+ author = {Richardson, Scott A. and Sloan, Richard G. and Soliman, Mark T. and Tuna, İrem},
300+ title = {{Accrual reliability, earnings persistence and stock prices}},
301+ journal = {Journal of Accounting and Economics},
302+ year = {2005},
303+ volume = {39},
304+ number = {3},
305+ pages = {437--485},
306+ doi = {10.1016/j.jacceco.2005.04.005}
307+}
308+
309+@article{beneish1999,
310+ author = {Beneish, Messod D.},
311+ title = {{The Detection of Earnings Manipulation}},
312+ journal = {Financial Analysts Journal},
313+ year = {1999},
314+ volume = {55},
315+ number = {5},
316+ pages = {24--36},
317+ doi = {10.2469/faj.v55.n5.2296}
318+}
319+
320+@article{campbell2008,
321+ author = {CAMPBELL, JOHN Y. and HILSCHER, JENS and SZILAGYI, JAN},
322+ title = {{In Search of Distress Risk}},
323+ journal = {The Journal of Finance},
324+ year = {2008},
325+ volume = {63},
326+ number = {6},
327+ pages = {2899--2939},
328+ doi = {10.1111/j.1540-6261.2008.01416.x}
329+}
330+
331+@article{fama1973,
332+ author = {Fama, Eugene F. and MacBeth, James D.},
333+ title = {{Risk, Return, and Equilibrium: Empirical Tests}},
334+ journal = {Journal of Political Economy},
335+ year = {1973},
336+ volume = {81},
337+ number = {3},
338+ pages = {607--636},
339+ doi = {10.1086/260061}
340+}
341+
342+@article{petersen2009,
343+ author = {Petersen, Mitchell A.},
344+ title = {{Estimating Standard Errors in Finance Panel Data Sets: Comparing Approaches}},
345+ journal = {Review of Financial Studies},
346+ year = {2009},
347+ volume = {22},
348+ number = {1},
349+ pages = {435--480},
350+ doi = {10.1093/rfs/hhn053}
351+}
352+
353+@article{cameron2011,
354+ author = {Cameron, A. Colin and Gelbach, Jonah B. and Miller, Douglas L.},
355+ title = {{Robust Inference With Multiway Clustering}},
356+ journal = {Journal of Business \& Economic Statistics},
357+ year = {2011},
358+ volume = {29},
359+ number = {2},
360+ pages = {238--249},
361+ doi = {10.1198/jbes.2010.07136}
362+}
363+
364+@article{novymarx2016,
365+ author = {Novy-Marx, Robert and Velikov, Mihail},
366+ title = {{A Taxonomy of Anomalies and Their Trading Costs}},
367+ journal = {Review of Financial Studies},
368+ year = {2016},
369+ volume = {29},
370+ number = {1},
371+ pages = {104--147},
372+ doi = {10.1093/rfs/hhv063}
373+}
374+
375+@article{bollerslev2016beta,
376+ author = {Bollerslev, Tim and Li, Sophia Zhengzi and Todorov, Viktor},
377+ title = {{Roughing up beta: Continuous versus discontinuous betas and the cross section of expected stock returns}},
378+ journal = {Journal of Financial Economics},
379+ year = {2016},
380+ volume = {120},
381+ number = {3},
382+ pages = {464--490},
383+ doi = {10.1016/j.jfineco.2016.02.001}
384+}
385+
386+@article{lo1988,
387+ author = {Lo, Andrew W. and MacKinlay, A. Craig},
388+ title = {{Stock Market Prices Do Not Follow Random Walks: Evidence from a Simple Specification Test}},
389+ journal = {Review of Financial Studies},
390+ year = {1988},
391+ volume = {1},
392+ number = {1},
393+ pages = {41--66},
394+ doi = {10.1093/rfs/1.1.41}
395+}
396+
397+@article{freyberger2020,
398+ author = {Freyberger, Joachim and Neuhierl, Andreas and Weber, Michael},
399+ title = {{Dissecting Characteristics Nonparametrically}},
400+ journal = {The Review of Financial Studies},
401+ year = {2020},
402+ volume = {33},
403+ number = {5},
404+ pages = {2326--2377},
405+ doi = {10.1093/rfs/hhz123}
406+}
407+
408+@article{bollerslev2016errors,
409+ author = {Bollerslev, Tim and Patton, Andrew J. and Quaedvlieg, Rogier},
410+ title = {{Exploiting the errors: A simple approach for improved volatility forecasting}},
411+ journal = {Journal of Econometrics},
412+ year = {2016},
413+ volume = {192},
414+ number = {1},
415+ pages = {1--18},
416+ doi = {10.1016/j.jeconom.2015.10.007}
417+}
418+
419+@article{liu2015,
420+ author = {Liu, Lily Y. and Patton, Andrew J. and Sheppard, Kevin},
421+ title = {{Does anything beat 5-minute RV? A comparison of realized measures across multiple asset classes}},
422+ journal = {Journal of Econometrics},
423+ year = {2015},
424+ volume = {187},
425+ number = {1},
426+ pages = {293--311},
427+ doi = {10.1016/j.jeconom.2015.02.008}
428+}
429+
430+@article{andersen1998,
431+ author = {Andersen, Torben G. and Bollerslev, Tim},
432+ title = {{Answering the Skeptics: Yes, Standard Volatility Models do Provide Accurate Forecasts}},
433+ journal = {International Economic Review},
434+ year = {1998},
435+ volume = {39},
436+ number = {4},
437+ pages = {885},
438+ doi = {10.2307/2527343}
439+}
440+
441+@article{aboody2010,
442+ author = {Aboody, David and Lehavy, Reuven and Trueman, Brett},
443+ title = {{Limited attention and the earnings announcement returns of past stock market winners}},
444+ journal = {Review of Accounting Studies},
445+ year = {2010},
446+ volume = {15},
447+ number = {2},
448+ pages = {317--344},
449+ doi = {10.1007/s11142-009-9104-9}
450+}
451+
452+@article{kothari2001,
453+ author = {Kothari, S.P},
454+ title = {{Capital markets research in accounting}},
455+ journal = {Journal of Accounting and Economics},
456+ year = {2001},
457+ volume = {31},
458+ number = {1-3},
459+ pages = {105--231},
460+ doi = {10.1016/s0165-4101(01)00030-1}
461+}
462+
463+@article{easley2012,
464+ author = {Easley, David and López de Prado, Marcos M. and O'Hara, Maureen},
465+ title = {{Flow Toxicity and Liquidity in a High-frequency World}},
466+ journal = {Review of Financial Studies},
467+ year = {2012},
468+ volume = {25},
469+ number = {5},
470+ pages = {1457--1493},
471+ doi = {10.1093/rfs/hhs053}
472+}
473+
474+@article{hou2015,
475+ author = {Hou, Kewei and Xue, Chen and Zhang, Lu},
476+ title = {{Digesting Anomalies: An Investment Approach}},
477+ journal = {Review of Financial Studies},
478+ year = {2015},
479+ volume = {28},
480+ number = {3},
481+ pages = {650--705},
482+ doi = {10.1093/rfs/hhu068}
483+}
484+
485+@article{dechow2010,
486+ author = {Dechow, Patricia and Ge, Weili and Schrand, Catherine},
487+ title = {{Understanding earnings quality: A review of the proxies, their determinants and their consequences}},
488+ journal = {Journal of Accounting and Economics},
489+ year = {2010},
490+ volume = {50},
491+ number = {2-3},
492+ pages = {344--401},
493+ doi = {10.1016/j.jacceco.2010.09.001}
494+}
495+
496+@article{fama1970,
497+ author = {Fama, Eugene F.},
498+ title = {{Efficient Capital Markets: A Review of Theory and Empirical Work}},
499+ journal = {The Journal of Finance},
500+ year = {1970},
501+ volume = {25},
502+ number = {2},
503+ pages = {383},
504+ doi = {10.2307/2325486}
505+}
506+
507+@article{bollerslev2011tails,
508+ author = {BOLLERSLEV, TIM and TODOROV, VIKTOR},
509+ title = {{Tails, Fears, and Risk Premia}},
510+ journal = {The Journal of Finance},
511+ year = {2011},
512+ volume = {66},
513+ number = {6},
514+ pages = {2165--2211},
515+ doi = {10.1111/j.1540-6261.2011.01695.x}
516+}
517+
518+@article{hirshleifer2003,
519+ author = {Hirshleifer, David and Teoh, Siew Hong},
520+ title = {{Limited attention, information disclosure, and financial reporting}},
521+ journal = {Journal of Accounting and Economics},
522+ year = {2003},
523+ volume = {36},
524+ number = {1-3},
525+ pages = {337--386},
526+ doi = {10.1016/j.jacceco.2003.10.002}
527+}
528+
529+@article{savor2016,
530+ author = {SAVOR, PAVEL and WILSON, MUNGO},
531+ title = {{Earnings Announcements and Systematic Risk}},
532+ journal = {The Journal of Finance},
533+ year = {2016},
534+ volume = {71},
535+ number = {1},
536+ pages = {83--138},
537+ doi = {10.1111/jofi.12361}
538+}
539+
540+@article{budish2015,
541+ author = {Budish, Eric and Cramton, Peter and Shim, John},
542+ title = {{The High-Frequency Trading Arms Race: Frequent Batch Auctions as a Market Design Response *}},
543+ journal = {The Quarterly Journal of Economics},
544+ year = {2015},
545+ volume = {130},
546+ number = {4},
547+ pages = {1547--1621},
548+ doi = {10.1093/qje/qjv027}
549+}
550+
551+@article{chen2024deep,
552+ author = {Chen, Luyang and Pelger, Markus and Zhu, Jason},
553+ title = {{Deep Learning in Asset Pricing}},
554+ journal = {Management Science},
555+ year = {2024},
556+ volume = {70},
557+ number = {2},
558+ pages = {714--750},
559+ doi = {10.1287/mnsc.2023.4695}
560+}
561+
562+@article{kozak2020,
563+ author = {Kozak, Serhiy and Nagel, Stefan and Santosh, Shrihari},
564+ title = {{Shrinking the cross-section}},
565+ journal = {Journal of Financial Economics},
566+ year = {2020},
567+ volume = {135},
568+ number = {2},
569+ pages = {271--292},
570+ doi = {10.1016/j.jfineco.2019.06.008}
571+}
572+
573+@article{brogaard2019,
574+ author = {BROGAARD, JONATHAN and HENDERSHOTT, TERRENCE and RIORDAN, RYAN},
575+ title = {{Price Discovery without Trading: Evidence from Limit Orders}},
576+ journal = {The Journal of Finance},
577+ year = {2019},
578+ volume = {74},
579+ number = {4},
580+ pages = {1621--1658},
581+ doi = {10.1111/jofi.12769}
582+}
583+
584+@article{ang2006,
585+ author = {ANG, ANDREW and HODRICK, ROBERT J. and XING, YUHANG and ZHANG, XIAOYAN},
586+ title = {{The Cross-Section of Volatility and Expected Returns}},
587+ journal = {The Journal of Finance},
588+ year = {2006},
589+ volume = {61},
590+ number = {1},
591+ pages = {259--299},
592+ doi = {10.1111/j.1540-6261.2006.00836.x}
593+}
594+
595+@article{skinner2002,
596+ author = {Skinner, Douglas J. and Sloan, Richard G.},
597+ title = {{Earnings Surprises, Growth Expectations, and Stock Returns or Don't Let an Earnings Torpedo Sink Your Portfolio}},
598+ journal = {Review of Accounting Studies},
599+ year = {2002},
600+ volume = {7},
601+ number = {2-3},
602+ pages = {289--312},
603+ doi = {10.1023/a:1020294523516}
604+}
605+
606+@article{levi2015,
607+ author = {Levi, Shai and Zhang, Xiao-Jun},
608+ title = {{Asymmetric decrease in liquidity trading before earnings announcements and the announcement return premium}},
609+ journal = {Journal of Financial Economics},
610+ year = {2015},
611+ volume = {118},
612+ number = {2},
613+ pages = {383--398},
614+ doi = {10.1016/j.jfineco.2015.08.003}
615+}
616+
617+@article{campbell1993,
618+ author = {Campbell, J. Y. and Grossman, S. J. and Wang, J.},
619+ title = {{Trading Volume and Serial Correlation in Stock Returns}},
620+ journal = {The Quarterly Journal of Economics},
621+ year = {1993},
622+ volume = {108},
623+ number = {4},
624+ pages = {905--939},
625+ doi = {10.2307/2118454}
626+}
627+
628+@article{fang2009,
629+ author = {FANG, LILY and PERESS, JOEL},
630+ title = {{Media Coverage and the Cross-section of Stock Returns}},
631+ journal = {The Journal of Finance},
632+ year = {2009},
633+ volume = {64},
634+ number = {5},
635+ pages = {2023--2052},
636+ doi = {10.1111/j.1540-6261.2009.01493.x}
637+}
638+
639+@article{beaver1980,
640+ author = {Beaver, William and Lambert, Richard and Morse, Dale},
641+ title = {{The information content of security prices}},
642+ journal = {Journal of Accounting and Economics},
643+ year = {1980},
644+ volume = {2},
645+ number = {1},
646+ pages = {3--28},
647+ doi = {10.1016/0165-4101(80)90013-0}
648+}
649+
650+@article{kothari1992,
651+ author = {Kothari, S.P and Sloan, Richard G},
652+ title = {{Information in prices about future earnings}},
653+ journal = {Journal of Accounting and Economics},
654+ year = {1992},
655+ volume = {15},
656+ number = {2-3},
657+ pages = {143--171},
658+ doi = {10.1016/0165-4101(92)90016-u}
659+}
660+
661+@article{collins1994,
662+ author = {Collins, Daniel W. and Kothari, S.P. and Shanken, Jay and Sloan, Richard G.},
663+ title = {{Lack of timeliness and noise as explanations for the low contemporaneuos return-earnings association}},
664+ journal = {Journal of Accounting and Economics},
665+ year = {1994},
666+ volume = {18},
667+ number = {3},
668+ pages = {289--324},
669+ doi = {10.1016/0165-4101(94)90024-8}
670+}
671+
672+@article{foster1977,
673+ author = {Foster, George},
674+ title = {{Quarterly Accounting Data: Time-Series Properties and Predicative-Ability Results.}},
675+ journal = {The Accounting Review},
676+ year = {1977},
677+ volume = {52},
678+ number = {1},
679+ pages = {1--21},
680+ doi = {10.2308/tar-4493420}
681+}
682+
683+@article{weller2018,
684+ author = {Weller, Brian M.},
685+ title = {{Does Algorithmic Trading Reduce Information Acquisition?}},
686+ journal = {The Review of Financial Studies},
687+ year = {2018},
688+ volume = {31},
689+ number = {6},
690+ pages = {2184--2226},
691+ doi = {10.1093/rfs/hhx137}
692+}
693+
694+@article{bollerslev2018volume,
695+ author = {Bollerslev, Tim and Li, Jia and Xue, Yuan},
696+ title = {{Volume, Volatility, and Public News Announcements}},
697+ journal = {The Review of Economic Studies},
698+ year = {2018},
699+ volume = {85},
700+ number = {4},
701+ pages = {2005--2041},
702+ doi = {10.1093/restud/rdy003}
703+}
704+
705+@article{patton2012,
706+ author = {Patton, Andrew J. and Verardo, Michela},
707+ title = {{Does Beta Move with News? Firm-Specific Information Flows and Learning about Profitability}},
708+ journal = {Review of Financial Studies},
709+ year = {2012},
710+ volume = {25},
711+ number = {9},
712+ pages = {2789--2839},
713+ doi = {10.1093/rfs/hhs073}
714+}
715+
716+@article{kaniel2012,
717+ author = {KANIEL, RON and LIU, SHUMING and SAAR, GIDEON and TITMAN, SHERIDAN},
718+ title = {{Individual Investor Trading and Return Patterns around Earnings Announcements}},
719+ journal = {The Journal of Finance},
720+ year = {2012},
721+ volume = {67},
722+ number = {2},
723+ pages = {639--680},
724+ doi = {10.1111/j.1540-6261.2012.01727.x}
725+}
726+
727+@article{christophe2004,
728+ author = {Christophe, Stephen E. and Ferri, Michael G. and Angel, James J.},
729+ title = {{Short-Selling Prior to Earnings Announcements}},
730+ journal = {The Journal of Finance},
731+ year = {2004},
732+ volume = {59},
733+ number = {4},
734+ pages = {1845--1876},
735+ doi = {10.1111/j.1540-6261.2004.00681.x}
736+}
737+
738+@article{berkman2009,
739+ author = {Berkman, Henk and Dimitrov, Valentin and Jain, Prem C. and Koch, Paul D. and Tice, Sheri},
740+ title = {{Sell on the news: Differences of opinion, short-sales constraints, and returns around earnings announcements☆}},
741+ journal = {Journal of Financial Economics},
742+ year = {2009},
743+ volume = {92},
744+ number = {3},
745+ pages = {376--399},
746+ doi = {10.1016/j.jfineco.2008.04.009}
747+}
748+
749+@article{hendershott2015,
750+ author = {Hendershott, Terrence and Livdan, Dmitry and Schürhoff, Norman},
751+ title = {{Are institutions informed about news?}},
752+ journal = {Journal of Financial Economics},
753+ year = {2015},
754+ volume = {117},
755+ number = {2},
756+ pages = {249--287},
757+ doi = {10.1016/j.jfineco.2015.03.007}
758+}
759+
760+@article{lee2012,
761+ author = {Lee, Suzanne S.},
762+ title = {{Jumps and Information Flow in Financial Markets}},
763+ journal = {Review of Financial Studies},
764+ year = {2012},
765+ volume = {25},
766+ number = {2},
767+ pages = {439--479},
768+ doi = {10.1093/rfs/hhr084}
769+}
770+
771+@article{cao2024,
772+ author = {Cao, Sean and Jiang, Wei and Wang, Junbo and Yang, Baozhong},
773+ title = {{From Man vs. Machine to Man + Machine: The art and AI of stock analyses}},
774+ journal = {Journal of Financial Economics},
775+ year = {2024},
776+ volume = {160},
777+ pages = {103910},
778+ doi = {10.1016/j.jfineco.2024.103910}
779+}
780+
781+@article{bali2023,
782+ author = {Bali, Turan G and Beckmeyer, Heiner and Mörke, Mathis and Weigert, Florian},
783+ title = {{Option Return Predictability with Machine Learning and Big Data}},
784+ journal = {The Review of Financial Studies},
785+ year = {2023},
786+ volume = {36},
787+ number = {9},
788+ pages = {3548--3602},
789+ doi = {10.1093/rfs/hhad017}
790+}
791+
792+@article{easton2024,
793+ author = {Easton, Peter D. and Kapons, Martin M. and Monahan, Steven J. and Schütt, Harm H. and Weisbrod, Eric H.},
794+ title = {{Forecasting Earnings Using \emph{k}-Nearest Neighbors}},
795+ journal = {The Accounting Review},
796+ year = {2024},
797+ volume = {99},
798+ number = {3},
799+ pages = {115--140},
800+ doi = {10.2308/tar-2021-0478}
801+}
802+
803+@article{so2013,
804+ author = {So, Eric C.},
805+ title = {{A new approach to predicting analyst forecast errors: Do investors overweight analyst forecasts?}},
806+ journal = {Journal of Financial Economics},
807+ year = {2013},
808+ volume = {108},
809+ number = {3},
810+ pages = {615--640},
811+ doi = {10.1016/j.jfineco.2013.02.002}
812+}
813+
814+@article{hou2012,
815+ author = {Hou, Kewei and van Dijk, Mathijs A. and Zhang, Yinglei},
816+ title = {{The implied cost of capital: A new approach}},
817+ journal = {Journal of Accounting and Economics},
818+ year = {2012},
819+ volume = {53},
820+ number = {3},
821+ pages = {504--526},
822+ doi = {10.1016/j.jacceco.2011.12.001}
823+}
824+
825+@article{hansen2006,
826+ author = {Hansen, Peter R and Lunde, Asger},
827+ title = {{Realized Variance and Market Microstructure Noise}},
828+ journal = {Journal of Business \& Economic Statistics},
829+ year = {2006},
830+ volume = {24},
831+ number = {2},
832+ pages = {127--161},
833+ doi = {10.1198/073500106000000071}
834+}
835+
836+@article{barndorff2008,
837+ author = {Barndorff-Nielsen, Ole E. and Hansen, Peter Reinhard and Lunde, Asger and Shephard, Neil},
838+ title = {{Designing Realized Kernels to Measure the ex post Variation of Equity Prices in the Presence of Noise}},
839+ journal = {Econometrica},
840+ year = {2008},
841+ volume = {76},
842+ number = {6},
843+ pages = {1481--1536},
844+ doi = {10.3982/ecta6495}
845+}
846+
847+@article{vankervel2019,
848+ author = {VAN KERVEL, VINCENT and MENKVELD, ALBERT J.},
849+ title = {{High-Frequency Trading around Large Institutional Orders}},
850+ journal = {The Journal of Finance},
851+ year = {2019},
852+ volume = {74},
853+ number = {3},
854+ pages = {1091--1137},
855+ doi = {10.1111/jofi.12759}
856+}
857+
858+@article{avramov2023,
859+ author = {Avramov, Doron and Cheng, Si and Metzker, Lior},
860+ title = {{Machine Learning vs. Economic Restrictions: Evidence from Stock Return Predictability}},
861+ journal = {Management Science},
862+ year = {2023},
863+ volume = {69},
864+ number = {5},
865+ pages = {2587--2619},
866+ doi = {10.1287/mnsc.2022.4449}
867+}
868+
869+@article{chordia2020,
870+ author = {Chordia, Tarun and Goyal, Amit and Saretto, Alessio},
871+ title = {{Anomalies and False Rejections}},
872+ journal = {The Review of Financial Studies},
873+ year = {2020},
874+ volume = {33},
875+ number = {5},
876+ pages = {2134--2179},
877+ doi = {10.1093/rfs/hhaa018}
878+}
879+
880+@article{barber2013,
881+ author = {Barber, Brad M. and De George, Emmanuel T. and Lehavy, Reuven and Trueman, Brett},
882+ title = {{The earnings announcement premium around the globe}},
883+ journal = {Journal of Financial Economics},
884+ year = {2013},
885+ volume = {108},
886+ number = {1},
887+ pages = {118--138},
888+ doi = {10.1016/j.jfineco.2012.10.006}
889+}
890+
891+@article{politis1994,
892+ author = {Politis, Dimitris N. and Romano, Joseph P.},
893+ title = {{The Stationary Bootstrap}},
894+ journal = {Journal of the American Statistical Association},
895+ year = {1994},
896+ volume = {89},
897+ number = {428},
898+ pages = {1303--1313},
899+ doi = {10.1080/01621459.1994.10476870}
900+}
901+
902+@article{hansen2011mcs,
903+ author = {Hansen, Peter R. and Lunde, Asger and Nason, James M.},
904+ title = {{The Model Confidence Set}},
905+ journal = {Econometrica},
906+ year = {2011},
907+ volume = {79},
908+ number = {2},
909+ pages = {453--497},
910+ doi = {10.3982/ecta5771}
911+}
912+
913+@article{benjamini1995,
914+ author = {Benjamini, Yoav and Hochberg, Yosef},
915+ title = {{Controlling the False Discovery Rate: A Practical and Powerful Approach to Multiple Testing}},
916+ journal = {Journal of the Royal Statistical Society Series B: Statistical Methodology},
917+ year = {1995},
918+ volume = {57},
919+ number = {1},
920+ pages = {289--300},
921+ doi = {10.1111/j.2517-6161.1995.tb02031.x}
922+}
923+
924+@article{amihud2002,
925+ author = {Amihud, Yakov},
926+ title = {{Illiquidity and stock returns: cross-section and time-series effects}},
927+ journal = {Journal of Financial Markets},
928+ year = {2002},
929+ volume = {5},
930+ number = {1},
931+ pages = {31--56},
932+ doi = {10.1016/s1386-4181(01)00024-6}
933+}
934+
935+@article{fama1993,
936+ author = {Fama, Eugene F. and French, Kenneth R.},
937+ title = {{Common risk factors in the returns on stocks and bonds}},
938+ journal = {Journal of Financial Economics},
939+ year = {1993},
940+ volume = {33},
941+ number = {1},
942+ pages = {3--56},
943+ doi = {10.1016/0304-405x(93)90023-5}
944+}
945+
946+@article{grossman1980,
947+ author = {Grossman, Sanford J. and Stiglitz, Joseph E.},
948+ title = {{On the Impossibility of Informationally Efficient Markets}},
949+ journal = {American Economic Review},
950+ year = {1980},
951+ volume = {70},
952+ number = {3},
953+ pages = {393--408}
954+}
955+@article{holm1979,
956+ author = {Holm, Sture},
957+ title = {{A Simple Sequentially Rejective Multiple Test Procedure}},
958+ journal = {Scandinavian Journal of Statistics},
959+ year = {1979},
960+ volume = {6},
961+ number = {2},
962+ pages = {65--70}
963+}
964+@article{kunsch1989,
965+ author = {K{\"u}nsch, Hans R.},
966+ title = {{The Jackknife and the Bootstrap for General Stationary Observations}},
967+ journal = {Annals of Statistics},
968+ year = {1989},
969+ volume = {17},
970+ number = {3},
971+ pages = {1217--1241},
972+ doi = {10.1214/aos/1176347265}
973+}
974+@inproceedings{ke2017lightgbm,
975+ author = {Ke, Guolin and Meng, Qi and Finley, Thomas and Wang, Taifeng and Chen, Wei and Ma, Weidong and Ye, Qiwei and Liu, Tie-Yan},
976+ title = {{LightGBM: A Highly Efficient Gradient Boosting Decision Tree}},
977+ booktitle = {Advances in Neural Information Processing Systems 30},
978+ year = {2017},
979+ pages = {3146--3154}
980+}
981+@book{lopezdeprado2018,
982+ author = {L{\'o}pez de Prado, Marcos},
983+ title = {{Advances in Financial Machine Learning}},
984+ publisher = {Wiley},
985+ address = {Hoboken, NJ},
986+ year = {2018}
987+}
988+@techreport{frazzini2018costs,
989+ author = {Frazzini, Andrea and Israel, Ronen and Moskowitz, Tobias J.},
990+ title = {{Trading Costs}},
991+ institution = {SSRN Working Paper 3229719},
992+ year = {2018},
993+ doi = {10.2139/ssrn.3229719}
994+}
995+@incollection{barndorff2010semivariance,
996+ author = {Barndorff-Nielsen, Ole E. and Kinnebrock, Silja and Shephard, Neil},
997+ title = {{Measuring Downside Risk: Realised Semivariance}},
998+ booktitle = {Volatility and Time Series Econometrics: Essays in Honor of Robert F. Engle},
999+ editor = {Bollerslev, Tim and Russell, Jeffrey and Watson, Mark},
1000+ publisher = {Oxford University Press},
1001+ year = {2010},
1002+ pages = {117--136},
1003+ doi = {10.1093/acprof:oso/9780199549498.003.0007}
1004+}
1005+@article{boucher2026wp15,
1006+ author = {Boucher, Simon-Pierre},
1007+ title = {{Faster, Wider, More Fragile? The Evolution of Intraday Shock Propagation in U.S. Equity Markets, 1992--2026}},
1008+ journal = {UQO Working Paper No. 15},
1009+ year = {2026}
1010+}
1011+
1012+@article{harvey2016,
1013+ author = {Harvey, Campbell R. and Liu, Yan and Zhu, Heqing},
1014+ title = {{\ldots and the Cross-Section of Expected Returns}},
1015+ journal = {The Review of Financial Studies},
1016+ year = {2016},
1017+ volume = {29},
1018+ number = {1},
1019+ pages = {5--68},
1020+ doi = {10.1093/rfs/hhv059}
1021+}
added paper/sections/data.tex +83 −0
@@ -0,0 +1,83 @@
1+\section{Data}
2+\label{sec:data}
3+
4+\subsection{Source and license}
5+
6+All market and accounting data come from FinData OS, a private lakehouse of FirstRate Data operated by the author and
7+accessed through its versioned API (\texttt{findata.spboucher.ai/api/v1}). The lake holds 6.7 billion raw 1-minute
8+bars for 16,195 U.S. stock symbols (2000--2026, including 8,069 delisted symbols), quarterly fundamentals for
9+\nEntities{} entities (67 metrics, filing dates 2012--2026) and 373,326 earnings-release events. Bars are timestamped
10+at the bar open in UTC and cover 04:00--20:00 Eastern; zero-volume minutes are absent at the source and are never
11+imputed. FirstRate Data is licensed for private research use; no raw record is redistributed, and the replication
12+package contains code, hashed extraction manifests (dataset identifiers and versions, row counts, SHA-256 digests) and
13+derived aggregates only. Public augmentations are the SEC's \texttt{company\_tickers.json} and \texttt{submissions}
14+files (CIK, SIC industry), and the Kenneth French daily factor library.
15+
16+\subsection{Universe and survivorship}
17+
18+The accounting panel contains the \nEntities{} entities for which the vendor supplies quarterly statements; it has
19+\emph{no delisted entity}, because the vendor ingestion covered tickers active on 1 November 2026. The price side is
20+survivorship-free (delisted names are present in the bar lake), but the firm-quarter sample is conditioned on survival
21+to the end of the sample. This is a structural caveat: firms that deteriorated and delisted are absent, so adverse
22+base rates are understated and the most vulnerable firms are under-represented. We state it in the abstract and
23+treat it as the first limitation. Table~\ref{tab:universe} reports the construction of the universe: \nEligible{}
24+firms with at least eight quarterly periods, at least 250 sessions of 1-minute bars after 2012 and a non-governmental
25+SIC division; \nWithCik{} are matched to a CIK. Identifiers are vendor tickers; the vendor merges ticker changes under
26+the latest ticker, and we audit reuse with the SEC's former-name history.
27+
28+\subsection{Reveal dates and point-in-time discipline}
29+
30+Earnings-release dates are matched to fiscal quarters as the first release in $[E_q, F_q+10\text{ days}]$;
31+\releaseMatchedShare\% of firm-quarters are matched with a filing lag of at most 30 days (median lag between release
32+and filing: \filingLagMedian{} day). Release timing is known (before-open or after-close) for about two thirds of events.
33+Structured facts carry \texttt{filing\_date}, \texttt{available\_at} ($=$ filing date $+1$ day, a documented policy
34+rather than a historical vintage) and a revision identifier; because every fact was first ingested in November 2026 and
35+carries revision~1, restatements that occurred before ingestion are not reconstructible. Inputs therefore use prior
36+quarters only, outcomes use the vendor's current value of quarter $q$, and the stringent as-originally-reported
37+specification is left for a future SEC-filing-level reconstruction (Section~\ref{sec:limitations}).
38+
39+\subsection{Sample}
40+
41+The quarter panel has \nFirmQuarters{} firm-quarters for \nFirmsPanel{} firms. After the admissibility and exclusion
42+cascade of Table~\ref{tab:cascade} (market window of at least 15 valid sessions, raw close of at least one dollar,
43+available fundamentals, prediction of the next quarter, defined expectation, regular fiscal calendar), the event-time
44+sample has \nSampleEvent{} observations across the five horizons (\nSampleFirms{} firms, \nSampleQuarters{} firm-quarters)
45+and the primary $h=20$ cohort has \nObsPrimary{} observations with cutoffs from \firstCutoff{} to \lastCutoff; the
46+fixed-calendar sample has \nSampleCalendar{} firm-months. The adverse outcome occurs in \baseRatePrimary\% of
47+primary-cohort firm-quarters. Table~\ref{tab:summary} reports summary statistics and Figure~\ref{fig:coverage} the
48+coverage by year together with the adverse rate.
49+
50+\begin{table}[H]
51+\centering\footnotesize
52+\caption{Construction of the universe and of the primary sample}
53+\label{tab:universe}
54+\begin{minipage}[t]{0.42\textwidth}\centering
55+\input{tables/tab02_universe}
56+\end{minipage}\hfill
57+\begin{minipage}[t]{0.56\textwidth}\centering
58+\label{tab:cascade}
59+\input{tables/tab02b_sample_cascade}
60+\end{minipage}
61+\begin{minipage}{\textwidth}\vspace{0.3em}\footnotesize
62+\emph{Notes.} Left: firms. Right: event-time observations (five horizons pooled); steps are applied in the frozen order
63+of \texttt{docs/PIT\_PROTOCOL.md}.
64+\end{minipage}
65+\end{table}
66+
67+\begin{table}[H]
68+\centering\footnotesize
69+\caption{Summary statistics, primary cohort ($h=20$)}
70+\label{tab:summary}
71+\adjustbox{max width=\textwidth}{\input{tables/tab03_summary}}
72+\begin{minipage}{\textwidth}\vspace{0.3em}\footnotesize
73+\emph{Notes.} Intraday families are daily values averaged over the 20-session window ending at the cutoff; realized
74+variances are sums of squared bar log-returns within the regular session. Raw, unwinsorised values.
75+\end{minipage}
76+\end{table}
77+
78+\begin{figure}[H]
79+\centering
80+\includegraphics[width=\textwidth]{figures/fig02_coverage.pdf}
81+\caption{Sample coverage and adverse-outcome rate by year of cutoff ($h=20$ cohort).}
82+\label{fig:coverage}
83+\end{figure}
added paper/sections/literature.tex +46 −0
@@ -0,0 +1,46 @@
1+\section{Related literature}
2+\label{sec:literature}
3+
4+\paragraph{Prices lead earnings.} Since \citet{ball1968}, accounting research has documented that stock prices
5+anticipate accounting income: annual returns forecast earnings changes \citep{beaver1980}, leading returns raise
6+earnings-response coefficients \citep{kothari1992}, and the low contemporaneous return--earnings association reflects
7+prices' timeliness rather than noise \citep{collins1994}. The post-earnings-announcement drift \citep{bernard1989,
8+bernard1990} and the accrual anomaly \citep{sloan1996, richardson2005} show that the incorporation of accounting news
9+is incomplete, while fundamental analysis \citep{ou1989, piotroski2000} extracts forecasts of future earnings from
10+statements alone. Recent work replaces hand-crafted ratios by machine learning on detailed statement items
11+\citep{chen2022, easton2024, cao2024}. All of these studies operate at daily or lower frequency and treat the market side
12+as a return; our contribution is to fix the accounting side and vary the \emph{resolution} of the market side.
13+
14+\paragraph{Information in intraday activity.} Market microstructure theory ties the composition of trading to private
15+information \citep{kyle1985, easley1987}, and realized-measure econometrics separates continuous variation, jumps and
16+their signs from high-frequency bars \citep{andersen1998, andersen2003, barndorff2004, barndorff2010semivariance,
17+patton2015, lee2008}. Signed jump variation and downside semivariance price the cross-section
18+\citep{bollerslev2020good, bollerslev2016beta}, jumps cluster around news \citep{lee2012, bajgrowicz2016}, and
19+announcement days change betas and the volume--volatility relation \citep{patton2012, bollerslev2018volume,
20+savor2016}. The question of the best sampling frequency has been settled for \emph{variance measurement} -- five-minute
21+realized variance is hard to beat \citep{liu2015, bollerslev2016errors, bollerslev2018risk} -- but not for the
22+\emph{forecasting of fundamentals}, which is our object. \citet{zhang2023volatility} show that pooling intraday
23+commonality improves machine-learning volatility forecasts; we include volatility families at every frequency and use a
24+symmetric placebo target to separate a volatility channel from a signed-information channel.
25+
26+\paragraph{Pre-announcement trading and price discovery.} Short sellers \citep{christophe2004}, individual investors
27+\citep{kaniel2012}, institutions \citep{hendershott2015} and option traders trade informatively before earnings
28+announcements; differences of opinion shape announcement returns \citep{berkman2009}; algorithmic trading may reduce
29+pre-announcement information acquisition \citep{weller2018}; and the announcement reaction itself occurs largely in
30+quotes rather than trades \citep{gregoire2022, brogaard2019}. These studies use trader-type or quote data that bars do
31+not contain. We ask a complementary question with anonymous OHLCV only: how much of the forthcoming accounting outcome
32+is visible in the shape of pre-release trading, and does that visibility depend on resolution?
33+
34+\paragraph{Attention and processing.} Limited attention \citep{hirshleifer2003, hirshleifer2009, dellavigna2009,
35+aboody2010} and media coverage \citep{fang2009} govern how fast public information is processed; the timing and
36+heterogeneity hypotheses H3--H4 are motivated by these channels and by distress-risk measurement \citep{campbell2008,
37+beneish1999}.
38+
39+\paragraph{Forecast evaluation and machine learning in finance.} We follow the evaluation standards of
40+\citet{diebold1995}, \citet{giacomini2006}, \citet{hansen2005} and \citet{hansen2011mcs}, with multiple-testing control
41+\citep{holm1979, benjamini1995}, cluster-robust inference \citep{petersen2009, cameron2011}, block bootstrap
42+\citep{kunsch1989, politis1994} and purged, embargoed time splits \citep{lopezdeprado2018}. Equal-budget comparisons of
43+information sets are inspired by the asset-pricing machine-learning literature \citep{gu2020, freyberger2020, kozak2020,
44+chen2024deep, avramov2023} and its warnings about false discoveries \citep{harvey2016, chordia2020}. Gradient-boosted
45+trees \citep{ke2017lightgbm} provide the nonlinear benchmark. The companion UQO working paper on intraday shock
46+propagation \citep{boucher2026wp15} uses the same vendor data for a different question; no result is reused here.
added paper/sections/methodology.tex +78 −0
@@ -0,0 +1,78 @@
1+\section{Research design}
2+\label{sec:method}
3+
4+\subsection{Clocks, cutoffs and the information set}
5+
6+Figure~\ref{fig:timeline} summarises the event-time design. For firm $i$ and fiscal quarter $q$ we observe the period end
7+$E_{i,q}$, the earnings-release date $A_{i,q}$ (the reveal date, with its before-open/after-close timing when known), the
8+10-Q/10-K filing date $F_{i,q}$ and the vendor availability instant $V_{i,q}=F_{i,q}+1$ day. The prediction cutoff is
9+$c=A_{i,q}-h$ trading days for $h\in\{40,20,10,5,1\}$, with $h=20$ as the preregistered primary horizon. A cutoff is
10+admissible only if $c<\min(A_{i,q},F_{i,q})$ and $c>A_{i,q-1}$, so the feature window never contains the release of
11+quarter $q$ and never straddles two reveals. The information set $\mathcal{I}(c)$ contains regular-session bars with
12+session date $\le c$ and accounting facts with $V_{i,j}\le c$ only; the latest such quarter is $j^*(c)=q-1$ in the
13+retained sample (we require that the forecaster predicts the quarter immediately following the latest available one).
14+Twelve leakage guards (future facts, fiscal-end joins, post-cutoff bars, test-period transforms, daylight-saving errors,
15+identifier drift, split artefacts, release-day bars, overlapping windows across splits, survivorship, ingestion dates,
16+randomised placebos) are implemented as unit tests in the replication package; the local as-of join was verified live
17+against the FinData API's \texttt{as\_of} semantics on random entities and instants.
18+
19+A fixed-calendar design (month-end cutoffs, target = the next release within 65 trading days) is used for the pricing
20+tests and as a robustness check because it does not condition on knowledge of the release date.
21+
22+\subsection{Targets}
23+
24+The primary outcome is the scaled operating-income surprise $S_{i,q}$ of equation~\eqref{eq:surprise}, with
25+$\mathbb{E}[X_q]=X_{q-4}+\tfrac14\sum_{k=0}^{3}(X_{q-1-k}-X_{q-5-k})$ computed from quarters available at $c$ and scaled by
26+the latest available total assets. The binary adverse outcome is $D_{i,q}=\mathbb{1}[S_{i,q}\le-0.5\%]$, i.e., a
27+shortfall of at least half a percent of assets (roughly a fifth of a typical quarterly return on assets). Secondary
28+outcomes are the net-income analogue, a year-on-year ROA decline of at least one point, a negative operating-cash-flow
29+innovation of at least one percent of assets, a liquidity/coverage deterioration (current ratio down 10\% or interest
30+coverage falling below one), a symmetric placebo $|S_{i,q}|\ge0.5\%$ that a pure volatility channel would predict, and an
31+exploratory EPS miss against the vendor's estimate (available for \epsEstimateShare\% of releases; vintage unknown).
32+The continuous surprise, winsorised at the training-period 1st/99th percentiles, is modelled by ridge regression. No
33+target uses prices.
34+
35+\subsection{Features by sampling frequency}
36+
37+All market features are derived from the same raw 1-minute bars of the regular session (09:30 to the closing print),
38+aggregated to 5-minute and 30-minute buckets and to daily bars. Each intraday \emph{family} is computed per session
39+on bars of the given length and averaged (or summed) over the 20-session window ending at $c$: realized variance,
40+bipower variation, relative jump, negative-semivariance share, signed-jump variation, realized skewness and kurtosis,
41+concentration of variance in the largest bar, first-order autocorrelation, intraday drift, opening and closing volume
42+shares, volume dispersion (Gini), down-bar volume share, share of expected bars present, share of variance on
43+market-down bars, realized beta to SPY (ratio of summed cross-products), variance ratio against the daily return variance,
44+and volatility-of-volatility. The 30-minute, 5-minute and 1-minute sets therefore have the \emph{same} \kIntra{} columns.
45+Daily features (\kDaily{} columns) are 20- and 60-day returns, volatilities, Parkinson range, skewness, kurtosis,
46+overnight and intraday return components, volume ratios, Amihud illiquidity, zero-return share, dollar volume, maximum
47+drawdown and bar activity. Point-in-time accounting features (\kFund{} columns) are margins, ROA, accruals, cash,
48+leverage, current ratio, coverage, capex, R\&D, issuance, revenue growth, size, the last available surprise, the trailing
49+mean surprise, the trailing adverse rate and the age of the information. Bar statistics are descriptive; nothing is
50+interpreted as order flow, spread or informed trading.
51+
52+\subsection{Baseline ladder and equal budgets}
53+
54+Table~\ref{tab:infosets} nests the information sets. B0 is the training base rate (and a shrunk firm-history rate);
55+B1--B5 are $\ell_2$-penalised logistic regressions whose penalty is tuned on the validation period only; LB1--LB5 are
56+gradient-boosted trees \citep{ke2017lightgbm} with an identical random-search budget (24 trials, early stopping on the
57+validation period) for every information set, so that the frequency comparison is a comparison of information sets,
58+not of capacity. Winsorisation bounds, imputation medians and standardisation constants are estimated on training rows
59+only. A supplementary set (B5all) concatenates all three intraday resolutions. Neural and text models are not used.
60+
61+\subsection{Validation and inference}
62+
63+The chronological split trains on cutoffs in 2012--2018, tunes on 2019--2020 and evaluates on 2021--2026, with purging
64+of training outcomes revealed after the next split's start minus a one-quarter embargo \citep{lopezdeprado2018}.
65+Expanding-window annual refits from 2016 provide out-of-fold predictions for the panel regressions and the timing and
66+pricing exercises; a 20\% firm hold-out evaluates transfer to unseen firms. The primary metric is log loss; Brier score,
67+AUC, PR-AUC and calibration are reported for every model. Paired differences (B5$-$B2, B5$-$B4, B5$-$B3) are assessed
68+with a block bootstrap over calendar quarters of the reveal date \citep{kunsch1989, politis1994} and with the
69+Giacomini--White test on quarterly mean loss differentials with Newey--West variance \citep{diebold1995,
70+giacomini2006}; Holm's correction is applied within each hypothesis family \citep{holm1979}. Panel regressions
71+\begin{equation}
72+ S_{i,q}=\alpha_i+\delta_{s(i)\times t(q)}+\beta\,\mathrm{LFDS}_{i,c}+\gamma'X_{i,c}+\varepsilon_{i,q}
73+ \label{eq:panel}
74+\end{equation}
75+use the out-of-fold B5 probability as the Latent Fundamental Deterioration Signal (LFDS), firm and sector-by-quarter
76+fixed effects, and two-way clustering by firm and calendar quarter \citep{petersen2009, cameron2011}. Mechanism tests
77+(H3) interact the \emph{incremental} signal $\mathrm{LFDS}^{\mathrm{B5}}-\mathrm{LFDS}^{\mathrm{B2}}$ with ex-ante
78+terciles of size, illiquidity and leverage formed within each calendar quarter.
added paper/sections/theory_hypotheses.tex +64 −0
@@ -0,0 +1,64 @@
1+\section{Economic framework and hypotheses}
2+\label{sec:theory}
3+
4+\subsection{Anticipation versus revelation}
5+
6+Let $X_{i,q}$ denote an accounting flow of firm $i$ for fiscal quarter $q$ (operating income in our primary
7+specification), revealed to the public at the earnings-release date $A_{i,q}$ and structured in the SEC filing of date
8+$F_{i,q}\ge A_{i,q}$. A forecaster standing at a trading day $c<A_{i,q}$ holds an information set $\mathcal{I}(c)$ that
9+contains, at most, prices and volumes up to the close of $c$ and the accounting statements that were \emph{available} at
10+$c$, i.e., quarters $j$ with $F_{i,j}+1\le c$. The quantity of interest is the conditional distribution of the surprise
11+\begin{equation}
12+ S_{i,q} \;=\; \frac{X_{i,q}-\mathbb{E}\!\left[X_{i,q}\mid \mathcal{A}(c)\right]}{TA_{i,j^*(c)}},
13+ \label{eq:surprise}
14+\end{equation}
15+where $\mathcal{A}(c)\subset\mathcal{I}(c)$ is the accounting information alone, $j^*(c)$ is the latest available quarter,
16+and the expectation is a seasonal random walk with drift \citep{foster1977}, the classic honest benchmark when no
17+point-in-time consensus exists. Semi-strong efficiency \citep{fama1970} implies that prices at $c$ impound all public
18+information, including $\mathcal{A}(c)$; the ``prices lead earnings'' literature \citep{beaver1980, kothari1992,
19+collins1994} documents that daily and annual returns indeed forecast accounting changes. None of this is the question
20+we ask. Our question is whether the \emph{resolution} at which the market is observed carries incremental information,
21+holding the information set, the universe, the calendar coverage, the horizon and the model capacity fixed.
22+
23+\subsection{Why resolution could matter, and why it might not}
24+
25+Three mechanisms make fine-grained activity informative beyond daily aggregates. First, in sequential-trade models
26+\citep{kyle1985, easley1987}, informed traders split orders and trade when liquidity is deep, so the \emph{composition} of
27+a day's volume and return (intraday autocorrelation, concentration of variance in a few minutes, volume on down-bars) can
28+reveal private information that the daily net return hides. Second, realized measures built from fine bars separate the
29+continuous and jump components of variation \citep{barndorff2004, andersen2003} and their sign
30+\citep{barndorff2010semivariance, patton2015}; negative semivariance and signed jumps have been linked to the
31+cross-section of expected returns \citep{bollerslev2020good} and may proxy for the arrival of adverse firm-specific news.
32+Third, limited attention \citep{hirshleifer2003, hirshleifer2009, dellavigna2009} predicts that the incorporation of
33+public but costly-to-process information is gradual, so that the time path of trading within the day (opening and closing
34+concentration, overnight versus intraday returns) carries information about ongoing processing.
35+
36+Against these channels stand two forces. The Epps effect and microstructure noise contaminate 1-minute returns
37+\citep{hansen2006, barndorff2008}, so that the finest sampling is not the most informative for variance measurement
38+\citep{liu2015}; and whatever information intraday activity contains about fundamentals may be fully summarised by the
39+daily close, in which case the frequency premium is zero. Algorithmic trading may further have reduced the amount of
40+pre-announcement information acquisition \citep{weller2018}. The sign and size of the frequency premium are therefore
41+empirical questions, and a precise null is as informative as a positive estimate.
42+
43+\subsection{Hypotheses}
44+
45+All hypotheses, endpoints and decision rules were frozen before the test period was opened (\texttt{docs/RESEARCH\_DESIGN.md}
46+in the replication package). Let $\mathcal{L}(M)$ denote the out-of-sample log loss of model $M$ for the binary adverse
47+outcome $D_{i,q}=\mathbb{1}[S_{i,q}\le -0.5\%]$, and let B1--B5 be the nested information sets of Table~\ref{tab:infosets}.
48+
49+\begin{description}[leftmargin=1.5em]
50+\item[H1 (incremental information).] $\mathcal{L}(\mathrm{B5})<\mathcal{L}(\mathrm{B2})$ at the 20-trading-day horizon:
51+ one-minute features improve forecasts beyond point-in-time fundamentals and daily OHLCV. Null: no improvement.
52+\item[H2 (temporal resolution).] The gain of B5 over B2 exceeds the gains of B4 (5-minute) and B3 (30-minute) over B2,
53+ with the same feature families and the same number of inputs. Null: the gains are equal.
54+\item[H3 (heterogeneity).] The B5$-$B2 gain is larger for small, illiquid and highly levered firms, the groups in which
55+ information asymmetry and processing costs are highest. Null: homogeneous gain.
56+\item[H4 (timing).] The gain increases as the cutoff approaches the release ($h=40\to1$) and is absent at placebo cutoffs.
57+ Null: no gradient.
58+\item[H5 (pricing).] The ex-ante deterioration probability predicts the announcement-window abnormal return and
59+ post-cutoff factor-adjusted returns. Null: no return predictability after risk adjustment and costs.
60+\end{description}
61+
62+A positive H1 does not identify \emph{who} trades on what: observational prediction is not causal evidence of informed
63+trading, and we do not have quotes, trades or trader identities \citep{gregoire2022, hendershott2015}. The paper's claims
64+are confined to the forecasting content of anonymous bars and to the frequency at which that content appears.
added paper/sections/titlepage.tex +77 −0
@@ -0,0 +1,77 @@
1+% ============================================================================
2+% Title Page
3+% ============================================================================
4+\thispagestyle{empty}
5+
6+\begin{center}
7+
8+% --- Logo ---
9+\includegraphics[width=3.5cm]{uq_logo.jpg}
10+
11+\vspace{0.6cm}
12+
13+{\footnotesize\textsc{Université du Québec en Outaouais}}\\[0.15cm]
14+{\footnotesize\textsc{Département des sciences administratives}}
15+
16+\vspace{0.8cm}
17+
18+{\footnotesize\textsc{Working Paper No.~\WPnumber}}
19+
20+\vspace{1.2cm}
21+
22+% --- Title ---
23+{\LARGE\bfseries \WPtitle\par}
24+
25+\ifx\WPsubtitle\empty\else
26+ \vspace{0.3cm}
27+ {\large\itshape \WPsubtitle\par}
28+\fi
29+
30+\vspace{1.2cm}
31+
32+% --- Author ---
33+{\large \WPauthor}\\[0.3cm]
34+{\normalsize \WPaffiliation}\\[0.15cm]
35+{\normalsize \href{mailto:\WPemail}{\WPemail}}\\[0.15cm]
36+{\small \WPaddress}
37+
38+\vspace{0.8cm}
39+
40+% --- Date & Version ---
41+{\normalsize \WPdate}\\[0.1cm]
42+{\small Version~\WPversion}
43+
44+\end{center}
45+
46+\vfill
47+
48+\newpage
49+
50+% ============================================================================
51+% Abstract Page
52+% ============================================================================
53+\thispagestyle{empty}
54+
55+\vspace*{1cm}
56+
57+\noindent\rule{\textwidth}{0.4pt}
58+\vspace{0.3cm}
59+
60+\noindent\textbf{Abstract}
61+
62+\vspace{0.15cm}
63+
64+\noindent\WPabstract
65+
66+\vspace{0.4cm}
67+
68+\noindent\textbf{Keywords:} \WPkeywords
69+
70+\vspace{0.15cm}
71+
72+\noindent\textbf{JEL Classification:} \WPjel
73+
74+\vspace{0.3cm}
75+\noindent\rule{\textwidth}{0.4pt}
76+
77+\newpage
added paper/sections/titlepage_anon.tex +53 −0
@@ -0,0 +1,53 @@
1+% ============================================================================
2+% ANONYMOUS TITLE PAGE — for double-anonymized journal submission
3+% Used by paper/main_anon.tex (same macros as main.tex, no author block,
4+% no institution, no logo). Abstract/keywords/JEL page identical to titlepage.tex.
5+% ============================================================================
6+\thispagestyle{empty}
7+\begin{center}
8+
9+\vspace*{3cm}
10+
11+{\LARGE\bfseries \WPtitle\par}
12+
13+\ifx\WPsubtitle\empty\else
14+ \vspace{0.3cm}
15+ {\large\itshape \WPsubtitle\par}
16+\fi
17+
18+\vspace{1.5cm}
19+
20+{\normalsize [Author identity withheld for review]}
21+
22+\vspace{0.8cm}
23+
24+{\normalsize \WPdate}
25+
26+\end{center}
27+
28+\vfill
29+
30+\newpage
31+\thispagestyle{empty}
32+\vspace*{1cm}
33+
34+\noindent\rule{\textwidth}{0.4pt}
35+\vspace{0.3cm}
36+
37+\noindent\textbf{Abstract}
38+\vspace{0.15cm}
39+
40+\noindent\WPabstract
41+
42+\vspace{0.4cm}
43+
44+\noindent\textbf{Keywords:} \WPkeywords
45+
46+\vspace{0.15cm}
47+
48+\noindent\textbf{JEL Classification:} \WPjel
49+
50+\vspace{0.3cm}
51+\noindent\rule{\textwidth}{0.4pt}
52+
53+\newpage
added paper/uq_logo.jpg +0 −0

Binary file not shown.

added pyproject.toml +42 −0
@@ -0,0 +1,42 @@
1+[project]
2+name = "wp39-uqo"
3+version = "0.1.0"
4+description = "WP39 UQO - The Invisible Balance Sheet: can intraday markets detect corporate deterioration before accounting disclosures?"
5+readme = "README.md"
6+requires-python = ">=3.12"
7+license = { text = "Research code: MIT. Data: FirstRate Data private license (not redistributed)." }
8+authors = [{ name = "Simon-Pierre Boucher", email = "simon-pierre.boucher@uqo.ca" }]
9+dependencies = [
10+ "httpx>=0.27",
11+ "pyarrow>=17",
12+ "polars>=1.0",
13+ "duckdb>=1.0",
14+ "numpy>=1.26",
15+ "pandas>=2.2",
16+ "scikit-learn>=1.5",
17+ "lightgbm>=4.3",
18+ "statsmodels>=0.14",
19+ "scipy>=1.13",
20+ "pyyaml>=6",
21+ "matplotlib>=3.9",
22+ "exchange-calendars>=4.5",
23+]
24+
25+[project.optional-dependencies]
26+dev = ["pytest>=8", "pytest-cov", "ruff"]
27+
28+[build-system]
29+requires = ["setuptools>=68", "wheel"]
30+build-backend = "setuptools.build_meta"
31+
32+[tool.setuptools.packages.find]
33+where = ["src"]
34+
35+[tool.pytest.ini_options]
36+testpaths = ["tests"]
37+addopts = "-q"
38+markers = ["live: tests that call the live FinData API (skipped without WP39_LIVE=1)"]
39+
40+[tool.ruff]
41+line-length = 110
42+target-version = "py312"
added scripts/00_probe_findata.py +42 −0
@@ -0,0 +1,42 @@
1+#!/usr/bin/env python
2+"""Phase 0: authenticated probe of FinData OS — contract reconciliation, no raw data, no credentials printed."""
3+from __future__ import annotations
4+
5+import json
6+import sys
7+from pathlib import Path
8+
9+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10+from wp39.api_client import FinDataClient # noqa: E402
11+from wp39.catalog import probe # noqa: E402
12+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
13+from wp39.provenance import git_sha, now_iso, write_json # noqa: E402
14+
15+
16+def main() -> int:
17+ P = Paths.default()
18+ log = get_logger("00_probe", P.logs / "00_probe.log")
19+ contract = load_config("data_contract")
20+ client = FinDataClient(log_path=P.logs / "api_calls.jsonl")
21+ rep = probe(client, contract)
22+ rep["probed_at"], rep["code_sha"] = now_iso(), git_sha(P.root)
23+ # spot-check fundamentals + export schema on one well-known entity, counts only
24+ f = client.fundamentals("frd:AAPL", period_type="q", limit=20000)
25+ rep["aapl_quarterly_facts"] = len(f.get("data", []))
26+ rep["aapl_metrics"] = sorted({r["metric"] for r in f.get("data", [])})
27+ rep["pit_policy"] = f.get("meta", {}).get("pit_policy")
28+ periods = client.fundamentals_periods("frd:AAPL")
29+ rep["aapl_periods"] = len(periods)
30+ rep["aapl_first_filing"] = min(p["filing_date"] for p in periods if p.get("filing_date"))
31+ write_json(P.logs / "phase0_probe.json", rep)
32+ log.info("health=%s datasets=%d schema_ok=%s earnings_route=%s", rep["health_status"], rep["n_datasets"],
33+ rep["bars_fields_ok"], rep["has_earnings_route"])
34+ for k, v in rep["required_datasets"].items():
35+ log.info("dataset %s: %s", k, v)
36+ print(json.dumps({k: rep[k] for k in ("health_status", "n_datasets", "bars_fields_ok", "has_earnings_route",
37+ "aapl_quarterly_facts", "aapl_periods", "lake_free_gb")}, indent=1))
38+ return 0
39+
40+
41+if __name__ == "__main__":
42+ raise SystemExit(main())
added scripts/01_audit_universe.py +83 −0
@@ -0,0 +1,83 @@
1+#!/usr/bin/env python
2+"""Phase 2a: universe audit — fundamentals entities x 1-minute coverage x SEC crosswalk; exclusions documented.
3+
4+Inputs: node extracts produced by scripts/01a_extract_node_tables.sh (entities, earnings_events, corporate_actions —
5+read-only Postgres copies, documented exception), FinData /coverage/symbols and /instruments, SEC public files.
6+"""
7+from __future__ import annotations
8+
9+import json
10+import sys
11+from pathlib import Path
12+
13+import polars as pl
14+
15+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
16+from wp39.api_client import FinDataClient # noqa: E402
17+from wp39.instruments import build_crosswalk, fetch_sec_submissions, fetch_sec_tickers # noqa: E402
18+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
19+from wp39.provenance import now_iso, sha256_file, write_json # noqa: E402
20+
21+
22+def main() -> int:
23+ P = Paths.default()
24+ log = get_logger("01_audit", P.logs / "01_audit.log")
25+ cfg = load_config("research"); contract = load_config("data_contract")
26+ ext = P.cache / "external"
27+ ents = pl.read_csv(ext / "entities.csv")
28+ ents = ents.with_columns(pl.col("entity_id").str.replace("^frd:", "").alias("ticker"))
29+ log.info("fundamentals entities: %d", ents.height)
30+
31+ client = FinDataClient(log_path=P.logs / "api_calls.jsonl")
32+ cov = pl.DataFrame(list(client.coverage_symbols(contract["findata"]["datasets"]["bars_1m"], min_sessions=1)))
33+ cov = cov.rename({"symbol": "ticker", "sessions": "n_sessions_1m", "d0": "first_session_1m", "d1": "last_session_1m", "bars": "n_bars_1m"})
34+ cov = cov.with_columns([pl.col("first_session_1m").str.to_date(), pl.col("last_session_1m").str.to_date()])
35+ log.info("1m coverage rows: %d (incl. %d delisted)", cov.height, cov.filter(pl.col("ticker").str.ends_with("-DELISTED")).height)
36+
37+ sec = fetch_sec_tickers(ext)
38+ u = ents.join(cov, on="ticker", how="left")
39+ u = u.join(sec.unique(subset=["ticker"]), on="ticker", how="left")
40+ subs = fetch_sec_submissions(sorted(u["cik"].drop_nulls().unique().to_list()), ext, log=log)
41+ u = u.join(subs, on="cik", how="left")
42+ u = u.with_columns([
43+ pl.col("first_q").str.to_date(), pl.col("last_q").str.to_date(), pl.col("first_filing").str.to_date(),
44+ pl.col("cik").is_not_null().alias("has_cik"),
45+ pl.col("sic_division").fill_null("unknown"),
46+ ])
47+ # eligibility (frozen): >= min quarters, 1m coverage overlapping 2012+, SIC not public administration
48+ u = u.with_columns([
49+ (pl.col("n_q") >= cfg["sample"]["min_quarters_per_firm"]).alias("ok_quarters"),
50+ (pl.col("n_sessions_1m").fill_null(0) >= 250).alias("ok_coverage"),
51+ (pl.col("last_session_1m") >= pl.date(2012, 6, 30)).fill_null(False).alias("ok_period"),
52+ (pl.col("sic_division") != "J_public_admin").alias("ok_sic"),
53+ ])
54+ u = u.with_columns((pl.col("ok_quarters") & pl.col("ok_coverage") & pl.col("ok_period") & pl.col("ok_sic")).alias("eligible"))
55+ u.write_parquet(P.processed / "universe.parquet")
56+ cascade = [
57+ {"step": "fundamentals_entities", "n": u.height},
58+ {"step": ">= 8 quarterly periods", "n": u.filter(pl.col("ok_quarters")).height},
59+ {"step": "+ 1m bars (>= 250 sessions)", "n": u.filter(pl.col("ok_quarters") & pl.col("ok_coverage")).height},
60+ {"step": "+ bars after 2012-06", "n": u.filter(pl.col("ok_quarters") & pl.col("ok_coverage") & pl.col("ok_period")).height},
61+ {"step": "+ not public administration", "n": u.filter(pl.col("eligible")).height},
62+ ]
63+ rep = {
64+ "built_at": now_iso(), "entities": u.height, "eligible": u.filter(pl.col("eligible")).height,
65+ "with_cik": u.filter(pl.col("has_cik")).height, "with_sic": u.filter(pl.col("sic").is_not_null()).height,
66+ "sic_division_counts": u.filter(pl.col("eligible")).group_by("sic_division").len().sort("len", descending=True).to_dicts(),
67+ "first_q_year_counts": u.filter(pl.col("eligible")).with_columns(pl.col("first_q").dt.year().alias("y")).group_by("y").len().sort("y").to_dicts(),
68+ "exchanges": u.filter(pl.col("eligible")).explode("exchanges").group_by("exchanges").len().sort("len", descending=True).head(8).to_dicts(),
69+ "delisted_symbols_in_1m_lake": int(cov.filter(pl.col("ticker").str.ends_with("-DELISTED")).height),
70+ "delisted_entities_with_fundamentals": 0,
71+ "cascade": cascade,
72+ "n_former_names_gt0": u.filter(pl.col("eligible") & (pl.col("n_former_names") > 0)).height,
73+ "sec_tickers_snapshot_sha256": sha256_file(ext / "sec_company_tickers.json"),
74+ }
75+ write_json(P.results / "universe_audit.json", rep)
76+ pl.DataFrame(cascade).write_csv(P.results / "table_universe_cascade.csv")
77+ log.info("eligible firms: %d / %d; with CIK %d; with SIC %d", rep["eligible"], rep["entities"], rep["with_cik"], rep["with_sic"])
78+ print(json.dumps({k: rep[k] for k in ("entities", "eligible", "with_cik", "with_sic", "cascade")}, indent=1, default=str))
79+ return 0
80+
81+
82+if __name__ == "__main__":
83+ raise SystemExit(main())
added scripts/01a_extract_node_tables.sh +16 −0
@@ -0,0 +1,16 @@
1+#!/bin/bash
2+# Phase 2a (run ON the FinData storage node M3U96a): read-only Postgres extracts of three tables the API does not expose.
3+# Documented exception (docs/API_INTEGRATION.md). Outputs go to $WP39_CACHE/external and are hashed into manifests.
4+set -euo pipefail
5+OUT="${WP39_CACHE:-$HOME/wp39-cache}/external"; mkdir -p "$OUT"
6+MAN="$(cd "$(dirname "$0")/.." && pwd)/artifacts/manifests"; mkdir -p "$MAN"
7+psql -d findata -Atc "\copy (select entity_id, security_id, min(fiscal_period_end) first_q, max(fiscal_period_end) last_q, count(distinct fiscal_period_end) n_q, min(filing_date) first_filing from fundamentals_facts where fiscal_period_type='q' and metric='rev' group by 1,2 order by 1) to '$OUT/entities.csv' csv header"
8+psql -d findata -Atc "\copy (select entity_id, event_date, eps_estimate, eps_reported, release_time, source_id from earnings_events where event_date <= date '2026-10-30' order by 1,2) to '$OUT/earnings_events.csv' csv header"
9+psql -d findata -Atc "\copy (select symbol, action_type, ex_date, ratio, amount from corporate_actions order by 1,3) to '$OUT/corporate_actions.csv' csv header" 2>/dev/null || \
10+psql -d findata -Atc "\copy (select * from corporate_actions order by 1) to '$OUT/corporate_actions_raw.csv' csv header"
11+for f in entities earnings_events corporate_actions corporate_actions_raw; do
12+ [ -f "$OUT/$f.csv" ] || continue
13+ rows=$(($(wc -l < "$OUT/$f.csv") - 1)); sha=$(shasum -a 256 "$OUT/$f.csv" | cut -d' ' -f1)
14+ printf '{"table":"%s","rows":%d,"sha256":"%s","extracted_at":"%s","source":"findata postgres (read-only copy)","license":"frd-private"}\n' "$f" "$rows" "$sha" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MAN/node_extract_$f.json"
15+ echo "$f: $rows rows"
16+done
added scripts/02_build_pit_panel.py +75 −0
@@ -0,0 +1,75 @@
1+#!/usr/bin/env python
2+"""Phase 2b: fundamentals extraction per eligible entity (API, cached), wide quarterly panel, reveal dates, labels.
3+Also freezes the research design (hash of docs/RESEARCH_DESIGN.md + configs) before any model is fitted."""
4+from __future__ import annotations
5+
6+import json
7+import sys
8+from concurrent.futures import ThreadPoolExecutor
9+from pathlib import Path
10+
11+import polars as pl
12+
13+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14+from wp39.api_client import FinDataClient # noqa: E402
15+from wp39.labels import build_quarter_table, finalize_labels, match_release_dates # noqa: E402
16+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
17+from wp39.pit_fundamentals import add_quarter_index, fetch_entity_facts, wide_quarterly # noqa: E402
18+from wp39.provenance import git_sha, now_iso, sha256_file, write_json # noqa: E402
19+
20+
21+def main() -> int:
22+ P = Paths.default()
23+ log = get_logger("02_pit", P.logs / "02_pit.log")
24+ cfg = load_config("research")
25+ # design freeze
26+ freeze = {"frozen_at": now_iso(), "code_sha": git_sha(P.root),
27+ "files": {f: sha256_file(P.root / f) for f in ("docs/RESEARCH_DESIGN.md", "docs/PIT_PROTOCOL.md",
28+ "configs/research.yaml", "configs/models.yaml")}}
29+ fz = P.manifests / "design_freeze.json"
30+ if not fz.exists():
31+ write_json(fz, freeze)
32+ uni = pl.read_parquet(P.processed / "universe.parquet").filter(pl.col("eligible"))
33+ ents = uni["entity_id"].to_list()
34+ log.info("fetching quarterly facts for %d entities", len(ents))
35+ man = P.manifests / "fundamentals_manifest.jsonl"
36+
37+ def one(e: str):
38+ c = FinDataClient(log_path=P.logs / "api_calls.jsonl")
39+ try:
40+ return fetch_entity_facts(c, e, P.cache / "fundamentals" / f"{e.replace(':', '_')}.parquet", man)
41+ finally:
42+ c.close()
43+
44+ frames = []
45+ with ThreadPoolExecutor(max_workers=6) as ex:
46+ for i, df in enumerate(ex.map(one, ents)):
47+ frames.append(df)
48+ if i % 500 == 0:
49+ log.info("fundamentals %d/%d", i, len(ents))
50+ facts = pl.concat([f for f in frames if f.height], how="diagonal_relaxed")
51+ log.info("facts: %d rows, revisions>1: %d", facts.height, facts.filter(pl.col("revision_id") > 1).height)
52+ wide = add_quarter_index(wide_quarterly(facts))
53+ events = pl.read_csv(P.cache / "external" / "earnings_events.csv", try_parse_dates=True)
54+ wide = match_release_dates(wide, events)
55+ q = finalize_labels(build_quarter_table(wide), threshold=cfg["targets"]["primary"]["threshold"])
56+ q.write_parquet(P.processed / "quarters.parquet")
57+ rep = {
58+ "built_at": now_iso(), "entities": q["entity_id"].n_unique(), "firm_quarters": q.height,
59+ "release_match": q.group_by("release_match").len().sort("len", descending=True).to_dicts(),
60+ "release_time": q.group_by("release_time").len().sort("len", descending=True).to_dicts(),
61+ "filing_minus_release_days_quantiles": {str(p): q["filing_minus_release_days"].quantile(p) for p in (0.1, 0.5, 0.9)},
62+ "label_base_rates_all": {y: float(q[y].mean()) for y in ("y_adverse_opinc", "y_adverse_netinc", "y_roa_decline",
63+ "y_ocf_negative", "y_liquidity_deterioration", "y_abs_surprise")},
64+ "eps_miss_rate_where_available": float(q["y_eps_miss"].drop_nulls().mean()) if q["y_eps_miss"].drop_nulls().len() else None,
65+ "eps_estimate_available_share": float(q["eps_estimate"].is_not_null().mean()),
66+ "S_opinc_quantiles": {str(p): q["S_opinc"].drop_nulls().quantile(p) for p in (0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99)},
67+ "regular_calendar_share": float((q["_gap_ok8"] == 8).mean()),
68+ }
69+ write_json(P.results / "pit_panel_audit.json", rep)
70+ print(json.dumps(rep, indent=1, default=str))
71+ return 0
72+
73+
74+if __name__ == "__main__":
75+ raise SystemExit(main())
added scripts/03_build_features.py +116 −0
@@ -0,0 +1,116 @@
1+#!/usr/bin/env python
2+"""Phase 2c: streaming export -> session features at 1m/5m/30m/1d -> rolling windows, per symbol (multiprocess).
3+Raw Parquet is deleted after the feature rows are written; manifests keep hashes and dataset versions."""
4+from __future__ import annotations
5+
6+import argparse
7+import datetime as dt
8+import json
9+import os
10+import sys
11+import time
12+from concurrent.futures import ProcessPoolExecutor, as_completed
13+from pathlib import Path
14+
15+import polars as pl
16+
17+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
18+from wp39.api_client import FinDataClient # noqa: E402
19+from wp39.corporate_actions import load_actions # noqa: E402
20+from wp39.features import compute_spy_buckets, compute_symbol_features, session_bounds_table # noqa: E402
21+from wp39.ingest import export_symbol, mark_consumed # noqa: E402
22+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
23+from wp39.provenance import append_jsonl, now_iso, write_json # noqa: E402
24+from wp39.samples import rolling_window_features # noqa: E402
25+
26+START, END = dt.date(2011, 1, 1), dt.date(2026, 10, 30)
27+
28+
29+def work(symbol: str, keep_raw: bool) -> dict:
30+ P = Paths.default()
31+ contract = load_config("data_contract")
32+ ds = contract["findata"]["datasets"]["bars_1m"]
33+ out = P.cache / "features" / f"{symbol}.parquet"
34+ win_out = P.cache / "windows" / f"{symbol}.parquet"
35+ win_out.parent.mkdir(exist_ok=True)
36+ if out.exists() and win_out.exists():
37+ return {"symbol": symbol, "status": "cached"}
38+ t0 = time.time()
39+ client = FinDataClient(log_path=P.logs / f"api_calls_worker_{os.getpid()}.jsonl")
40+ try:
41+ paths = export_symbol(client, symbol, START, END, ds, P.cache / "bars", P.manifests / "bars_manifest.jsonl", keep=keep_raw)
42+ finally:
43+ client.close()
44+ if not paths:
45+ return {"symbol": symbol, "status": "no_export"}
46+ bounds = pl.read_parquet(P.cache / "external" / "session_bounds.parquet")
47+ spy = {k: pl.read_parquet(P.cache / "external" / f"spy_buckets_{k}.parquet") for k in ("1m", "5m", "30m")}
48+ actions = pl.read_parquet(P.cache / "external" / "actions.parquet")
49+ feat = compute_symbol_features(paths, symbol, bounds, spy, actions)
50+ if feat.height == 0:
51+ return {"symbol": symbol, "status": "empty"}
52+ feat.write_parquet(out)
53+ rolling_window_features(feat).write_parquet(win_out)
54+ if not keep_raw:
55+ for p in paths:
56+ p.unlink(missing_ok=True)
57+ mark_consumed(P.manifests / "bars_manifest.jsonl", symbol, ds)
58+ return {"symbol": symbol, "status": "ok", "sessions": feat.height, "first": str(feat["session_date"].min()),
59+ "last": str(feat["session_date"].max()), "secs": round(time.time() - t0, 1)}
60+
61+
62+def main() -> int:
63+ ap = argparse.ArgumentParser()
64+ ap.add_argument("--workers", type=int, default=6)
65+ ap.add_argument("--limit", type=int, default=0, help="pilot: only the first N eligible symbols")
66+ ap.add_argument("--symbols", type=str, default="", help="comma-separated explicit list")
67+ ap.add_argument("--keep-raw", action="store_true")
68+ args = ap.parse_args()
69+ P = Paths.default()
70+ log = get_logger("03_features", P.logs / "03_features.log")
71+ contract = load_config("data_contract")
72+ ext = P.cache / "external"
73+ # shared inputs: session bounds, SPY buckets, corporate actions
74+ if not (ext / "session_bounds.parquet").exists():
75+ session_bounds_table(START, END).write_parquet(ext / "session_bounds.parquet")
76+ if not (ext / "spy_buckets_1m.parquet").exists():
77+ client = FinDataClient(log_path=P.logs / "api_calls.jsonl")
78+ paths = export_symbol(client, "SPY", START, END, contract["findata"]["datasets"]["bars_1m_etf"], P.cache / "bars",
79+ P.manifests / "bars_manifest.jsonl", keep=True)
80+ client.close()
81+ bounds = pl.read_parquet(ext / "session_bounds.parquet")
82+ for k, v in compute_spy_buckets(paths, bounds).items():
83+ v.write_parquet(ext / f"spy_buckets_{k}.parquet")
84+ log.info("SPY buckets written")
85+ if not (ext / "actions.parquet").exists():
86+ load_actions(ext / "corporate_actions_raw.csv").write_parquet(ext / "actions.parquet")
87+ if args.symbols:
88+ symbols = args.symbols.split(",")
89+ else:
90+ uni = pl.read_parquet(P.processed / "universe.parquet").filter(pl.col("eligible")).sort("ticker")
91+ symbols = uni["ticker"].to_list()
92+ if args.limit:
93+ symbols = symbols[: args.limit]
94+ log.info("symbols to process: %d (workers=%d)", len(symbols), args.workers)
95+ status = P.logs / "03_features_status.jsonl"
96+ done = {json.loads(l)["symbol"] for l in open(status)} if status.exists() else set()
97+ todo = [s for s in symbols if s not in done]
98+ t0 = time.time(); n_ok = 0
99+ with ProcessPoolExecutor(max_workers=args.workers) as ex:
100+ futs = {ex.submit(work, s, args.keep_raw): s for s in todo}
101+ for i, f in enumerate(as_completed(futs)):
102+ try:
103+ r = f.result()
104+ except Exception as exc: # noqa: BLE001
105+ r = {"symbol": futs[f], "status": "error", "error": str(exc)[:300]}
106+ r["ts"] = now_iso()
107+ append_jsonl(status, r)
108+ n_ok += r["status"] in ("ok", "cached")
109+ if i % 50 == 0:
110+ log.info("%d/%d done (ok=%d) elapsed=%.0fs last=%s", i + 1, len(todo), n_ok, time.time() - t0, r)
111+ log.info("finished: %d symbols, %.0fs", len(todo), time.time() - t0)
112+ return 0
113+
114+
115+if __name__ == "__main__":
116+ raise SystemExit(main())
added scripts/04_build_labels.py +83 −0
@@ -0,0 +1,83 @@
1+#!/usr/bin/env python
2+"""Phase 2d/3a: cohorts (event-time per horizon + fixed calendar), PIT joins, exclusion cascade, base rates."""
3+from __future__ import annotations
4+
5+import datetime as dt
6+import json
7+import sys
8+from pathlib import Path
9+
10+import polars as pl
11+
12+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
14+from wp39.provenance import now_iso, write_json # noqa: E402
15+from wp39.samples import (apply_admissibility, calendar_cutoffs, event_cutoffs, exclusion_cascade, join_fundamentals, # noqa: E402
16+ join_market)
17+
18+
19+def hist_features(sample: pl.DataFrame, quarters: pl.DataFrame) -> pl.DataFrame:
20+ """Information age, last available surprise, mean of last 4 surprises, trailing adverse rate — all from quarters
21+ available at the cutoff (pit_qidx and earlier)."""
22+ qq = quarters.select(["entity_id", "qidx", "S_opinc", "y_adverse_opinc", "A_q"]).sort(["entity_id", "qidx"])
23+ qq = qq.with_columns([
24+ pl.col("S_opinc").alias("last_surprise"),
25+ pl.col("S_opinc").rolling_mean(window_size=4, min_samples=2).over("entity_id").alias("mean_surprise4"),
26+ pl.col("y_adverse_opinc").cast(pl.Float64).rolling_mean(window_size=8, min_samples=2).over("entity_id").alias("hist_adverse_rate"),
27+ pl.col("A_q").alias("A_pit"),
28+ ]).select(["entity_id", pl.col("qidx").alias("pit_qidx"), "last_surprise", "mean_surprise4", "hist_adverse_rate", "A_pit"])
29+ s = sample.join(qq, on=["entity_id", "pit_qidx"], how="left")
30+ return s.with_columns((pl.col("cutoff") - pl.col("A_pit")).dt.total_days().alias("info_age_td"))
31+
32+
33+def build(design: str, cfg: dict, P: Paths, quarters: pl.DataFrame, windows: pl.DataFrame, sym: pl.DataFrame, log) -> pl.DataFrame:
34+ if design == "event":
35+ cut = event_cutoffs(quarters, cfg["cutoffs"]["event_time_horizons_td"])
36+ else:
37+ s0, s1 = dt.date.fromisoformat(str(cfg["sample"]["start"])), dt.date.fromisoformat(str(cfg["sample"]["end"]))
38+ cut = calendar_cutoffs(quarters, s0, s1)
39+ cut = apply_admissibility(cut)
40+ log.info("%s cutoffs after admissibility: %d", design, cut.height)
41+ j = join_market(cut, windows, sym)
42+ j = join_fundamentals(j, quarters)
43+ # attach the outcome of quarter q (the row keyed by entity/fiscal_period_end)
44+ ycols = ["S_opinc", "S_netinc", "S_ocf", "S_rev", "ocf_innov", "roa_yoy", "y_adverse_opinc", "y_adverse_netinc", "y_roa_decline",
45+ "y_ocf_negative", "y_liquidity_deterioration", "y_eps_miss", "y_abs_surprise", "assets_tot_lag1", "_gap_ok8",
46+ "release_time", "eps_estimate"]
47+ j = j.join(quarters.select(["entity_id", "fiscal_period_end"] + ycols), on=["entity_id", "fiscal_period_end"], how="left")
48+ j = hist_features(j, quarters)
49+ sample, cascade = exclusion_cascade(j, cfg["sample"]["min_price_usd"], cfg["sample"]["min_valid_days_in_window"])
50+ cascade.write_csv(P.results / f"table_sample_cascade_{design}.csv")
51+ log.info("%s sample: %d rows; cascade=%s", design, sample.height, cascade.to_dicts())
52+ return sample
53+
54+
55+def main() -> int:
56+ P = Paths.default()
57+ log = get_logger("04_labels", P.logs / "04_labels.log")
58+ cfg = load_config("research")
59+ quarters = pl.read_parquet(P.processed / "quarters.parquet")
60+ uni = pl.read_parquet(P.processed / "universe.parquet").filter(pl.col("eligible"))
61+ sym = uni.select(["entity_id", "ticker", "sic_division", "sic"]).rename({"ticker": "symbol"})
62+ wins = [p for p in (P.cache / "windows").glob("*.parquet") if p.stem in set(sym["symbol"].to_list())]
63+ windows = pl.concat([pl.read_parquet(p) for p in wins], how="diagonal_relaxed")
64+ log.info("windows: %d rows, %d symbols", windows.height, len(wins))
65+ quarters = quarters.filter(pl.col("entity_id").is_in(sym["entity_id"].to_list()))
66+ out = {}
67+ for design in ("event", "calendar"):
68+ s = build(design, cfg, P, quarters, windows, sym, log)
69+ s.write_parquet(P.processed / f"sample_{design}.parquet")
70+ out[design] = {
71+ "rows": s.height, "firms": s["entity_id"].n_unique(), "quarters": s.select(["entity_id", "fiscal_period_end"]).unique().height,
72+ "by_h": s.group_by("h").agg([pl.len().alias("n"), pl.col("y_adverse_opinc").mean().alias("base_rate")]).sort("h").to_dicts() if design == "event" else None,
73+ "base_rates": {y: float(s[y].mean()) for y in ("y_adverse_opinc", "y_adverse_netinc", "y_roa_decline", "y_ocf_negative",
74+ "y_liquidity_deterioration", "y_abs_surprise")},
75+ "years": s.with_columns(pl.col("cutoff").dt.year().alias("y")).group_by("y").len().sort("y").to_dicts(),
76+ }
77+ write_json(P.results / "sample_audit.json", {"built_at": now_iso(), **out})
78+ print(json.dumps(out, indent=1, default=str))
79+ return 0
80+
81+
82+if __name__ == "__main__":
83+ raise SystemExit(main())
added scripts/05_train_baselines.py +108 −0
@@ -0,0 +1,108 @@
1+#!/usr/bin/env python
2+"""Phase 3: baseline ladder B0-B5 (base rates, logistic ridge per information set) on the chronological split, every
3+horizon, primary + secondary targets; walk-forward out-of-fold predictions; firm hold-out variant.
4+Predictions are saved per (design, h, target, model) so that 07_evaluate.py computes every metric from files."""
5+from __future__ import annotations
6+
7+import json
8+import sys
9+from pathlib import Path
10+
11+import numpy as np
12+import polars as pl
13+
14+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15+from wp39.models import INFO_SETS, Preprocessor, base_rate_predict, columns_for, fit_logistic, fit_ridge, firm_history_predict, predict_proba # noqa: E402
16+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
17+from wp39.provenance import now_iso, write_json # noqa: E402
18+from wp39.samples import feature_columns # noqa: E402
19+from wp39.splits import chronological, firm_holdout_mask, overlap_check, walk_forward # noqa: E402
20+
21+KEY = ["entity_id", "symbol", "fiscal_period_end", "h", "cutoff", "A_q", "sic_division", "release_time"]
22+TARGETS = ["y_adverse_opinc", "y_adverse_netinc", "y_roa_decline", "y_ocf_negative", "y_liquidity_deterioration", "y_abs_surprise"]
23+
24+
25+def save_preds(P: Paths, df: pl.DataFrame, p: np.ndarray, tag: str, design: str, h: int, target: str, model: str, part: str) -> None:
26+ out = df.select(KEY + [target]).with_columns([pl.Series("p", p), pl.lit(model).alias("model"), pl.lit(part).alias("part"),
27+ pl.lit(tag).alias("tag")])
28+ d = P.evaluation / "preds"; d.mkdir(exist_ok=True)
29+ out.write_parquet(d / f"{design}_h{h}_{target}_{model}_{part}_{tag}.parquet")
30+
31+
32+def run_split(P, cfg, mcfg, fc, design, h, target, train, valid, test, tag, log, info_sets, record):
33+ ytr, yva, yte = (x[target].to_numpy().astype(int) for x in (train, valid, test))
34+ # B0
35+ save_preds(P, test, base_rate_predict(ytr, test.height), tag, design, h, target, "B0", "test")
36+ save_preds(P, valid, base_rate_predict(ytr, valid.height), tag, design, h, target, "B0", "validation")
37+ save_preds(P, test, firm_history_predict(train, test, target), tag, design, h, target, "B0b", "test")
38+ for name in info_sets:
39+ cols = columns_for(name, fc)
40+ pp = Preprocessor(cols).fit(train)
41+ Xtr, Xva, Xte = pp.transform(train), pp.transform(valid), pp.transform(test)
42+ m, info = fit_logistic(Xtr, ytr, Xva, yva, mcfg["logistic"]["C_grid"], cfg["seed"])
43+ save_preds(P, valid, predict_proba(m, Xva), tag, design, h, target, name, "validation")
44+ save_preds(P, test, predict_proba(m, Xte), tag, design, h, target, name, "test")
45+ record.append({"design": design, "h": h, "target": target, "model": name, "tag": tag, "n_train": train.height,
46+ "n_valid": valid.height, "n_test": test.height, "n_features": len(cols), **info,
47+ "coef": dict(zip(cols, m.coef_[0].round(4).tolist()))})
48+ log.info("%s h=%d %s %s %s: C=%s val_ll=%.4f (p=%d)", design, h, target, name, tag, info["C"], info["val_logloss"], len(cols))
49+
50+
51+def main() -> int:
52+ P = Paths.default()
53+ log = get_logger("05_baselines", P.logs / "05_baselines.log")
54+ cfg, mcfg = load_config("research"), load_config("models")
55+ record = []
56+ for design in ("event", "calendar"):
57+ df = pl.read_parquet(P.processed / f"sample_{design}.parquet")
58+ if design == "calendar":
59+ df = df.with_columns(pl.lit(0).alias("h")) # calendar: h varies; pooled model
60+ fc = feature_columns(df)
61+ write_json(P.evaluation / f"feature_columns_{design}.json", fc)
62+ horizons = sorted(df["h"].unique().to_list()) if design == "event" else [0]
63+ for h in horizons:
64+ d = df.filter(pl.col("h") == h) if design == "event" else df
65+ targets = TARGETS if (design == "event" and h == cfg["cutoffs"]["primary_horizon_td"]) else ["y_adverse_opinc"]
66+ for target in targets:
67+ dd = d.filter(pl.col(target).is_not_null())
68+ sp = chronological(dd, cfg["splits"])
69+ chk = overlap_check(sp["train"], sp["test"])
70+ assert chk["shared_observations"] == 0 and chk["train_outcomes_revealed_in_test"] == 0, chk
71+ sets = list(INFO_SETS) if target == "y_adverse_opinc" else ["B1", "B2", "B5"]
72+ run_split(P, cfg, mcfg, fc, design, h, target, sp["train"], sp["validation"], sp["test"], "chrono", log, sets, record)
73+ if design == "event" and h == cfg["cutoffs"]["primary_horizon_td"] and target == "y_adverse_opinc":
74+ # continuous target (ridge) on the same split
75+ for name in ("B2", "B5"):
76+ cols = columns_for(name, fc); pp = Preprocessor(cols).fit(sp["train"])
77+ ytr = sp["train"]["S_opinc"].to_numpy(); lo, hi = np.quantile(ytr, [0.01, 0.99]); ytr = np.clip(ytr, lo, hi)
78+ m, info = fit_ridge(pp.transform(sp["train"]), ytr, pp.transform(sp["validation"]), np.clip(sp["validation"]["S_opinc"].to_numpy(), lo, hi))
79+ save_preds(P, sp["test"], m.predict(pp.transform(sp["test"])), "chrono_cont", design, h, "S_opinc", name, "test")
80+ record.append({"design": design, "h": h, "target": "S_opinc", "model": name, "tag": "chrono_cont", "winsor": [float(lo), float(hi)],
81+ "train_mean": float(ytr.mean()), **info})
82+ # firm hold-out: train/validate on non-held-out firms, test on both groups separately
83+ hold = firm_holdout_mask(dd["entity_id"], cfg["splits"]["firm_holdout_fraction"])
84+ dd2 = dd.with_columns(pl.Series("_hold", hold))
85+ sp2 = chronological(dd2.filter(~pl.col("_hold")), cfg["splits"])
86+ te_hold = dd2.filter(pl.col("_hold") & (pl.col("cutoff") >= sp2["test"]["cutoff"].min()))
87+ run_split(P, cfg, mcfg, fc, design, h, target, sp2["train"], sp2["validation"], te_hold, "firmhold_heldout", log, ["B2", "B5"], record)
88+ run_split(P, cfg, mcfg, fc, design, h, target, sp2["train"], sp2["validation"], sp2["test"], "firmhold_seen", log, ["B2", "B5"], record)
89+ # walk-forward out-of-fold predictions (logistic, C fixed at the chronological choice per set)
90+ for name in ("B1", "B2", "B3", "B4", "B5"):
91+ cols = columns_for(name, fc)
92+ C = next(r["C"] for r in record if r["model"] == name and r["tag"] == "chrono" and r["h"] == h and r["target"] == target)
93+ parts = []
94+ for y, tr, te in walk_forward(dd, cfg["splits"], 2026):
95+ pp = Preprocessor(cols).fit(tr)
96+ from sklearn.linear_model import LogisticRegression
97+ m = LogisticRegression(C=C, max_iter=2000, random_state=cfg["seed"]).fit(pp.transform(tr), tr[target].to_numpy().astype(int))
98+ parts.append(te.select(KEY + [target]).with_columns([pl.Series("p", predict_proba(m, pp.transform(te))), pl.lit(y).alias("fold")]))
99+ oof = pl.concat(parts).with_columns([pl.lit(name).alias("model"), pl.lit("oof").alias("part"), pl.lit("walkforward").alias("tag")])
100+ oof.write_parquet(P.evaluation / "preds" / f"{design}_h{h}_{target}_{name}_oof_walkforward.parquet")
101+ log.info("walk-forward %s: %d oof rows", name, oof.height)
102+ write_json(P.evaluation / "baselines_fit_log.json", {"run_at": now_iso(), "fits": record})
103+ print(json.dumps({"fits": len(record)}, indent=1))
104+ return 0
105+
106+
107+if __name__ == "__main__":
108+ raise SystemExit(main())
added scripts/06_train_advanced.py +116 −0
@@ -0,0 +1,116 @@
1+#!/usr/bin/env python
2+"""Phase 4: LightGBM (B6) with an identical random-search budget for every information set on the chronological split
3+(primary horizon, primary target + secondary targets), horizon ladder and walk-forward refits with a fixed configuration
4+(no tuning inside folds, so earlier folds never see later data). No neural/text models (DECISIONS D-006)."""
5+from __future__ import annotations
6+
7+import json
8+import sys
9+from pathlib import Path
10+
11+import numpy as np
12+import polars as pl
13+
14+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15+from wp39.models import INFO_SETS, Preprocessor, columns_for, fit_lightgbm, predict_proba # noqa: E402
16+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
17+from wp39.provenance import now_iso, write_json # noqa: E402
18+from wp39.samples import feature_columns # noqa: E402
19+from wp39.splits import chronological, firm_holdout_mask, walk_forward # noqa: E402
20+
21+KEY = ["entity_id", "symbol", "fiscal_period_end", "h", "cutoff", "A_q", "sic_division", "release_time"]
22+FIXED = {"num_leaves": 15, "learning_rate": 0.03, "min_child_samples": 100, "feature_fraction": 0.7, "bagging_fraction": 0.8,
23+ "bagging_freq": 1, "lambda_l2": 1.0, "n_estimators": 600, "verbose": -1, "n_jobs": 12}
24+
25+
26+def save(P, df, p, design, h, target, model, part, tag, extra=None):
27+ out = df.select(KEY + [target]).with_columns([pl.Series("p", p), pl.lit(model).alias("model"), pl.lit(part).alias("part"), pl.lit(tag).alias("tag")])
28+ if extra is not None:
29+ out = out.with_columns(extra)
30+ out.write_parquet(P.evaluation / "preds" / f"{design}_h{h}_{target}_{model}_{part}_{tag}.parquet")
31+
32+
33+def tuned(P, cfg, mcfg, fc, design, h, target, sp, tag, sets, log, record):
34+ ytr, yva = sp["train"][target].to_numpy().astype(int), sp["validation"][target].to_numpy().astype(int)
35+ for name in sets:
36+ cols = columns_for(name, fc); pp = Preprocessor(cols).fit(sp["train"])
37+ m, info = fit_lightgbm(pp.transform(sp["train"]), ytr, pp.transform(sp["validation"]), yva, mcfg["lightgbm"], cfg["seed"], n_jobs=12)
38+ save(P, sp["validation"], predict_proba(m, pp.transform(sp["validation"])), design, h, target, "L" + name, "validation", tag)
39+ save(P, sp["test"], predict_proba(m, pp.transform(sp["test"])), design, h, target, "L" + name, "test", tag)
40+ imp = dict(zip(cols, (m.booster_.feature_importance("gain") / max(m.booster_.feature_importance("gain").sum(), 1)).round(4).tolist()))
41+ record.append({"design": design, "h": h, "target": target, "model": "L" + name, "tag": tag, "n_features": len(cols),
42+ "val_score": info["val_score"], "params": info["params"], "trials": len(info["trials"]), "importance_gain": imp})
43+ log.info("%s h=%d %s L%s %s: val_ll=%.4f params=%s", design, h, target, name, tag, info["val_score"], info["params"])
44+
45+
46+def fixed_fit(train, test, cols, target, seed):
47+ import lightgbm as lgb
48+ pp = Preprocessor(cols).fit(train)
49+ # early stopping on the last 4 calendar quarters of the training window (purged by construction: still before test)
50+ cut = train["cutoff"].quantile(0.85)
51+ tr, va = train.filter(pl.col("cutoff") < cut), train.filter(pl.col("cutoff") >= cut)
52+ m = lgb.LGBMClassifier(random_state=seed, **FIXED)
53+ m.fit(pp.transform(tr), tr[target].to_numpy().astype(int), eval_set=[(pp.transform(va), va[target].to_numpy().astype(int))],
54+ callbacks=[lgb.early_stopping(50, verbose=False)])
55+ return predict_proba(m, pp.transform(test)), int(m.best_iteration_ or 0)
56+
57+
58+def main() -> int:
59+ P = Paths.default()
60+ log = get_logger("06_advanced", P.logs / "06_advanced.log")
61+ cfg, mcfg = load_config("research"), load_config("models")
62+ hp = cfg["cutoffs"]["primary_horizon_td"]
63+ record = []
64+ df = pl.read_parquet(P.processed / "sample_event.parquet")
65+ fc = feature_columns(df)
66+ # 1) primary: tuned LightGBM, all information sets, h = 20, primary target
67+ d = df.filter((pl.col("h") == hp) & pl.col("y_adverse_opinc").is_not_null())
68+ sp = chronological(d, cfg["splits"])
69+ tuned(P, cfg, mcfg, fc, "event", hp, "y_adverse_opinc", sp, "chrono", list(INFO_SETS), log, record)
70+ # 2) secondary targets (B2 vs B5 only), tuned
71+ for target in ("y_adverse_netinc", "y_roa_decline", "y_ocf_negative", "y_liquidity_deterioration", "y_abs_surprise"):
72+ dt_ = df.filter((pl.col("h") == hp) & pl.col(target).is_not_null())
73+ tuned(P, cfg, mcfg, fc, "event", hp, target, chronological(dt_, cfg["splits"]), "chrono", ["B2", "B5"], log, record)
74+ # 3) horizon ladder (fixed config): B2 vs B5 at every h on the chronological split
75+ for h in sorted(df["h"].unique().to_list()):
76+ dh = df.filter((pl.col("h") == h) & pl.col("y_adverse_opinc").is_not_null())
77+ sph = chronological(dh, cfg["splits"])
78+ for name in ("B2", "B5"):
79+ p, it = fixed_fit(pl.concat([sph["train"], sph["validation"]]), sph["test"], columns_for(name, fc), "y_adverse_opinc", cfg["seed"])
80+ save(P, sph["test"], p, "event", h, "y_adverse_opinc", "L" + name, "test", "fixed")
81+ record.append({"design": "event", "h": h, "target": "y_adverse_opinc", "model": "L" + name, "tag": "fixed", "best_iteration": it})
82+ # 4) firm hold-out (fixed config)
83+ hold = firm_holdout_mask(d["entity_id"], cfg["splits"]["firm_holdout_fraction"])
84+ d2 = d.with_columns(pl.Series("_hold", hold))
85+ sp2 = chronological(d2.filter(~pl.col("_hold")), cfg["splits"])
86+ te_hold = d2.filter(pl.col("_hold") & (pl.col("cutoff") >= sp2["test"]["cutoff"].min()))
87+ for name in ("B2", "B5"):
88+ trv = pl.concat([sp2["train"], sp2["validation"]])
89+ p, _ = fixed_fit(trv, te_hold, columns_for(name, fc), "y_adverse_opinc", cfg["seed"]); save(P, te_hold, p, "event", hp, "y_adverse_opinc", "L" + name, "test", "firmhold_heldout")
90+ p, _ = fixed_fit(trv, sp2["test"], columns_for(name, fc), "y_adverse_opinc", cfg["seed"]); save(P, sp2["test"], p, "event", hp, "y_adverse_opinc", "L" + name, "test", "firmhold_seen")
91+ # 5) walk-forward out-of-fold (fixed config) for B1..B5 at h = 20 -> LFDS for the panel regressions / H4 / H5
92+ for name in ("B1", "B2", "B3", "B4", "B5"):
93+ parts = []
94+ for y, tr, te in walk_forward(d, cfg["splits"], 2026):
95+ p, it = fixed_fit(tr, te, columns_for(name, fc), "y_adverse_opinc", cfg["seed"])
96+ parts.append(te.select(KEY + ["y_adverse_opinc"]).with_columns([pl.Series("p", p), pl.lit(y).alias("fold")]))
97+ oof = pl.concat(parts).with_columns([pl.lit("L" + name).alias("model"), pl.lit("oof").alias("part"), pl.lit("walkforward").alias("tag")])
98+ oof.write_parquet(P.evaluation / "preds" / f"event_h{hp}_y_adverse_opinc_L{name}_oof_walkforward.parquet")
99+ log.info("walk-forward L%s: %d oof rows", name, oof.height)
100+ # 6) calendar design: walk-forward OOF for B2/B5 (scores for H5 portfolio sorts)
101+ dc = pl.read_parquet(P.processed / "sample_calendar.parquet").with_columns(pl.lit(0).alias("h"))
102+ fcc = feature_columns(dc)
103+ for name in ("B2", "B5"):
104+ parts = []
105+ for y, tr, te in walk_forward(dc, cfg["splits"], 2026):
106+ p, _ = fixed_fit(tr, te, columns_for(name, fcc), "y_adverse_opinc", cfg["seed"])
107+ parts.append(te.select(KEY + ["y_adverse_opinc"]).with_columns([pl.Series("p", p), pl.lit(y).alias("fold")]))
108+ oof = pl.concat(parts).with_columns([pl.lit("L" + name).alias("model"), pl.lit("oof").alias("part"), pl.lit("walkforward").alias("tag")])
109+ oof.write_parquet(P.evaluation / "preds" / f"calendar_h0_y_adverse_opinc_L{name}_oof_walkforward.parquet")
110+ write_json(P.evaluation / "advanced_fit_log.json", {"run_at": now_iso(), "fixed_config": FIXED, "fits": record})
111+ print(json.dumps({"fits": len(record)}, indent=1))
112+ return 0
113+
114+
115+if __name__ == "__main__":
116+ raise SystemExit(main())
added scripts/07_evaluate.py +209 −0
@@ -0,0 +1,209 @@
1+#!/usr/bin/env python
2+"""Phase 3/4 evaluation: every metric and every paired contrast is computed from saved prediction files.
3+Outputs (artifacts/evaluation + results/): metrics_all.csv, contrasts.csv, horizon_ladder.csv, secondary_targets.csv,
4+firm_holdout.csv, walkforward_by_year.csv, reliability.csv, continuous.csv, power.json."""
5+from __future__ import annotations
6+
7+import json
8+import sys
9+from pathlib import Path
10+
11+import numpy as np
12+import pandas as pd
13+import polars as pl
14+
15+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
16+from wp39.inference import (binary_metrics, continuous_metrics, dm_gw_test, holm, paired_block_bootstrap, # noqa: E402
17+ pointwise_logloss, quarter_blocks, reliability_table)
18+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
19+from wp39.provenance import now_iso, write_json # noqa: E402
20+
21+CONTRASTS = [("B5", "B2"), ("B4", "B2"), ("B3", "B2"), ("B5", "B4"), ("B5", "B3"), ("B5all", "B2"), ("B5all", "B5"), ("B2", "B1"), ("B1", "B0")]
22+FAMILIES = {"freq_vs_daily": [("B5", "B2"), ("B4", "B2"), ("B3", "B2")], "fine_vs_coarse": [("B5", "B4"), ("B5", "B3")]}
23+
24+
25+def load_preds(P: Paths) -> pl.DataFrame:
26+ files = sorted((P.evaluation / "preds").glob("*.parquet"))
27+ frames = []
28+ for f in files:
29+ d = pl.read_parquet(f)
30+ target = [c for c in d.columns if c.startswith("y_") or c == "S_opinc"][0]
31+ d = d.rename({target: "y"}).with_columns(pl.lit(target).alias("target"))
32+ if "fold" not in d.columns:
33+ d = d.with_columns(pl.lit(None, dtype=pl.Int64).alias("fold"))
34+ frames.append(d.select(["entity_id", "symbol", "fiscal_period_end", "h", "cutoff", "A_q", "sic_division", "release_time",
35+ "y", "p", "model", "part", "tag", "target", "fold"]))
36+ return pl.concat(frames)
37+
38+
39+def contrast(a: pl.DataFrame, b: pl.DataFrame, n_boot: int, seed: int) -> dict:
40+ key = ["entity_id", "fiscal_period_end", "cutoff"]
41+ j = a.select(key + ["y", "p", "A_q"]).join(b.select(key + [pl.col("p").alias("pb")]), on=key, how="inner")
42+ y = j["y"].to_numpy().astype(int); pa, pb = j["p"].to_numpy(), j["pb"].to_numpy()
43+ d_ll = pointwise_logloss(y, pa) - pointwise_logloss(y, pb)
44+ d_br = (pa - y) ** 2 - (pb - y) ** 2
45+ blocks = quarter_blocks(j["A_q"].to_numpy())
46+ bll, bbr = paired_block_bootstrap(d_ll, blocks, n_boot, seed), paired_block_bootstrap(d_br, blocks, n_boot, seed)
47+ gw = dm_gw_test(d_ll, blocks)
48+ return {"n": int(len(y)), "d_logloss": bll["mean"], "ci_lo": bll["ci_lo"], "ci_hi": bll["ci_hi"], "p_boot": bll["p_boot"],
49+ "gw_t": gw["t"], "gw_p": gw["p"], "n_quarters": gw["T"], "d_brier": bbr["mean"], "brier_ci_lo": bbr["ci_lo"], "brier_ci_hi": bbr["ci_hi"]}
50+
51+
52+def main() -> int:
53+ P = Paths.default()
54+ log = get_logger("07_eval", P.logs / "07_eval.log")
55+ cfg = load_config("research")
56+ hp, nb, seed = cfg["cutoffs"]["primary_horizon_td"], cfg["inference"]["n_bootstrap"], cfg["seed"]
57+ preds = load_preds(P)
58+ log.info("prediction rows: %d (%d files)", preds.height, len(list((P.evaluation / "preds").glob("*.parquet"))))
59+
60+ # 1) metrics for every binary prediction set
61+ rows = []
62+ grp = preds.filter(pl.col("target") != "S_opinc").with_columns(pl.col("cutoff").dt.year().alias("year"))
63+ for keys, g in grp.group_by(["h", "target", "model", "part", "tag"], maintain_order=True):
64+ h, target, model, part, tag = keys
65+ m = binary_metrics(g["y"].to_numpy().astype(int), g["p"].to_numpy())
66+ rows.append({"h": h, "target": target, "model": model, "part": part, "tag": tag, **m})
67+ metrics = pd.DataFrame(rows).sort_values(["target", "h", "tag", "part", "model"])
68+ metrics.to_csv(P.evaluation / "metrics_all.csv", index=False)
69+
70+ # 2) primary contrasts (h = hp, primary target, chronological test) for logistic and LightGBM
71+ out = []
72+ base = preds.filter((pl.col("h") == hp) & (pl.col("target") == "y_adverse_opinc") & (pl.col("part") == "test") & (pl.col("tag") == "chrono"))
73+ for prefix in ("", "L"):
74+ pv = {}
75+ for a, b in CONTRASTS:
76+ A, B = base.filter(pl.col("model") == prefix + a), base.filter(pl.col("model") == (prefix + b if b != "B0" else "B0"))
77+ if A.height == 0 or B.height == 0:
78+ continue
79+ c = contrast(A, B, nb, seed); c.update({"family": "logistic" if prefix == "" else "lightgbm", "a": prefix + a, "b": prefix + b})
80+ out.append(c); pv[(a, b)] = c["gw_p"]
81+ for fam, pairs in FAMILIES.items():
82+ adj = holm({f"{a}-{b}": pv[(a, b)] for a, b in pairs if (a, b) in pv})
83+ for r in out:
84+ if r["family"] == ("logistic" if prefix == "" else "lightgbm") and f"{r['a'][len(prefix):]}-{r['b'][len(prefix):]}" in adj:
85+ r["holm_family"], r["gw_p_holm"] = fam, adj[f"{r['a'][len(prefix):]}-{r['b'][len(prefix):]}"]
86+ contrasts = pd.DataFrame(out); contrasts.to_csv(P.evaluation / "contrasts.csv", index=False)
87+ log.info("primary contrasts:\n%s", contrasts[["family", "a", "b", "n", "d_logloss", "ci_lo", "ci_hi", "gw_p"]].to_string())
88+
89+ # 3) horizon ladder (B5 vs B2) logistic chrono + LightGBM fixed
90+ lad = []
91+ for h in sorted(preds["h"].unique().to_list()):
92+ for fam, tag, a, b in (("logistic", "chrono", "B5", "B2"), ("lightgbm", "fixed", "LB5", "LB2")):
93+ s = preds.filter((pl.col("h") == h) & (pl.col("target") == "y_adverse_opinc") & (pl.col("part") == "test") & (pl.col("tag") == tag))
94+ A, B = s.filter(pl.col("model") == a), s.filter(pl.col("model") == b)
95+ if A.height and B.height:
96+ c = contrast(A, B, nb, seed); c.update({"h": h, "family": fam})
97+ c["auc_a"] = binary_metrics(A["y"].to_numpy().astype(int), A["p"].to_numpy()).get("auc")
98+ c["auc_b"] = binary_metrics(B["y"].to_numpy().astype(int), B["p"].to_numpy()).get("auc")
99+ lad.append(c)
100+ ladder = pd.DataFrame(lad); ladder.to_csv(P.evaluation / "horizon_ladder.csv", index=False)
101+ h4 = {}
102+ for fam, g in ladder.groupby("family"):
103+ g = g.sort_values("h", ascending=False)
104+ from scipy.stats import spearmanr
105+ rho, p = spearmanr(-g["h"], -g["d_logloss"]) # gain (= -d) should rise as h falls
106+ h4[fam] = {"spearman_gain_vs_minus_h": float(rho), "p": float(p), "horizons": g["h"].tolist(), "d_logloss": g["d_logloss"].round(5).tolist()}
107+ write_json(P.evaluation / "h4_timing.json", h4)
108+
109+ # 4) secondary targets (B5 vs B2) logistic + lightgbm at hp
110+ sec = []
111+ for target in ("y_adverse_netinc", "y_roa_decline", "y_ocf_negative", "y_liquidity_deterioration", "y_abs_surprise", "y_adverse_opinc"):
112+ for fam, a, b in (("logistic", "B5", "B2"), ("lightgbm", "LB5", "LB2")):
113+ s = preds.filter((pl.col("h") == hp) & (pl.col("target") == target) & (pl.col("part") == "test") & (pl.col("tag") == "chrono"))
114+ A, B = s.filter(pl.col("model") == a), s.filter(pl.col("model") == b)
115+ if A.height and B.height:
116+ c = contrast(A, B, nb, seed); c.update({"target": target, "family": fam, "base_rate": float(A["y"].mean()),
117+ "auc_a": binary_metrics(A["y"].to_numpy().astype(int), A["p"].to_numpy()).get("auc"),
118+ "auc_b": binary_metrics(B["y"].to_numpy().astype(int), B["p"].to_numpy()).get("auc")})
119+ sec.append(c)
120+ pd.DataFrame(sec).to_csv(P.evaluation / "secondary_targets.csv", index=False)
121+
122+ # 5) firm hold-out
123+ fh = []
124+ for tag in ("firmhold_heldout", "firmhold_seen"):
125+ for fam, a, b in (("logistic", "B5", "B2"), ("lightgbm", "LB5", "LB2")):
126+ s = preds.filter((pl.col("h") == hp) & (pl.col("target") == "y_adverse_opinc") & (pl.col("part") == "test") & (pl.col("tag") == tag))
127+ A, B = s.filter(pl.col("model") == a), s.filter(pl.col("model") == b)
128+ if A.height and B.height:
129+ c = contrast(A, B, nb, seed); c.update({"tag": tag, "family": fam}); fh.append(c)
130+ pd.DataFrame(fh).to_csv(P.evaluation / "firm_holdout.csv", index=False)
131+
132+ # 6) walk-forward by year
133+ wf = []
134+ oof = preds.filter((pl.col("tag") == "walkforward") & (pl.col("h") == hp)).with_columns(pl.col("cutoff").dt.year().alias("year"))
135+ for keys, g in oof.group_by(["model", "year"], maintain_order=True):
136+ m = binary_metrics(g["y"].to_numpy().astype(int), g["p"].to_numpy()); wf.append({"model": keys[0], "year": keys[1], **m})
137+ wfd = pd.DataFrame(wf).sort_values(["model", "year"]); wfd.to_csv(P.evaluation / "walkforward_by_year.csv", index=False)
138+ wfc = []
139+ for fam, a, b in (("logistic", "B5", "B2"), ("lightgbm", "LB5", "LB2")):
140+ A, B = oof.filter(pl.col("model") == a), oof.filter(pl.col("model") == b)
141+ if A.height and B.height:
142+ for y in sorted(A["year"].unique().to_list()):
143+ c = contrast(A.filter(pl.col("year") == y), B.filter(pl.col("year") == y), 300, seed); c.update({"family": fam, "year": y}); wfc.append(c)
144+ c = contrast(A, B, nb, seed); c.update({"family": fam, "year": "all"}); wfc.append(c)
145+ pd.DataFrame(wfc).to_csv(P.evaluation / "walkforward_contrasts.csv", index=False)
146+
147+ # 7) reliability (test, chrono) for B2, B5, LB2, LB5
148+ rel = []
149+ for model in ("B2", "B5", "LB2", "LB5"):
150+ s = base.filter(pl.col("model") == model)
151+ if s.height:
152+ t = reliability_table(s["y"].to_numpy().astype(int), s["p"].to_numpy()); t["model"] = model; rel.append(t)
153+ if rel:
154+ pd.concat(rel).to_csv(P.evaluation / "reliability.csv", index=False)
155+
156+ # 8) continuous target
157+ cont = []
158+ fitlog = json.load(open(P.evaluation / "baselines_fit_log.json"))["fits"]
159+ for model in ("B2", "B5"):
160+ s = preds.filter((pl.col("target") == "S_opinc") & (pl.col("model") == model) & (pl.col("tag") == "chrono_cont"))
161+ if s.height:
162+ rec = next(r for r in fitlog if r.get("tag") == "chrono_cont" and r["model"] == model)
163+ lo, hi = rec["winsor"]
164+ y = np.clip(s["y"].to_numpy(), lo, hi)
165+ cont.append({"model": model, **continuous_metrics(y, s["p"].to_numpy(), rec["train_mean"])})
166+ if cont:
167+ A = preds.filter((pl.col("target") == "S_opinc") & (pl.col("model") == "B5") & (pl.col("tag") == "chrono_cont"))
168+ B = preds.filter((pl.col("target") == "S_opinc") & (pl.col("model") == "B2") & (pl.col("tag") == "chrono_cont"))
169+ key = ["entity_id", "fiscal_period_end", "cutoff"]
170+ j = A.join(B.select(key + [pl.col("p").alias("pb")]), on=key)
171+ lo, hi = next(r for r in fitlog if r.get("tag") == "chrono_cont" and r["model"] == "B5")["winsor"]
172+ y = np.clip(j["y"].to_numpy(), lo, hi)
173+ d = (j["p"].to_numpy() - y) ** 2 - (j["pb"].to_numpy() - y) ** 2
174+ bb = paired_block_bootstrap(d, quarter_blocks(j["A_q"].to_numpy()), nb, seed)
175+ cont.append({"model": "B5-B2 d_mse", **bb})
176+ pd.DataFrame(cont).to_csv(P.evaluation / "continuous.csv", index=False)
177+
178+ # 9) power (validation period, logistic B5 vs B2): MDE at 80 % power, alpha 5 %, quarter clustering
179+ v = preds.filter((pl.col("h") == hp) & (pl.col("target") == "y_adverse_opinc") & (pl.col("part") == "validation") & (pl.col("tag") == "chrono"))
180+ A, B = v.filter(pl.col("model") == "B5"), v.filter(pl.col("model") == "B2")
181+ power = {}
182+ if A.height and B.height:
183+ key = ["entity_id", "fiscal_period_end", "cutoff"]
184+ j = A.join(B.select(key + [pl.col("p").alias("pb")]), on=key)
185+ y = j["y"].to_numpy().astype(int)
186+ d = pointwise_logloss(y, j["p"].to_numpy()) - pointwise_logloss(y, j["pb"].to_numpy())
187+ bl = quarter_blocks(j["A_q"].to_numpy()); ub = np.unique(bl)
188+ dq = np.array([d[bl == b].mean() for b in ub]); se = dq.std(ddof=1) / np.sqrt(len(ub))
189+ n_test_q = preds.filter((pl.col("part") == "test") & (pl.col("tag") == "chrono") & (pl.col("h") == hp))["A_q"].to_numpy()
190+ T_test = len(np.unique(quarter_blocks(n_test_q))) if len(n_test_q) else 23
191+ se_test = dq.std(ddof=1) / np.sqrt(T_test)
192+ power = {"validation_quarters": int(len(ub)), "sd_quarterly_diff": float(dq.std(ddof=1)), "test_quarters_assumed": int(T_test),
193+ "mde_logloss_80pct_power": float((1.96 + 0.84) * se_test), "validation_mean_diff": float(d.mean())}
194+ write_json(P.evaluation / "power.json", power)
195+
196+ # copies to results/
197+ for f in ("metrics_all", "contrasts", "horizon_ladder", "secondary_targets", "firm_holdout", "walkforward_by_year",
198+ "walkforward_contrasts", "reliability", "continuous"):
199+ src = P.evaluation / f"{f}.csv"
200+ if src.exists():
201+ (P.results / f"{f}.csv").write_text(src.read_text())
202+ write_json(P.results / "evaluation_summary.json", {"run_at": now_iso(), "power": power, "h4": h4,
203+ "primary": contrasts.to_dict(orient="records") if len(contrasts) else []})
204+ print(contrasts[["family", "a", "b", "n", "d_logloss", "ci_lo", "ci_hi", "gw_p"]].to_string() if len(contrasts) else "no contrasts")
205+ return 0
206+
207+
208+if __name__ == "__main__":
209+ raise SystemExit(main())
added scripts/08_run_robustness.py +156 −0
@@ -0,0 +1,156 @@
1+#!/usr/bin/env python
2+"""Phase 5: negative controls, subgroup stress tests (H3), panel regressions (H3), pricing tests (H5)."""
3+from __future__ import annotations
4+
5+import json
6+import sys
7+from pathlib import Path
8+
9+import numpy as np
10+import pandas as pd
11+import polars as pl
12+
13+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14+from wp39.inference import binary_metrics, paired_block_bootstrap, pointwise_logloss, quarter_blocks, two_way_cluster_ols # noqa: E402
15+from wp39.models import Preprocessor, columns_for, predict_proba # noqa: E402
16+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
17+from wp39.portfolio import car_window, download_french, factor_alpha, monthly_decile_portfolios, read_french_daily # noqa: E402
18+from wp39.provenance import now_iso, write_json # noqa: E402
19+from wp39.robustness import add_terciles, shuffle_labels_within_quarter, subgroup_masks # noqa: E402
20+from wp39.samples import feature_columns # noqa: E402
21+from wp39.splits import chronological # noqa: E402
22+
23+KEY = ["entity_id", "fiscal_period_end", "cutoff"]
24+FIXED = {"num_leaves": 15, "learning_rate": 0.03, "min_child_samples": 100, "feature_fraction": 0.7, "bagging_fraction": 0.8,
25+ "bagging_freq": 1, "lambda_l2": 1.0, "n_estimators": 600, "verbose": -1, "n_jobs": 12}
26+
27+
28+def fixed_fit(train, test, cols, target, seed):
29+ import lightgbm as lgb
30+ pp = Preprocessor(cols).fit(train)
31+ cut = train["cutoff"].quantile(0.85)
32+ tr, va = train.filter(pl.col("cutoff") < cut), train.filter(pl.col("cutoff") >= cut)
33+ m = lgb.LGBMClassifier(random_state=seed, **FIXED)
34+ m.fit(pp.transform(tr), tr[target].to_numpy().astype(int), eval_set=[(pp.transform(va), va[target].to_numpy().astype(int))],
35+ callbacks=[lgb.early_stopping(50, verbose=False)])
36+ return predict_proba(m, pp.transform(test))
37+
38+
39+def paired(A: pl.DataFrame, B: pl.DataFrame, y: str, nb: int, seed: int) -> dict:
40+ j = A.select(KEY + [y, "p", "A_q"]).join(B.select(KEY + [pl.col("p").alias("pb")]), on=KEY)
41+ yy = j[y].to_numpy().astype(int)
42+ d = pointwise_logloss(yy, j["p"].to_numpy()) - pointwise_logloss(yy, j["pb"].to_numpy())
43+ r = paired_block_bootstrap(d, quarter_blocks(j["A_q"].to_numpy()), nb, seed)
44+ r["auc_a"] = binary_metrics(yy, j["p"].to_numpy()).get("auc"); r["auc_b"] = binary_metrics(yy, j["pb"].to_numpy()).get("auc")
45+ r["base_rate"] = float(yy.mean())
46+ return r
47+
48+
49+def main() -> int:
50+ P = Paths.default()
51+ log = get_logger("08_robust", P.logs / "08_robust.log")
52+ cfg = load_config("research"); hp, nb, seed = cfg["cutoffs"]["primary_horizon_td"], cfg["inference"]["n_bootstrap"], cfg["seed"]
53+ y = "y_adverse_opinc"
54+ df = pl.read_parquet(P.processed / "sample_event.parquet").filter((pl.col("h") == hp) & pl.col(y).is_not_null())
55+ fc = feature_columns(df)
56+ sp = chronological(df, cfg["splits"])
57+ trv = pl.concat([sp["train"], sp["validation"]])
58+
59+ # ---------------------------------------------------------------- placebo 1: labels shuffled within calendar quarter
60+ plc = []
61+ for s in range(3):
62+ shuf_tr = shuffle_labels_within_quarter(trv, y, seed + s); shuf_te = shuffle_labels_within_quarter(sp["test"], y, seed + 100 + s)
63+ A = shuf_te.with_columns(pl.Series("p", fixed_fit(shuf_tr, shuf_te, columns_for("B5", fc), y, seed)))
64+ B = shuf_te.with_columns(pl.Series("p", fixed_fit(shuf_tr, shuf_te, columns_for("B2", fc), y, seed)))
65+ r = paired(A, B, y, nb, seed); r.update({"placebo": "shuffled_labels", "draw": s}); plc.append(r)
66+ log.info("placebo shuffled labels draw %d: d=%.5f [%.5f, %.5f] auc5=%.3f", s, r["mean"], r["ci_lo"], r["ci_hi"], r["auc_a"])
67+ pd.DataFrame(plc).to_csv(P.evaluation / "placebo_shuffled.csv", index=False)
68+
69+ # ---------------------------------------------------------------- subgroups on the chronological LightGBM test predictions
70+ preds = {m: pl.read_parquet(P.evaluation / "preds" / f"event_h{hp}_{y}_{m}_test_chrono.parquet") for m in ("LB2", "LB5", "B2", "B5")}
71+ tr_t, te_t = add_terciles(sp["train"], [sp["test"]])
72+ te_t = te_t.with_columns([
73+ ((pl.col("cutoff") - pl.col("A_prev")).dt.total_days() >= 35).fill_null(True).alias("window_clean"),
74+ (pl.col("w1m_present_share") >= 0.98).alias("dense_bars"),
75+ ])
76+ extra = {"window_clean": pl.col("window_clean"), "window_straddles_prev_release": ~pl.col("window_clean"),
77+ "dense_bars": pl.col("dense_bars"), "sparse_bars": ~pl.col("dense_bars")}
78+ sub = []
79+ for fam, a, b in (("lightgbm", "LB5", "LB2"), ("logistic", "B5", "B2")):
80+ for name, mask in {**subgroup_masks(te_t), **extra}.items():
81+ ids = te_t.filter(mask).select(KEY)
82+ A, B = preds[a].join(ids, on=KEY), preds[b].join(ids, on=KEY)
83+ if A.height < 500:
84+ continue
85+ r = paired(A, B, y, nb, seed); r.update({"family": fam, "subgroup": name}); sub.append(r)
86+ subdf = pd.DataFrame(sub); subdf.to_csv(P.evaluation / "subgroups.csv", index=False)
87+ log.info("subgroups:\n%s", subdf[subdf.family == "lightgbm"][["subgroup", "n", "mean", "ci_lo", "ci_hi", "auc_a", "auc_b"]].to_string())
88+
89+ # ---------------------------------------------------------------- H3 panel regressions on walk-forward OOF signals
90+ oof5 = pl.read_parquet(P.evaluation / "preds" / f"event_h{hp}_{y}_LB5_oof_walkforward.parquet").select(KEY + [pl.col("p").alias("lfds5")])
91+ oof2 = pl.read_parquet(P.evaluation / "preds" / f"event_h{hp}_{y}_LB2_oof_walkforward.parquet").select(KEY + [pl.col("p").alias("lfds2")])
92+ pan = df.join(oof5, on=KEY).join(oof2, on=KEY)
93+ pan = pan.with_columns([
94+ (pl.col("cutoff").dt.year().cast(pl.Utf8) + "Q" + ((pl.col("cutoff").dt.month() - 1) // 3 + 1).cast(pl.Utf8)).alias("cq"),
95+ ])
96+ pan = pan.with_columns((pl.col("sic_division") + "|" + pl.col("cq")).alias("sector_q"))
97+ # ex-ante cross-sectional terciles within calendar quarter
98+ for name, col in (("size", "f_log_mcap"), ("illiq", "wd_amihud60"), ("lev", "f_leverage")):
99+ pan = pan.with_columns(((pl.col(col).rank("average").over("cq") - 1) / pl.len().over("cq") * 3).floor().clip(0, 2).cast(pl.Int8).alias(f"t_{name}"))
100+ pd_ = pan.to_pandas()
101+ lo, hi = np.quantile(pd_["S_opinc"].dropna(), [0.01, 0.99]); pd_["S_w"] = pd_["S_opinc"].clip(lo, hi)
102+ pd_["d_lfds"] = pd_["lfds5"] - pd_["lfds2"]
103+ for name in ("size", "illiq", "lev"):
104+ for k in (0, 2):
105+ pd_[f"lfds5_x_{name}{k}"] = pd_["lfds5"] * (pd_[f"t_{name}"] == k)
106+ pd_[f"dlfds_x_{name}{k}"] = pd_["d_lfds"] * (pd_[f"t_{name}"] == k)
107+ regs = {}
108+ controls = ["f_roa", "f_leverage", "f_log_mcap", "wd_ret20", "wd_vol20", "last_surprise", "mean_surprise4"]
109+ regs["S_on_lfds5"] = two_way_cluster_ols(pd_, "S_w", ["lfds5"] + controls, "entity_id", "sector_q", "entity_id", "cq")
110+ regs["S_on_lfds2_and_dlfds"] = two_way_cluster_ols(pd_, "S_w", ["lfds2", "d_lfds"] + controls, "entity_id", "sector_q", "entity_id", "cq")
111+ regs["y_on_lfds5"] = two_way_cluster_ols(pd_.assign(yb=pd_[y].astype(float)), "yb", ["lfds5"] + controls, "entity_id", "sector_q", "entity_id", "cq")
112+ for name in ("size", "illiq", "lev"):
113+ regs[f"dlfds_x_{name}"] = two_way_cluster_ols(pd_, "S_w", ["lfds2", "d_lfds", f"dlfds_x_{name}0", f"dlfds_x_{name}2"] + controls,
114+ "entity_id", "sector_q", "entity_id", "cq")
115+ allreg = pd.concat([r.assign(spec=k) for k, r in regs.items()]); allreg.to_csv(P.evaluation / "panel_regressions.csv", index=False)
116+ log.info("panel:\n%s", allreg[allreg.spec.isin(["S_on_lfds5", "S_on_lfds2_and_dlfds"])][["spec", "var", "coef", "se", "t", "p", "n"]].head(12).to_string())
117+
118+ # ---------------------------------------------------------------- H5a: announcement-window CAR on the ex-ante signal
119+ ext = P.cache / "external"
120+ ff = read_french_daily(download_french(load_config("data_contract")["external"]["ff5_daily"], ext / "ff5_daily.zip"))
121+ mom = read_french_daily(download_french(load_config("data_contract")["external"]["mom_daily"], ext / "mom_daily.zip"))
122+ ff = ff.join(mom.iloc[:, :1].rename(columns={mom.columns[0]: "Mom"}), how="left")
123+ daily = pl.concat([pl.read_parquet(p).select(["symbol", "session_date", "d_ret_cc", "d_close", "d_dollar_vol"]) for p in (P.cache / "features").glob("*.parquet")])
124+ syms = set(pan["symbol"].unique().to_list())
125+ daily = daily.filter(pl.col("symbol").is_in(list(syms)))
126+ car = car_window(daily, ff, pan.select(["entity_id", "symbol", "cutoff", "A_q", "fiscal_period_end"]))
127+ pc = pd_.merge(car.to_pandas(), on=["entity_id", "symbol", "cutoff", "A_q", "fiscal_period_end"], how="inner")
128+ lo, hi = np.quantile(pc["car"].dropna(), [0.01, 0.99]); pc["car_w"] = pc["car"].clip(lo, hi)
129+ h5 = {"car_on_lfds5": two_way_cluster_ols(pc, "car_w", ["lfds5"] + controls, "entity_id", "sector_q", "entity_id", "cq"),
130+ "car_on_lfds2_dlfds": two_way_cluster_ols(pc, "car_w", ["lfds2", "d_lfds"] + controls, "entity_id", "sector_q", "entity_id", "cq")}
131+ pd.concat([r.assign(spec=k) for k, r in h5.items()]).to_csv(P.evaluation / "h5_car_regressions.csv", index=False)
132+ log.info("H5 CAR:\n%s", h5["car_on_lfds2_dlfds"][["var", "coef", "se", "t", "p", "n"]].head(3).to_string())
133+
134+ # ---------------------------------------------------------------- H5b: monthly decile sorts (calendar design, OOF scores)
135+ port_out = {}
136+ for m in ("LB5", "LB2"):
137+ f = P.evaluation / "preds" / f"calendar_h0_{y}_{m}_oof_walkforward.parquet"
138+ if not f.exists():
139+ continue
140+ sig = pl.read_parquet(f).select(["symbol", "cutoff", pl.col("p").alias("score")]).to_pandas()
141+ res = monthly_decile_portfolios(sig, daily.to_pandas(), ff, cost_bps=20.0)
142+ port = res["portfolios"]; port.to_csv(P.evaluation / f"portfolios_{m}.csv", index=False)
143+ port_out[m] = {"gross": factor_alpha(port, ff, 1, 10, net=False), "net20bps": factor_alpha(port, ff, 1, 10, net=True),
144+ "decile_means": port.groupby("decile")["ret"].mean().round(5).to_dict(),
145+ "avg_turnover": float(port["turnover"].mean())}
146+ log.info("portfolio %s: %s", m, port_out[m]["gross"])
147+ write_json(P.evaluation / "h5_portfolios.json", port_out)
148+
149+ for f in ("placebo_shuffled", "subgroups", "panel_regressions", "h5_car_regressions"):
150+ (P.results / f"{f}.csv").write_text((P.evaluation / f"{f}.csv").read_text())
151+ write_json(P.results / "robustness_summary.json", {"run_at": now_iso(), "placebo": plc, "portfolios": port_out})
152+ return 0
153+
154+
155+if __name__ == "__main__":
156+ raise SystemExit(main())
added scripts/09_make_manuscript.py +203 −0
@@ -0,0 +1,203 @@
1+#!/usr/bin/env python
2+"""Phase 6: generate LaTeX tables, figures and a results-macro file from saved artifacts (never from memory)."""
3+from __future__ import annotations
4+
5+import json
6+import sys
7+from pathlib import Path
8+
9+import numpy as np
10+import pandas as pd
11+import polars as pl
12+
13+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14+from wp39 import figures as F # noqa: E402
15+from wp39.pipeline import Paths, get_logger, load_config # noqa: E402
16+
17+T = {} # macros
18+
19+
20+def macro(name: str, value, fmt: str = "{}"):
21+ T[name] = fmt.format(value)
22+
23+
24+def fnum(x, d=4):
25+ return "" if x is None or (isinstance(x, float) and np.isnan(x)) else f"{x:.{d}f}"
26+
27+
28+def stars(p):
29+ return "***" if p < 0.01 else "**" if p < 0.05 else "*" if p < 0.1 else ""
30+
31+
32+def tex_table(df: pd.DataFrame, path: Path, cols: list[tuple[str, str, str]], note: str = ""):
33+ """cols: (column, header, fmt) — fmt is a python format or 'int'/'str'."""
34+ lines = ["\\begin{tabular}{l" + "r" * (len(cols) - 1) + "}", "\\toprule", " & ".join(h for _, h, _ in cols) + " \\\\", "\\midrule"]
35+ for _, r in df.iterrows():
36+ cells = []
37+ for c, _, fmt in cols:
38+ v = r[c]
39+ if fmt == "int":
40+ cells.append(f"{int(v):,}" if pd.notna(v) else "")
41+ elif fmt == "str":
42+ cells.append(str(v).replace("_", " ").replace("%", "\\%"))
43+ else:
44+ cells.append(fmt.format(v) if pd.notna(v) else "")
45+ lines.append(" & ".join(cells) + " \\\\")
46+ lines += ["\\bottomrule", "\\end{tabular}"]
47+ path.write_text("\n".join(lines) + "\n")
48+
49+
50+def main() -> int:
51+ P = Paths.default()
52+ log = get_logger("09_manuscript", P.logs / "09_manuscript.log")
53+ cfg = load_config("research"); hp = cfg["cutoffs"]["primary_horizon_td"]
54+ E, R, TB, FG = P.evaluation, P.results, P.paper_tables, P.paper_figures
55+ ua = json.load(open(R / "universe_audit.json")); pa = json.load(open(R / "pit_panel_audit.json")); sa = json.load(open(R / "sample_audit.json"))
56+ ev = pd.read_parquet(P.processed / "sample_event.parquet", columns=["entity_id", "fiscal_period_end", "h", "cutoff", "A_q", "y_adverse_opinc",
57+ "S_opinc", "f_log_mcap", "wd_amihud60", "f_leverage", "release_time", "sic_division"])
58+ e20 = ev[ev.h == hp]
59+ # ---------------- macros: sample
60+ macro("nEntities", ua["entities"], "{:,}"); macro("nEligible", ua["eligible"], "{:,}"); macro("nWithCik", ua["with_cik"], "{:,}")
61+ macro("nFirmQuarters", pa["firm_quarters"], "{:,}"); macro("nFirmsPanel", pa["entities"], "{:,}")
62+ macro("nSampleEvent", sa["event"]["rows"], "{:,}"); macro("nSampleFirms", sa["event"]["firms"], "{:,}")
63+ macro("nSampleQuarters", sa["event"]["quarters"], "{:,}"); macro("nSampleCalendar", sa["calendar"]["rows"], "{:,}")
64+ macro("nObsPrimary", len(e20), "{:,}"); macro("baseRatePrimary", 100 * e20.y_adverse_opinc.mean(), "{:.1f}")
65+ macro("firstCutoff", str(e20.cutoff.min())[:10]); macro("lastCutoff", str(e20.cutoff.max())[:10])
66+ macro("releaseMatchedShare", 100 * next(r["len"] for r in pa["release_match"] if r["release_match"] == "ok") / pa["firm_quarters"], "{:.1f}")
67+ macro("filingLagMedian", pa["filing_minus_release_days_quantiles"]["0.5"], "{:.0f}")
68+ macro("epsEstimateShare", 100 * pa["eps_estimate_available_share"], "{:.0f}")
69+ # ---------------- Table 2: universe cascade
70+ tex_table(pd.read_csv(R / "table_universe_cascade.csv"), TB / "tab02_universe.tex", [("step", "Step", "str"), ("n", "Firms", "int")])
71+ casc = pd.read_csv(R / "table_sample_cascade_event.csv")
72+ tex_table(casc, TB / "tab02b_sample_cascade.tex", [("step", "Exclusion step", "str"), ("remaining", "Remaining", "int"), ("dropped", "Dropped", "int")])
73+ # ---------------- Table 3: summary statistics (h = 20 sample)
74+ feat = pd.read_parquet(P.processed / "sample_event.parquet")
75+ feat = feat[feat.h == hp]
76+ desc_cols = {"S_opinc": "Scaled operating-income surprise $S_q$", "y_adverse_opinc": "Adverse surprise ($S_q \\le -0.5\\%$)", "f_roa": "ROA (latest available)",
77+ "f_leverage": "Debt / assets", "f_log_mcap": "log market cap", "wd_ret20": "20-day return", "wd_vol20": "20-day daily volatility",
78+ "wd_amihud60": "Amihud illiquidity (60d, $\\times 10^6$)", "w1m_rv": "1-min realized variance (daily mean)", "w1m_rj": "1-min relative jump",
79+ "w1m_rsneg_share": "1-min negative semivariance share", "w1m_down_vol_share": "1-min down-bar volume share", "w1m_present_share": "1-min bars present (share)",
80+ "w30m_rv": "30-min realized variance", "w5m_rv": "5-min realized variance", "info_age_td": "Days since last release"}
81+ rows = []
82+ for c, lab in desc_cols.items():
83+ s = pd.to_numeric(feat[c], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna()
84+ rows.append({"var": lab, "n": len(s), "mean": s.mean(), "sd": s.std(), "p10": s.quantile(0.1), "p50": s.median(), "p90": s.quantile(0.9)})
85+ tex_table(pd.DataFrame(rows), TB / "tab03_summary.tex", [("var", "Variable", "str"), ("n", "N", "int"), ("mean", "Mean", "{:.4g}"), ("sd", "SD", "{:.4g}"),
86+ ("p10", "P10", "{:.4g}"), ("p50", "Median", "{:.4g}"), ("p90", "P90", "{:.4g}")])
87+ # ---------------- Table 4: information sets
88+ fc = json.load(open(E / "feature_columns_event.json"))
89+ info = pd.DataFrame([{"set": "B1", "desc": "PIT fundamentals + history + size", "k": len(fc["fundamentals"])},
90+ {"set": "B2", "desc": "B1 + daily OHLCV (20/60-day)", "k": len(fc["fundamentals"]) + len(fc["daily"])},
91+ {"set": "B3", "desc": "B2 + 30-minute families", "k": len(fc["fundamentals"]) + len(fc["daily"]) + len(fc["intraday_30m"])},
92+ {"set": "B4", "desc": "B2 + 5-minute families", "k": len(fc["fundamentals"]) + len(fc["daily"]) + len(fc["intraday_5m"])},
93+ {"set": "B5", "desc": "B2 + 1-minute families", "k": len(fc["fundamentals"]) + len(fc["daily"]) + len(fc["intraday_1m"])},
94+ {"set": "B5all", "desc": "B2 + 30m + 5m + 1m", "k": sum(len(v) for v in fc.values())}])
95+ tex_table(info, TB / "tab04_info_sets.tex", [("set", "Set", "str"), ("desc", "Information set", "str"), ("k", "Features", "int")])
96+ macro("kFund", len(fc["fundamentals"])); macro("kDaily", len(fc["daily"])); macro("kIntra", len(fc["intraday_1m"]))
97+ # ---------------- Table 5: headline OOS metrics (h=20, chrono test)
98+ met = pd.read_csv(E / "metrics_all.csv")
99+ m20 = met[(met.h == hp) & (met.target == "y_adverse_opinc") & (met.part == "test") & (met.tag == "chrono")].copy()
100+ order = ["B0", "B0b", "B1", "B2", "B3", "B4", "B5", "B5all", "LB1", "LB2", "LB3", "LB4", "LB5", "LB5all"]
101+ m20["o"] = m20.model.map({m: i for i, m in enumerate(order)}); m20 = m20.sort_values("o")
102+ tex_table(m20, TB / "tab05_headline.tex", [("model", "Model", "str"), ("n", "N", "int"), ("logloss", "Log loss", "{:.4f}"), ("brier", "Brier", "{:.4f}"),
103+ ("auc", "AUC", "{:.3f}"), ("pr_auc", "PR-AUC", "{:.3f}"), ("cal_slope", "Cal. slope", "{:.2f}"), ("cal_intercept", "Cal. int.", "{:.2f}")])
104+ for _, r in m20.iterrows():
105+ macro(f"ll{r.model}", r.logloss, "{:.4f}"); macro(f"auc{r.model}", r.auc, "{:.3f}")
106+ # ---------------- Table 5b: paired contrasts
107+ con = pd.read_csv(E / "contrasts.csv")
108+ con["ci"] = con.apply(lambda r: f"[{r.ci_lo:+.4f}, {r.ci_hi:+.4f}]", axis=1)
109+ con["gwp"] = con.apply(lambda r: f"{r.gw_p:.3f}{stars(r.gw_p)}", axis=1)
110+ con["holm"] = con["gw_p_holm"].map(lambda v: "" if pd.isna(v) else f"{v:.3f}")
111+ tex_table(con, TB / "tab05b_contrasts.tex", [("family", "Family", "str"), ("a", "A", "str"), ("b", "B", "str"), ("n", "N", "int"), ("d_logloss", "$\\Delta$ log loss", "{:+.4f}"),
112+ ("ci", "95\\% CI", "str"), ("gwp", "GW $p$", "str"), ("holm", "Holm", "str"), ("d_brier", "$\\Delta$ Brier", "{:+.5f}")])
113+ for _, r in con.iterrows():
114+ k = f"{r.family[:3]}{r.a}vs{r.b}".replace("B5all", "Ball")
115+ macro(f"d{k}", r.d_logloss, "{:+.4f}"); macro(f"lo{k}", r.ci_lo, "{:+.4f}"); macro(f"hi{k}", r.ci_hi, "{:+.4f}"); macro(f"p{k}", r.gw_p, "{:.3f}")
116+ macro(f"n{k}", r.n, "{:,}")
117+ # ---------------- Table 7: horizon ladder, secondary targets, firm holdout
118+ lad = pd.read_csv(E / "horizon_ladder.csv")
119+ lad["ci"] = lad.apply(lambda r: f"[{r.ci_lo:+.4f}, {r.ci_hi:+.4f}]", axis=1)
120+ tex_table(lad.sort_values(["family", "h"], ascending=[True, False]), TB / "tab07_horizons.tex", [("family", "Family", "str"), ("h", "$h$", "int"), ("n", "N", "int"),
121+ ("auc_b", "AUC B2", "{:.3f}"), ("auc_a", "AUC B5", "{:.3f}"), ("d_logloss", "$\\Delta$ log loss", "{:+.4f}"), ("ci", "95\\% CI", "str"), ("gw_p", "GW $p$", "{:.3f}")])
122+ h4 = json.load(open(E / "h4_timing.json"))
123+ for fam, v in h4.items():
124+ macro(f"h4rho{fam}", v["spearman_gain_vs_minus_h"], "{:+.2f}"); macro(f"h4p{fam}", v["p"], "{:.2f}")
125+ sec = pd.read_csv(E / "secondary_targets.csv")
126+ sec["ci"] = sec.apply(lambda r: f"[{r.ci_lo:+.4f}, {r.ci_hi:+.4f}]", axis=1)
127+ tex_table(sec, TB / "tab10_targets.tex", [("target", "Target", "str"), ("family", "Family", "str"), ("n", "N", "int"), ("base_rate", "Base rate", "{:.3f}"),
128+ ("auc_b", "AUC B2", "{:.3f}"), ("auc_a", "AUC B5", "{:.3f}"), ("d_logloss", "$\\Delta$ log loss", "{:+.4f}"), ("ci", "95\\% CI", "str"), ("gw_p", "GW $p$", "{:.3f}")])
129+ fh = pd.read_csv(E / "firm_holdout.csv"); fh["ci"] = fh.apply(lambda r: f"[{r.ci_lo:+.4f}, {r.ci_hi:+.4f}]", axis=1)
130+ tex_table(fh, TB / "tab09b_firmholdout.tex", [("tag", "Firms", "str"), ("family", "Family", "str"), ("n", "N", "int"), ("d_logloss", "$\\Delta$ log loss", "{:+.4f}"), ("ci", "95\\% CI", "str"), ("gw_p", "GW $p$", "{:.3f}")])
131+ # ---------------- Table 6: panel regressions
132+ pr = pd.read_csv(E / "panel_regressions.csv")
133+ pr = pr[pr["var"].isin(["lfds5", "lfds2", "d_lfds", "dlfds_x_size0", "dlfds_x_size2", "dlfds_x_illiq0", "dlfds_x_illiq2", "dlfds_x_lev0", "dlfds_x_lev2"])].copy()
134+ pr["coef_s"] = pr.apply(lambda r: f"{r.coef:.4f}{stars(r.p)}", axis=1); pr["se_s"] = pr.se.map(lambda v: f"({v:.4f})")
135+ tex_table(pr, TB / "tab06_panel.tex", [("spec", "Specification", "str"), ("var", "Regressor", "str"), ("coef_s", "Coef.", "str"), ("se_s", "SE", "str"), ("n", "N", "int"), ("n_firms", "Firms", "int"), ("n_quarters", "Quarters", "int")])
136+ for _, r in pr.iterrows():
137+ macro(f"pan{r.spec.replace('_', '')}{r['var'].replace('_', '')}", r.coef, "{:+.4f}"); macro(f"pant{r.spec.replace('_', '')}{r['var'].replace('_', '')}", r.t, "{:.2f}")
138+ # ---------------- Table 7b: mechanisms / subgroups; Table 8: placebos
139+ sub = pd.read_csv(E / "subgroups.csv"); sub["ci"] = sub.apply(lambda r: f"[{r.ci_lo:+.4f}, {r.ci_hi:+.4f}]", axis=1)
140+ tex_table(sub[sub.family == "lightgbm"], TB / "tab07b_subgroups.tex", [("subgroup", "Subgroup", "str"), ("n", "N", "int"), ("base_rate", "Base rate", "{:.3f}"),
141+ ("auc_b", "AUC LB2", "{:.3f}"), ("auc_a", "AUC LB5", "{:.3f}"), ("mean", "$\\Delta$ log loss", "{:+.4f}"), ("ci", "95\\% CI", "str")])
142+ plc = pd.read_csv(E / "placebo_shuffled.csv"); plc["ci"] = plc.apply(lambda r: f"[{r.ci_lo:+.4f}, {r.ci_hi:+.4f}]", axis=1)
143+ tex_table(plc, TB / "tab08_placebo.tex", [("placebo", "Placebo", "str"), ("draw", "Draw", "int"), ("n", "N", "int"), ("auc_a", "AUC LB5", "{:.3f}"), ("auc_b", "AUC LB2", "{:.3f}"), ("mean", "$\\Delta$ log loss", "{:+.4f}"), ("ci", "95\\% CI", "str")])
144+ macro("placeboMeanD", plc["mean"].mean(), "{:+.4f}"); macro("placeboAUC", plc["auc_a"].mean(), "{:.3f}")
145+ # ---------------- Table 11: economic value
146+ h5 = json.load(open(E / "h5_portfolios.json")); car = pd.read_csv(E / "h5_car_regressions.csv")
147+ rows = []
148+ for m, v in h5.items():
149+ for k in ("gross", "net20bps"):
150+ a = v[k]; rows.append({"model": m, "variant": k, "alpha": 100 * a["alpha_monthly"], "t": a["alpha_t"], "mean_ls": 100 * a["mean_ls"], "sharpe": a["sharpe_ann"], "T": a["T"], "turnover": v["avg_turnover"]})
151+ tex_table(pd.DataFrame(rows), TB / "tab11_economic.tex", [("model", "Score", "str"), ("variant", "Variant", "str"), ("mean_ls", "Mean L/S (\\%/mo)", "{:.2f}"), ("alpha", "FF5+MOM $\\alpha$ (\\%/mo)", "{:+.2f}"),
152+ ("t", "$t$ (HAC)", "{:.2f}"), ("sharpe", "Sharpe (ann.)", "{:.2f}"), ("turnover", "Turnover", "{:.2f}"), ("T", "Months", "int")])
153+ for m, v in h5.items():
154+ macro(f"alpha{m}gross", 100 * v["gross"]["alpha_monthly"], "{:+.2f}"); macro(f"alphat{m}gross", v["gross"]["alpha_t"], "{:.2f}")
155+ macro(f"alpha{m}net", 100 * v["net20bps"]["alpha_monthly"], "{:+.2f}"); macro(f"alphat{m}net", v["net20bps"]["alpha_t"], "{:.2f}")
156+ carr = car[car["var"].isin(["lfds5", "lfds2", "d_lfds"])].copy(); carr["coef_s"] = carr.apply(lambda r: f"{r.coef:.4f}{stars(r.p)}", axis=1); carr["se_s"] = carr.se.map(lambda v: f"({v:.4f})")
157+ tex_table(carr, TB / "tab11b_car.tex", [("spec", "Specification", "str"), ("var", "Regressor", "str"), ("coef_s", "Coef.", "str"), ("se_s", "SE", "str"), ("n", "N", "int")])
158+ for _, r in carr.iterrows():
159+ macro(f"car{r.spec.replace('_', '')}{r['var'].replace('_', '')}", r.coef, "{:+.4f}"); macro(f"cart{r.spec.replace('_', '')}{r['var'].replace('_', '')}", r.t, "{:.2f}")
160+ # continuous + power
161+ cont = pd.read_csv(E / "continuous.csv")
162+ tex_table(cont[cont.model.isin(["B2", "B5"])], TB / "tab10b_continuous.tex", [("model", "Model", "str"), ("n", "N", "int"), ("mae", "MAE", "{:.5f}"), ("rmse", "RMSE", "{:.5f}"), ("oos_r2", "OOS $R^2$", "{:.4f}")])
163+ for _, r in cont.iterrows():
164+ if r.model in ("B2", "B5"):
165+ macro(f"r2{r.model}", r.oos_r2, "{:.4f}")
166+ pw = json.load(open(E / "power.json")); macro("mde", pw.get("mde_logloss_80pct_power", float("nan")), "{:.4f}"); macro("valQuarters", pw.get("validation_quarters", 0))
167+ # ---------------- figures
168+ F.fig_timeline(FG)
169+ yrs = e20.assign(year=pd.to_datetime(e20.cutoff).dt.year).groupby("year").agg(firms=("entity_id", "nunique"), firm_quarters=("entity_id", "size"), adverse_rate=("y_adverse_opinc", "mean")).reset_index()
170+ F.fig_coverage(FG, yrs)
171+ te = e20[pd.to_datetime(e20.cutoff) >= pd.Timestamp(str(cfg["splits"]["test"][0]))].copy()
172+ prev_rows = [{"label": "All firm-quarters", "sel": np.ones(len(te), bool)}]
173+ for name, col, lab in (("size", "f_log_mcap", "Market cap"), ("illiq", "wd_amihud60", "Illiquidity"), ("lev", "f_leverage", "Leverage")):
174+ q = te[col].rank(pct=True)
175+ prev_rows += [{"label": f"{lab}: bottom tercile", "sel": (q <= 1 / 3).values}, {"label": f"{lab}: top tercile", "sel": (q > 2 / 3).values}]
176+ for rt in ("before_open", "after_close"):
177+ prev_rows.append({"label": f"Release {rt.replace('_', ' ')}", "sel": (te.release_time == rt).values})
178+ pr_ = []
179+ for r in prev_rows:
180+ y = te.y_adverse_opinc.values[r["sel"]]; n = len(y); p = y.mean() if n else np.nan; se = np.sqrt(p * (1 - p) / n) if n else np.nan
181+ pr_.append({"label": r["label"], "rate": p, "lo": p - 1.96 * se, "hi": p + 1.96 * se})
182+ F.fig_prevalence(FG, pd.DataFrame(pr_))
183+ F.fig_frequency_gains(FG, con)
184+ F.fig_reliability(FG, pd.read_csv(E / "reliability.csv"))
185+ F.fig_signal_evolution(FG, lad)
186+ F.fig_heterogeneity(FG, sub, ["all", "size_small", "size_large", "illiq_high", "illiq_low", "lev_high", "lev_low", "loss_firms", "profit_firms",
187+ "covid_2020", "ex_covid", "window_clean", "window_straddles_prev_release", "dense_bars", "sparse_bars", "sector_finance", "sector_nonfinance"])
188+ F.fig_rolling(FG, pd.read_csv(E / "walkforward_by_year.csv"), pd.read_csv(E / "walkforward_contrasts.csv"))
189+ if "LB5" in h5:
190+ F.fig_economic(FG, pd.read_csv(E / "portfolios_LB5.csv"), h5["LB5"])
191+ # ---------------- macros file
192+ lines = ["% Auto-generated by scripts/09_make_manuscript.py — do not edit"]
193+ for k, v in T.items():
194+ words = {"0": "Zero", "1": "One", "2": "Two", "3": "Three", "4": "Four", "5": "Five", "6": "Six", "7": "Seven", "8": "Eight", "9": "Nine"}
195+ kk = "".join(words.get(ch, ch) if ch.isdigit() else ch for ch in k if ch.isalnum())
196+ lines.append(f"\\newcommand{{\\{kk}}}{{{v}}}")
197+ (P.root / "paper" / "results_macros.tex").write_text("\n".join(lines) + "\n")
198+ log.info("wrote %d macros, %d tables, figures to %s", len(T), len(list(TB.glob('*.tex'))), FG)
199+ return 0
200+
201+
202+if __name__ == "__main__":
203+ raise SystemExit(main())
added src/wp39/__init__.py +2 −0
@@ -0,0 +1,2 @@
1+"""WP39 UQO — The Invisible Balance Sheet (research package)."""
2+__version__ = "0.1.0"
added src/wp39/api_client.py +226 −0
@@ -0,0 +1,226 @@
1+"""Typed, redacting client for the FinData OS API (https://findata.spboucher.ai/api/v1).
2+
3+Design rules (docs/API_INTEGRATION.md):
4+- the bearer token is read from the environment or a mode-600 file, never logged, never placed in a URL;
5+- bounded exponential backoff with jitter on 429/5xx and transport errors (idempotent GETs; export creation only when
6+ no export id was obtained);
7+- every call appends one provenance line (method, path, status, request id, dataset id/version, elapsed) to a JSONL log;
8+- Parquet exports are validated against the published bars schema before being accepted.
9+"""
10+from __future__ import annotations
11+
12+import hashlib
13+import json
14+import os
15+import random
16+import re
17+import time
18+from dataclasses import dataclass, asdict
19+from pathlib import Path
20+from typing import Any, Iterator
21+
22+import httpx
23+
24+_TOKEN_RE = re.compile(r"fd_[A-Za-z0-9_.\-]+")
25+
26+
27+def _redact(text: str) -> str:
28+ return _TOKEN_RE.sub("fd_<REDACTED>", text)
29+
30+
31+class FinDataError(RuntimeError):
32+ def __init__(self, status: int, code: str, message: str, request_id: str | None = None):
33+ super().__init__(f"HTTP {status} {code}: {_redact(message)} (request_id={request_id})")
34+ self.status, self.code, self.request_id = status, code, request_id
35+
36+
37+def load_api_key() -> str:
38+ key = os.environ.get("FINDATA_API_KEY")
39+ if key:
40+ return key.strip()
41+ path = os.path.expanduser(os.environ.get("FINDATA_API_KEY_FILE", "~/findata-secrets/api-key-wp39.txt"))
42+ if not os.path.exists(path):
43+ path = os.path.expanduser("~/Desktop/Cluster/secrets/findata/api-key-wp39.txt")
44+ with open(path) as fh:
45+ return fh.read().strip()
46+
47+
48+@dataclass
49+class ExportResult:
50+ export_id: str
51+ dataset_id: str
52+ dataset_version: int | None
53+ symbols: list[str]
54+ start: str
55+ end: str
56+ adjustment: str
57+ row_count: int
58+ bytes: int
59+ sha256: str
60+ path: str
61+ request_id: str | None
62+ exported_at: str
63+
64+
65+class FinDataClient:
66+ def __init__(
67+ self,
68+ base_url: str | None = None,
69+ api_key: str | None = None,
70+ log_path: str | os.PathLike | None = None,
71+ timeout: float = 60.0,
72+ max_attempts: int = 5,
73+ ):
74+ self.base_url = (base_url or os.environ.get("FINDATA_BASE_URL") or "https://findata.spboucher.ai/api/v1").rstrip("/")
75+ self._key = api_key or load_api_key()
76+ self.max_attempts = max_attempts
77+ self.log_path = Path(log_path) if log_path else None
78+ self._client = httpx.Client(timeout=timeout, headers={"Authorization": f"Bearer {self._key}", "User-Agent": "wp39-uqo/0.1"})
79+
80+ # ------------------------------------------------------------------ core
81+ def _log(self, rec: dict[str, Any]) -> None:
82+ if not self.log_path:
83+ return
84+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
85+ with open(self.log_path, "a") as fh:
86+ fh.write(json.dumps(rec, default=str) + "\n")
87+
88+ def _request(self, method: str, path: str, *, params=None, json_body=None, stream_to: Path | None = None,
89+ retry: bool = True) -> httpx.Response:
90+ url = path if path.startswith("http") else f"{self.base_url}{path}"
91+ attempt, delay = 0, 0.5
92+ while True:
93+ attempt += 1
94+ t0 = time.perf_counter()
95+ try:
96+ if stream_to is not None:
97+ with self._client.stream(method, url, params=params, json=json_body) as r:
98+ if r.status_code >= 400:
99+ r.read()
100+ resp = r
101+ else:
102+ stream_to.parent.mkdir(parents=True, exist_ok=True)
103+ h = hashlib.sha256()
104+ with open(stream_to, "wb") as fh:
105+ for chunk in r.iter_bytes():
106+ fh.write(chunk)
107+ h.update(chunk)
108+ r.sha256 = h.hexdigest() # type: ignore[attr-defined]
109+ resp = r
110+ else:
111+ resp = self._client.request(method, url, params=params, json=json_body)
112+ elapsed = time.perf_counter() - t0
113+ self._log({
114+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "method": method, "path": path, "status": resp.status_code,
115+ "request_id": resp.headers.get("X-Request-ID"), "dataset_id": resp.headers.get("X-Dataset-Id"),
116+ "dataset_version": resp.headers.get("X-Dataset-Version"), "elapsed_s": round(elapsed, 3), "attempt": attempt,
117+ })
118+ if resp.status_code < 400:
119+ return resp
120+ retryable = resp.status_code in (429, 500, 502, 503, 504)
121+ if not (retry and retryable) or attempt >= self.max_attempts:
122+ try:
123+ err = resp.json().get("error", {})
124+ except Exception:
125+ err = {"code": "http_error", "message": resp.text[:300]}
126+ raise FinDataError(resp.status_code, err.get("code", "http_error"), err.get("message", ""), err.get("request_id"))
127+ except (httpx.TransportError, httpx.TimeoutException) as exc:
128+ self._log({"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "method": method, "path": path, "status": None,
129+ "error": _redact(type(exc).__name__), "attempt": attempt})
130+ if not retry or attempt >= self.max_attempts:
131+ raise RuntimeError(f"FinData transport failure after {attempt} attempts on {path}: {_redact(str(exc))}") from None
132+ time.sleep(delay + random.uniform(0, delay / 2))
133+ delay = min(delay * 2, 8.0)
134+
135+ def get(self, path: str, **params) -> Any:
136+ return self._request("GET", path, params={k: v for k, v in params.items() if v is not None}).json()
137+
138+ def post(self, path: str, body: dict, retry: bool = False) -> Any:
139+ return self._request("POST", path, json_body=body, retry=retry).json()
140+
141+ # --------------------------------------------------------------- catalog
142+ def health(self) -> dict:
143+ return self.get("/health")
144+
145+ def openapi(self) -> dict:
146+ return self.get("/openapi.json")
147+
148+ def datasets(self) -> list[dict]:
149+ return self.get("/catalog/datasets")["data"]
150+
151+ def dataset(self, dataset_id: str) -> dict:
152+ return self.get(f"/catalog/datasets/{dataset_id}")
153+
154+ def schema_bars(self) -> dict:
155+ return self.get("/catalog/schema/bars")
156+
157+ # ----------------------------------------------------------- instruments
158+ def instrument(self, symbol: str) -> dict:
159+ return self.get(f"/instruments/{symbol}")
160+
161+ def instruments_search(self, query: str | None = None, asset_class: str | None = None, status: str | None = None,
162+ limit: int = 200) -> Iterator[dict]:
163+ cursor = None
164+ while True:
165+ page = self.get("/instruments", query=query, asset_class=asset_class, status=status, limit=limit, cursor=cursor)
166+ yield from page.get("data", [])
167+ cursor = page.get("next_cursor")
168+ if not cursor:
169+ return
170+
171+ def coverage_symbols(self, dataset_id: str, min_sessions: int = 1, limit: int = 5000) -> Iterator[dict]:
172+ cursor = None
173+ while True:
174+ page = self.get("/coverage/symbols", dataset_id=dataset_id, min_sessions=min_sessions, limit=limit, cursor=cursor)
175+ yield from page.get("data", [])
176+ cursor = page.get("next_cursor")
177+ if not cursor:
178+ return
179+
180+ def coverage(self, dataset_id: str, symbol: str, start: str, end: str, limit: int = 5000) -> list[dict]:
181+ return self.get("/coverage", dataset_id=dataset_id, symbol=symbol, start=start, end=end, limit=limit)["data"]
182+
183+ # ---------------------------------------------------------- fundamentals
184+ def fundamentals(self, entity_id: str, as_of: str | None = None, metrics: list[str] | None = None,
185+ period_type: str | None = "q", limit: int = 20000, include_superseded: bool = False) -> dict:
186+ return self.get(f"/fundamentals/{entity_id}", as_of=as_of, metrics=",".join(metrics) if metrics else None,
187+ period_type=period_type, limit=limit, include_superseded=str(include_superseded).lower())
188+
189+ def fundamentals_periods(self, entity_id: str) -> list[dict]:
190+ return self.get(f"/fundamentals/{entity_id}/periods")["periods"]
191+
192+ # ----------------------------------------------------------------- query
193+ def query(self, sql: str, max_rows: int = 1000) -> tuple[list[str], list[list[Any]], dict]:
194+ d = self.post("/query", {"sql": sql, "max_rows": max_rows}, retry=True)
195+ return [c["name"] for c in d["columns"]], d["rows"], d.get("meta", {})
196+
197+ # --------------------------------------------------------------- exports
198+ def export(self, dataset_id: str, symbols: list[str], start: str, end: str, dest: Path, adjustment: str = "raw",
199+ poll_s: float = 1.0, max_wait_s: float = 600.0) -> ExportResult:
200+ body = {"dataset_id": dataset_id, "symbols": symbols, "start": start, "end": end, "adjustment": adjustment, "format": "parquet"}
201+ r = self._request("POST", "/exports", json_body=body, retry=False)
202+ created = r.json()
203+ export_id, request_id = created["id"], r.headers.get("X-Request-ID")
204+ t0 = time.time()
205+ while True:
206+ st = self.get(f"/exports/{export_id}")
207+ if st["status"] == "ready":
208+ break
209+ if st["status"] in ("failed", "expired"):
210+ raise FinDataError(500, "export_" + st["status"], str(st.get("error")), request_id)
211+ if time.time() - t0 > max_wait_s:
212+ raise FinDataError(504, "export_timeout", f"export {export_id} not ready after {max_wait_s}s", request_id)
213+ time.sleep(poll_s)
214+ resp = self._request("GET", f"/exports/{export_id}/download", stream_to=dest)
215+ snap = st.get("snapshot") or {}
216+ return ExportResult(export_id=export_id, dataset_id=dataset_id, dataset_version=snap.get("version"), symbols=symbols,
217+ start=start, end=end, adjustment=adjustment, row_count=int(st.get("row_count") or 0),
218+ bytes=int(st.get("bytes") or 0), sha256=getattr(resp, "sha256", ""), path=str(dest),
219+ request_id=request_id, exported_at=time.strftime("%Y-%m-%dT%H:%M:%S%z"))
220+
221+ def close(self) -> None:
222+ self._client.close()
223+
224+
225+def export_result_dict(res: ExportResult) -> dict:
226+ return asdict(res)
added src/wp39/calendar.py +78 −0
@@ -0,0 +1,78 @@
1+"""XNYS trading calendar helpers (America/New_York sessions, UTC storage)."""
2+from __future__ import annotations
3+
4+import datetime as dt
5+from functools import lru_cache
6+from zoneinfo import ZoneInfo
7+
8+import numpy as np
9+import pandas as pd
10+
11+ET = ZoneInfo("America/New_York")
12+UTC = ZoneInfo("UTC")
13+
14+
15+@lru_cache(maxsize=1)
16+def _xnys():
17+ import exchange_calendars as xc
18+ return xc.get_calendar("XNYS", start="2000-01-01", end="2027-12-31")
19+
20+
21+@lru_cache(maxsize=1)
22+def sessions() -> pd.DatetimeIndex:
23+ """All XNYS sessions 2000→2027 as tz-naive dates."""
24+ return _xnys().sessions.tz_localize(None) if _xnys().sessions.tz is not None else _xnys().sessions
25+
26+
27+def session_array() -> np.ndarray:
28+ return sessions().to_numpy().astype("datetime64[D]")
29+
30+
31+def is_session(day: dt.date) -> bool:
32+ return np.datetime64(day) in set(session_array().tolist())
33+
34+
35+def shift_trading_days(day: dt.date | np.datetime64 | pd.Timestamp, n: int) -> dt.date:
36+ """Shift by n trading days; if `day` is not a session, start from the previous session (n<=0) or next (n>0)."""
37+ arr = session_array()
38+ d = np.datetime64(pd.Timestamp(day).date())
39+ pos = int(np.searchsorted(arr, d))
40+ if pos < len(arr) and arr[pos] == d:
41+ base = pos
42+ else:
43+ base = pos - 1 if n <= 0 else pos
44+ idx = base + n
45+ if idx < 0 or idx >= len(arr):
46+ raise ValueError("date out of calendar range")
47+ return arr[idx].astype("datetime64[D]").astype(dt.date)
48+
49+
50+def trading_days_between(a: dt.date, b: dt.date) -> int:
51+ """Number of sessions strictly after a up to and including b (negative if b<a)."""
52+ arr = session_array()
53+ ia, ib = np.searchsorted(arr, np.datetime64(a), side="right"), np.searchsorted(arr, np.datetime64(b), side="right")
54+ return int(ib - ia)
55+
56+
57+def month_end_sessions(start: dt.date, end: dt.date) -> list[dt.date]:
58+ s = pd.Series(sessions())
59+ s = s[(s >= pd.Timestamp(start)) & (s <= pd.Timestamp(end))]
60+ return [d.date() for d in s.groupby(s.dt.to_period("M")).max()]
61+
62+
63+def close_time_utc(session: dt.date) -> pd.Timestamp:
64+ """Open instant (UTC) of the closing-print bar (16:00 ET, or 13:00 ET on early-close days)."""
65+ cal = _xnys()
66+ ts = pd.Timestamp(session)
67+ close = cal.session_close(ts)
68+ return pd.Timestamp(close).tz_convert("UTC") if close.tz is not None else pd.Timestamp(close).tz_localize("UTC")
69+
70+
71+def regular_session_bounds_utc(session: dt.date) -> tuple[pd.Timestamp, pd.Timestamp]:
72+ """[open 09:30 ET, close bar open] in UTC for the session (inclusive bar-open bounds)."""
73+ cal = _xnys()
74+ ts = pd.Timestamp(session)
75+ o, c = cal.session_open(ts), cal.session_close(ts)
76+ o = pd.Timestamp(o).tz_convert("UTC") if o.tz is not None else pd.Timestamp(o).tz_localize("UTC")
77+ c = pd.Timestamp(c).tz_convert("UTC") if c.tz is not None else pd.Timestamp(c).tz_localize("UTC")
78+ return o, c
added src/wp39/catalog.py +31 −0
@@ -0,0 +1,31 @@
1+"""Catalog reconciliation: documented contract vs observed API."""
2+from __future__ import annotations
3+
4+from .api_client import FinDataClient
5+
6+EXPECTED_BARS_FIELDS = ["instrument_id", "symbol_as_reported", "venue", "asset_class", "interval", "timestamp_utc",
7+ "session_date", "open", "high", "low", "close", "volume", "currency", "trade_count", "vwap",
8+ "adjustment_type", "source_id", "ingested_at", "quality_flags", "revision_id"]
9+
10+
11+def probe(client: FinDataClient, contract: dict) -> dict:
12+ health = client.health()
13+ datasets = {d["id"]: d for d in client.datasets()}
14+ schema = client.schema_bars()
15+ spec = client.openapi()
16+ routes = sorted(f"{m.upper()} {p}" for p, ops in spec["paths"].items() for m in ops)
17+ needed = [contract["findata"]["datasets"][k] for k in ("bars_1m", "bars_1m_etf")]
18+ report = {
19+ "health_status": health.get("status"),
20+ "n_datasets": len(datasets),
21+ "schema_version": schema.get("schema_version"),
22+ "timestamp_convention": schema.get("timestamp_convention"),
23+ "bars_fields_ok": [f["name"] for f in schema["fields"]] == EXPECTED_BARS_FIELDS,
24+ "required_datasets": {d: {"status": datasets.get(d, {}).get("status"), "rows": datasets.get(d, {}).get("row_count"),
25+ "first": datasets.get(d, {}).get("first_session"), "last": datasets.get(d, {}).get("last_session"),
26+ "license": datasets.get(d, {}).get("license_class")} for d in needed},
27+ "has_earnings_route": any("/earnings" in r for r in routes),
28+ "routes": routes,
29+ "lake_free_gb": (health.get("components", {}).get("lake") or {}).get("free_gb"),
30+ }
31+ return report
added src/wp39/corporate_actions.py +36 −0
@@ -0,0 +1,36 @@
1+"""Splits and cash dividends (FinData `corporate_actions`, vendor meta files) -> per-symbol ex-date adjustment table.
2+
3+The extract is produced once on the storage node (read-only `psql \\copy`, see docs/API_INTEGRATION.md) because the API
4+has no corporate-actions route; its SHA-256 is in artifacts/manifests/corporate_actions.json. Raw bars stay unadjusted;
5+adjustments are applied explicitly in features.daily_block (ex-date rule).
6+"""
7+from __future__ import annotations
8+
9+from pathlib import Path
10+
11+import polars as pl
12+
13+
14+def load_actions(path: Path) -> pl.DataFrame:
15+ """Raw Postgres copy: id, instrument_id, action_type (split|dividend), ex_date, value, ... .
16+ Vendor quirk: dividends are keyed `firstrate:stock:<SYM>_DIVS`; `value` = split ratio (new per old) or cash amount."""
17+ if not path.exists():
18+ return pl.DataFrame(schema={"symbol": pl.Utf8, "ex_date": pl.Date, "split_ratio": pl.Float64, "dividend": pl.Float64})
19+ a = pl.read_csv(path, try_parse_dates=True, infer_schema_length=10000)
20+ a = a.with_columns([
21+ pl.col("instrument_id").str.replace("^firstrate:[a-z]+:", "").str.replace("_DIVS$", "").alias("symbol"),
22+ pl.col("ex_date").cast(pl.Date), pl.col("value").cast(pl.Float64),
23+ ])
24+ splits = a.filter(pl.col("action_type") == "split").group_by(["symbol", "ex_date"]).agg(pl.col("value").product().alias("split_ratio"))
25+ divs = a.filter(pl.col("action_type") == "dividend").group_by(["symbol", "ex_date"]).agg(pl.col("value").sum().alias("dividend"))
26+ out = splits.join(divs, on=["symbol", "ex_date"], how="full", coalesce=True).with_columns([
27+ pl.col("split_ratio").fill_null(1.0), pl.col("dividend").fill_null(0.0)])
28+ out = out.filter((pl.col("split_ratio") > 0) & (pl.col("dividend") >= 0))
29+ return out.sort(["symbol", "ex_date"])
30+
31+
32+def split_events_in_window(actions: pl.DataFrame, symbol: str, start, end) -> int:
33+ if actions.height == 0:
34+ return 0
35+ return actions.filter((pl.col("symbol") == symbol) & (pl.col("ex_date") >= start) & (pl.col("ex_date") <= end)
36+ & (pl.col("split_ratio") != 1.0)).height
added src/wp39/features.py +181 −0
@@ -0,0 +1,181 @@
1+"""Frequency-controlled OHLCV features computed per symbol-session from the raw 1-minute export.
2+
3+Every intraday family is computed identically on bars of length 1m, 5m and 30m, obtained by aggregating the *same* raw
4+1-minute bars within the regular session (09:30 -> closing print). Daily features come from the same aggregation (D-003).
5+Zero-volume minutes are absent at the source and are never invented; the share of expected buckets present is a feature.
6+Bar statistics are labelled as such; nothing here is order flow, spread or informed trading.
7+"""
8+from __future__ import annotations
9+
10+import math
11+from pathlib import Path
12+
13+import numpy as np
14+import polars as pl
15+
16+from .calendar import _xnys
17+
18+FREQS = {"1m": 1, "5m": 5, "30m": 30}
19+INTRADAY_FAMILIES = ["rv", "bpv", "rj", "rsneg_share", "sjv", "rskew", "rkurt", "max_share", "ac1", "drift",
20+ "vol_first30", "vol_last30", "vol_gini", "down_vol_share", "present_share", "rv_mktdown_share",
21+ "sxy", "syy", "n_bars"]
22+
23+
24+def session_bounds_table(start, end) -> pl.DataFrame:
25+ """session_date, open_utc (09:30 ET bar open), close_utc (closing-print bar open), close_minute (minutes after 09:30)."""
26+ cal = _xnys()
27+ s = cal.sessions
28+ s = s[(s >= str(start)) & (s <= str(end))]
29+ opens = cal.opens.loc[s] if hasattr(cal, "opens") else None
30+ rows = []
31+ for d in s:
32+ o, c = cal.session_open(d), cal.session_close(d)
33+ rows.append({"session_date": d.date(), "open_utc": o.tz_convert("UTC").to_pydatetime(),
34+ "close_utc": c.tz_convert("UTC").to_pydatetime(),
35+ "close_minute": int((c - o).total_seconds() // 60)})
36+ df = pl.DataFrame(rows)
37+ return df.with_columns([pl.col("open_utc").dt.replace_time_zone("UTC"), pl.col("close_utc").dt.replace_time_zone("UTC")])
38+
39+
40+def load_regular_session(paths: list[Path], bounds: pl.DataFrame) -> pl.DataFrame:
41+ """Raw 1m bars restricted to the regular session, with minute-of-session index m (0 = 09:30, close_minute = closing print)."""
42+ lf = pl.concat([pl.scan_parquet(str(p)) for p in paths]).select(
43+ ["symbol_as_reported", "timestamp_utc", "session_date", "open", "high", "low", "close", "volume", "quality_flags"])
44+ df = lf.collect()
45+ if df.height == 0:
46+ return df
47+ # the lake's base+delta layouts (and chunk boundaries) can publish the same bar twice: keep the latest ingestion
48+ df = pl.concat([pl.scan_parquet(str(p)) for p in paths]).select(
49+ ["symbol_as_reported", "timestamp_utc", "session_date", "open", "high", "low", "close", "volume", "quality_flags",
50+ "ingested_at"]).collect().sort("ingested_at").unique(subset=["timestamp_utc"], keep="last").drop("ingested_at")
51+ df = df.with_columns(pl.col("timestamp_utc").dt.convert_time_zone("UTC"))
52+ df = df.join(bounds, on="session_date", how="inner")
53+ df = df.filter((pl.col("timestamp_utc") >= pl.col("open_utc")) & (pl.col("timestamp_utc") <= pl.col("close_utc")))
54+ df = df.with_columns(((pl.col("timestamp_utc") - pl.col("open_utc")).dt.total_minutes()).cast(pl.Int32).alias("m"))
55+ df = df.filter(pl.col("quality_flags").list.len() == 0) # drop rows flagged by FinData (ohlc_inconsistent, price_jump, ...)
56+ return df.sort(["session_date", "m"])
57+
58+
59+def aggregate_buckets(reg: pl.DataFrame, freq_min: int) -> pl.DataFrame:
60+ """Aggregate regular-session 1m bars into buckets of `freq_min` minutes; the closing print joins the last bucket."""
61+ if freq_min == 1:
62+ b = reg.with_columns(pl.col("m").alias("b"))
63+ return b.select(["session_date", "b", "open", "high", "low", "close", "volume", "m", "close_minute"])
64+ nb = (pl.col("close_minute") / freq_min).ceil().cast(pl.Int32) # buckets per session
65+ b = reg.with_columns(pl.min_horizontal((pl.col("m") / freq_min).floor().cast(pl.Int32), nb - 1).alias("b"))
66+ agg = b.group_by(["session_date", "b"], maintain_order=True).agg([
67+ pl.col("open").first(), pl.col("high").max(), pl.col("low").min(), pl.col("close").last(), pl.col("volume").sum(),
68+ pl.col("m").first(), pl.col("close_minute").first(),
69+ ])
70+ return agg.sort(["session_date", "b"])
71+
72+
73+def bucket_returns(bk: pl.DataFrame) -> pl.DataFrame:
74+ """Within-session log returns: first bucket open->close, then close->close."""
75+ prev_close = pl.col("close").shift(1).over("session_date")
76+ r = pl.when(prev_close.is_null()).then((pl.col("close") / pl.col("open")).log()).otherwise((pl.col("close") / prev_close).log())
77+ return bk.with_columns(r.alias("r"))
78+
79+
80+def _gini_expr() -> pl.Expr:
81+ v = pl.col("volume").sort()
82+ n = pl.len()
83+ i = pl.int_range(1, n + 1).cast(pl.Float64)
84+ return ((2.0 * (i * v).sum()) / (n.cast(pl.Float64) * v.sum() + 1e-12) - (n.cast(pl.Float64) + 1.0) / n.cast(pl.Float64))
85+
86+
87+def session_features(bk: pl.DataFrame, freq_min: int, spy: pl.DataFrame | None) -> pl.DataFrame:
88+ """One row per session with the intraday families for this frequency (prefix f{freq}_)."""
89+ df = bucket_returns(bk)
90+ if spy is not None:
91+ df = df.join(spy.select(["session_date", "b", pl.col("r").alias("r_spy")]), on=["session_date", "b"], how="left")
92+ else:
93+ df = df.with_columns(pl.lit(None, dtype=pl.Float64).alias("r_spy"))
94+ r = pl.col("r")
95+ r2 = r.pow(2)
96+ rv = r2.sum()
97+ n = pl.len().cast(pl.Float64)
98+ prev = r.shift(1).over("session_date")
99+ expected = pl.when(pl.lit(freq_min) == 1).then(pl.col("close_minute").first() + 1).otherwise(
100+ (pl.col("close_minute").first() / freq_min).ceil()).cast(pl.Float64)
101+ first30 = pl.col("m") < 30
102+ last30 = pl.col("m") > (pl.col("close_minute") - 30)
103+ out = df.group_by("session_date", maintain_order=True).agg([
104+ n.alias("n_bars"),
105+ rv.alias("rv"),
106+ ((math.pi / 2.0) * (r.abs() * prev.abs()).sum()).alias("bpv"),
107+ (r2.filter(r < 0).sum()).alias("rs_neg"),
108+ (r2.filter(r > 0).sum()).alias("rs_pos"),
109+ (n.sqrt() * r.pow(3).sum() / (rv.pow(1.5) + 1e-18)).alias("rskew"),
110+ (n * r.pow(4).sum() / (rv.pow(2) + 1e-18)).alias("rkurt"),
111+ (r2.max() / (rv + 1e-18)).alias("max_share"),
112+ pl.corr(r, prev).alias("ac1"),
113+ (pl.col("close").last() / pl.col("open").first()).log().alias("drift"),
114+ (pl.col("volume").filter(first30).sum() / (pl.col("volume").sum() + 1e-9)).alias("vol_first30"),
115+ (pl.col("volume").filter(last30).sum() / (pl.col("volume").sum() + 1e-9)).alias("vol_last30"),
116+ _gini_expr().alias("vol_gini"),
117+ (pl.col("volume").filter(r < 0).sum() / (pl.col("volume").sum() + 1e-9)).alias("down_vol_share"),
118+ (n / expected).alias("present_share"),
119+ (r2.filter(pl.col("r_spy") < 0).sum() / (rv + 1e-18)).alias("rv_mktdown_share"),
120+ (r * pl.col("r_spy")).sum().alias("sxy"),
121+ pl.col("r_spy").pow(2).sum().alias("syy"),
122+ pl.col("volume").sum().alias("volume"),
123+ pl.col("open").first().alias("open"), pl.col("close").last().alias("close"),
124+ pl.col("high").max().alias("high"), pl.col("low").min().alias("low"),
125+ ])
126+ out = out.with_columns([
127+ (pl.max_horizontal(pl.col("rv") - pl.col("bpv"), 0.0) / (pl.col("rv") + 1e-18)).alias("rj"),
128+ (pl.col("rs_neg") / (pl.col("rv") + 1e-18)).alias("rsneg_share"),
129+ ((pl.col("rs_pos") - pl.col("rs_neg")) / (pl.col("rv") + 1e-18)).alias("sjv"),
130+ ])
131+ pre = f"f{freq_min}m_"
132+ keep = {c: pre + c for c in INTRADAY_FAMILIES}
133+ return out.select(["session_date"] + [pl.col(c).alias(keep[c]) for c in INTRADAY_FAMILIES] +
134+ ([pl.col("volume").alias("d_volume"), pl.col("open").alias("d_open"), pl.col("close").alias("d_close"),
135+ pl.col("high").alias("d_high"), pl.col("low").alias("d_low")] if freq_min == 1 else []))
136+
137+
138+def daily_block(day: pl.DataFrame, actions: pl.DataFrame | None, symbol: str) -> pl.DataFrame:
139+ """Daily OHLCV features from the 1m aggregation + split/dividend-adjusted close-to-close returns."""
140+ d = day.sort("session_date")
141+ if actions is not None and actions.height:
142+ a = actions.filter(pl.col("symbol") == symbol).select(["ex_date", "split_ratio", "dividend"])
143+ d = d.join(a, left_on="session_date", right_on="ex_date", how="left").with_columns([
144+ pl.col("split_ratio").fill_null(1.0), pl.col("dividend").fill_null(0.0)])
145+ else:
146+ d = d.with_columns([pl.lit(1.0).alias("split_ratio"), pl.lit(0.0).alias("dividend")])
147+ prev_close_adj = (pl.col("d_close").shift(1) / pl.col("split_ratio"))
148+ d = d.with_columns([
149+ ((pl.col("d_close") + pl.col("dividend")) / prev_close_adj - 1.0).alias("d_ret_cc"),
150+ (pl.col("d_open") / prev_close_adj - 1.0).alias("d_ret_co"),
151+ (pl.col("d_close") / pl.col("d_open") - 1.0).alias("d_ret_oc"),
152+ ((pl.col("d_high") / pl.col("d_low")).log().pow(2) / (4.0 * math.log(2.0))).alias("d_parkinson"),
153+ (pl.col("d_volume") * pl.col("d_close")).alias("d_dollar_vol"),
154+ ])
155+ return d
156+
157+
158+def compute_symbol_features(paths: list[Path], symbol: str, bounds: pl.DataFrame, spy_buckets: dict[str, pl.DataFrame] | None,
159+ actions: pl.DataFrame | None) -> pl.DataFrame:
160+ reg = load_regular_session(paths, bounds)
161+ if reg.height == 0:
162+ return pl.DataFrame()
163+ blocks = []
164+ for name, fmin in FREQS.items():
165+ bk = aggregate_buckets(reg, fmin)
166+ spy = spy_buckets.get(name) if spy_buckets else None
167+ blocks.append(session_features(bk, fmin, spy))
168+ out = blocks[0]
169+ for b in blocks[1:]:
170+ out = out.join(b, on="session_date", how="left")
171+ out = daily_block(out, actions, symbol)
172+ return out.with_columns(pl.lit(symbol).alias("symbol")).sort("session_date")
173+
174+
175+def compute_spy_buckets(paths: list[Path], bounds: pl.DataFrame) -> dict[str, pl.DataFrame]:
176+ reg = load_regular_session(paths, bounds)
177+ out = {}
178+ for name, fmin in FREQS.items():
179+ out[name] = bucket_returns(aggregate_buckets(reg, fmin)).select(["session_date", "b", "r"]).unique(
180+ subset=["session_date", "b"], keep="last").sort(["session_date", "b"])
181+ return out
added src/wp39/figures.py +156 −0
@@ -0,0 +1,156 @@
1+"""Journal figures (vector PDF, matplotlib). One axis per panel, thin marks, fixed categorical order, direct labels.
2+Palette validated with the dataviz validator (light surface): blue, orange, aqua, yellow (+ gray for references)."""
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import matplotlib
8+matplotlib.use("Agg")
9+import matplotlib.pyplot as plt # noqa: E402
10+import numpy as np # noqa: E402
11+import pandas as pd # noqa: E402
12+
13+SERIES = {"daily": "#2a78d6", "30m": "#eb6834", "5m": "#1baf7a", "1m": "#eda100", "fund": "#6250d6", "ref": "#8a8a86", "neg": "#e34948"}
14+MODEL_COLOR = {"B1": SERIES["fund"], "B2": SERIES["daily"], "B3": SERIES["30m"], "B4": SERIES["5m"], "B5": SERIES["1m"], "B5all": "#52514e",
15+ "LB1": SERIES["fund"], "LB2": SERIES["daily"], "LB3": SERIES["30m"], "LB4": SERIES["5m"], "LB5": SERIES["1m"], "LB5all": "#52514e"}
16+MODEL_LABEL = {"B1": "B1 fundamentals", "B2": "B2 + daily", "B3": "B3 + 30-min", "B4": "B4 + 5-min", "B5": "B5 + 1-min", "B5all": "all frequencies"}
17+
18+plt.rcParams.update({"font.family": "serif", "font.size": 9, "axes.spines.top": False, "axes.spines.right": False,
19+ "axes.edgecolor": "#8a8a86", "axes.linewidth": 0.6, "xtick.color": "#52514e", "ytick.color": "#52514e",
20+ "axes.labelcolor": "#0b0b0b", "grid.color": "#e6e5e1", "grid.linewidth": 0.5, "legend.frameon": False,
21+ "figure.dpi": 150, "savefig.bbox": "tight"})
22+
23+
24+def _save(fig, out: Path, name: str):
25+ out.mkdir(parents=True, exist_ok=True)
26+ fig.savefig(out / f"{name}.pdf"); fig.savefig(out / f"{name}.png", dpi=200); plt.close(fig)
27+
28+
29+def fig_timeline(out: Path, h_list=(40, 20, 10, 5, 1)):
30+ """Event-time timeline and the no-leakage rule (schematic drawn from the protocol, labelled as such)."""
31+ fig, ax = plt.subplots(figsize=(6.4, 2.2))
32+ ax.set_xlim(-75, 15); ax.set_ylim(0, 3); ax.axis("off")
33+ ax.plot([-75, 12], [1, 1], color="#52514e", lw=1)
34+ for x, lab, c in ((-63, "$A_{q-1}$\nprevious release", SERIES["ref"]), (0, "$A_q$\nrelease of quarter $q$", SERIES["neg"]),
35+ (1, "$F_q$ filing", SERIES["ref"]), (2, "$V_q=F_q+1$\nfacts available", SERIES["ref"])):
36+ ax.plot([x], [1], marker="|", ms=14, color=c, mew=1.5)
37+ ax.text(x, 0.15 if x not in (1,) else 1.55, lab, ha="center", va="center", fontsize=7, color="#0b0b0b")
38+ for i, h in enumerate(h_list):
39+ ax.plot([-h], [1], marker="o", ms=4, color=SERIES["1m"], mec="white", mew=0.8)
40+ ax.text(-h, 1.35 + 0.28 * (i % 2), f"$c=A_q-{h}$", ha="center", fontsize=6.5, color="#52514e")
41+ ax.add_patch(plt.Rectangle((-40, 0.85), 20, 0.3, color=SERIES["1m"], alpha=0.25, lw=0))
42+ ax.text(-30, 2.6, "20-session feature window ends at $c$; bars after $c$ and facts with $V_j>c$ never enter the information set",
43+ ha="center", fontsize=7, color="#52514e")
44+ ax.set_title("Event-time design (schematic of the protocol, not data)", fontsize=8, loc="left")
45+ _save(fig, out, "fig01_timeline")
46+
47+
48+def fig_coverage(out: Path, years: pd.DataFrame, sizes: pd.DataFrame | None = None):
49+ """years: columns year, firms, firm_quarters, adverse_rate."""
50+ fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.4))
51+ ax = axes[0]
52+ ax.bar(years["year"], years["firms"], color=SERIES["daily"], width=0.8)
53+ ax.set_ylabel("Firms in sample"); ax.set_xlabel("Year of cutoff"); ax.grid(axis="y")
54+ for x, v in zip(years["year"].iloc[::3], years["firms"].iloc[::3]):
55+ ax.text(x, v, f"{int(v):,}", ha="center", va="bottom", fontsize=6, color="#52514e")
56+ ax = axes[1]
57+ ax.plot(years["year"], years["adverse_rate"], color=SERIES["neg"], lw=2, marker="o", ms=4, mec="white")
58+ ax.set_ylabel("Adverse-surprise rate"); ax.set_xlabel("Year of cutoff"); ax.grid(axis="y"); ax.set_ylim(0, None)
59+ _save(fig, out, "fig02_coverage")
60+
61+
62+def fig_prevalence(out: Path, prev: pd.DataFrame):
63+ """prev: columns group (str), label, rate, lo, hi — conditional adverse prevalence by ex-ante characteristics."""
64+ fig, ax = plt.subplots(figsize=(6.0, 2.6))
65+ y = np.arange(len(prev))
66+ ax.hlines(y, prev["lo"], prev["hi"], color="#c3c2b7", lw=2)
67+ ax.plot(prev["rate"], y, "o", color=SERIES["neg"], ms=5, mec="white")
68+ ax.set_yticks(y); ax.set_yticklabels(prev["label"]); ax.invert_yaxis()
69+ ax.set_xlabel("Share of firm-quarters with an adverse operating-income surprise (test period)"); ax.grid(axis="x")
70+ _save(fig, out, "fig03_prevalence")
71+
72+
73+def fig_frequency_gains(out: Path, contrasts: pd.DataFrame):
74+ """Paired Δ log loss vs B2 for B3/B4/B5 (logistic and LightGBM), with 95 % block-bootstrap intervals."""
75+ fam = [("logistic", ""), ("lightgbm", "L")]
76+ fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.6), sharey=True)
77+ for ax, (name, pre) in zip(axes, fam):
78+ sub = contrasts[(contrasts.family == name) & (contrasts.b == pre + "B2") & (contrasts.a.isin([pre + "B3", pre + "B4", pre + "B5", pre + "B5all"]))]
79+ order = [pre + "B3", pre + "B4", pre + "B5", pre + "B5all"]
80+ sub = sub.set_index("a").reindex([o for o in order if o in sub.a.values]).reset_index()
81+ x = np.arange(len(sub))
82+ for i, r in sub.iterrows():
83+ c = MODEL_COLOR.get(r["a"].replace("L", "", 1) if r["a"].startswith("L") else r["a"], "#52514e")
84+ ax.vlines(i, r["ci_lo"], r["ci_hi"], color=c, lw=2)
85+ ax.plot(i, r["d_logloss"], "o", color=c, ms=6, mec="white")
86+ ax.text(i, r["ci_hi"], f"{r['d_logloss']:+.4f}", ha="center", va="bottom", fontsize=6.5, color="#52514e")
87+ ax.axhline(0, color="#8a8a86", lw=0.8)
88+ ax.set_xticks(x); ax.set_xticklabels([MODEL_LABEL[a.replace("L", "", 1) if a.startswith("L") else a].replace("B5all", "all") for a in sub.a], fontsize=7)
89+ ax.set_title(f"{name}", fontsize=8, loc="left"); ax.grid(axis="y")
90+ axes[0].set_ylabel("Δ log loss vs. B2 (daily + accounting)\nnegative = better")
91+ _save(fig, out, "fig04_frequency_gains")
92+
93+
94+def fig_reliability(out: Path, rel: pd.DataFrame):
95+ fig, ax = plt.subplots(figsize=(3.6, 3.2))
96+ ax.plot([0, rel[["mean_pred", "obs_rate"]].max().max()], [0, rel[["mean_pred", "obs_rate"]].max().max()], color="#8a8a86", lw=0.8, ls="--")
97+ for m in ("LB2", "LB5", "B2", "B5"):
98+ s = rel[rel.model == m]
99+ if len(s):
100+ ax.plot(s["mean_pred"], s["obs_rate"], marker="o", ms=4, mec="white", lw=1.6, color=MODEL_COLOR[m], label=m)
101+ ax.set_xlabel("Mean predicted probability (decile)"); ax.set_ylabel("Observed adverse rate"); ax.legend(fontsize=7); ax.grid()
102+ _save(fig, out, "fig05_reliability")
103+
104+
105+def fig_signal_evolution(out: Path, ladder: pd.DataFrame):
106+ """Mean AUC and Δ log loss by horizon (all firm-quarters, no selection)."""
107+ fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.5))
108+ for fam, c in (("logistic", SERIES["daily"]), ("lightgbm", SERIES["1m"])):
109+ s = ladder[ladder.family == fam].sort_values("h", ascending=False)
110+ axes[0].plot(-s["h"], s["auc_a"], marker="o", ms=4, mec="white", lw=1.6, color=c, label=f"{fam}: B5")
111+ axes[0].plot(-s["h"], s["auc_b"], marker="s", ms=3.5, mec="white", lw=1.2, ls="--", color=c, label=f"{fam}: B2")
112+ axes[1].errorbar(-s["h"], s["d_logloss"], yerr=[s["d_logloss"] - s["ci_lo"], s["ci_hi"] - s["d_logloss"]], fmt="o-", ms=4, lw=1.4,
113+ color=c, capsize=2, label=fam)
114+ axes[0].set_xlabel("Trading days before the release (−h)"); axes[0].set_ylabel("Test AUC"); axes[0].legend(fontsize=6); axes[0].grid()
115+ axes[1].axhline(0, color="#8a8a86", lw=0.8); axes[1].set_xlabel("Trading days before the release (−h)")
116+ axes[1].set_ylabel("Δ log loss B5 − B2"); axes[1].legend(fontsize=7); axes[1].grid()
117+ _save(fig, out, "fig06_signal_evolution")
118+
119+
120+def fig_heterogeneity(out: Path, sub: pd.DataFrame, groups: list[str]):
121+ s = sub[(sub.family == "lightgbm") & sub.subgroup.isin(groups)].set_index("subgroup").reindex(groups).reset_index()
122+ fig, ax = plt.subplots(figsize=(6.0, 2.8))
123+ y = np.arange(len(s))
124+ ax.hlines(y, s["ci_lo"], s["ci_hi"], color="#c3c2b7", lw=2)
125+ ax.plot(s["mean"], y, "o", color=SERIES["1m"], ms=5, mec="white")
126+ for yi, r in zip(y, s.itertuples()):
127+ ax.text(r.ci_hi, yi, f" n={int(r.n):,}", va="center", fontsize=6, color="#52514e")
128+ ax.axvline(0, color="#8a8a86", lw=0.8)
129+ ax.set_yticks(y); ax.set_yticklabels([g.replace("_", " ") for g in s["subgroup"]]); ax.invert_yaxis()
130+ ax.set_xlabel("Δ log loss LB5 − LB2 (test period), 95 % block-bootstrap CI"); ax.grid(axis="x")
131+ _save(fig, out, "fig07_heterogeneity")
132+
133+
134+def fig_rolling(out: Path, wf: pd.DataFrame, wfc: pd.DataFrame):
135+ fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.5))
136+ for m in ("LB2", "LB5", "B2", "B5"):
137+ s = wf[wf.model == m].sort_values("year")
138+ if len(s):
139+ axes[0].plot(s["year"], s["auc"], marker="o", ms=3.5, mec="white", lw=1.4, color=MODEL_COLOR[m], ls="-" if m.startswith("L") else "--", label=m)
140+ axes[0].set_ylabel("Out-of-fold AUC (annual refits)"); axes[0].legend(fontsize=6, ncol=2); axes[0].grid()
141+ for fam, c in (("lightgbm", SERIES["1m"]), ("logistic", SERIES["daily"])):
142+ s = wfc[(wfc.family == fam) & (wfc.year != "all")].copy(); s["year"] = s["year"].astype(int); s = s.sort_values("year")
143+ axes[1].errorbar(s["year"], s["d_logloss"], yerr=[s["d_logloss"] - s["ci_lo"], s["ci_hi"] - s["d_logloss"]], fmt="o-", ms=3.5, lw=1.2, capsize=2, color=c, label=fam)
144+ axes[1].axhline(0, color="#8a8a86", lw=0.8); axes[1].set_ylabel("Δ log loss B5 − B2 by year"); axes[1].legend(fontsize=7); axes[1].grid()
145+ _save(fig, out, "fig08_rolling")
146+
147+
148+def fig_economic(out: Path, port: pd.DataFrame, alphas: dict):
149+ fig, ax = plt.subplots(figsize=(4.2, 2.6))
150+ dm = port.groupby("decile")["ret"].mean() * 100
151+ ax.bar(dm.index, dm.values, color=SERIES["daily"], width=0.8)
152+ ax.axhline(0, color="#8a8a86", lw=0.8); ax.set_xlabel("Decile of ex-ante deterioration score (10 = most likely)")
153+ ax.set_ylabel("Mean monthly return (%)"); ax.grid(axis="y")
154+ ax.set_title(f"L/S 1−10 alpha: {alphas['gross']['alpha_monthly']*100:+.2f}% (t={alphas['gross']['alpha_t']:.1f}) gross, "
155+ f"{alphas['net20bps']['alpha_monthly']*100:+.2f}% net of 20 bps", fontsize=7, loc="left")
156+ _save(fig, out, "fig09_economic")
added src/wp39/inference.py +134 −0
@@ -0,0 +1,134 @@
1+"""Evaluation metrics, paired block-bootstrap intervals, Diebold-Mariano / Giacomini-White tests, Holm correction,
2+calibration, and two-way clustered panel regressions."""
3+from __future__ import annotations
4+
5+import numpy as np
6+import pandas as pd
7+import polars as pl
8+from scipy import stats
9+from sklearn.metrics import average_precision_score, brier_score_loss, log_loss, roc_auc_score
10+
11+
12+# --------------------------------------------------------------------- metrics
13+def binary_metrics(y: np.ndarray, p: np.ndarray) -> dict:
14+ p = np.clip(p, 1e-6, 1 - 1e-6)
15+ out = {"n": int(len(y)), "base_rate": float(y.mean()), "logloss": float(log_loss(y, p)), "brier": float(brier_score_loss(y, p))}
16+ if 0 < y.mean() < 1:
17+ out["auc"] = float(roc_auc_score(y, p))
18+ out["pr_auc"] = float(average_precision_score(y, p))
19+ # calibration intercept / slope (logistic recalibration of the logit)
20+ z = np.log(p / (1 - p))
21+ try:
22+ from sklearn.linear_model import LogisticRegression
23+ m = LogisticRegression(C=1e6, solver="lbfgs", max_iter=1000).fit(z.reshape(-1, 1), y)
24+ out["cal_slope"], out["cal_intercept"] = float(m.coef_[0][0]), float(m.intercept_[0])
25+ except Exception:
26+ pass
27+ return out
28+
29+
30+def continuous_metrics(y: np.ndarray, yhat: np.ndarray, y_train_mean: float) -> dict:
31+ e = y - yhat
32+ ss = np.sum((y - y_train_mean) ** 2)
33+ return {"n": int(len(y)), "mae": float(np.mean(np.abs(e))), "rmse": float(np.sqrt(np.mean(e ** 2))),
34+ "oos_r2": float(1 - np.sum(e ** 2) / ss) if ss > 0 else float("nan")}
35+
36+
37+def pointwise_logloss(y: np.ndarray, p: np.ndarray) -> np.ndarray:
38+ p = np.clip(p, 1e-6, 1 - 1e-6)
39+ return -(y * np.log(p) + (1 - y) * np.log(1 - p))
40+
41+
42+def reliability_table(y: np.ndarray, p: np.ndarray, bins: int = 10) -> pd.DataFrame:
43+ q = np.quantile(p, np.linspace(0, 1, bins + 1))
44+ q[0], q[-1] = -np.inf, np.inf
45+ b = np.digitize(p, q[1:-1])
46+ rows = []
47+ for k in range(bins):
48+ m = b == k
49+ if m.sum():
50+ rows.append({"decile": k + 1, "n": int(m.sum()), "mean_pred": float(p[m].mean()), "obs_rate": float(y[m].mean())})
51+ return pd.DataFrame(rows)
52+
53+
54+# ------------------------------------------------------------ paired inference
55+def quarter_blocks(dates: np.ndarray) -> np.ndarray:
56+ d = pd.to_datetime(dates)
57+ return (d.year * 4 + (d.month - 1) // 3).to_numpy()
58+
59+
60+def paired_block_bootstrap(d: np.ndarray, blocks: np.ndarray, n_boot: int = 1000, seed: int = 20261102) -> dict:
61+ """Mean of a per-observation loss differential with a cluster (block) bootstrap over calendar quarters.
62+ Returns the mean, a percentile 95 % CI and a two-sided bootstrap p-value for H0: mean = 0."""
63+ rng = np.random.default_rng(seed)
64+ ub = np.unique(blocks)
65+ # per-block sums and counts -> resample blocks with replacement (equal-weight by observation)
66+ sums = np.array([d[blocks == b].sum() for b in ub]); cnts = np.array([(blocks == b).sum() for b in ub])
67+ mean = float(d.mean())
68+ draws = np.empty(n_boot)
69+ for i in range(n_boot):
70+ idx = rng.integers(0, len(ub), len(ub))
71+ draws[i] = sums[idx].sum() / cnts[idx].sum()
72+ lo, hi = np.percentile(draws, [2.5, 97.5])
73+ p = float(2 * min((draws - mean <= -abs(mean)).mean(), (draws - mean >= abs(mean)).mean()))
74+ return {"mean": mean, "ci_lo": float(lo), "ci_hi": float(hi), "p_boot": p, "n_blocks": int(len(ub)), "n": int(len(d))}
75+
76+
77+def dm_gw_test(d: np.ndarray, blocks: np.ndarray) -> dict:
78+ """Unconditional Giacomini-White (= Diebold-Mariano with HAC) on the per-quarter mean loss differential.
79+ HAC: Newey-West with lag = floor(4 (T/100)^(2/9))."""
80+ ub = np.unique(blocks)
81+ dq = np.array([d[blocks == b].mean() for b in ub])
82+ T = len(dq)
83+ if T < 4:
84+ return {"t": float("nan"), "p": float("nan"), "T": T}
85+ m = dq.mean(); u = dq - m
86+ L = int(np.floor(4 * (T / 100) ** (2 / 9)))
87+ s2 = u @ u / T
88+ for l in range(1, L + 1):
89+ w = 1 - l / (L + 1)
90+ s2 += 2 * w * (u[l:] @ u[:-l]) / T
91+ se = np.sqrt(max(s2, 1e-18) / T)
92+ t = m / se
93+ return {"t": float(t), "p": float(2 * (1 - stats.t.cdf(abs(t), df=T - 1))), "T": int(T), "mean_quarterly": float(m)}
94+
95+
96+def holm(pvals: dict[str, float]) -> dict[str, float]:
97+ items = sorted(pvals.items(), key=lambda kv: kv[1])
98+ m = len(items); out = {}; running = 0.0
99+ for i, (k, p) in enumerate(items):
100+ adj = min(1.0, (m - i) * p); running = max(running, adj); out[k] = running
101+ return out
102+
103+
104+# ------------------------------------------------------------- panel regression
105+def demean_two_way(df: pd.DataFrame, cols: list[str], fe1: str, fe2: str, iters: int = 25) -> pd.DataFrame:
106+ """Alternating projections for two fixed effects (firm, sector x quarter)."""
107+ x = df[cols].astype(float).copy()
108+ for _ in range(iters):
109+ x = x - x.groupby(df[fe1]).transform("mean")
110+ x = x - x.groupby(df[fe2]).transform("mean")
111+ return x
112+
113+
114+def two_way_cluster_ols(df: pd.DataFrame, y: str, xs: list[str], fe1: str, fe2: str, cl1: str, cl2: str) -> pd.DataFrame:
115+ """OLS with two-way FE (within transformation) and Cameron-Gelbach-Miller two-way clustered SEs."""
116+ d = df.dropna(subset=[y] + xs + [fe1, fe2, cl1, cl2]).copy()
117+ z = demean_two_way(d, [y] + xs, fe1, fe2)
118+ X = z[xs].to_numpy(); Y = z[y].to_numpy()
119+ XtX_inv = np.linalg.pinv(X.T @ X)
120+ beta = XtX_inv @ X.T @ Y
121+ e = Y - X @ beta
122+ def meat(groups):
123+ S = np.zeros((X.shape[1], X.shape[1]))
124+ for _, idx in pd.Series(np.arange(len(groups))).groupby(groups.to_numpy()).groups.items():
125+ idx = np.asarray(list(idx)); s = X[idx].T @ e[idx]; S += np.outer(s, s)
126+ return S
127+ g12 = (d[cl1].astype(str) + "|" + d[cl2].astype(str))
128+ V = XtX_inv @ (meat(d[cl1]) + meat(d[cl2]) - meat(g12)) @ XtX_inv
129+ se = np.sqrt(np.clip(np.diag(V), 0, None))
130+ t = beta / se
131+ G = min(d[cl1].nunique(), d[cl2].nunique())
132+ p = 2 * (1 - stats.t.cdf(np.abs(t), df=max(G - 1, 1)))
133+ return pd.DataFrame({"var": xs, "coef": beta, "se": se, "t": t, "p": p, "n": len(d), "n_firms": d[cl1].nunique(),
134+ "n_quarters": d[cl2].nunique()})
added src/wp39/ingest.py +63 −0
@@ -0,0 +1,63 @@
1+"""Bounded, idempotent FinData exports of raw 1-minute bars (<= 12 years per request, one symbol per request)."""
2+from __future__ import annotations
3+
4+import datetime as dt
5+from pathlib import Path
6+
7+import pyarrow.parquet as pq
8+
9+from .api_client import FinDataClient, export_result_dict
10+from .provenance import append_jsonl, manifest_index
11+
12+BARS_FIELDS = ["instrument_id", "symbol_as_reported", "venue", "asset_class", "interval", "timestamp_utc", "session_date",
13+ "open", "high", "low", "close", "volume", "currency", "trade_count", "vwap", "adjustment_type", "source_id",
14+ "ingested_at", "quality_flags", "revision_id"]
15+
16+
17+def chunk_ranges(start: dt.date, end: dt.date, max_years: int = 12) -> list[tuple[dt.date, dt.date]]:
18+ out, a = [], start
19+ while a <= end:
20+ b = min(end, dt.date(a.year + max_years - 1, 12, 31))
21+ out.append((a, b))
22+ a = dt.date(b.year + 1, 1, 1)
23+ return out
24+
25+
26+def validate_bars_parquet(path: Path) -> dict:
27+ meta = pq.read_metadata(path)
28+ schema = pq.read_schema(path)
29+ names = schema.names
30+ if names != BARS_FIELDS:
31+ raise ValueError(f"unexpected bars schema in {path.name}: {names}")
32+ md = {k.decode(): v.decode() for k, v in (schema.metadata or {}).items()}
33+ if md.get("findata.schema") != "bars" or md.get("findata.timestamp_convention") != "bar_open":
34+ raise ValueError(f"missing/unexpected FinData schema metadata in {path.name}: {md}")
35+ return {"rows": meta.num_rows, "schema_version": md.get("findata.schema_version")}
36+
37+
38+def export_symbol(client: FinDataClient, symbol: str, start: dt.date, end: dt.date, dataset_id: str, cache_dir: Path,
39+ manifest: Path, keep: bool = False) -> list[Path]:
40+ """Export all chunks of one symbol; skip chunks already in the manifest when the file is still present (or keep=False
41+ and the chunk was already consumed). Returns the list of Parquet paths available on disk."""
42+ idx = manifest_index(manifest, ["symbol", "start", "end", "dataset_id"])
43+ paths = []
44+ for a, b in chunk_ranges(start, end):
45+ dest = cache_dir / f"{symbol}__{a}__{b}.parquet"
46+ key = (symbol, str(a), str(b), dataset_id)
47+ if dest.exists():
48+ paths.append(dest)
49+ continue
50+ if key in idx and idx[key].get("consumed") and not keep:
51+ # already processed in a previous run; downstream will find the feature rows
52+ continue
53+ res = client.export(dataset_id, [symbol], str(a), str(b), dest)
54+ info = validate_bars_parquet(dest)
55+ rec = export_result_dict(res)
56+ rec.update({"symbol": symbol, "rows_in_file": info["rows"], "schema_version": info["schema_version"], "consumed": False})
57+ append_jsonl(manifest, rec)
58+ paths.append(dest)
59+ return paths
60+
61+
62+def mark_consumed(manifest: Path, symbol: str, dataset_id: str) -> None:
63+ append_jsonl(manifest, {"symbol": symbol, "dataset_id": dataset_id, "consumed": True, "event": "features_written"})
added src/wp39/instruments.py +119 −0
@@ -0,0 +1,119 @@
1+"""Instrument master and identifier crosswalk: FinData entity/instrument ↔ ticker ↔ SEC CIK/SIC.
2+
3+SEC data are public; requests carry the fair-access User-Agent (no secret) and are throttled to <= 8 req/s.
4+"""
5+from __future__ import annotations
6+
7+import gzip
8+import json
9+import os
10+import time
11+from pathlib import Path
12+
13+import httpx
14+import polars as pl
15+
16+SEC_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
17+SEC_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik:010d}.json"
18+
19+# SIC divisions (US Department of Labor): used as the coarse "sector" for fixed effects and demeaning.
20+SIC_DIVISIONS = [
21+ (100, 999, "A_agriculture"), (1000, 1499, "B_mining"), (1500, 1799, "C_construction"), (2000, 3999, "D_manufacturing"),
22+ (4000, 4999, "E_transport_utilities"), (5000, 5199, "F_wholesale"), (5200, 5999, "G_retail"),
23+ (6000, 6799, "H_finance_insurance_realestate"), (7000, 8999, "I_services"), (9100, 9729, "J_public_admin"),
24+]
25+
26+
27+def sic_division(sic: int | None) -> str:
28+ if sic is None:
29+ return "unknown"
30+ for lo, hi, name in SIC_DIVISIONS:
31+ if lo <= sic <= hi:
32+ return name
33+ return "unknown"
34+
35+
36+def _ua() -> str:
37+ return os.environ.get("SEC_USER_AGENT", "UQO WP39 research (mailto:simon-pierre.boucher@uqo.ca)")
38+
39+
40+def _get_json(client: httpx.Client, url: str) -> dict:
41+ r = client.get(url, headers={"User-Agent": _ua(), "Accept-Encoding": "gzip, deflate"})
42+ r.raise_for_status()
43+ raw = r.content
44+ if raw[:2] == b"\x1f\x8b":
45+ raw = gzip.decompress(raw)
46+ return json.loads(raw)
47+
48+
49+def fetch_sec_tickers(cache_dir: Path) -> pl.DataFrame:
50+ """Current SEC ticker→CIK table (snapshot cached with its download date)."""
51+ cache_dir.mkdir(parents=True, exist_ok=True)
52+ f = cache_dir / "sec_company_tickers.json"
53+ if not f.exists():
54+ with httpx.Client(timeout=60) as c:
55+ d = _get_json(c, SEC_TICKERS_URL)
56+ f.write_text(json.dumps({"downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "data": d}))
57+ d = json.loads(f.read_text())["data"]
58+ rows = [{"ticker": v["ticker"].upper(), "cik": int(v["cik_str"]), "sec_name": v["title"]} for v in d.values()]
59+ return pl.DataFrame(rows)
60+
61+
62+def fetch_sec_submissions(ciks: list[int], cache_dir: Path, rate_per_s: float = 8.0, log=None, workers: int = 6) -> pl.DataFrame:
63+ """SIC code, exchanges, fiscal year end and former names per CIK (cached per CIK; global token-bucket rate limit)."""
64+ import threading
65+ from concurrent.futures import ThreadPoolExecutor
66+
67+ out_dir = cache_dir / "sec_submissions"
68+ out_dir.mkdir(parents=True, exist_ok=True)
69+ lock, state = threading.Lock(), {"last": 0.0}
70+ min_gap = 1.0 / rate_per_s
71+
72+ def fetch_one(cik: int) -> None:
73+ f = out_dir / f"CIK{cik:010d}.json"
74+ if f.exists():
75+ return
76+ with lock:
77+ wait = min_gap - (time.time() - state["last"])
78+ if wait > 0:
79+ time.sleep(wait)
80+ state["last"] = time.time()
81+ with httpx.Client(timeout=60) as c:
82+ try:
83+ d = _get_json(c, SEC_SUBMISSIONS_URL.format(cik=cik))
84+ except httpx.HTTPStatusError as exc:
85+ d = {"error": exc.response.status_code}
86+ except Exception as exc: # noqa: BLE001
87+ d = {"error": type(exc).__name__}
88+ slim = {k: d.get(k) for k in ("cik", "sic", "sicDescription", "tickers", "exchanges", "fiscalYearEnd",
89+ "formerNames", "name", "stateOfIncorporation", "error")}
90+ f.write_text(json.dumps(slim))
91+
92+ with ThreadPoolExecutor(max_workers=workers) as ex:
93+ for i, _ in enumerate(ex.map(fetch_one, ciks)):
94+ if log and i % 500 == 0:
95+ log.info("SEC submissions %d/%d", i, len(ciks))
96+ rows = []
97+ for cik in ciks:
98+ d = json.loads((out_dir / f"CIK{cik:010d}.json").read_text())
99+ if d.get("error") and not d.get("sic"):
100+ (out_dir / f"CIK{cik:010d}.json").unlink(missing_ok=True) if d.get("error") not in (404,) else None
101+ sic = int(d["sic"]) if d.get("sic") not in (None, "") else None
102+ rows.append({"cik": cik, "sic": sic, "sic_description": d.get("sicDescription"),
103+ "sic_division": sic_division(sic), "sec_tickers": d.get("tickers") or [],
104+ "exchanges": d.get("exchanges") or [], "fiscal_year_end": d.get("fiscalYearEnd"),
105+ "n_former_names": len(d.get("formerNames") or []), "sec_error": d.get("error")})
106+ return pl.DataFrame(rows)
107+
108+
109+def build_crosswalk(entities: pl.DataFrame, sec_tickers: pl.DataFrame, submissions: pl.DataFrame | None) -> pl.DataFrame:
110+ """entities: entity_id, security_id, ticker (from FinData). Effective-date ranges are the fundamentals span."""
111+ xw = entities.join(sec_tickers.unique(subset=["ticker"]), on="ticker", how="left")
112+ if submissions is not None:
113+ xw = xw.join(submissions, on="cik", how="left")
114+ xw = xw.with_columns([
115+ pl.col("cik").is_not_null().alias("has_cik"),
116+ pl.when(pl.col("cik").is_null()).then(pl.lit("unknown")).otherwise(pl.col("sic_division").fill_null("unknown")).alias("sic_division")
117+ if submissions is not None else pl.lit("unknown").alias("sic_division"),
118+ ])
119+ return xw
added src/wp39/labels.py +104 −0
@@ -0,0 +1,104 @@
1+"""Firm-quarter outcomes: reveal dates, seasonal-random-walk expectations and adverse-outcome labels (PIT_PROTOCOL §4).
2+
3+Nothing here uses prices. The expectation E_c[X_q] uses quarters q-1..q-8 *restricted to those available at the cutoff*;
4+samples.py passes the cutoff-specific availability mask. The quarterly series X_q is the first (only) revision.
5+"""
6+from __future__ import annotations
7+
8+import datetime as dt
9+
10+import polars as pl
11+
12+TARGET_VARS = ["op_inc", "net_inc", "cash_op", "rev"]
13+
14+
15+def match_release_dates(wide: pl.DataFrame, events: pl.DataFrame, max_lag_days: int = 30) -> pl.DataFrame:
16+ """Attach A_q (first release in [E_q, F_q + 10 d]) and its timing to each firm-quarter; flag exclusions."""
17+ e = events.select(["entity_id", pl.col("event_date").cast(pl.Date), "release_time", "eps_estimate", "eps_reported"]).sort(
18+ ["entity_id", "event_date"])
19+ w = wide.select(["entity_id", "fiscal_period_end", "filing_date"]).with_columns(
20+ pl.col("fiscal_period_end").alias("_key")).sort(["entity_id", "_key"])
21+ # forward as-of: first event on/after the fiscal period end
22+ j = w.join_asof(e, left_on="_key", right_on="event_date", by="entity_id", strategy="forward")
23+ j = j.with_columns([
24+ pl.col("event_date").alias("A_q"),
25+ (pl.col("event_date") <= pl.col("filing_date") + pl.duration(days=10)).alias("_within"),
26+ ])
27+ j = j.with_columns([
28+ pl.when(pl.col("_within")).then(pl.col("A_q")).otherwise(None).alias("A_q"),
29+ pl.when(pl.col("_within")).then(pl.col("release_time")).otherwise(None).alias("release_time"),
30+ pl.when(pl.col("_within")).then(pl.col("eps_estimate")).otherwise(None).alias("eps_estimate"),
31+ pl.when(pl.col("_within")).then(pl.col("eps_reported")).otherwise(None).alias("eps_reported"),
32+ ])
33+ j = j.with_columns([
34+ (pl.col("filing_date") - pl.col("A_q")).dt.total_days().alias("filing_minus_release_days"),
35+ ])
36+ j = j.with_columns([
37+ pl.when(pl.col("A_q").is_null()).then(pl.lit("no_release"))
38+ .when(pl.col("filing_minus_release_days") < 0).then(pl.lit("filing_before_release"))
39+ .when(pl.col("filing_minus_release_days") > max_lag_days).then(pl.lit("release_lag_gt_30d"))
40+ .otherwise(pl.lit("ok")).alias("release_match"),
41+ ])
42+ return wide.join(j.select(["entity_id", "fiscal_period_end", "A_q", "release_time", "eps_estimate", "eps_reported",
43+ "filing_minus_release_days", "release_match"]), on=["entity_id", "fiscal_period_end"], how="left")
44+
45+
46+def seasonal_expectation(w: pl.DataFrame, var: str) -> pl.DataFrame:
47+ """E[X_q] = X_{q-4} + mean_{k=0..3}(X_{q-1-k} - X_{q-5-k}); requires q-1..q-8 (by quarter index, regular calendar)."""
48+ x = pl.col(var)
49+ lags = {k: x.shift(k).over("entity_id") for k in range(1, 9)}
50+ drift = sum((lags[1 + k] - lags[5 + k]) for k in range(4)) / 4.0
51+ return w.with_columns([
52+ (lags[4] + drift).alias(f"E_{var}"),
53+ lags[4].alias(f"{var}_lag4"),
54+ lags[1].alias(f"{var}_lag1"),
55+ (x - (lags[4] + drift)).alias(f"surprise_{var}"),
56+ ])
57+
58+
59+def build_quarter_table(wide: pl.DataFrame) -> pl.DataFrame:
60+ """Quarter-level target ingredients (unscaled). Regularity: consecutive quarters 60-120 days apart."""
61+ w = wide.sort(["entity_id", "fiscal_period_end"])
62+ w = w.with_columns(pl.col("fiscal_period_end").diff().over("entity_id").dt.total_days().alias("days_since_prev_q"))
63+ # a quarter is "regular" if the previous 8 gaps are all within [60, 120]
64+ ok = (pl.col("days_since_prev_q") >= 60) & (pl.col("days_since_prev_q") <= 120)
65+ w = w.with_columns(ok.cast(pl.Int8).alias("_gap_ok"))
66+ w = w.with_columns(pl.col("_gap_ok").rolling_sum(window_size=8, min_samples=8).over("entity_id").alias("_gap_ok8"))
67+ for v in TARGET_VARS:
68+ w = seasonal_expectation(w, v)
69+ # secondary ingredients
70+ w = w.with_columns([
71+ pl.col("roa").shift(4).over("entity_id").alias("roa_lag4"),
72+ (pl.col("assets_c") / (pl.col("liab_c").abs() + 1e-9)).alias("current_ratio"),
73+ ])
74+ w = w.with_columns([
75+ pl.col("current_ratio").shift(4).over("entity_id").alias("current_ratio_lag4"),
76+ pl.col("intr_cover").shift(4).over("entity_id").alias("intr_cover_lag4"),
77+ pl.col("assets_tot").shift(1).over("entity_id").alias("assets_tot_lag1"),
78+ ])
79+ return w
80+
81+
82+def finalize_labels(q: pl.DataFrame, threshold: float = -0.005) -> pl.DataFrame:
83+ """Scale by the latest *available* total assets (lag-1 quarter) and build binary targets."""
84+ ta = pl.col("assets_tot_lag1").abs() + 1e-9
85+ q = q.with_columns([
86+ (pl.col("surprise_op_inc") / ta).alias("S_opinc"),
87+ (pl.col("surprise_net_inc") / ta).alias("S_netinc"),
88+ (pl.col("surprise_cash_op") / ta).alias("S_ocf"),
89+ (pl.col("surprise_rev") / ta).alias("S_rev"),
90+ ((pl.col("cash_op") - pl.col("cash_op_lag4")) / ta).alias("ocf_innov"),
91+ (pl.col("roa") - pl.col("roa_lag4")).alias("roa_yoy"),
92+ ])
93+ q = q.with_columns([
94+ (pl.col("S_opinc") <= threshold).cast(pl.Int8).alias("y_adverse_opinc"),
95+ (pl.col("S_netinc") <= threshold).cast(pl.Int8).alias("y_adverse_netinc"),
96+ (pl.col("roa_yoy") <= -0.01).cast(pl.Int8).alias("y_roa_decline"),
97+ (pl.col("ocf_innov") <= -0.01).cast(pl.Int8).alias("y_ocf_negative"),
98+ (((pl.col("current_ratio") / (pl.col("current_ratio_lag4") + 1e-9)) <= 0.9) |
99+ ((pl.col("intr_cover") < 1.0) & (pl.col("intr_cover_lag4") >= 1.0))).cast(pl.Int8).alias("y_liquidity_deterioration"),
100+ pl.when(pl.col("eps_estimate").is_not_null() & pl.col("eps_reported").is_not_null())
101+ .then((pl.col("eps_reported") < pl.col("eps_estimate")).cast(pl.Int8)).otherwise(None).alias("y_eps_miss"),
102+ (pl.col("S_opinc").abs() >= 0.005).cast(pl.Int8).alias("y_abs_surprise"), # symmetric placebo target (volatility channel)
103+ ])
104+ return q
added src/wp39/models.py +137 −0
@@ -0,0 +1,137 @@
1+"""Baseline ladder B0-B6 with train-only preprocessing and an equal tuning budget per information set."""
2+from __future__ import annotations
3+
4+import json
5+import random
6+from dataclasses import dataclass, field
7+
8+import numpy as np
9+import polars as pl
10+from sklearn.linear_model import LogisticRegression, Ridge
11+from sklearn.metrics import log_loss
12+
13+INFO_SETS = {
14+ "B1": ["fundamentals"],
15+ "B2": ["fundamentals", "daily"],
16+ "B3": ["fundamentals", "daily", "intraday_30m"],
17+ "B4": ["fundamentals", "daily", "intraday_5m"],
18+ "B5": ["fundamentals", "daily", "intraday_1m"],
19+ "B5all": ["fundamentals", "daily", "intraday_30m", "intraday_5m", "intraday_1m"],
20+}
21+
22+
23+@dataclass
24+class Preprocessor:
25+ """Winsorise (1/99 %), impute (median) and standardise using TRAINING statistics only."""
26+ cols: list[str]
27+ lo: dict = field(default_factory=dict)
28+ hi: dict = field(default_factory=dict)
29+ med: dict = field(default_factory=dict)
30+ mu: dict = field(default_factory=dict)
31+ sd: dict = field(default_factory=dict)
32+ fitted_on_rows: int = 0
33+
34+ def fit(self, X: pl.DataFrame) -> "Preprocessor":
35+ for c in self.cols:
36+ s = X[c].cast(pl.Float64)
37+ s = s.filter(s.is_finite())
38+ if s.len() == 0:
39+ self.lo[c], self.hi[c], self.med[c], self.mu[c], self.sd[c] = 0.0, 0.0, 0.0, 0.0, 1.0
40+ continue
41+ lo, hi = float(s.quantile(0.01)), float(s.quantile(0.99))
42+ w = s.clip(lo, hi)
43+ self.lo[c], self.hi[c], self.med[c] = lo, hi, float(w.median())
44+ self.mu[c] = float(w.mean())
45+ sd = float(w.std()) if w.len() > 1 else 1.0
46+ self.sd[c] = sd if sd and sd > 1e-12 else 1.0
47+ self.fitted_on_rows = X.height
48+ return self
49+
50+ def transform(self, X: pl.DataFrame) -> np.ndarray:
51+ out = np.empty((X.height, len(self.cols)), dtype=np.float64)
52+ for j, c in enumerate(self.cols):
53+ v = X[c].cast(pl.Float64).to_numpy().astype(np.float64)
54+ v = np.where(np.isfinite(v), v, np.nan)
55+ v = np.clip(v, self.lo[c], self.hi[c])
56+ v = np.where(np.isnan(v), self.med[c], v)
57+ out[:, j] = (v - self.mu[c]) / self.sd[c]
58+ return out
59+
60+ def to_dict(self) -> dict:
61+ return {"cols": self.cols, "lo": self.lo, "hi": self.hi, "med": self.med, "mu": self.mu, "sd": self.sd,
62+ "fitted_on_rows": self.fitted_on_rows}
63+
64+
65+def columns_for(info_set: str, fc: dict[str, list[str]]) -> list[str]:
66+ cols = []
67+ for fam in INFO_SETS[info_set]:
68+ cols += fc[fam]
69+ return cols
70+
71+
72+# ----------------------------------------------------------------------------- B0
73+def base_rate_predict(train_y: np.ndarray, n: int) -> np.ndarray:
74+ return np.full(n, float(np.clip(train_y.mean(), 1e-4, 1 - 1e-4)))
75+
76+
77+def firm_history_predict(train: pl.DataFrame, test: pl.DataFrame, y: str) -> np.ndarray:
78+ """B0b: trailing firm-level adverse rate (from training rows only, shrunk to the pooled rate with prior weight 8)."""
79+ pooled = float(train[y].mean())
80+ g = train.group_by("entity_id").agg([pl.col(y).sum().alias("k"), pl.len().alias("n")])
81+ t = test.select("entity_id").join(g, on="entity_id", how="left").with_columns([pl.col("k").fill_null(0), pl.col("n").fill_null(0)])
82+ k, n = t["k"].to_numpy().astype(float), t["n"].to_numpy().astype(float)
83+ return np.clip((k + 8 * pooled) / (n + 8), 1e-4, 1 - 1e-4)
84+
85+
86+# ------------------------------------------------------------------------ logistic
87+def fit_logistic(Xtr: np.ndarray, ytr: np.ndarray, Xva: np.ndarray, yva: np.ndarray, C_grid: list[float], seed: int) -> tuple:
88+ best, best_ll, best_C = None, np.inf, None
89+ for C in C_grid:
90+ m = LogisticRegression(C=C, penalty="l2", solver="lbfgs", max_iter=2000, random_state=seed)
91+ m.fit(Xtr, ytr)
92+ ll = log_loss(yva, np.clip(m.predict_proba(Xva)[:, 1], 1e-6, 1 - 1e-6))
93+ if ll < best_ll:
94+ best, best_ll, best_C = m, ll, C
95+ return best, {"C": best_C, "val_logloss": best_ll}
96+
97+
98+def fit_ridge(Xtr, ytr, Xva, yva, alpha_grid=(0.1, 1, 10, 100, 1000)):
99+ best, best_mse, best_a = None, np.inf, None
100+ for a in alpha_grid:
101+ m = Ridge(alpha=a).fit(Xtr, ytr)
102+ mse = float(np.mean((m.predict(Xva) - yva) ** 2))
103+ if mse < best_mse:
104+ best, best_mse, best_a = m, mse, a
105+ return best, {"alpha": best_a, "val_mse": best_mse}
106+
107+
108+# ------------------------------------------------------------------------ LightGBM
109+def _sample_params(space: dict, rng: random.Random) -> dict:
110+ return {k: rng.choice(v) for k, v in space.items()}
111+
112+
113+def fit_lightgbm(Xtr, ytr, Xva, yva, cfg: dict, seed: int, objective: str = "binary", n_jobs: int = 8) -> tuple:
114+ import lightgbm as lgb
115+ rng = random.Random(seed)
116+ best, best_score, best_params, trials = None, np.inf, None, []
117+ for t in range(int(cfg["budget_trials"])):
118+ p = _sample_params(cfg["search_space"], rng)
119+ params = dict(objective=objective, learning_rate=p["learning_rate"], num_leaves=p["num_leaves"],
120+ min_child_samples=p["min_child_samples"], feature_fraction=p["feature_fraction"],
121+ bagging_fraction=p["bagging_fraction"], bagging_freq=1 if p["bagging_fraction"] < 1 else 0,
122+ lambda_l2=p["lambda_l2"], n_estimators=int(cfg["n_estimators_max"]), random_state=seed + t,
123+ n_jobs=n_jobs, verbose=-1)
124+ m = lgb.LGBMClassifier(**params) if objective == "binary" else lgb.LGBMRegressor(**params)
125+ m.fit(Xtr, ytr, eval_set=[(Xva, yva)], callbacks=[lgb.early_stopping(int(cfg["early_stopping_rounds"]), verbose=False)])
126+ if objective == "binary":
127+ score = log_loss(yva, np.clip(m.predict_proba(Xva)[:, 1], 1e-6, 1 - 1e-6))
128+ else:
129+ score = float(np.mean((m.predict(Xva) - yva) ** 2))
130+ trials.append({"trial": t, "params": p, "best_iteration": int(m.best_iteration_ or 0), "val_score": score})
131+ if score < best_score:
132+ best, best_score, best_params = m, score, {**p, "best_iteration": int(m.best_iteration_ or 0)}
133+ return best, {"val_score": best_score, "params": best_params, "trials": trials}
134+
135+
136+def predict_proba(model, X: np.ndarray) -> np.ndarray:
137+ return np.clip(model.predict_proba(X)[:, 1], 1e-6, 1 - 1e-6)
added src/wp39/pipeline.py +62 −0
@@ -0,0 +1,62 @@
1+"""Shared configuration, paths and logging for the WP39 pipeline."""
2+from __future__ import annotations
3+
4+import logging
5+import os
6+from dataclasses import dataclass
7+from pathlib import Path
8+
9+import yaml
10+
11+ROOT = Path(__file__).resolve().parents[2]
12+
13+
14+@dataclass(frozen=True)
15+class Paths:
16+ root: Path
17+ configs: Path
18+ artifacts: Path
19+ manifests: Path
20+ logs: Path
21+ evaluation: Path
22+ models: Path
23+ processed: Path
24+ results: Path
25+ figures: Path
26+ paper_tables: Path
27+ paper_figures: Path
28+ cache: Path
29+
30+ @classmethod
31+ def default(cls) -> "Paths":
32+ cache = Path(os.path.expanduser(os.environ.get("WP39_CACHE", "~/wp39-cache")))
33+ p = cls(root=ROOT, configs=ROOT / "configs", artifacts=ROOT / "artifacts", manifests=ROOT / "artifacts/manifests",
34+ logs=ROOT / "artifacts/logs", evaluation=ROOT / "artifacts/evaluation", models=ROOT / "artifacts/models",
35+ processed=ROOT / "data/processed", results=ROOT / "results", figures=ROOT / "figures",
36+ paper_tables=ROOT / "paper/tables", paper_figures=ROOT / "paper/figures", cache=cache)
37+ for d in (p.manifests, p.logs, p.evaluation, p.models, p.processed, p.results, p.figures, p.paper_tables,
38+ p.paper_figures, p.cache / "bars", p.cache / "fundamentals", p.cache / "features", p.cache / "external"):
39+ d.mkdir(parents=True, exist_ok=True)
40+ return p
41+
42+
43+def load_config(name: str) -> dict:
44+ with open(ROOT / "configs" / f"{name}.yaml") as fh:
45+ return yaml.safe_load(fh)
46+
47+
48+def get_logger(name: str, logfile: Path | None = None) -> logging.Logger:
49+ log = logging.getLogger(name)
50+ if log.handlers:
51+ return log
52+ log.setLevel(logging.INFO)
53+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
54+ h = logging.StreamHandler()
55+ h.setFormatter(fmt)
56+ log.addHandler(h)
57+ if logfile:
58+ logfile.parent.mkdir(parents=True, exist_ok=True)
59+ fh = logging.FileHandler(logfile)
60+ fh.setFormatter(fmt)
61+ log.addHandler(fh)
62+ return log
added src/wp39/pit_fundamentals.py +115 −0
@@ -0,0 +1,115 @@
1+"""Point-in-time fundamentals: extraction, wide quarterly panel, and as-of joins (docs/PIT_PROTOCOL.md).
2+
3+The API's `as_of` semantics are replicated locally on the full extract (available_at <= as_of and revision 1) so that
4+one call per entity suffices; `tests/test_api_contract.py::test_asof_equivalence` checks the equivalence live.
5+"""
6+from __future__ import annotations
7+
8+import datetime as dt
9+from pathlib import Path
10+
11+import polars as pl
12+
13+from .api_client import FinDataClient
14+from .provenance import append_jsonl, now_iso, sha256_file
15+
16+METRICS = ["rev", "cogs", "gp", "op_inc", "op_ex", "ebit", "ebitda", "net_inc", "net_inc_com", "eps", "eps_d",
17+ "cash_op", "capex", "cash_inv", "cash_fin", "cash", "assets_c", "assets_tot", "liab_c", "liab_tot", "debt",
18+ "debt_c", "debt_nc", "equity", "receivables", "inventory", "payables", "ppe", "intangibles", "research_dev",
19+ "sgna", "interest_exp", "intr_cover", "depr", "shares", "shares_av", "shares_av_diluted", "roa", "roe",
20+ "roic", "gm", "om", "ret_earnings", "cash_div", "dps", "share_comp", "tax_expense"]
21+
22+FACT_COLS = ["entity_id", "security_id", "metric", "value", "unit", "fiscal_period_start", "fiscal_period_end",
23+ "fiscal_period_type", "fiscal_year", "fiscal_period", "filing_date", "available_at", "observed_at",
24+ "revision_id", "restatement_flag", "superseded_at", "source_document"]
25+
26+
27+def fetch_entity_facts(client: FinDataClient, entity_id: str, dest: Path, manifest: Path) -> pl.DataFrame:
28+ """All quarterly facts of one entity (no as_of → full history, current revision). Cached as Parquet."""
29+ if dest.exists():
30+ return pl.read_parquet(dest)
31+ d = client.fundamentals(entity_id, as_of=None, period_type="q", limit=20000)
32+ rows = d.get("data", [])
33+ if len(rows) >= 20000:
34+ raise RuntimeError(f"{entity_id}: fact count hit the API limit; narrow the metrics")
35+ df = pl.DataFrame([{k: r.get(k) for k in FACT_COLS} for r in rows]) if rows else pl.DataFrame(schema={c: pl.Utf8 for c in FACT_COLS})
36+ if rows:
37+ df = df.with_columns([
38+ pl.col("value").cast(pl.Float64), pl.col("fiscal_year").cast(pl.Int32), pl.col("revision_id").cast(pl.Int32),
39+ pl.col("fiscal_period_end").str.to_date(), pl.col("fiscal_period_start").str.to_date(),
40+ pl.col("filing_date").str.to_date(),
41+ pl.col("available_at").str.to_datetime(time_zone="UTC", strict=False),
42+ pl.col("observed_at").str.to_datetime(time_zone="UTC", strict=False),
43+ ])
44+ dest.parent.mkdir(parents=True, exist_ok=True)
45+ df.write_parquet(dest)
46+ append_jsonl(manifest, {"entity_id": entity_id, "rows": df.height, "sha256": sha256_file(dest), "fetched_at": now_iso(),
47+ "pit_policy": d.get("meta", {}).get("pit_policy"), "license": d.get("meta", {}).get("license"),
48+ "path": str(dest)})
49+ return df
50+
51+
52+def wide_quarterly(facts: pl.DataFrame) -> pl.DataFrame:
53+ """One row per (entity, fiscal_period_end) with metric columns, filing_date, available_at (ET date of availability)."""
54+ f = facts.filter((pl.col("fiscal_period_type") == "q") & (pl.col("revision_id") == 1) & pl.col("metric").is_in(METRICS))
55+ if f.height == 0:
56+ return pl.DataFrame()
57+ wide = f.pivot(on="metric", index=["entity_id", "security_id", "fiscal_period_end", "fiscal_year", "fiscal_period",
58+ "filing_date", "available_at"], values="value", aggregate_function="first")
59+ for m in METRICS:
60+ if m not in wide.columns:
61+ wide = wide.with_columns(pl.lit(None, dtype=pl.Float64).alias(m))
62+ wide = wide.with_columns(pl.col("available_at").dt.convert_time_zone("America/New_York").dt.date().alias("available_date"))
63+ return wide.sort(["entity_id", "fiscal_period_end"])
64+
65+
66+def add_quarter_index(wide: pl.DataFrame) -> pl.DataFrame:
67+ """Sequential quarter index per entity + gap flags (irregular fiscal calendars)."""
68+ w = wide.sort(["entity_id", "fiscal_period_end"]).with_columns([
69+ pl.col("fiscal_period_end").diff().over("entity_id").dt.total_days().alias("days_since_prev_q"),
70+ ])
71+ w = w.with_columns(pl.int_range(pl.len()).over("entity_id").alias("qidx"))
72+ return w
73+
74+
75+def asof_latest_available(wide: pl.DataFrame, cutoffs: pl.DataFrame) -> pl.DataFrame:
76+ """For each (entity_id, cutoff) return the latest quarter with available_date <= cutoff (strict PIT).
77+
78+ cutoffs: columns entity_id, cutoff (date). Output keeps all cutoff columns and appends the fundamentals row (suffix _pit).
79+ """
80+ cols = [m for m in METRICS if m in wide.columns]
81+ w = wide.select(["entity_id", "fiscal_period_end", "available_date", "qidx"] + cols).rename(
82+ {"fiscal_period_end": "pit_period_end", "available_date": "pit_available_date", "qidx": "pit_qidx"})
83+ w = w.sort(["entity_id", "pit_available_date"])
84+ c = cutoffs.sort(["entity_id", "cutoff"])
85+ joined = c.join_asof(w, left_on="cutoff", right_on="pit_available_date", by="entity_id", strategy="backward")
86+ # join_asof with strategy backward is <=; make the inequality explicit for the leakage test
87+ joined = joined.with_columns((pl.col("pit_available_date") <= pl.col("cutoff")).alias("pit_ok"))
88+ return joined
89+
90+
91+def pit_feature_block(df: pl.DataFrame) -> pl.DataFrame:
92+ """Fundamental ratios from the latest available quarter (columns from asof_latest_available) + lagged growth.
93+
94+ Growth rates need the same-quarter-last-year value *as of the cutoff*; they are supplied by `samples.py` through the
95+ wide panel (quarters with available_date <= cutoff only).
96+ """
97+ eps = 1e-9
98+ ta = pl.col("assets_tot").abs() + eps
99+ rev = pl.col("rev").abs() + eps
100+ return df.with_columns([
101+ (pl.col("gp") / rev).alias("f_gross_margin"),
102+ (pl.col("op_inc") / rev).alias("f_op_margin"),
103+ (pl.col("net_inc") / ta).alias("f_ni_assets"),
104+ (pl.col("cash_op") / ta).alias("f_ocf_assets"),
105+ ((pl.col("net_inc") - pl.col("cash_op")) / ta).alias("f_accruals"),
106+ (pl.col("cash") / ta).alias("f_cash_assets"),
107+ (pl.col("debt") / ta).alias("f_leverage"),
108+ (pl.col("assets_c") / (pl.col("liab_c").abs() + eps)).alias("f_current_ratio"),
109+ (pl.col("capex") / ta).alias("f_capex_assets"),
110+ (pl.col("research_dev") / rev).alias("f_rd_rev"),
111+ pl.col("intr_cover").clip(-50, 50).alias("f_int_cover"),
112+ (pl.col("net_inc") < 0).cast(pl.Int8).alias("f_loss"),
113+ pl.col("assets_tot").abs().log1p().alias("f_log_assets"),
114+ pl.col("roa").alias("f_roa"),
115+ ])
added src/wp39/portfolio.py +124 −0
@@ -0,0 +1,124 @@
1+"""H5: pricing tests. (a) announcement-window abnormal returns regressed on the ex-ante signal;
2+(b) secondary portfolio sorts with factor adjustment, turnover and conservative costs. No fill at the signal close."""
3+from __future__ import annotations
4+
5+import io
6+import zipfile
7+from pathlib import Path
8+
9+import httpx
10+import numpy as np
11+import pandas as pd
12+import polars as pl
13+
14+
15+def download_french(url: str, dest: Path) -> Path:
16+ if not dest.exists():
17+ r = httpx.get(url, timeout=120, follow_redirects=True)
18+ r.raise_for_status()
19+ dest.write_bytes(r.content)
20+ return dest
21+
22+
23+def read_french_daily(zip_path: Path) -> pd.DataFrame:
24+ with zipfile.ZipFile(zip_path) as z:
25+ name = z.namelist()[0]
26+ txt = z.read(name).decode("latin1")
27+ lines = txt.splitlines()
28+ start = next(i for i, l in enumerate(lines) if l.strip()[:8].isdigit())
29+ body = "\n".join(l for l in lines[start:] if l.strip()[:8].isdigit())
30+ df = pd.read_csv(io.StringIO(body), header=None)
31+ hdr = [h.strip() for h in lines[start - 1].split(",")]
32+ hdr[0] = "date"
33+ df.columns = hdr[: df.shape[1]]
34+ df["date"] = pd.to_datetime(df["date"].astype(str), format="%Y%m%d")
35+ for c in df.columns[1:]:
36+ df[c] = pd.to_numeric(df[c], errors="coerce") / 100.0
37+ return df.set_index("date")
38+
39+
40+def car_window(daily: pl.DataFrame, ff: pd.DataFrame, entity_dates: pl.DataFrame) -> pl.DataFrame:
41+ """Cumulative market-adjusted return from cutoff+1 to A_q+1 (inclusive) per (symbol, cutoff, A_q)."""
42+ d = daily.select(["symbol", "session_date", "d_ret_cc"]).to_pandas()
43+ mkt = (ff["Mkt-RF"] + ff["RF"]).rename("mkt")
44+ d = d.merge(mkt, left_on="session_date", right_index=True, how="left")
45+ d["ar"] = d["d_ret_cc"] - d["mkt"]
46+ d = d.sort_values(["symbol", "session_date"])
47+ d["cum_ar"] = d.groupby("symbol")["ar"].cumsum()
48+ key = d.set_index(["symbol", "session_date"])["cum_ar"]
49+ rows = []
50+ for r in entity_dates.iter_rows(named=True):
51+ s = r["symbol"]
52+ try:
53+ a = key.loc[(s, pd.Timestamp(r["cutoff"]))]
54+ b_idx = d[(d.symbol == s) & (d.session_date > pd.Timestamp(r["A_q"]))].head(1)
55+ b = float(b_idx["cum_ar"].iloc[0]) if len(b_idx) else np.nan
56+ rows.append({**{k: r[k] for k in ("entity_id", "symbol", "cutoff", "A_q", "fiscal_period_end")}, "car": b - float(a)})
57+ except KeyError:
58+ continue
59+ return pl.DataFrame(rows)
60+
61+
62+def monthly_decile_portfolios(sig: pd.DataFrame, daily: pd.DataFrame, ff: pd.DataFrame, cost_bps: float = 20.0) -> dict:
63+ """sig: columns symbol, cutoff (month-end), score (higher = more likely deterioration). Positions formed at the
64+ close of the *next* session after the cutoff (no same-close fill), held one month, equal-weighted deciles."""
65+ daily = daily.sort_values(["symbol", "session_date"])
66+ sessions = np.sort(daily["session_date"].unique())
67+ out_rows, prev_hold = [], {}
68+ months = sorted(sig["cutoff"].unique())
69+ for i, c in enumerate(months):
70+ s = sig[sig.cutoff == c].dropna(subset=["score"])
71+ if len(s) < 50:
72+ continue
73+ s = s.assign(dec=pd.qcut(s["score"].rank(method="first"), 10, labels=False) + 1)
74+ nxt = sessions[sessions > np.datetime64(c)]
75+ if len(nxt) < 2:
76+ continue
77+ entry = nxt[0]
78+ exit_ = months[i + 1] if i + 1 < len(months) else sessions[-1]
79+ exit_entry = sessions[sessions > np.datetime64(exit_)]
80+ exit_day = exit_entry[0] if len(exit_entry) else sessions[-1]
81+ w = daily[(daily.session_date > entry) & (daily.session_date <= exit_day)]
82+ hold_ret = w.groupby("symbol")["d_ret_cc"].apply(lambda x: float(np.prod(1 + x.fillna(0)) - 1))
83+ s = s.merge(hold_ret.rename("ret"), left_on="symbol", right_index=True, how="left")
84+ for dec, g in s.groupby("dec"):
85+ names = set(g.symbol)
86+ prev = prev_hold.get(dec, set())
87+ turnover = len(names ^ prev) / max(len(names), 1) / 2
88+ prev_hold[dec] = names
89+ out_rows.append({"cutoff": c, "entry": entry, "exit": exit_day, "decile": int(dec), "n": len(g),
90+ "ret": float(g["ret"].mean()), "turnover": turnover,
91+ "ret_net": float(g["ret"].mean()) - 2 * turnover * cost_bps / 1e4})
92+ port = pd.DataFrame(out_rows)
93+ return {"portfolios": port}
94+
95+
96+def factor_alpha(port: pd.DataFrame, ff: pd.DataFrame, decile_long: int, decile_short: int, net: bool = False) -> dict:
97+ """Monthly long-short (long low-score, short high-score) regressed on FF5 + MOM compounded over the same windows."""
98+ col = "ret_net" if net else "ret"
99+ L = port[port.decile == decile_long].set_index("cutoff")[col]
100+ S = port[port.decile == decile_short].set_index("cutoff")[col]
101+ ls = (L - S).dropna()
102+ rows = []
103+ for c in ls.index:
104+ w = port[(port.cutoff == c) & (port.decile == decile_long)].iloc[0]
105+ f = ff[(ff.index > pd.Timestamp(w["entry"])) & (ff.index <= pd.Timestamp(w["exit"]))]
106+ rows.append({"cutoff": c, **{k: float(np.prod(1 + f[k]) - 1) for k in ("Mkt-RF", "SMB", "HML", "RMW", "CMA", "Mom", "RF") if k in f}})
107+ F = pd.DataFrame(rows).set_index("cutoff")
108+ y = (ls - F["RF"]).to_numpy()
109+ X = np.column_stack([np.ones(len(F))] + [F[k].to_numpy() for k in ("Mkt-RF", "SMB", "HML", "RMW", "CMA", "Mom") if k in F])
110+ beta, *_ = np.linalg.lstsq(X, y, rcond=None)
111+ e = y - X @ beta
112+ T = len(y); L_ = int(np.floor(4 * (T / 100) ** (2 / 9)))
113+ # HAC se for the intercept
114+ Xi = np.linalg.pinv(X.T @ X)
115+ S0 = sum(np.outer(X[t] * e[t], X[t] * e[t]) for t in range(T))
116+ for l in range(1, L_ + 1):
117+ wgt = 1 - l / (L_ + 1)
118+ for t in range(l, T):
119+ G = np.outer(X[t] * e[t], X[t - l] * e[t - l]); S0 += wgt * (G + G.T)
120+ V = Xi @ S0 @ Xi
121+ se = np.sqrt(np.diag(V))
122+ return {"alpha_monthly": float(beta[0]), "alpha_se": float(se[0]), "alpha_t": float(beta[0] / se[0]) if se[0] > 0 else float("nan"),
123+ "mean_ls": float(ls.mean()), "sd_ls": float(ls.std()), "sharpe_ann": float(ls.mean() / ls.std() * np.sqrt(12)) if ls.std() > 0 else float("nan"),
124+ "T": int(T), "net": net}
added src/wp39/provenance.py +61 −0
@@ -0,0 +1,61 @@
1+"""Dataset manifests, hashes and code versions (no raw data, no secrets)."""
2+from __future__ import annotations
3+
4+import hashlib
5+import json
6+import os
7+import subprocess
8+import time
9+from pathlib import Path
10+from typing import Any, Iterable
11+
12+
13+def sha256_file(path: str | os.PathLike, chunk: int = 1 << 20) -> str:
14+ h = hashlib.sha256()
15+ with open(path, "rb") as fh:
16+ for block in iter(lambda: fh.read(chunk), b""):
17+ h.update(block)
18+ return h.hexdigest()
19+
20+
21+def sha256_text(text: str) -> str:
22+ return hashlib.sha256(text.encode()).hexdigest()
23+
24+
25+def git_sha(repo: str | os.PathLike | None = None) -> str:
26+ try:
27+ return subprocess.check_output(["git", "rev-parse", "--short=12", "HEAD"], cwd=repo, text=True,
28+ stderr=subprocess.DEVNULL).strip()
29+ except Exception:
30+ return "nogit"
31+
32+
33+def now_iso() -> str:
34+ return time.strftime("%Y-%m-%dT%H:%M:%S%z")
35+
36+
37+def append_jsonl(path: str | os.PathLike, record: dict[str, Any]) -> None:
38+ p = Path(path)
39+ p.parent.mkdir(parents=True, exist_ok=True)
40+ with open(p, "a") as fh:
41+ fh.write(json.dumps(record, default=str, sort_keys=True) + "\n")
42+
43+
44+def read_jsonl(path: str | os.PathLike) -> list[dict[str, Any]]:
45+ p = Path(path)
46+ if not p.exists():
47+ return []
48+ with open(p) as fh:
49+ return [json.loads(line) for line in fh if line.strip()]
50+
51+
52+def manifest_index(path: str | os.PathLike, keys: Iterable[str]) -> dict[tuple, dict[str, Any]]:
53+ keys = tuple(keys)
54+ return {tuple(r.get(k) for k in keys): r for r in read_jsonl(path)}
55+
56+
57+def write_json(path: str | os.PathLike, obj: Any) -> None:
58+ p = Path(path)
59+ p.parent.mkdir(parents=True, exist_ok=True)
60+ with open(p, "w") as fh:
61+ json.dump(obj, fh, indent=1, default=str, sort_keys=True)
added src/wp39/robustness.py +46 −0
@@ -0,0 +1,46 @@
1+"""Negative controls, placebos and subgroup stress tests (Phase 5)."""
2+from __future__ import annotations
3+
4+import numpy as np
5+import polars as pl
6+
7+
8+def shuffle_labels_within_quarter(df: pl.DataFrame, y: str, seed: int) -> pl.DataFrame:
9+ """Permute the label within calendar quarter of the cutoff: any 'gain' left is overfitting/leakage."""
10+ rng = np.random.default_rng(seed)
11+ q = df.with_columns((pl.col("cutoff").dt.year() * 4 + (pl.col("cutoff").dt.month() - 1) // 3).alias("_cq"))
12+ parts = []
13+ for _, g in q.group_by("_cq", maintain_order=True):
14+ v = g[y].to_numpy().copy(); rng.shuffle(v)
15+ parts.append(g.with_columns(pl.Series(y, v)))
16+ return pl.concat(parts).drop("_cq")
17+
18+
19+def subgroup_masks(df: pl.DataFrame) -> dict[str, pl.Expr]:
20+ """Pre-specified subgroups (terciles are computed on the TRAINING rows and passed as cut-points by the caller)."""
21+ return {
22+ "all": pl.lit(True),
23+ "size_small": pl.col("tercile_size") == 1, "size_large": pl.col("tercile_size") == 3,
24+ "illiq_high": pl.col("tercile_illiq") == 3, "illiq_low": pl.col("tercile_illiq") == 1,
25+ "lev_high": pl.col("tercile_lev") == 3, "lev_low": pl.col("tercile_lev") == 1,
26+ "loss_firms": pl.col("f_loss") == 1, "profit_firms": pl.col("f_loss") == 0,
27+ "covid_2020": (pl.col("cutoff").dt.year() == 2020), "ex_covid": (pl.col("cutoff").dt.year() != 2020),
28+ "after_close_release": pl.col("release_time") == "after_close", "before_open_release": pl.col("release_time") == "before_open",
29+ "sector_finance": pl.col("sic_division") == "H_finance_insurance_realestate",
30+ "sector_nonfinance": pl.col("sic_division") != "H_finance_insurance_realestate",
31+ }
32+
33+
34+def add_terciles(train: pl.DataFrame, others: list[pl.DataFrame]) -> list[pl.DataFrame]:
35+ """Tercile cut-points for size, illiquidity and leverage computed on training rows only."""
36+ cuts = {}
37+ for name, col in (("size", "f_log_mcap"), ("illiq", "wd_amihud60"), ("lev", "f_leverage")):
38+ s = train[col].drop_nulls().drop_nans()
39+ cuts[name] = (float(s.quantile(1 / 3)), float(s.quantile(2 / 3)))
40+ out = []
41+ for d in [train] + others:
42+ for name, col in (("size", "f_log_mcap"), ("illiq", "wd_amihud60"), ("lev", "f_leverage")):
43+ lo, hi = cuts[name]
44+ d = d.with_columns(pl.when(pl.col(col) <= lo).then(1).when(pl.col(col) <= hi).then(2).otherwise(3).alias(f"tercile_{name}"))
45+ out.append(d)
46+ return out
added src/wp39/samples.py +180 −0
@@ -0,0 +1,180 @@
1+"""Cohort construction: cutoffs, window aggregation of session features, PIT fundamentals join, exclusions.
2+
3+Event-time: c = A_q - h trading days (h in horizons). Fixed-calendar: c = month-end sessions, target = next release.
4+All joins are explicit as-of joins on `session_date <= c` (bars) and `available_date <= c` (fundamentals).
5+"""
6+from __future__ import annotations
7+
8+import datetime as dt
9+
10+import numpy as np
11+import polars as pl
12+
13+from .calendar import session_array, shift_trading_days, month_end_sessions
14+from .features import FREQS, INTRADAY_FAMILIES
15+from .pit_fundamentals import METRICS, pit_feature_block
16+
17+WINDOW_MEAN = ["rv", "bpv", "rj", "rsneg_share", "sjv", "rskew", "rkurt", "max_share", "ac1", "vol_first30", "vol_last30",
18+ "vol_gini", "down_vol_share", "present_share", "rv_mktdown_share"]
19+
20+
21+def complete_session_index(feat: pl.DataFrame) -> pl.DataFrame:
22+ """Reindex a symbol's session features to every XNYS session between its first and last session (rows for missing
23+ sessions have null features) so that rolling windows are measured in trading days."""
24+ arr = session_array()
25+ d0, d1 = feat["session_date"].min(), feat["session_date"].max()
26+ s = arr[(arr >= np.datetime64(d0)) & (arr <= np.datetime64(d1))]
27+ idx = pl.DataFrame({"session_date": pl.Series(s.astype("datetime64[D]")).cast(pl.Date)})
28+ return idx.join(feat, on="session_date", how="left").sort("session_date")
29+
30+
31+def rolling_window_features(feat: pl.DataFrame, short: int = 20, long: int = 60, min_valid: int = 15) -> pl.DataFrame:
32+ """Rolling (trailing, inclusive) aggregates per session; the row at session c is the feature vector for cutoff c."""
33+ f = complete_session_index(feat)
34+ exprs = []
35+ valid = pl.col("f1m_n_bars").is_not_null() & (pl.col("f1m_n_bars") >= 100)
36+ f = f.with_columns(valid.cast(pl.Int8).alias("_valid"))
37+ exprs.append(pl.col("_valid").rolling_sum(window_size=short, min_samples=1).alias("w_valid_days"))
38+ # intraday families: window means (RV-type quantities averaged -> per-day average, same across frequencies)
39+ for name in FREQS:
40+ p = f"f{name}_"
41+ for fam in WINDOW_MEAN:
42+ exprs.append(pl.col(p + fam).rolling_mean(window_size=short, min_samples=min_valid).alias(f"w{name}_{fam}"))
43+ exprs.append(pl.col(p + "drift").rolling_sum(window_size=short, min_samples=min_valid).alias(f"w{name}_drift"))
44+ exprs.append((pl.col(p + "sxy").rolling_sum(window_size=short, min_samples=min_valid) /
45+ (pl.col(p + "syy").rolling_sum(window_size=short, min_samples=min_valid) + 1e-18)).alias(f"w{name}_beta"))
46+ exprs.append(pl.col(p + "rv").rolling_std(window_size=short, min_samples=min_valid).alias(f"w{name}_volofvol"))
47+ # variance ratio: mean intraday RV vs variance of daily close-to-close returns over the window
48+ exprs.append((pl.col(p + "rv").rolling_mean(window_size=short, min_samples=min_valid) /
49+ (pl.col("d_ret_cc").rolling_var(window_size=short, min_samples=min_valid) + 1e-18)).alias(f"w{name}_vratio"))
50+ # daily block
51+ r = pl.col("d_ret_cc")
52+ exprs += [
53+ ((1.0 + r).log().rolling_sum(window_size=short, min_samples=min_valid)).alias("wd_ret20"),
54+ ((1.0 + r).log().rolling_sum(window_size=long, min_samples=3 * min_valid)).alias("wd_ret60"),
55+ r.rolling_std(window_size=short, min_samples=min_valid).alias("wd_vol20"),
56+ r.rolling_std(window_size=long, min_samples=3 * min_valid).alias("wd_vol60"),
57+ pl.col("d_parkinson").rolling_mean(window_size=short, min_samples=min_valid).sqrt().alias("wd_parkinson20"),
58+ r.rolling_skew(window_size=long).alias("wd_skew60"),
59+ r.rolling_kurtosis(window_size=long).alias("wd_kurt60"),
60+ (pl.col("d_ret_co").rolling_sum(window_size=short, min_samples=min_valid)).alias("wd_overnight20"),
61+ (pl.col("d_ret_oc").rolling_sum(window_size=short, min_samples=min_valid)).alias("wd_intraday20"),
62+ (pl.col("d_volume").rolling_mean(window_size=short, min_samples=min_valid) /
63+ (pl.col("d_volume").rolling_median(window_size=long, min_samples=3 * min_valid) + 1.0)).alias("wd_volratio20"),
64+ (pl.col("d_volume").rolling_mean(window_size=5, min_samples=3) /
65+ (pl.col("d_volume").rolling_median(window_size=long, min_samples=3 * min_valid) + 1.0)).alias("wd_volratio5"),
66+ ((r.abs() / (pl.col("d_dollar_vol") + 1.0)) * 1e6).rolling_mean(window_size=long, min_samples=3 * min_valid).alias("wd_amihud60"),
67+ (r == 0).cast(pl.Float64).rolling_mean(window_size=long, min_samples=3 * min_valid).alias("wd_zeroret60"),
68+ pl.col("d_dollar_vol").rolling_mean(window_size=long, min_samples=3 * min_valid).log1p().alias("wd_log_dollar_vol60"),
69+ pl.col("d_close").alias("wd_close"),
70+ pl.col("f1m_n_bars").rolling_mean(window_size=short, min_samples=min_valid).alias("wd_bars20"),
71+ ]
72+ f = f.with_columns(exprs)
73+ # max drawdown over 60 sessions from cumulative log returns
74+ f = f.with_columns((1.0 + r).log().fill_null(0.0).cum_sum().alias("_cum"))
75+ f = f.with_columns((pl.col("_cum") - pl.col("_cum").rolling_max(window_size=long, min_samples=3 * min_valid)).rolling_min(
76+ window_size=long, min_samples=3 * min_valid).alias("wd_maxdd60"))
77+ # daily beta / residual vol vs SPY daily (from 1m family sums at daily horizon are not available -> use 5m beta proxy)
78+ cols = ["session_date", "symbol", "w_valid_days"] + [c for c in f.columns if c.startswith("w1m_") or c.startswith("w5m_")
79+ or c.startswith("w30m_") or c.startswith("wd_")]
80+ return f.select(cols)
81+
82+
83+def event_cutoffs(quarters: pl.DataFrame, horizons: list[int]) -> pl.DataFrame:
84+ """One row per (entity, quarter, h) with cutoff c = A_q - h trading days (requires release_match == ok)."""
85+ q = quarters.filter(pl.col("release_match") == "ok").select(
86+ ["entity_id", "fiscal_period_end", "A_q", "filing_date", "available_date", "qidx"])
87+ rows = []
88+ a_prev = q.with_columns(pl.col("A_q").shift(1).over("entity_id").alias("A_prev"))
89+ for r in a_prev.iter_rows(named=True):
90+ for h in horizons:
91+ try:
92+ c = shift_trading_days(r["A_q"], -h)
93+ except ValueError:
94+ continue
95+ rows.append({"entity_id": r["entity_id"], "fiscal_period_end": r["fiscal_period_end"], "h": h, "cutoff": c,
96+ "A_q": r["A_q"], "F_q": r["filing_date"], "A_prev": r["A_prev"], "qidx": r["qidx"]})
97+ return pl.DataFrame(rows)
98+
99+
100+def calendar_cutoffs(quarters: pl.DataFrame, start: dt.date, end: dt.date, max_td: int = 65) -> pl.DataFrame:
101+ """Month-end cutoffs; target = next release after c within max_td trading days."""
102+ me = pl.DataFrame({"cutoff": month_end_sessions(start, end)})
103+ q = quarters.filter(pl.col("release_match") == "ok").select(["entity_id", "fiscal_period_end", "A_q", "filing_date", "qidx"])
104+ ents = q.select("entity_id").unique()
105+ grid = ents.join(me, how="cross").sort(["entity_id", "cutoff"])
106+ qq = q.sort(["entity_id", "A_q"]).with_columns(pl.col("A_q").alias("_a"))
107+ j = grid.join_asof(qq, left_on="cutoff", right_on="_a", by="entity_id", strategy="forward")
108+ j = j.filter(pl.col("A_q") > pl.col("cutoff"))
109+ arr = session_array()
110+ c = np.searchsorted(arr, j["cutoff"].to_numpy().astype("datetime64[D]"), side="right")
111+ a = np.searchsorted(arr, j["A_q"].to_numpy().astype("datetime64[D]"), side="right")
112+ j = j.with_columns(pl.Series("h", (a - c).astype(int))).filter(pl.col("h") <= max_td)
113+ return j.rename({"filing_date": "F_q"}).with_columns(pl.lit(None, dtype=pl.Date).alias("A_prev"))
114+
115+
116+def apply_admissibility(cut: pl.DataFrame) -> pl.DataFrame:
117+ """c < min(A_q, F_q) and c > A_prev (previous reveal already happened)."""
118+ return cut.filter((pl.col("cutoff") < pl.col("A_q")) & (pl.col("cutoff") < pl.col("F_q")) &
119+ (pl.col("A_prev").is_null() | (pl.col("cutoff") > pl.col("A_prev"))))
120+
121+
122+def join_market(cut: pl.DataFrame, win: pl.DataFrame, symbol_of: pl.DataFrame) -> pl.DataFrame:
123+ """As-of join of the rolling window features at session_date <= cutoff (exact session normally)."""
124+ c = cut.join(symbol_of, on="entity_id", how="left").sort(["symbol", "cutoff"])
125+ w = win.sort(["symbol", "session_date"])
126+ j = c.join_asof(w, left_on="cutoff", right_on="session_date", by="symbol", strategy="backward", tolerance="5d")
127+ return j.rename({"session_date": "market_asof_date"})
128+
129+
130+def join_fundamentals(j: pl.DataFrame, wide: pl.DataFrame) -> pl.DataFrame:
131+ """Latest available quarter at cutoff + yoy growth from the quarter 4 back (also available by construction)."""
132+ w = wide.select(["entity_id", "fiscal_period_end", "available_date", "qidx", "A_q"] + METRICS).sort(["entity_id", "available_date"])
133+ w4 = wide.select(["entity_id", pl.col("qidx").alias("pit_qidx4"), pl.col("rev").alias("rev_l4"), pl.col("op_inc").alias("op_inc_l4"),
134+ pl.col("shares").alias("shares_l4"), pl.col("available_date").alias("avail_l4")])
135+ wr = w.rename({c: c for c in METRICS}).rename({"fiscal_period_end": "pit_period_end", "available_date": "pit_available_date",
136+ "qidx": "pit_qidx", "A_q": "pit_A"})
137+ out = j.sort(["entity_id", "cutoff"]).join_asof(wr, left_on="cutoff", right_on="pit_available_date", by="entity_id", strategy="backward")
138+ out = out.with_columns((pl.col("pit_qidx") - 4).alias("pit_qidx4")).join(w4, on=["entity_id", "pit_qidx4"], how="left")
139+ out = out.with_columns([
140+ (pl.col("pit_available_date") <= pl.col("cutoff")).alias("pit_ok"),
141+ ((pl.col("rev") - pl.col("rev_l4")) / (pl.col("rev_l4").abs() + 1e-9)).alias("f_rev_growth"),
142+ ((pl.col("shares") - pl.col("shares_l4")) / (pl.col("shares_l4").abs() + 1e-9)).alias("f_net_issuance"),
143+ (pl.col("qidx") - pl.col("pit_qidx")).alias("quarters_ahead"), # 1 = we predict the quarter right after the latest available
144+ ])
145+ out = pit_feature_block(out)
146+ out = out.with_columns([
147+ (pl.col("wd_close") * pl.col("shares").abs()).log1p().alias("f_log_mcap"),
148+ ])
149+ return out
150+
151+
152+def exclusion_cascade(df: pl.DataFrame, min_price: float, min_valid_days: int) -> tuple[pl.DataFrame, pl.DataFrame]:
153+ """Apply frozen exclusions in order; return (sample, cascade table)."""
154+ steps = [
155+ ("cutoff_admissible", pl.lit(True)),
156+ ("market_window_available", pl.col("market_asof_date").is_not_null() & (pl.col("w_valid_days") >= min_valid_days)),
157+ ("price_ge_min", pl.col("wd_close") >= min_price),
158+ ("fundamentals_available", pl.col("pit_ok").fill_null(False) & pl.col("pit_qidx").is_not_null()),
159+ ("predicting_next_quarter", pl.col("quarters_ahead") == 1),
160+ ("expectation_defined", pl.col("S_opinc").is_not_null() & pl.col("assets_tot_lag1").is_not_null()),
161+ ("regular_fiscal_calendar", pl.col("_gap_ok8") == 8),
162+ ]
163+ rows, cur = [], df
164+ for name, cond in steps:
165+ before = cur.height
166+ cur = cur.filter(cond)
167+ rows.append({"step": name, "remaining": cur.height, "dropped": before - cur.height})
168+ return cur, pl.DataFrame(rows)
169+
170+
171+def feature_columns(df: pl.DataFrame) -> dict[str, list[str]]:
172+ fund = [c for c in df.columns if c.startswith("f_")] + ["info_age_td", "last_surprise", "mean_surprise4", "hist_adverse_rate"]
173+ daily = [c for c in df.columns if c.startswith("wd_") and c != "wd_close"]
174+ return {
175+ "fundamentals": [c for c in fund if c in df.columns],
176+ "daily": daily,
177+ "intraday_30m": [c for c in df.columns if c.startswith("w30m_")],
178+ "intraday_5m": [c for c in df.columns if c.startswith("w5m_")],
179+ "intraday_1m": [c for c in df.columns if c.startswith("w1m_")],
180+ }
added src/wp39/splits.py +65 −0
@@ -0,0 +1,65 @@
1+"""Chronological, walk-forward and firm-held-out splits with purge and embargo (RESEARCH_DESIGN §6).
2+
3+An observation is identified by (entity_id, fiscal_period_end, cutoff, A_q). The *target* is revealed at A_q, so a
4+training observation is purged if its A_q falls after (split_start - embargo); test observations start at split_start.
5+"""
6+from __future__ import annotations
7+
8+import datetime as dt
9+import hashlib
10+
11+import polars as pl
12+
13+
14+def _q_start(d: dt.date) -> dt.date:
15+ return dt.date(d.year, 3 * ((d.month - 1) // 3) + 1, 1)
16+
17+
18+def _shift_quarters(d: dt.date, n: int) -> dt.date:
19+ m = d.month - 1 + 3 * n
20+ return dt.date(d.year + m // 12, m % 12 + 1, 1)
21+
22+
23+def purge_train(df: pl.DataFrame, boundary: dt.date, embargo_quarters: int = 1) -> pl.DataFrame:
24+ """Keep training rows whose outcome was revealed before boundary - embargo (quarter-aligned)."""
25+ limit = _shift_quarters(_q_start(boundary), -embargo_quarters)
26+ return df.filter(pl.col("A_q") < limit)
27+
28+
29+def chronological(df: pl.DataFrame, cfg: dict) -> dict[str, pl.DataFrame]:
30+ tr0, tr1 = [dt.date.fromisoformat(str(x)) for x in cfg["train"]]
31+ va0, va1 = [dt.date.fromisoformat(str(x)) for x in cfg["validation"]]
32+ te0, te1 = [dt.date.fromisoformat(str(x)) for x in cfg["test"]]
33+ emb = cfg["walk_forward"]["embargo_quarters"]
34+ train = purge_train(df.filter((pl.col("cutoff") >= tr0) & (pl.col("cutoff") <= tr1)), va0, emb)
35+ valid = purge_train(df.filter((pl.col("cutoff") >= va0) & (pl.col("cutoff") <= va1)), te0, emb)
36+ test = df.filter((pl.col("cutoff") >= te0) & (pl.col("cutoff") <= te1))
37+ return {"train": train, "validation": valid, "test": test}
38+
39+
40+def walk_forward(df: pl.DataFrame, cfg: dict, last_year: int) -> list[tuple[int, pl.DataFrame, pl.DataFrame]]:
41+ """Expanding annual folds: train on cutoffs < Jan 1 of year y (purged, embargo), test on cutoffs in year y."""
42+ out = []
43+ emb = cfg["walk_forward"]["embargo_quarters"]
44+ for y in range(cfg["walk_forward"]["first_test_year"], last_year + 1):
45+ b = dt.date(y, 1, 1)
46+ train = purge_train(df.filter(pl.col("cutoff") < b), b, emb)
47+ test = df.filter((pl.col("cutoff") >= b) & (pl.col("cutoff") < dt.date(y + 1, 1, 1)))
48+ if train.height and test.height:
49+ out.append((y, train, test))
50+ return out
51+
52+
53+def firm_holdout_mask(entity_ids: pl.Series, fraction: float = 0.2, salt: str = "wp39") -> pl.Series:
54+ def h(e: str) -> bool:
55+ return int(hashlib.sha256(f"{salt}:{e}".encode()).hexdigest()[:8], 16) / 0xFFFFFFFF < fraction
56+ return pl.Series([h(e) for e in entity_ids.to_list()], dtype=pl.Boolean)
57+
58+
59+def overlap_check(train: pl.DataFrame, test: pl.DataFrame) -> dict:
60+ """Diagnostics used by tests: no training outcome revealed on/after the first test cutoff; no shared observation ids."""
61+ first_test_cutoff = test["cutoff"].min()
62+ leak = train.filter(pl.col("A_q") >= first_test_cutoff).height
63+ key = ["entity_id", "fiscal_period_end", "h"] if "h" in train.columns else ["entity_id", "fiscal_period_end"]
64+ shared = train.select(key).join(test.select(key), on=key, how="inner").height
65+ return {"train_outcomes_revealed_in_test": leak, "shared_observations": shared}
added tests/__init__.py +0 −0
added tests/conftest.py +39 −0
@@ -0,0 +1,39 @@
1+import datetime as dt
2+import os
3+import sys
4+from pathlib import Path
5+
6+import polars as pl
7+import pytest
8+
9+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10+
11+LIVE = os.environ.get("WP39_LIVE") == "1"
12+live = pytest.mark.skipif(not LIVE, reason="set WP39_LIVE=1 to run live FinData tests")
13+
14+
15+@pytest.fixture
16+def toy_wide():
17+ """Two firms, regular quarterly calendar, filing 40 d after period end."""
18+ rows = []
19+ for e in ("frd:AAA", "frd:BBB"):
20+ base = 100.0 if e == "frd:AAA" else 50.0
21+ for i in range(14):
22+ pe = dt.date(2015, 3, 31) + dt.timedelta(days=int(91.3 * i))
23+ fd = pe + dt.timedelta(days=40)
24+ rows.append({"entity_id": e, "security_id": f"firstrate:stock:{e[4:]}", "fiscal_period_end": pe,
25+ "fiscal_year": pe.year, "fiscal_period": f"q{(pe.month - 1) // 3 + 1}", "filing_date": fd,
26+ "available_date": fd + dt.timedelta(days=1), "op_inc": base + i + (5 if (i % 4 == 0) else 0),
27+ "net_inc": base * 0.7 + i, "cash_op": base * 1.1 + i, "rev": base * 10 + 10 * i,
28+ "assets_tot": base * 20, "assets_c": base * 5, "liab_c": base * 3, "roa": 0.02 + 0.001 * i,
29+ "intr_cover": 5.0, "shares": 1000.0, "gp": base * 4, "debt": base * 6, "cash": base,
30+ "capex": base * 0.2, "research_dev": base * 0.1, "qidx": i})
31+ return pl.DataFrame(rows)
32+
33+
34+@pytest.fixture
35+def toy_events(toy_wide):
36+ ev = toy_wide.select(["entity_id", "filing_date"]).with_columns(
37+ (pl.col("filing_date") - pl.duration(days=1)).alias("event_date"), pl.lit("after_close").alias("release_time"),
38+ pl.lit(None, dtype=pl.Float64).alias("eps_estimate"), pl.lit(None, dtype=pl.Float64).alias("eps_reported")).drop("filing_date")
39+ return ev
added tests/test_api_contract.py +42 −0
@@ -0,0 +1,42 @@
1+import datetime as dt
2+import random
3+
4+import polars as pl
5+import pytest
6+
7+from tests.conftest import live
8+from wp39.api_client import FinDataClient, _redact
9+
10+
11+def test_redaction():
12+ assert "fd_abc.def" not in _redact("token fd_abc.def leaked")
13+
14+
15+@live
16+def test_schema_and_datasets():
17+ c = FinDataClient()
18+ s = c.schema_bars()
19+ assert s["timestamp_convention"] == "bar_open" and s["schema_version"] == 1
20+ ids = {d["id"] for d in c.datasets()}
21+ assert {"market_bars.stock.1m.raw", "market_bars.etf.1m.raw"} <= ids
22+
23+
24+@live
25+def test_asof_equivalence():
26+ """Local filter available_at <= as_of on the full extract == API as_of result (metric, period) set."""
27+ c = FinDataClient()
28+ full = pl.DataFrame(c.fundamentals("frd:MSFT", period_type="q", limit=20000)["data"])
29+ full = full.with_columns(pl.col("available_at").str.to_datetime(time_zone="UTC", strict=False))
30+ for as_of in ("2016-03-01T00:00:00Z", "2019-11-15T00:00:00Z", "2023-08-01T00:00:00Z"):
31+ api = pl.DataFrame(c.fundamentals("frd:MSFT", as_of=as_of, period_type="q", limit=20000)["data"])
32+ local = full.filter(pl.col("available_at") <= pl.lit(as_of).str.to_datetime(time_zone="UTC"))
33+ a = set(zip(api["metric"], api["fiscal_period_end"])); b = set(zip(local["metric"], local["fiscal_period_end"]))
34+ assert a == b, (as_of, len(a), len(b))
35+
36+
37+@live
38+def test_bars_limits_are_enforced():
39+ from wp39.api_client import FinDataError
40+ c = FinDataClient()
41+ with pytest.raises(FinDataError):
42+ c.get("/market/bars", symbol="AAPL", interval="1m", start="2024-01-01", end="2024-03-15", limit=10)
added tests/test_corporate_actions.py +22 −0
@@ -0,0 +1,22 @@
1+import datetime as dt
2+
3+import polars as pl
4+
5+from wp39.features import daily_block
6+
7+
8+def test_split_adjusted_overnight_return():
9+ day = pl.DataFrame({"session_date": [dt.date(2020, 8, 28), dt.date(2020, 8, 31)], "d_open": [500.0, 127.0],
10+ "d_close": [499.0, 129.0], "d_high": [505.0, 131.0], "d_low": [495.0, 126.0], "d_volume": [1e6, 4e6]})
11+ acts = pl.DataFrame({"symbol": ["AAPL"], "ex_date": [dt.date(2020, 8, 31)], "split_ratio": [4.0], "dividend": [0.0]})
12+ d = daily_block(day, acts, "AAPL")
13+ assert abs(d["d_ret_cc"][1] - (129.0 / (499.0 / 4.0) - 1)) < 1e-9
14+ assert abs(d["d_ret_co"][1] - (127.0 / (499.0 / 4.0) - 1)) < 1e-9
15+
16+
17+def test_dividend_adjusted_return():
18+ day = pl.DataFrame({"session_date": [dt.date(2021, 2, 4), dt.date(2021, 2, 5)], "d_open": [100.0, 99.0],
19+ "d_close": [100.0, 99.5], "d_high": [101.0, 100.0], "d_low": [99.0, 98.0], "d_volume": [1e6, 1e6]})
20+ acts = pl.DataFrame({"symbol": ["X"], "ex_date": [dt.date(2021, 2, 5)], "split_ratio": [1.0], "dividend": [0.5]})
21+ d = daily_block(day, acts, "X")
22+ assert abs(d["d_ret_cc"][1] - ((99.5 + 0.5) / 100.0 - 1)) < 1e-12
added tests/test_features.py +37 −0
@@ -0,0 +1,37 @@
1+import datetime as dt
2+import math
3+
4+import numpy as np
5+import polars as pl
6+
7+from wp39.features import aggregate_buckets, bucket_returns, session_features
8+
9+
10+def _reg_day(n=391, seed=0):
11+ rng = np.random.default_rng(seed)
12+ r = rng.normal(0, 1e-3, n)
13+ px = 100 * np.exp(np.cumsum(r))
14+ return pl.DataFrame({"session_date": [dt.date(2024, 3, 5)] * n, "m": list(range(n)), "open": np.r_[100.0, px[:-1]],
15+ "high": px * 1.0005, "low": px * 0.9995, "close": px, "volume": rng.integers(100, 1000, n).astype(float),
16+ "close_minute": [390] * n})
17+
18+
19+def test_bucket_aggregation_counts_and_close_print():
20+ reg = _reg_day()
21+ b5 = aggregate_buckets(reg, 5); b30 = aggregate_buckets(reg, 30)
22+ assert b5.height == 78 and b30.height == 13
23+ assert b5["close"][-1] == reg["close"][-1] and b30["close"][-1] == reg["close"][-1]
24+ assert abs(b5["volume"].sum() - reg["volume"].sum()) < 1e-9
25+
26+
27+def test_rv_identities_and_shares():
28+ reg = _reg_day()
29+ for f in (1, 5, 30):
30+ out = session_features(aggregate_buckets(reg, f), f, None)
31+ r = out.row(0, named=True); p = f"f{f}m_"
32+ assert 0 <= r[p + "rsneg_share"] <= 1 and 0 <= r[p + "rj"] <= 1 and 0 < r[p + "present_share"] <= 1.0 + 1e-9
33+ assert 0 <= r[p + "vol_first30"] <= 1 and 0 <= r[p + "down_vol_share"] <= 1 and 0 <= r[p + "vol_gini"] <= 1
34+ o1 = session_features(aggregate_buckets(reg, 1), 1, None).row(0, named=True)
35+ rr = bucket_returns(aggregate_buckets(reg, 1))["r"].to_numpy()
36+ assert abs(o1["f1m_rv"] - np.sum(rr ** 2)) < 1e-12
37+ assert abs(o1["f1m_drift"] - math.log(reg["close"][-1] / reg["open"][0])) < 1e-12
added tests/test_instrument_ids.py +17 −0
@@ -0,0 +1,17 @@
1+import polars as pl
2+
3+from wp39.instruments import build_crosswalk, sic_division
4+
5+
6+def test_sic_divisions():
7+ assert sic_division(3571) == "D_manufacturing" and sic_division(6021) == "H_finance_insurance_realestate"
8+ assert sic_division(None) == "unknown"
9+
10+
11+def test_crosswalk_keeps_all_entities_and_flags_missing_cik():
12+ ents = pl.DataFrame({"entity_id": ["frd:AAA", "frd:ZZZ"], "security_id": ["firstrate:stock:AAA", "firstrate:stock:ZZZ"],
13+ "ticker": ["AAA", "ZZZ"]})
14+ sec = pl.DataFrame({"ticker": ["AAA"], "cik": [1], "sec_name": ["AAA Corp"]})
15+ subs = pl.DataFrame({"cik": [1], "sic": [3571], "sic_division": ["D_manufacturing"]})
16+ xw = build_crosswalk(ents, sec, subs)
17+ assert xw.height == 2 and xw.filter(pl.col("ticker") == "ZZZ")["has_cik"][0] is False
added tests/test_labels.py +26 −0
@@ -0,0 +1,26 @@
1+import datetime as dt
2+
3+import polars as pl
4+
5+from wp39.labels import build_quarter_table, finalize_labels, match_release_dates
6+
7+
8+def test_release_match_rules(toy_wide, toy_events):
9+ ev = toy_events.with_columns(pl.when(pl.col("entity_id") == "frd:BBB").then(pl.col("event_date") - pl.duration(days=60))
10+ .otherwise(pl.col("event_date")).alias("event_date"))
11+ w = match_release_dates(toy_wide, ev)
12+ assert (w.filter(pl.col("entity_id") == "frd:AAA")["release_match"] == "ok").all()
13+ assert (w.filter(pl.col("entity_id") == "frd:BBB")["release_match"] != "ok").all()
14+
15+
16+def test_labels_are_not_price_based(toy_wide, toy_events):
17+ q = finalize_labels(build_quarter_table(match_release_dates(toy_wide, toy_events)))
18+ assert not any(c.startswith("w") or c.startswith("d_") for c in q.columns)
19+ assert set(q["y_adverse_opinc"].drop_nulls().unique().to_list()) <= {0, 1}
20+
21+
22+def test_release_day_excluded(toy_wide, toy_events):
23+ from wp39.samples import apply_admissibility, event_cutoffs
24+ w = match_release_dates(toy_wide, toy_events)
25+ c = apply_admissibility(event_cutoffs(w, [1]))
26+ assert (c["cutoff"] < c["A_q"]).all()
added tests/test_pit_leakage.py +45 −0
@@ -0,0 +1,45 @@
1+import datetime as dt
2+
3+import polars as pl
4+
5+from wp39.labels import build_quarter_table, finalize_labels, match_release_dates
6+from wp39.pit_fundamentals import asof_latest_available
7+from wp39.samples import apply_admissibility, event_cutoffs
8+
9+
10+def test_no_future_fundamentals(toy_wide):
11+ cut = pl.DataFrame({"entity_id": ["frd:AAA"] * 3, "cutoff": [dt.date(2015, 5, 1), dt.date(2015, 5, 11), dt.date(2015, 5, 12)]})
12+ j = asof_latest_available(toy_wide, cut)
13+ # period end 2015-03-31, filing 2015-05-10, available 2015-05-11
14+ assert j["pit_period_end"].to_list() == [None, dt.date(2015, 3, 31), dt.date(2015, 3, 31)]
15+ assert j.filter(pl.col("pit_period_end").is_not_null())["pit_ok"].all()
16+
17+
18+def test_join_key_is_available_at_not_period_end(toy_wide):
19+ cut = pl.DataFrame({"entity_id": ["frd:AAA"], "cutoff": [dt.date(2015, 4, 15)]}) # after period end, before filing
20+ j = asof_latest_available(toy_wide, cut)
21+ assert j["pit_period_end"][0] is None
22+
23+
24+def test_cutoff_precedes_release_and_filing(toy_wide, toy_events):
25+ w = match_release_dates(toy_wide, toy_events)
26+ cut = apply_admissibility(event_cutoffs(w, [20, 5, 1]))
27+ assert (cut["cutoff"] < cut["A_q"]).all() and (cut["cutoff"] < cut["F_q"]).all()
28+ assert cut.filter(pl.col("A_prev").is_not_null()).select((pl.col("cutoff") > pl.col("A_prev")).all()).item()
29+
30+
31+def test_observed_at_not_used():
32+ import inspect
33+ from wp39 import labels, pit_fundamentals, samples
34+ src = inspect.getsource(samples) + inspect.getsource(labels) + inspect.getsource(pit_fundamentals.asof_latest_available)
35+ assert "observed_at" not in src.replace("observed_at\",", "") # only in the column list of the extract
36+
37+
38+def test_expectation_uses_only_prior_quarters(toy_wide, toy_events):
39+ w = match_release_dates(toy_wide, toy_events)
40+ q = finalize_labels(build_quarter_table(w))
41+ r = q.filter((pl.col("entity_id") == "frd:AAA") & (pl.col("qidx") == 9)).row(0, named=True)
42+ x = toy_wide.filter(pl.col("entity_id") == "frd:AAA").sort("qidx")["op_inc"].to_list()
43+ drift = sum(x[9 - 1 - k] - x[9 - 5 - k] for k in range(4)) / 4
44+ assert abs(r["E_op_inc"] - (x[5] + drift)) < 1e-9
45+ assert q.filter(pl.col("qidx") < 8)["E_op_inc"].null_count() == q.filter(pl.col("qidx") < 8).height
added tests/test_reproducibility.py +23 −0
@@ -0,0 +1,23 @@
1+import numpy as np
2+
3+from wp39.inference import holm, paired_block_bootstrap, dm_gw_test
4+
5+
6+def test_bootstrap_is_seeded_and_centered():
7+ rng = np.random.default_rng(0)
8+ d = rng.normal(0.01, 0.1, 2000)
9+ blocks = np.repeat(np.arange(20), 100)
10+ a = paired_block_bootstrap(d, blocks, n_boot=300, seed=1)
11+ b = paired_block_bootstrap(d, blocks, n_boot=300, seed=1)
12+ assert a == b and a["ci_lo"] < a["mean"] < a["ci_hi"]
13+
14+
15+def test_gw_test_rejects_clear_difference():
16+ d = np.full(2000, 0.05) + np.random.default_rng(1).normal(0, 0.02, 2000)
17+ blocks = np.repeat(np.arange(20), 100)
18+ assert dm_gw_test(d, blocks)["p"] < 0.001
19+
20+
21+def test_holm_monotone():
22+ adj = holm({"a": 0.01, "b": 0.04, "c": 0.5})
23+ assert adj["a"] <= adj["b"] <= adj["c"] and adj["a"] == 0.03
added tests/test_splits.py +37 −0
@@ -0,0 +1,37 @@
1+import datetime as dt
2+
3+import polars as pl
4+
5+from wp39.splits import chronological, firm_holdout_mask, overlap_check, walk_forward
6+
7+CFG = {"train": ["2012-01-01", "2018-12-31"], "validation": ["2019-01-01", "2020-12-31"], "test": ["2021-01-01", "2026-10-30"],
8+ "walk_forward": {"first_test_year": 2016, "embargo_quarters": 1}, "firm_holdout_fraction": 0.2}
9+
10+
11+def _panel():
12+ rows = []
13+ d = dt.date(2012, 1, 15)
14+ for i in range(140):
15+ c = d + dt.timedelta(days=int(i * 36.5))
16+ rows.append({"entity_id": f"frd:E{i % 7}", "fiscal_period_end": c - dt.timedelta(days=30), "h": 20, "cutoff": c,
17+ "A_q": c + dt.timedelta(days=28), "y": i % 3 == 0})
18+ return pl.DataFrame(rows)
19+
20+
21+def test_chronological_purge_and_embargo():
22+ s = chronological(_panel(), CFG)
23+ assert s["train"]["A_q"].max() < dt.date(2018, 10, 1) # validation starts 2019-01-01, embargo one quarter
24+ assert s["validation"]["A_q"].max() < dt.date(2020, 10, 1)
25+ assert overlap_check(s["train"], s["test"])["shared_observations"] == 0
26+
27+
28+def test_walk_forward_folds():
29+ folds = walk_forward(_panel(), CFG, 2025)
30+ for y, tr, te in folds:
31+ assert tr["A_q"].max() < dt.date(y - 1, 10, 1)
32+ assert te["cutoff"].min().year == y
33+
34+
35+def test_firm_holdout_is_deterministic():
36+ e = pl.Series(["frd:A", "frd:B", "frd:C"])
37+ assert firm_holdout_mask(e).to_list() == firm_holdout_mask(e).to_list()
added tests/test_timestamp_dsts.py +30 −0
@@ -0,0 +1,30 @@
1+import datetime as dt
2+
3+import polars as pl
4+
5+from wp39.calendar import close_time_utc, regular_session_bounds_utc, shift_trading_days, trading_days_between
6+from wp39.features import session_bounds_table
7+
8+
9+def test_regular_session_bounds_in_both_dst_regimes():
10+ o, c = regular_session_bounds_utc(dt.date(2024, 1, 10)) # EST: 09:30 ET = 14:30 UTC
11+ assert (o.hour, o.minute) == (14, 30) and (c.hour, c.minute) == (21, 0)
12+ o, c = regular_session_bounds_utc(dt.date(2024, 7, 10)) # EDT: 09:30 ET = 13:30 UTC
13+ assert (o.hour, o.minute) == (13, 30) and (c.hour, c.minute) == (20, 0)
14+
15+
16+def test_early_close_day():
17+ b = session_bounds_table(dt.date(2023, 11, 20), dt.date(2023, 11, 30))
18+ r = b.filter(pl.col("session_date") == dt.date(2023, 11, 24)).row(0, named=True)
19+ assert r["close_minute"] == 210 # 13:00 ET close the day after Thanksgiving
20+
21+
22+def test_shift_trading_days_skips_weekends_and_holidays():
23+ assert shift_trading_days(dt.date(2024, 7, 5), -1) == dt.date(2024, 7, 3) # July 4 holiday
24+ assert shift_trading_days(dt.date(2024, 7, 8), -1) == dt.date(2024, 7, 5)
25+ assert trading_days_between(dt.date(2024, 7, 3), dt.date(2024, 7, 8)) == 2
26+
27+
28+def test_close_print_is_at_1600_et():
29+ c = close_time_utc(dt.date(2022, 3, 14)) # first session after the spring-forward
30+ assert (c.hour, c.minute) == (20, 0)
added tests/test_train_only_transforms.py +15 −0
@@ -0,0 +1,15 @@
1+import numpy as np
2+import polars as pl
3+
4+from wp39.models import Preprocessor
5+
6+
7+def test_preprocessor_statistics_come_from_train_only():
8+ tr = pl.DataFrame({"x": [1.0, 2.0, 3.0, 4.0, 100.0], "z": [0.0, 0.0, 1.0, 1.0, 1.0]})
9+ te = pl.DataFrame({"x": [1000.0, None, -5.0], "z": [5.0, 5.0, 5.0]})
10+ pp = Preprocessor(["x", "z"]).fit(tr)
11+ Xte = pp.transform(te)
12+ assert pp.fitted_on_rows == 5
13+ assert Xte[0, 0] <= (pp.hi["x"] - pp.mu["x"]) / pp.sd["x"] + 1e-9 # clipped at the train 99th percentile
14+ assert abs(Xte[1, 0] - (pp.med["x"] - pp.mu["x"]) / pp.sd["x"]) < 1e-9 # imputed with the train median
15+ assert np.all(np.isfinite(Xte))
16
Menu

Theme