# PIT_PROTOCOL.md — point-in-time rules for WP39 Frozen 2026-11-02 before any model was estimated. Any change is an amendment logged in `DECISIONS.md`. ## 1. Clocks All instants are converted to **America/New_York** for session logic and kept in UTC in storage. Trading days follow the XNYS calendar (`exchange_calendars`). For firm `i` and fiscal quarter `q`: | Symbol | Meaning | Source | |---|---|---| | `E_q` | fiscal period end | `fundamentals_facts.fiscal_period_end` | | `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]` | | `τ_q` | release timing: `before_open`, `after_close`, or `unknown` | `earnings_events.release_time` | | `F_q` | SEC filing date of the 10-Q/10-K | `fundamentals_facts.filing_date` | | `V_q` | vendor-proxy availability of the structured facts | `available_at = F_q + 1 d 00:00 ET` | | `c` | prediction cutoff (a trading day; features use information up to the **close** of `c`) | design | ## 2. The information set at cutoff `c` `I(c)` contains exactly: 1. **Market bars** with `session_date ≤ c` (regular session 09:30–16:00 ET for the main specification; the 16:00 bar is the closing print; extended hours 04:00–09:29 and 16:01–19:59 enter only the "extended" variants, never mixed). 2. **Fundamentals** with `available_at ≤ c 00:00 ET`, i.e. `F_j + 1 ≤ c`. The latest such quarter is `j*(c)`, and the model also receives the *age* `c − A_{j*}` in trading days. Because `V_q > F_q ≥ A_q > c` by construction (§3), quarter-`q` facts can never enter `I(c)`. 3. **Earnings-release history** with `event_date < c` (prior realized releases, used for the historical base-rate feature B0). 4. **Static crosswalk** (CIK, SIC) — time-invariant by assumption; `formerNames` are checked for ticker reuse (Phase 2 audit). 5. **Nothing else.** No consensus estimates for quarter `q` (vintage unknown), no index-membership backfills, no contemporaneous factors. ## 3. Cutoff rules - **Event-time design** (primary, H1–H4): for horizon `h ∈ {40, 20, 10, 5, 1}` trading days, `c = A_q − h` trading days. Admissible only if `c < min(A_q, F_q)` *and* `c ≥ A_{q−1} + 1` (the previous release has already happened, so the cohort window never straddles two reveals). With `τ_q = before_open`, bars of `A_q` contain the reaction and are excluded (`c ≤ A_q − 1` guarantees it); with `τ_q = after_close` or `unknown` the same rule applies — we never use `A_q` bars. - **Fixed-calendar design** (H1 robustness, H5): `c` = last trading day of each month; the target is the *next* release after `c` (first `A_q > c`), restricted to `A_q − c ≤ 65` trading days. The identity of "the next quarter" is known ex ante; its date is not needed by the forecaster. - **Firm-quarter exclusions** (frozen): no matched release; `F_q < A_q`; `F_q − A_q > 30` calendar days; `E_q` not within 60–120 days of `E_{q−1}` (irregular fiscal calendars); quarter `q − 4` or `q − 1` not available at `c` (expectation undefined); raw close at `c` < 1 USD; fewer than 15 valid sessions in the 20-day feature window. ## 4. Label construction (no price-based labels) Primary: `S_q = (X_q − E_c[X_q]) / TA_{j*}`, `X = op_inc` (quarterly operating income), `TA` = total assets of the latest 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` (seasonal random walk with drift; Foster 1977). `D_q = 1[S_q ≤ −0.005]`. The secondary targets are in `configs/research.yaml`. `X_q` itself is taken from the **first ingested revision** of quarter `q` (the only one available), which is the vendor's current value — see LIMITATIONS §PIT. ## 5. Leakage battery (each item is a unit test in `tests/`) | # | Risk | Guard | Test | |---|---|---|---| | L1 | quarter-`q` facts used as inputs | as-of join on `available_at ≤ c` | `test_pit_leakage.py::test_no_future_fundamentals` | | 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` | | L3 | post-release bars in the feature window | `session_date ≤ c < A_q` | `test_pit_leakage.py::test_no_bars_after_cutoff` | | L4 | global standardisation / imputation / tuning on test data | transformers fit on train rows only; tuning on validation period only | `test_train_only_transforms.py` | | L5 | DST / timezone errors | UTC → ET conversion, 09:30 and 16:00 bars identified on both DST regimes | `test_timestamp_dsts.py` | | L6 | ticker reuse / identifier drift | crosswalk with effective dates; SEC `formerNames` audit | `test_instrument_ids.py` | | L7 | splits/dividends creating mechanical returns | split-adjusted returns from `corporate_actions` (ex-date rule), raw bars immutable | `test_corporate_actions.py` | | L8 | release timing assumed intraday | release-day bars never used; timing enters only as a descriptive variable | `test_labels.py::test_release_day_excluded` | | L9 | overlapping target windows across splits | purge + 1-quarter embargo at every split boundary | `test_splits.py` | | L10 | survivorship by construction | universe = all fundamentals entities (documented survivor conditioning), delisted bars kept where present | `01_audit_universe.py` report | | 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` | | L12 | randomised placebo | labels shuffled within calendar quarter → gain must vanish; cutoffs shifted −45 td → H4 gain must vanish | `08_run_robustness.py` | ## 6. Equivalence check of the local as-of join with the API For 25 random entities and 10 random `as_of` instants, the set of (metric, period) pairs returned by `GET /fundamentals/{entity}?as_of=` must equal the set produced by the local filter `available_at ≤ as_of` on the full extract. Implemented in `tests/test_api_contract.py::test_asof_equivalence` (live test, `WP39_LIVE=1`).