1# PIT_PROTOCOL.md — point-in-time rules for WP3923Frozen 2026-11-02 before any model was estimated. Any change is an amendment logged in `DECISIONS.md`.45## 1. Clocks67All instants are converted to **America/New_York** for session logic and kept in UTC in storage. Trading days follow the8XNYS calendar (`exchange_calendars`). For firm `i` and fiscal quarter `q`:910| 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 |1819## 2. The information set at cutoff `c`2021`I(c)` contains exactly:22231. **Market bars** with `session_date ≤ c` (regular session 09:30–16:00 ET for the main specification; the 16:00 bar is the24 closing print; extended hours 04:00–09:29 and 16:01–19:59 enter only the "extended" variants, never mixed).252. **Fundamentals** with `available_at ≤ c 00:00 ET`, i.e. `F_j + 1 ≤ c`. The latest such quarter is `j*(c)`, and the26 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)`.283. **Earnings-release history** with `event_date < c` (prior realized releases, used for the historical base-rate feature B0).294. **Static crosswalk** (CIK, SIC) — time-invariant by assumption; `formerNames` are checked for ticker reuse (Phase 2 audit).305. **Nothing else.** No consensus estimates for quarter `q` (vintage unknown), no index-membership backfills, no31 contemporaneous factors.3233## 3. Cutoff rules3435- **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 the37 cohort window never straddles two reveals). With `τ_q = before_open`, bars of `A_q` contain the reaction and are excluded38 (`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* release40 after `c` (first `A_q > c`), restricted to `A_q − c ≤ 65` trading days. The identity of "the next quarter" is known ex41 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 within43 60–120 days of `E_{q−1}` (irregular fiscal calendars); quarter `q − 4` or `q − 1` not available at `c` (expectation44 undefined); raw close at `c` < 1 USD; fewer than 15 valid sessions in the 20-day feature window.4546## 4. Label construction (no price-based labels)4748Primary: `S_q = (X_q − E_c[X_q]) / TA_{j*}`, `X = op_inc` (quarterly operating income), `TA` = total assets of the latest49available 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 in51`configs/research.yaml`. `X_q` itself is taken from the **first ingested revision** of quarter `q` (the only one available),52which is the vendor's current value — see LIMITATIONS §PIT.5354## 5. Leakage battery (each item is a unit test in `tests/`)5556| # | 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` |7071## 6. Equivalence check of the local as-of join with the API7273For 25 random entities and 10 random `as_of` instants, the set of (metric, period) pairs returned by74`GET /fundamentals/{entity}?as_of=` must equal the set produced by the local filter `available_at ≤ as_of` on the full75extract. Implemented in `tests/test_api_contract.py::test_asof_equivalence` (live test, `WP39_LIVE=1`).76