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:
- 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). - Fundamentals with
available_at ≤ c 00:00 ET, i.e.F_j + 1 ≤ c. The latest such quarter isj*(c), and the model also receives the agec − A_{j*}in trading days. BecauseV_q > F_q ≥ A_q > cby construction (§3), quarter-qfacts can never enterI(c). - Earnings-release history with
event_date < c(prior realized releases, used for the historical base-rate feature B0). - Static crosswalk (CIK, SIC) — time-invariant by assumption;
formerNamesare checked for ticker reuse (Phase 2 audit). - 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 − htrading days. Admissible only ifc < min(A_q, F_q)andc ≥ A_{q−1} + 1(the previous release has already happened, so the cohort window never straddles two reveals). Withτ_q = before_open, bars ofA_qcontain the reaction and are excluded (c ≤ A_q − 1guarantees it); withτ_q = after_closeorunknownthe same rule applies — we never useA_qbars. - Fixed-calendar design (H1 robustness, H5):
c= last trading day of each month; the target is the next release afterc(firstA_q > c), restricted toA_q − c ≤ 65trading 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 > 30calendar days;E_qnot within 60–120 days ofE_{q−1}(irregular fiscal calendars); quarterq − 4orq − 1not available atc(expectation undefined); raw close atc< 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).