research: Task 2 closed — SUE dead, earnings gap informational

Earnings backfill sourced from the public DoltHub earnings repo at a
pinned commit rather than the FMP API: reproducible for anyone re-running
the study, and it burns no request quota. 12,414 events, 98.6% of symbols
with >=8 announcements, 99.2% paired actual/estimate, no keyed duplicates.

2a earnings-gap diagnostic: INFORMATIONAL, no filter shipped. The
pre-earnings cohort's right tail was better, so the registered
avoid-earnings condition failed. Note the raw 23/266 vs 115/574 incidence
gap is largely a duration confound -- severe losses stop out fast and have
less time to span an announcement -- so it is not evidence that holding
through earnings is safe.

2b SUE: FAIL against the pre-registered +0.03 bar (unconditional IC
+0.0151 over 56 reliable windows, momentum-conditional +0.0213). Signs
stable across eras, so this is a clean null rather than an ambiguous one,
consistent with post-earnings drift having decayed in large caps.

Closes the Tier-1 arc: Task 1 dead on deep evidence, Task 2 dead here,
Task 3 complete as diagnostic. No in-sample research thread remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:10:46 +02:00
co-authored by Claude Fable 5
parent 1fa3d70dec
commit c7c60a64f2
14 changed files with 3705 additions and 1828 deletions
+110 -145
View File
@@ -1,202 +1,167 @@
# Earnings gap diagnostic + SUE / PEAD (Tier-1 alpha research)
**Status:** **PARK** (incomplete earnings coverage; SUE fails iron rule on available sample).
**Status:** **CLOSED — SUE DEAD**.
**Branch:** `research/earnings-gap-and-sue`
**Production impact:** none. Local research only. **No filters shipped from 2a.**
**Artifacts:** `reports/earnings-gap-sue-20260719-093129.json` (+ companion `.md`)
**Production impact:** none. Local research only; no earnings filter or SUE integration is shipped.
---
## Pre-registration (locked before first research run)
## Pre-registration (locked before the final research run)
### Data
- Historical earnings calendar for the production universe over the full snapshot
window (and deeper if the feed provides it).
- Preferred source: FMP **date-range earnings-calendar** (bulk). If unavailable on
free tier, fall back to per-symbol `/stable/earnings` with request accounting.
- Store in a real local table `earnings_events` (symbol + announce_date key).
- Point-in-time: a surprise is usable only from **announce date + 1 trading day**
onward.
- Historical earnings announcements for the production universe, stored in the
real `earnings_events` table and deduplicated on symbol + announcement date.
- The originally requested 2016 start is amended, with user approval, to the
public source's announcement coverage start of 2020-01-22. Earlier EPS-period
history may scale later surprises but may never activate a live signal.
- Report coverage, pairing, duplicates/restatements, annual-rate sanity, and
announcement-session quality before either experiment.
- Point-in-time: an earnings surprise is usable only from announcement date +1
trading day. Same-day use is forbidden.
### Experiment 2a — earnings-gap risk (defense, report-only)
Join simulated production-config trades (`fill_mode=close`) with earnings dates.
Run the production-config book on the approximately 505-name production
universe with close fills and 0.001 transaction cost per side. Join simulated
trades to earnings by symbol and date.
**Pre-registered questions:**
1. Among closed trades with realized net R ≤ -1.0, report the fraction with an
announcement strictly after entry and before exit, alongside the base rate
for all trades.
2. Compare entries within three trading sessions before an announcement with
all other entries: count, mean/median R, win rate, p05, and p95.
3. Compare stops within one trading session after an announcement with all
other stops and exits.
1. What fraction of losses worse than **1R** occur with an earnings announcement
**between entry and exit** (inclusive of the holding window)?
2. What is the mean R of entries taken within **3 trading days BEFORE** an
announcement vs all other entries — report **both tails** of the R
distribution (rule 4: any earnings-avoid entry filter is presumed guilty of
right-tail trimming until the win distribution shows otherwise)?
**Output:** distributions and counts only.
**No filter is shipped.** If numbers argue for a filter → report and stop.
Verdict is always `INFORMATIONAL`. Report only: no filter arm, recommendation,
or implementation. The right tail must be shown alongside the left tail.
### Experiment 2b — SUE / PEAD (offense)
Signal `sue_latest`:
\[
\text{SUE} = \frac{\text{actual} - \text{estimate}}{\sigma(\text{trailing 8 surprises})}
\text{SUE} = \frac{\text{actual} - \text{estimate}}
{\sigma(\text{trailing 8 surprises})}
\]
Fallback if estimate history is thin: scale surprise by price.
Carry forward from announce+1 for **63 trading days**, else NaN (name drops out
of that cross-section).
Use at least four trailing surprises; if estimate history fails the registered
quality gate, use `(actual - estimate) / price` and name that fallback. Activate
at announcement date +1 trading day, carry for 63 trading days, then drop the
symbol from the cross-section.
**Iron rule (IC harness):** mean weekly Spearman IC on non-overlapping weeks;
\|mean IC\| ≥ ~0.03, **positive** sign (drift), `reliable: true` (≥12 windows).
Evaluate mean weekly Spearman IC on the existing non-overlapping-window harness.
Always report `sue_latest`, `mom_12_1`, and `mom_12_1_resid` on identical
week-symbol-forward-return cells, plus SUE inside the top momentum quintile.
Always side-by-side with `mom_12_1` and `mom_12_1_resid` on **identical**
cross-sections.
### Mechanical verdict rule
Also report **momentum-conditional** IC (within top momentum quintile).
**If it passes iron rule:** STOP and report. Book-integration design is a
separate human-approved step — do not wire.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | (2b only) iron rule cleared → human designs tilt/gate |
| **PARK** | Interesting but incomplete / weak |
| **DEAD** | No edge / diagnostic argues against action |
| **REPORT-ONLY** | (2a) always — never auto-filter |
---
## Data provenance
| item | result |
|---|---|
| Snapshot | `backtest_snapshots/prod.sqlite` (506 names) |
| FMP bulk `earnings-calendar` | **402 Premium** — not available on free tier |
| FMP per-symbol `/stable/earnings` | used; hit daily rate limit ~225 reqs |
| Alpha Vantage `EARNINGS` | used for +24 symbols (announce = `reportedDate`) |
| Symbols with events | **48 / 506 (9.5%)** |
| Total events | 5,612 (5,018 with actual+estimate) |
| Announce range | 1985-08-31 → 2026-07-16 |
| FMP requests (first day) | 260 FMP + 25 AV (see `reports/earnings-backfill-status.json`) |
**Incomplete backfill is first-class.** 2a under-detects earnings overlaps; 2b SUE
cross-section averages **~47 names**, not ~500. Resume:
```bash
# Day N (FMP free ~250/day; AV free ~25/day — prefer FMP after reset)
python scripts/backfill_earnings_events.py \
--snapshot backtest_snapshots/prod.sqlite \
--provider fmp --force-symbol --limit 250 --sleep 0.4
# When done==506:
python scripts/run_earnings_research.py \
--snapshot backtest_snapshots/prod.sqlite \
--workers 6 --allow-spawn
```
- **PASS** only if unconditional `sue_latest` has mean IC ≥ +0.03,
`reliable: true` (at least 12 windows), and positive signs in both the pre-2021
and post-2021 eras.
- **FAIL** otherwise, with terminal verdict `SUE DEAD for this stack`.
- PASS stops at `SUE PASS→PENDING_HUMAN`; integration design remains a separate
human decision. FAIL is terminal and no variants are proposed.
---
## Results
Generated: `2026-07-19T09:31:29`
### Data quality gate
### 2a — Earnings-gap risk (report-only)
Approved earnings window: 2020-01-22 to 2026-07-17. Source mode: dolthub_public_bulk_clone.
Production book sim: Sharpe 2.09 (SE 0.497), CAGR 51.6%, max DD 21.4%, **322 trades**,
`fill_mode=close`.
#### Q1 — Losses worse than 1R with earnings in hold
| metric | value |
| check | result |
|---|---:|
| n losses &lt; 1R | 28 |
| of which earnings in hold | **1** |
| fraction | **3.6%** |
| all trades with earnings in hold | 14 / 322 (4.4%) |
| Prod symbols requested / tradable | 506 / 505 |
| Manifest complete + live counts match | True |
| Prod symbols with pre-2021 bars | 491 (97.2%) |
| SPY benchmark depth | 2649 rows, 2016-01-04 to 2026-07-17 |
| Snapshot depth gate | True |
| Bulk source windows / requests logged | 1/1 / 1 |
| Source repository / pinned commit | https://www.dolthub.com/repositories/post-no-preference/earnings @ 9n0et3hpj9j7vue8f3qsldon3qa5sdjj |
| Source license / upstream provider documented | CC-BY-SA-4.0 / False |
| Existing-source conflicts preserved | 940 rows / 1526 fields |
| Symbols with >=8 announcements | 498 (98.6%) |
| Symbols with >=8 paired announcements | 495 (98.0%) |
| Events with estimate + actual | 12311/12414 (99.2%) |
| Duplicate rows in keyed table | 0 |
| Duplicate / restated payload rows fetched | 0 / 940 |
| Mean announcements per active symbol-year | 4.08 (expected about 4) |
| Symbols far off (<2 or >6/year, incl. zero) | 1 |
| Recognised BMO/AMC/during | 92.8% (reliable=True) |
| Point-in-time policy | announce_date_plus_1_trading_day_for_all_events |
| SUE price fallback | not_used |
**Read:** On incomplete earnings labels this is a **lower bound** on earnings
overlap, not a clean “earnings rarely hurt.” Do **not** conclude earnings risk is
immaterial until coverage ≥ ~95% of the books names.
Deduplication: UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment
#### Q2 — Entry within 3 trading days before announce (both tails)
Far-off announcement-rate symbols: SPCX
| cohort | n | mean R | win rate | p05 | p50 | p95 | max |
|---|---:|---:|---:|---:|---:|---:|---:|
| pre-earn (≤3d before) | **4** | 1.94 | 50% | 1.24 | 1.12 | 6.26 | 6.84 |
| other | 318 | 0.70 | 37% | 1.11 | 0.83 | 6.08 | **12.87** |
| all | 322 | 0.71 | 37% | 1.12 | 0.83 | 6.22 | 12.87 |
### Experiment 2a - earnings-gap risk diagnostic
**Tail-trim presumption:** n=4 is not a sample. Point estimate does **not** show
right-tail destruction of pre-earn entries (p95 similar; max actually higher in
“other”). **No earnings-avoid filter is supported.** Re-run after full backfill.
Verdict: **INFORMATIONAL**. Report-only; no filter arm or implementation.
---
Trade cohort is restricted to the approved earnings-coverage window 2020-01-22 to 2026-07-17; 0 simulated trades outside that window were excluded.
### 2b — SUE / PEAD IC
| cohort | count | fraction |
|---|---:|---:|
| Realized net R <= -1.0 | 266 | - |
| Losses with announcement strictly inside hold | 23 | 0.0865 |
| All trades with announcement strictly inside hold | 115 | 0.2003 |
#### Full-universe harness (mom on ~500; SUE only where labeled)
| Entry cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Within 3 sessions before earnings | 27 | 0.4837 | -1.0265 | 0.3333 | -1.1463 | 5.981 |
| All other entries | 547 | 0.2734 | -0.8316 | 0.3565 | -1.1228 | 4.5888 |
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable |
|---|---:|---:|---:|---:|---|
| mom_12_1_sector_resid | 0.0578 | 2.34 | 35 | 497.7 | true |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true |
| **sue_latest** | **0.0172** | **0.6** | 44 | **47.4** | true |
| fip_id | 0.045 | 2.91 | 35 | 497.7 | true |
Tail deltas (pre minus other): p05=-0.0235, p95=1.3922.
#### Identical SUE subset (fair side-by-side — use this while coverage is thin)
Registered directional tail condition is not present.
| signal | mean_ic | ic_t_stat | weeks | avg_N |
|---|---:|---:|---:|---:|
| sue_latest | 0.0172 | 0.6 | 44 | 47.4 |
| mom_12_1 | 0.0174 | 0.42 | 35 | 47.3 |
| mom_12_1_resid | 0.0104 | 0.27 | 35 | 47.3 |
| Exit cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Stops within 1 session after earnings | 26 | -0.6434 | -0.9753 | 0.2308 | -2.4614 | 1.1142 |
| All other stops | 433 | -0.5035 | -1.0278 | 0.1963 | -1.1373 | 1.3591 |
| All other exits | 548 | 0.3273 | -0.8361 | 0.3613 | -1.0644 | 4.7224 |
On the thin labeled subset, momentum itself is noise — so the subset is not yet
a meaningful PEAD test.
### Experiment 2b - SUE / post-earnings drift
#### Momentum-conditional SUE (top mom quintile)
Mechanical verdict: **FAIL** - SUE DEAD for this stack
| metric | value |
|---|---:|
| mean IC | **0.0065** |
| t | 0.1 |
| weeks | 35 |
Identical cross-sections:
Wrong sign vs “ride positive surprises inside the momentum gate.”
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0148 | 1.27 | 56 | 450.9 | 51.8 | true |
| mom_12_1 | 0.0195 | 0.74 | 56 | 450.9 | 58.9 | true |
| mom_12_1_resid | 0.0262 | 1.07 | 56 | 450.9 | 55.4 | true |
**Iron rule:** fail (\|IC\| 0.017 &lt; 0.03; t 0.6). **No promote.**
Unconditional SUE grade row:
---
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0151 | 1.29 | 56 | 451.4 | 51.8 | true |
## Verdict
Era stability:
| piece | verdict |
|---|---|
| **2a earnings-gap** | **REPORT-ONLY** — no filter. Coverage too thin for risk claims; tails do not argue for an avoid-filter on n=4. |
| **2b SUE** | **PARK** (effectively not green). Mild positive IC on ~48 names; fails iron bar; mom-conditional flat/negative. Re-score after full backfill before DEAD. |
| **Production** | **no change** |
| era | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| pre-2021 | 0.0286 | 0.7 | 9 | 398.3 | 55.6 | false |
| post-2021 | 0.0172 | 1.34 | 48 | 461.9 | 64.6 | true |
---
Coverage: 501 symbols with live SUE; avg weekly N=453.1; scored non-overlap avg N=451.4.
## What a human must decide next
Cross-section is not flagged thin at the registered <100-name read.
1. Resume multi-day earnings backfill to **506/506**, then re-run
`run_earnings_research.py` (heavy — MacBook OK).
2. Do **not** ship an earnings-avoid entry filter from 2a.
3. Do **not** wire SUE until a full-coverage IC clears the iron rule (and
preferably mom-conditional &gt; 0).
4. Do not merge into main strategy docs without review.
Momentum-conditional top-quintile SUE: mean IC=0.0213, t=1.3, windows=56, avg N=89.8.
---
## Artifacts
## Implementation notes
- `reports/earnings-2a-gap-20260720-dolthub-final.json` and companion Markdown
- `reports/earnings-2b-sue-20260720-dolthub-final.json` and companion Markdown
- `reports/earnings-backfill-status.json`
| piece | role |
|---|---|
| `scripts/backfill_earnings_events.py` | bulk attempt → FMP/AV per-symbol; `earnings_events` + meta on snapshot |
| `scripts/run_earnings_research.py` | 2a trade join + 2b SUE IC / mom-conditional |
| Snapshot table `earnings_events` | real table (not SystemSetting JSON) |
Production changes: **none**. No earnings filter or SUE integration was implemented.
## Final status: **Task 2 CLOSED (SUE DEAD)**
@@ -0,0 +1,348 @@
{
"generated_at": "2026-07-20T07:02:10.934892+00:00",
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"snapshot_depth": {
"manifest": {
"schema_version": 1,
"snapshot": "research.sqlite",
"snapshot_resolved": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"complete": true,
"finished_at": "2026-07-19T14:22:15.706192+00:00",
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144,
"sources": {
"pool": "source_snapshot"
},
"history_days": 5000,
"min_bars": 1262,
"fetch_ok": 505,
"fetch_fail": 1,
"limit": null,
"extra": {
"prod_symbols_at_start": 506,
"pool_size": 506,
"to_fetch": 506,
"source_symbols_only": true,
"benchmark_spy_rows": 2649
},
"live_counts": {
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144
}
},
"requested_symbols": 506,
"tradable_symbols": 505,
"missing_symbols": [],
"zero_bar_symbols": [
"RHM"
],
"bar_count": {
"min": 24,
"median": 2649,
"max": 2649
},
"symbols_with_pre2021_bars": 491,
"symbols_with_pre2021_bars_pct": 97.2,
"shallow_symbols_lt_1000_bars": [
{
"symbol": "SPCX",
"first_bar": "2026-06-12",
"last_bar": "2026-07-17",
"bars": 24
},
{
"symbol": "Q",
"first_bar": "2025-11-03",
"last_bar": "2026-07-17",
"bars": 176
},
{
"symbol": "PSKY",
"first_bar": "2025-08-07",
"last_bar": "2026-07-17",
"bars": 237
},
{
"symbol": "SNDK",
"first_bar": "2025-02-13",
"last_bar": "2026-07-17",
"bars": 357
},
{
"symbol": "GEV",
"first_bar": "2024-04-02",
"last_bar": "2026-07-17",
"bars": 575
},
{
"symbol": "SOLV",
"first_bar": "2024-04-01",
"last_bar": "2026-07-17",
"bars": 576
},
{
"symbol": "VLTO",
"first_bar": "2023-10-02",
"last_bar": "2026-07-17",
"bars": 700
},
{
"symbol": "KVUE",
"first_bar": "2023-05-04",
"last_bar": "2026-07-17",
"bars": 803
},
{
"symbol": "GEHC",
"first_bar": "2022-12-15",
"last_bar": "2026-07-17",
"bars": 898
}
],
"price_window": {
"min": "2016-01-04",
"max": "2026-07-17"
},
"benchmark_spy": {
"rows": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"gate_threshold": {
"min_tradable_symbols": 505,
"max_missing_or_zero_bar": 1,
"min_symbols_with_pre2021_bars_pct": 80.0,
"benchmark_min_rows": 1000,
"benchmark_must_begin_pre2021": true
},
"gate_pass": true
},
"data_quality": {
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"prod_symbols": 505,
"events": 12414,
"symbols_with_any_event": 504,
"symbols_with_ge8_announcements": 498,
"symbols_with_ge8_announcements_pct": 98.6,
"symbols_with_ge8_paired_announcements": 495,
"symbols_with_ge8_paired_announcements_pct": 98.0,
"events_with_actual_and_estimate": 12311,
"events_with_actual_and_estimate_pct": 99.2,
"duplicate_rows_in_table": 0,
"duplicate_rows_fetched": 0,
"restated_rows_fetched": 940,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_per_symbol_year": {
"mean_active_span_rate": 4.08,
"expected": "approximately 4",
"far_off_rule": "active-span rate <2 or >6, plus zero-event symbols",
"far_off_count": 1,
"far_off_symbols": [
{
"symbol": "SPCX",
"events": 0,
"events_per_year": 0.0
}
]
},
"announcement_session": {
"recognised_bmo_amc_or_during": 11520,
"recognised_pct": 92.8,
"reliable": true,
"assessment": "usable"
},
"point_in_time_policy": "announce_date_plus_1_trading_day_for_all_events",
"sue_scaling": {
"primary": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
"fallback_trigger": "paired event coverage <50% or symbols with >=8 paired events <50%",
"fallback_needed": false,
"fallback_name": "not_used"
},
"backfill": {
"mode": "dolthub_public_bulk_clone",
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"coverage_amendment": {
"approved_by_user": true,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": "2020-01-22"
},
"source": {
"repository": "https://www.dolthub.com/repositories/post-no-preference/earnings",
"commit": "9n0et3hpj9j7vue8f3qsldon3qa5sdjj",
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": false
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": {
"raw_rows": 117482,
"universe_rows_in_window": 12342,
"deduped_rows_in_window": 12342,
"duplicate_rows": 0,
"restated_rows": 0
},
"eps_history": {
"raw_rows": 165050,
"universe_rows": 18515,
"deduped_rows": 18515,
"duplicate_rows": 0,
"restated_rows": 0,
"complete_actual_and_estimate": 18304
},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-14,
90
],
"matched_calendar_events": 12271,
"unmatched_calendar_events": 71,
"unmatched_periods_in_pairing_window": 538,
"announce_minus_period_end_days": {
"min": -10,
"median": 30,
"max": 89
},
"pre_2020_eps_history_use": "trailing_surprise_stdev_only; never treated as an announcement or live signal event"
},
"duplicate_rows_logged_total": 0,
"restated_rows_logged_total": 940,
"conflicting_existing_rows": 940,
"conflicting_existing_fields": 1526,
"preserved_existing_fields": 2945,
"existing_enrichment_events_not_in_dolthub_calendar": 72,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_in_window": 12414,
"events_with_actual_and_estimate": 12311,
"symbols_done": 506,
"symbols_universe": 506,
"symbols_with_dolthub_calendar": 504,
"symbols_without_dolthub_calendar": [
"RHM",
"SPCX"
],
"announce_date_range": {
"min": "2020-01-22",
"max": "2026-07-17"
},
"complete": true
}
},
"production_impact": "none",
"experiment": "2a",
"result": {
"verdict": "INFORMATIONAL",
"costs": {
"per_side": 0.001,
"r_is_net_of_round_trip_costs": true
},
"closed_trades": 574,
"q1_loss_concentration": {
"loss_definition": "realized_net_R <= -1.0",
"holding_period_definition": "announcement strictly after entry and before exit",
"losses_count": 266,
"losses_with_announcement_count": 23,
"losses_with_announcement_fraction": 0.0865,
"all_trades_with_announcement_count": 115,
"all_trades_with_announcement_fraction": 0.2003
},
"q2_entries_within_3_trading_days_before_announcement": {
"pre_earnings": {
"count": 27,
"mean_r": 0.4837,
"median_r": -1.0265,
"win_rate": 0.3333,
"p05_r": -1.1463,
"p95_r": 5.981,
"min_r": -1.2449,
"max_r": 8.8996
},
"all_other_entries": {
"count": 547,
"mean_r": 0.2734,
"median_r": -0.8316,
"win_rate": 0.3565,
"p05_r": -1.1228,
"p95_r": 4.5888,
"min_r": -6.0161,
"max_r": 19.98
},
"tail_deltas_pre_minus_other": {
"p05_r": -0.0235,
"p95_r": 1.3922
},
"directional_tail_condition_present": false,
"tail_read": "Registered directional tail condition is not present."
},
"q3_stop_exits_within_1_trading_day_after_announcement": {
"stops_after_earnings": {
"count": 26,
"mean_r": -0.6434,
"median_r": -0.9753,
"win_rate": 0.2308,
"p05_r": -2.4614,
"p95_r": 1.1142,
"min_r": -2.6413,
"max_r": 1.7012
},
"all_other_stops": {
"count": 433,
"mean_r": -0.5035,
"median_r": -1.0278,
"win_rate": 0.1963,
"p05_r": -1.1373,
"p95_r": 1.3591,
"min_r": -6.0161,
"max_r": 19.98
},
"all_other_exits": {
"count": 548,
"mean_r": 0.3273,
"median_r": -0.8361,
"win_rate": 0.3613,
"p05_r": -1.0644,
"p95_r": 4.7224,
"min_r": -6.0161,
"max_r": 19.98
}
},
"implementation": "REPORT_ONLY_NO_FILTER_ARM_NO_FILTER_CHANGE",
"analysis_window": {
"from": "2020-01-22",
"to": "2026-07-17",
"rule": "entry_on_or_after_start_and_exit_on_or_before_end",
"simulation_trades_total": 574,
"trades_excluded_outside_earnings_coverage": 0
},
"run_config": {
"universe_symbols": 505,
"fill_mode": "close",
"cost_per_side": 0.001,
"momentum_cutoff": 80.0,
"exit_policy": "atr_trail3",
"hold_days": 30,
"max_positions": 10,
"risk_per_trade": 0.01
},
"sim_summary": {
"start_date": "2020-01-22",
"end_date": "2026-07-13",
"trades": 574,
"sharpe": 1.03,
"cagr_pct": 22.9,
"max_drawdown_pct": 26.6,
"total_return_pct": 280.6
}
}
}
@@ -0,0 +1,58 @@
# Earnings Task 2a - gap diagnostic
### Data quality gate
Approved earnings window: 2020-01-22 to 2026-07-17. Source mode: dolthub_public_bulk_clone.
| check | result |
|---|---:|
| Prod symbols requested / tradable | 506 / 505 |
| Manifest complete + live counts match | True |
| Prod symbols with pre-2021 bars | 491 (97.2%) |
| SPY benchmark depth | 2649 rows, 2016-01-04 to 2026-07-17 |
| Snapshot depth gate | True |
| Bulk source windows / requests logged | 1/1 / 1 |
| Source repository / pinned commit | https://www.dolthub.com/repositories/post-no-preference/earnings @ 9n0et3hpj9j7vue8f3qsldon3qa5sdjj |
| Source license / upstream provider documented | CC-BY-SA-4.0 / False |
| Existing-source conflicts preserved | 940 rows / 1526 fields |
| Symbols with >=8 announcements | 498 (98.6%) |
| Symbols with >=8 paired announcements | 495 (98.0%) |
| Events with estimate + actual | 12311/12414 (99.2%) |
| Duplicate rows in keyed table | 0 |
| Duplicate / restated payload rows fetched | 0 / 940 |
| Mean announcements per active symbol-year | 4.08 (expected about 4) |
| Symbols far off (<2 or >6/year, incl. zero) | 1 |
| Recognised BMO/AMC/during | 92.8% (reliable=True) |
| Point-in-time policy | announce_date_plus_1_trading_day_for_all_events |
| SUE price fallback | not_used |
Deduplication: UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment
Far-off announcement-rate symbols: SPCX
### Experiment 2a - earnings-gap risk diagnostic
Verdict: **INFORMATIONAL**. Report-only; no filter arm or implementation.
Trade cohort is restricted to the approved earnings-coverage window 2020-01-22 to 2026-07-17; 0 simulated trades outside that window were excluded.
| cohort | count | fraction |
|---|---:|---:|
| Realized net R <= -1.0 | 266 | - |
| Losses with announcement strictly inside hold | 23 | 0.0865 |
| All trades with announcement strictly inside hold | 115 | 0.2003 |
| Entry cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Within 3 sessions before earnings | 27 | 0.4837 | -1.0265 | 0.3333 | -1.1463 | 5.981 |
| All other entries | 547 | 0.2734 | -0.8316 | 0.3565 | -1.1228 | 4.5888 |
Tail deltas (pre minus other): p05=-0.0235, p95=1.3922.
Registered directional tail condition is not present.
| Exit cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Stops within 1 session after earnings | 26 | -0.6434 | -0.9753 | 0.2308 | -2.4614 | 1.1142 |
| All other stops | 433 | -0.5035 | -1.0278 | 0.1963 | -1.1373 | 1.3591 |
| All other exits | 548 | 0.3273 | -0.8361 | 0.3613 | -1.0644 | 4.7224 |
@@ -0,0 +1,349 @@
{
"generated_at": "2026-07-20T07:02:10.934892+00:00",
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"snapshot_depth": {
"manifest": {
"schema_version": 1,
"snapshot": "research.sqlite",
"snapshot_resolved": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"complete": true,
"finished_at": "2026-07-19T14:22:15.706192+00:00",
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144,
"sources": {
"pool": "source_snapshot"
},
"history_days": 5000,
"min_bars": 1262,
"fetch_ok": 505,
"fetch_fail": 1,
"limit": null,
"extra": {
"prod_symbols_at_start": 506,
"pool_size": 506,
"to_fetch": 506,
"source_symbols_only": true,
"benchmark_spy_rows": 2649
},
"live_counts": {
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144
}
},
"requested_symbols": 506,
"tradable_symbols": 505,
"missing_symbols": [],
"zero_bar_symbols": [
"RHM"
],
"bar_count": {
"min": 24,
"median": 2649,
"max": 2649
},
"symbols_with_pre2021_bars": 491,
"symbols_with_pre2021_bars_pct": 97.2,
"shallow_symbols_lt_1000_bars": [
{
"symbol": "SPCX",
"first_bar": "2026-06-12",
"last_bar": "2026-07-17",
"bars": 24
},
{
"symbol": "Q",
"first_bar": "2025-11-03",
"last_bar": "2026-07-17",
"bars": 176
},
{
"symbol": "PSKY",
"first_bar": "2025-08-07",
"last_bar": "2026-07-17",
"bars": 237
},
{
"symbol": "SNDK",
"first_bar": "2025-02-13",
"last_bar": "2026-07-17",
"bars": 357
},
{
"symbol": "GEV",
"first_bar": "2024-04-02",
"last_bar": "2026-07-17",
"bars": 575
},
{
"symbol": "SOLV",
"first_bar": "2024-04-01",
"last_bar": "2026-07-17",
"bars": 576
},
{
"symbol": "VLTO",
"first_bar": "2023-10-02",
"last_bar": "2026-07-17",
"bars": 700
},
{
"symbol": "KVUE",
"first_bar": "2023-05-04",
"last_bar": "2026-07-17",
"bars": 803
},
{
"symbol": "GEHC",
"first_bar": "2022-12-15",
"last_bar": "2026-07-17",
"bars": 898
}
],
"price_window": {
"min": "2016-01-04",
"max": "2026-07-17"
},
"benchmark_spy": {
"rows": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"gate_threshold": {
"min_tradable_symbols": 505,
"max_missing_or_zero_bar": 1,
"min_symbols_with_pre2021_bars_pct": 80.0,
"benchmark_min_rows": 1000,
"benchmark_must_begin_pre2021": true
},
"gate_pass": true
},
"data_quality": {
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"prod_symbols": 505,
"events": 12414,
"symbols_with_any_event": 504,
"symbols_with_ge8_announcements": 498,
"symbols_with_ge8_announcements_pct": 98.6,
"symbols_with_ge8_paired_announcements": 495,
"symbols_with_ge8_paired_announcements_pct": 98.0,
"events_with_actual_and_estimate": 12311,
"events_with_actual_and_estimate_pct": 99.2,
"duplicate_rows_in_table": 0,
"duplicate_rows_fetched": 0,
"restated_rows_fetched": 940,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_per_symbol_year": {
"mean_active_span_rate": 4.08,
"expected": "approximately 4",
"far_off_rule": "active-span rate <2 or >6, plus zero-event symbols",
"far_off_count": 1,
"far_off_symbols": [
{
"symbol": "SPCX",
"events": 0,
"events_per_year": 0.0
}
]
},
"announcement_session": {
"recognised_bmo_amc_or_during": 11520,
"recognised_pct": 92.8,
"reliable": true,
"assessment": "usable"
},
"point_in_time_policy": "announce_date_plus_1_trading_day_for_all_events",
"sue_scaling": {
"primary": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
"fallback_trigger": "paired event coverage <50% or symbols with >=8 paired events <50%",
"fallback_needed": false,
"fallback_name": "not_used"
},
"backfill": {
"mode": "dolthub_public_bulk_clone",
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"coverage_amendment": {
"approved_by_user": true,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": "2020-01-22"
},
"source": {
"repository": "https://www.dolthub.com/repositories/post-no-preference/earnings",
"commit": "9n0et3hpj9j7vue8f3qsldon3qa5sdjj",
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": false
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": {
"raw_rows": 117482,
"universe_rows_in_window": 12342,
"deduped_rows_in_window": 12342,
"duplicate_rows": 0,
"restated_rows": 0
},
"eps_history": {
"raw_rows": 165050,
"universe_rows": 18515,
"deduped_rows": 18515,
"duplicate_rows": 0,
"restated_rows": 0,
"complete_actual_and_estimate": 18304
},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-14,
90
],
"matched_calendar_events": 12271,
"unmatched_calendar_events": 71,
"unmatched_periods_in_pairing_window": 538,
"announce_minus_period_end_days": {
"min": -10,
"median": 30,
"max": 89
},
"pre_2020_eps_history_use": "trailing_surprise_stdev_only; never treated as an announcement or live signal event"
},
"duplicate_rows_logged_total": 0,
"restated_rows_logged_total": 940,
"conflicting_existing_rows": 940,
"conflicting_existing_fields": 1526,
"preserved_existing_fields": 2945,
"existing_enrichment_events_not_in_dolthub_calendar": 72,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_in_window": 12414,
"events_with_actual_and_estimate": 12311,
"symbols_done": 506,
"symbols_universe": 506,
"symbols_with_dolthub_calendar": 504,
"symbols_without_dolthub_calendar": [
"RHM",
"SPCX"
],
"announce_date_range": {
"min": "2020-01-22",
"max": "2026-07-17"
},
"complete": true
}
},
"production_impact": "none",
"experiment": "2b",
"result": {
"verdict": "FAIL",
"verdict_detail": "SUE DEAD for this stack",
"grade_rule": {
"mean_ic_ge_0_03_positive": false,
"reliable_ge_12_windows": true,
"positive_sign_pre_and_post_2021": true,
"pass": false
},
"sue_unconditional": {
"signal": "sue_latest",
"weeks": 56,
"avg_cross_section": 451.4,
"mean_ic": 0.0151,
"ic_t_stat": 1.29,
"ic_positive_pct": 51.8,
"mean_quintile_spread": 0.0041,
"reliable": true
},
"era_split": {
"pre_2021": {
"signal": "sue_latest",
"weeks": 9,
"avg_cross_section": 398.3,
"mean_ic": 0.0286,
"ic_t_stat": 0.7,
"ic_positive_pct": 55.6,
"mean_quintile_spread": 0.0084,
"reliable": false
},
"post_2021": {
"signal": "sue_latest",
"weeks": 48,
"avg_cross_section": 461.9,
"mean_ic": 0.0172,
"ic_t_stat": 1.34,
"ic_positive_pct": 64.6,
"mean_quintile_spread": 0.0038,
"reliable": true
}
},
"signal_eval_identical_cross_sections": {
"sue_latest": {
"signal": "sue_latest",
"weeks": 56,
"avg_cross_section": 450.9,
"mean_ic": 0.0148,
"ic_t_stat": 1.27,
"ic_positive_pct": 51.8,
"mean_quintile_spread": 0.004,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 56,
"avg_cross_section": 450.9,
"mean_ic": 0.0195,
"ic_t_stat": 0.74,
"ic_positive_pct": 58.9,
"mean_quintile_spread": 0.0104,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 56,
"avg_cross_section": 450.9,
"mean_ic": 0.0262,
"ic_t_stat": 1.07,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0114,
"reliable": true
}
},
"identical_cross_section_definition": "same week-symbol-forward-return cells where sue_latest, mom_12_1, and mom_12_1_resid are all non-null",
"momentum_conditional_top_quintile": {
"mean_ic": 0.0213,
"ic_t_stat": 1.3,
"weeks": 56,
"avg_cross_section": 89.8,
"population": "top_mom_12_1_quintile_only"
},
"coverage": {
"symbols_with_live_sue": 501,
"avg_weekly_live_n_all_weeks": 453.1,
"avg_cross_section_n_scored_nonoverlap": 451.4,
"thin_cross_section_lt_100": false,
"warning": null
},
"scaling": {
"method": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
"fallback": "not_used",
"counts": {
"standard_scaled_events": 12149,
"events_scaled_from_period_history": 12149,
"price_fallback_events": 0,
"dropped_insufficient_trailing_history": 95,
"dropped_missing_period_alignment": 67,
"dropped_zero_stdev": 0
},
"pre_coverage_history_policy": "period-end EPS surprises may scale later events but are never treated as live signals without an announcement date",
"availability": "announce_date_plus_1_trading_day",
"carry_trading_days": 63
},
"universe_symbols": 505
}
}
@@ -0,0 +1,62 @@
# Earnings Task 2b - SUE / PEAD
### Data quality gate
Approved earnings window: 2020-01-22 to 2026-07-17. Source mode: dolthub_public_bulk_clone.
| check | result |
|---|---:|
| Prod symbols requested / tradable | 506 / 505 |
| Manifest complete + live counts match | True |
| Prod symbols with pre-2021 bars | 491 (97.2%) |
| SPY benchmark depth | 2649 rows, 2016-01-04 to 2026-07-17 |
| Snapshot depth gate | True |
| Bulk source windows / requests logged | 1/1 / 1 |
| Source repository / pinned commit | https://www.dolthub.com/repositories/post-no-preference/earnings @ 9n0et3hpj9j7vue8f3qsldon3qa5sdjj |
| Source license / upstream provider documented | CC-BY-SA-4.0 / False |
| Existing-source conflicts preserved | 940 rows / 1526 fields |
| Symbols with >=8 announcements | 498 (98.6%) |
| Symbols with >=8 paired announcements | 495 (98.0%) |
| Events with estimate + actual | 12311/12414 (99.2%) |
| Duplicate rows in keyed table | 0 |
| Duplicate / restated payload rows fetched | 0 / 940 |
| Mean announcements per active symbol-year | 4.08 (expected about 4) |
| Symbols far off (<2 or >6/year, incl. zero) | 1 |
| Recognised BMO/AMC/during | 92.8% (reliable=True) |
| Point-in-time policy | announce_date_plus_1_trading_day_for_all_events |
| SUE price fallback | not_used |
Deduplication: UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment
Far-off announcement-rate symbols: SPCX
### Experiment 2b - SUE / post-earnings drift
Mechanical verdict: **FAIL** - SUE DEAD for this stack
Identical cross-sections:
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0148 | 1.27 | 56 | 450.9 | 51.8 | true |
| mom_12_1 | 0.0195 | 0.74 | 56 | 450.9 | 58.9 | true |
| mom_12_1_resid | 0.0262 | 1.07 | 56 | 450.9 | 55.4 | true |
Unconditional SUE grade row:
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0151 | 1.29 | 56 | 451.4 | 51.8 | true |
Era stability:
| era | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| pre-2021 | 0.0286 | 0.7 | 9 | 398.3 | 55.6 | false |
| post-2021 | 0.0172 | 1.34 | 48 | 461.9 | 64.6 | true |
Coverage: 501 symbols with live SUE; avg weekly N=453.1; scored non-overlap avg N=451.4.
Cross-section is not flagged thin at the registered <100-name read.
Momentum-conditional top-quintile SUE: mean IC=0.0213, t=1.3, windows=56, avg N=89.8.
+72 -12
View File
@@ -1,15 +1,75 @@
{
"mode": "per_symbol",
"fmp_requests": 25,
"events_written_this_run": 2541,
"total_events": 5612,
"symbols_done": 48,
"symbols_universe": 506,
"announce_date_range": {
"min": "1985-08-31",
"max": "2026-07-16"
"mode": "dolthub_public_bulk_clone",
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"events_with_actual_and_estimate": 5018,
"budget": 25,
"complete": false
"coverage_amendment": {
"approved_by_user": true,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": "2020-01-22"
},
"source": {
"repository": "https://www.dolthub.com/repositories/post-no-preference/earnings",
"commit": "9n0et3hpj9j7vue8f3qsldon3qa5sdjj",
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": false
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": {
"raw_rows": 117482,
"universe_rows_in_window": 12342,
"deduped_rows_in_window": 12342,
"duplicate_rows": 0,
"restated_rows": 0
},
"eps_history": {
"raw_rows": 165050,
"universe_rows": 18515,
"deduped_rows": 18515,
"duplicate_rows": 0,
"restated_rows": 0,
"complete_actual_and_estimate": 18304
},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-14,
90
],
"matched_calendar_events": 12271,
"unmatched_calendar_events": 71,
"unmatched_periods_in_pairing_window": 538,
"announce_minus_period_end_days": {
"min": -10,
"median": 30,
"max": 89
},
"pre_2020_eps_history_use": "trailing_surprise_stdev_only; never treated as an announcement or live signal event"
},
"duplicate_rows_logged_total": 0,
"restated_rows_logged_total": 940,
"conflicting_existing_rows": 940,
"conflicting_existing_fields": 1526,
"preserved_existing_fields": 2945,
"existing_enrichment_events_not_in_dolthub_calendar": 72,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_in_window": 12414,
"events_with_actual_and_estimate": 12311,
"symbols_done": 506,
"symbols_universe": 506,
"symbols_with_dolthub_calendar": 504,
"symbols_without_dolthub_calendar": [
"RHM",
"SPCX"
],
"announce_date_range": {
"min": "2020-01-22",
"max": "2026-07-17"
},
"complete": true
}
@@ -1,331 +0,0 @@
{
"generated_at": "2026-07-19T09:31:29.078611",
"data_provenance": {
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\prod.sqlite",
"n_earnings_events": 5612,
"backfill_meta": {
"done": 48,
"universe_tickers": 506
},
"announce_range": {
"min": "1985-08-31",
"max": "2026-07-16"
},
"with_actual_and_estimate": 5018
},
"experiment_2a": {
"sim_summary": {
"sharpe": 2.09,
"sharpe_se": 0.497,
"cagr_pct": 51.6,
"max_drawdown_pct": 21.4,
"trades": 322,
"total_return_pct": 424.6
},
"n_trades_parsed": 322,
"q1_losses_worse_than_minus_1r": {
"n_losses_lt_minus_1r": 28,
"n_with_earnings_in_hold": 1,
"fraction_with_earnings": 0.0357,
"all_trades_with_earnings_in_hold": 14,
"fraction_all_trades_with_earnings": 0.0435
},
"q2_entry_within_3d_before_announce": {
"pre_earn_entries": {
"n": 4,
"mean": 1.9379,
"win_rate": 0.5,
"p05": -1.2428,
"p25": -0.8833,
"p50": 1.1209,
"p75": 3.942,
"p95": 6.2623,
"min": -1.3327,
"max": 6.8424
},
"other_entries": {
"n": 318,
"mean": 0.6965,
"win_rate": 0.3711,
"p05": -1.1052,
"p25": -1.0,
"p50": -0.8259,
"p75": 2.1053,
"p95": 6.077,
"min": -3.2587,
"max": 12.8654
},
"all_entries": {
"n": 322,
"mean": 0.7119,
"win_rate": 0.3727,
"p05": -1.1209,
"p25": -1.0,
"p50": -0.8251,
"p75": 2.1595,
"p95": 6.2246,
"min": -3.2587,
"max": 12.8654
},
"tail_trim_note": "Compare p95/max and mean of pre_earn vs other. Rising win_rate with falling mean/p95 = right-tail trim red flag."
},
"note": "REPORT-ONLY \u2014 no filter shipped."
},
"experiment_2b": {
"signal_eval_side_by_side": {
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0531,
"ic_t_stat": 1.61,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0206,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0552,
"ic_t_stat": 1.98,
"ic_positive_pct": 60.0,
"mean_quintile_spread": 0.0207,
"reliable": true
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
"sue_latest": {
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.045,
"ic_t_stat": -2.91,
"ic_positive_pct": 25.7,
"mean_quintile_spread": -0.0168,
"reliable": true
}
},
"signal_eval_identical_sue_subset": {
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 47.3,
"mean_ic": -0.0174,
"ic_t_stat": -0.42,
"ic_positive_pct": 45.7,
"mean_quintile_spread": 0.0077,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 47.3,
"mean_ic": -0.0104,
"ic_t_stat": -0.27,
"ic_positive_pct": 51.4,
"mean_quintile_spread": 0.0075,
"reliable": true
},
"sue_latest": {
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
}
},
"identical_subset_note": "Mom baselines re-scored only on (week, symbol) cells where SUE exists. Use this table when backfill is incomplete \u2014 full-universe mom N is not comparable.",
"full_signal_eval": [
{
"signal": "vol_6m",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0609,
"ic_t_stat": 1.48,
"ic_positive_pct": 64.1,
"mean_quintile_spread": 0.0337,
"reliable": true
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
{
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0552,
"ic_t_stat": 1.98,
"ic_positive_pct": 60.0,
"mean_quintile_spread": 0.0207,
"reliable": true
},
{
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0531,
"ic_t_stat": 1.61,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0206,
"reliable": true
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
{
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
},
{
"signal": "trend_200",
"weeks": 37,
"avg_cross_section": 497.9,
"mean_ic": 0.0161,
"ic_t_stat": 0.44,
"ic_positive_pct": 59.5,
"mean_quintile_spread": 0.006,
"reliable": true
},
{
"signal": "reversal_1m",
"weeks": 43,
"avg_cross_section": 498.7,
"mean_ic": 0.0059,
"ic_t_stat": 0.22,
"ic_positive_pct": 53.5,
"mean_quintile_spread": 0.0053,
"reliable": true
},
{
"signal": "mom_6_1",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0051,
"ic_t_stat": 0.21,
"ic_positive_pct": 56.4,
"mean_quintile_spread": 0.0087,
"reliable": true
},
{
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 498.5,
"mean_ic": -0.0064,
"ic_t_stat": -0.25,
"ic_positive_pct": 50.0,
"mean_quintile_spread": 0.0046,
"reliable": true
},
{
"signal": "high_52w",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.0086,
"ic_t_stat": -0.26,
"ic_positive_pct": 54.3,
"mean_quintile_spread": -0.0088,
"reliable": true
},
{
"signal": "fip_id",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.045,
"ic_t_stat": -2.91,
"ic_positive_pct": 25.7,
"mean_quintile_spread": -0.0168,
"reliable": true
}
],
"sue_grade": {
"green": false,
"checks": {
"mean_ic": 0.0172,
"sign_positive": true,
"abs_ge_0_03": false,
"reliable": true,
"ic_t_stat": 0.6,
"weeks": 44
},
"reason": "iron rule not met",
"row": {
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
}
},
"momentum_conditional_sue": {
"mean_ic": -0.0065,
"ic_t_stat": -0.1,
"weeks": 35,
"note": "IC of sue_latest within top mom_12_1 quintile (non-overlapping weeks)"
},
"sue_coverage": {
"symbols_with_sue": 48,
"avg_weeks_with_sue": 47.1,
"weeks_with_min_cross_section": 256
}
},
"verdict": "PARK",
"verdict_detail": "SUE IC=0.0172 below iron bar or unreliable; keep data, no wire.",
"human_next": "- No SUE book change.\n- Read 2a tails before considering any earnings-avoid filter.",
"report_path": "reports/earnings-gap-sue-20260719-093129.json",
"fmp_note": "Bulk earnings-calendar is paid (402 on free tier). Backfill used per-symbol /stable/earnings; see earnings-backfill-status.json."
}
-202
View File
@@ -1,202 +0,0 @@
# Earnings gap diagnostic + SUE / PEAD (Tier-1 alpha research)
**Status:** **PARK** (incomplete earnings coverage; SUE fails iron rule on available sample).
**Branch:** `research/earnings-gap-and-sue`
**Production impact:** none. Local research only. **No filters shipped from 2a.**
**Artifacts:** `reports/earnings-gap-sue-20260719-093129.json` (+ companion `.md`)
---
## Pre-registration (locked before first research run)
### Data
- Historical earnings calendar for the production universe over the full snapshot
window (and deeper if the feed provides it).
- Preferred source: FMP **date-range earnings-calendar** (bulk). If unavailable on
free tier, fall back to per-symbol `/stable/earnings` with request accounting.
- Store in a real local table `earnings_events` (symbol + announce_date key).
- Point-in-time: a surprise is usable only from **announce date + 1 trading day**
onward.
### Experiment 2a — earnings-gap risk (defense, report-only)
Join simulated production-config trades (`fill_mode=close`) with earnings dates.
**Pre-registered questions:**
1. What fraction of losses worse than **1R** occur with an earnings announcement
**between entry and exit** (inclusive of the holding window)?
2. What is the mean R of entries taken within **3 trading days BEFORE** an
announcement vs all other entries — report **both tails** of the R
distribution (rule 4: any earnings-avoid entry filter is presumed guilty of
right-tail trimming until the win distribution shows otherwise)?
**Output:** distributions and counts only.
**No filter is shipped.** If numbers argue for a filter → report and stop.
### Experiment 2b — SUE / PEAD (offense)
Signal `sue_latest`:
\[
\text{SUE} = \frac{\text{actual} - \text{estimate}}{\sigma(\text{trailing 8 surprises})}
\]
Fallback if estimate history is thin: scale surprise by price.
Carry forward from announce+1 for **63 trading days**, else NaN (name drops out
of that cross-section).
**Iron rule (IC harness):** mean weekly Spearman IC on non-overlapping weeks;
\|mean IC\| ≥ ~0.03, **positive** sign (drift), `reliable: true` (≥12 windows).
Always side-by-side with `mom_12_1` and `mom_12_1_resid` on **identical**
cross-sections.
Also report **momentum-conditional** IC (within top momentum quintile).
**If it passes iron rule:** STOP and report. Book-integration design is a
separate human-approved step — do not wire.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | (2b only) iron rule cleared → human designs tilt/gate |
| **PARK** | Interesting but incomplete / weak |
| **DEAD** | No edge / diagnostic argues against action |
| **REPORT-ONLY** | (2a) always — never auto-filter |
---
## Data provenance
| item | result |
|---|---|
| Snapshot | `backtest_snapshots/prod.sqlite` (506 names) |
| FMP bulk `earnings-calendar` | **402 Premium** — not available on free tier |
| FMP per-symbol `/stable/earnings` | used; hit daily rate limit ~225 reqs |
| Alpha Vantage `EARNINGS` | used for +24 symbols (announce = `reportedDate`) |
| Symbols with events | **48 / 506 (9.5%)** |
| Total events | 5,612 (5,018 with actual+estimate) |
| Announce range | 1985-08-31 → 2026-07-16 |
| FMP requests (first day) | 260 FMP + 25 AV (see `reports/earnings-backfill-status.json`) |
**Incomplete backfill is first-class.** 2a under-detects earnings overlaps; 2b SUE
cross-section averages **~47 names**, not ~500. Resume:
```bash
# Day N (FMP free ~250/day; AV free ~25/day — prefer FMP after reset)
python scripts/backfill_earnings_events.py \
--snapshot backtest_snapshots/prod.sqlite \
--provider fmp --force-symbol --limit 250 --sleep 0.4
# When done==506:
python scripts/run_earnings_research.py \
--snapshot backtest_snapshots/prod.sqlite \
--workers 6 --allow-spawn
```
---
## Results
Generated: `2026-07-19T09:31:29`
### 2a — Earnings-gap risk (report-only)
Production book sim: Sharpe 2.09 (SE 0.497), CAGR 51.6%, max DD 21.4%, **322 trades**,
`fill_mode=close`.
#### Q1 — Losses worse than 1R with earnings in hold
| metric | value |
|---|---:|
| n losses &lt; 1R | 28 |
| of which earnings in hold | **1** |
| fraction | **3.6%** |
| all trades with earnings in hold | 14 / 322 (4.4%) |
**Read:** On incomplete earnings labels this is a **lower bound** on earnings
overlap, not a clean “earnings rarely hurt.” Do **not** conclude earnings risk is
immaterial until coverage ≥ ~95% of the books names.
#### Q2 — Entry within 3 trading days before announce (both tails)
| cohort | n | mean R | win rate | p05 | p50 | p95 | max |
|---|---:|---:|---:|---:|---:|---:|---:|
| pre-earn (≤3d before) | **4** | 1.94 | 50% | 1.24 | 1.12 | 6.26 | 6.84 |
| other | 318 | 0.70 | 37% | 1.11 | 0.83 | 6.08 | **12.87** |
| all | 322 | 0.71 | 37% | 1.12 | 0.83 | 6.22 | 12.87 |
**Tail-trim presumption:** n=4 is not a sample. Point estimate does **not** show
right-tail destruction of pre-earn entries (p95 similar; max actually higher in
“other”). **No earnings-avoid filter is supported.** Re-run after full backfill.
---
### 2b — SUE / PEAD IC
#### Full-universe harness (mom on ~500; SUE only where labeled)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable |
|---|---:|---:|---:|---:|---|
| mom_12_1_sector_resid | 0.0578 | 2.34 | 35 | 497.7 | true |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true |
| **sue_latest** | **0.0172** | **0.6** | 44 | **47.4** | true |
| fip_id | 0.045 | 2.91 | 35 | 497.7 | true |
#### Identical SUE subset (fair side-by-side — use this while coverage is thin)
| signal | mean_ic | ic_t_stat | weeks | avg_N |
|---|---:|---:|---:|---:|
| sue_latest | 0.0172 | 0.6 | 44 | 47.4 |
| mom_12_1 | 0.0174 | 0.42 | 35 | 47.3 |
| mom_12_1_resid | 0.0104 | 0.27 | 35 | 47.3 |
On the thin labeled subset, momentum itself is noise — so the subset is not yet
a meaningful PEAD test.
#### Momentum-conditional SUE (top mom quintile)
| metric | value |
|---|---:|
| mean IC | **0.0065** |
| t | 0.1 |
| weeks | 35 |
Wrong sign vs “ride positive surprises inside the momentum gate.”
**Iron rule:** fail (\|IC\| 0.017 &lt; 0.03; t 0.6). **No promote.**
---
## Verdict
| piece | verdict |
|---|---|
| **2a earnings-gap** | **REPORT-ONLY** — no filter. Coverage too thin for risk claims; tails do not argue for an avoid-filter on n=4. |
| **2b SUE** | **PARK** (effectively not green). Mild positive IC on ~48 names; fails iron bar; mom-conditional flat/negative. Re-score after full backfill before DEAD. |
| **Production** | **no change** |
---
## What a human must decide next
1. Resume multi-day earnings backfill to **506/506**, then re-run
`run_earnings_research.py` (heavy — MacBook OK).
2. Do **not** ship an earnings-avoid entry filter from 2a.
3. Do **not** wire SUE until a full-coverage IC clears the iron rule (and
preferably mom-conditional &gt; 0).
4. Do not merge into main strategy docs without review.
---
## Implementation notes
| piece | role |
|---|---|
| `scripts/backfill_earnings_events.py` | bulk attempt → FMP/AV per-symbol; `earnings_events` + meta on snapshot |
| `scripts/run_earnings_research.py` | 2a trade join + 2b SUE IC / mom-conditional |
| Snapshot table `earnings_events` | real table (not SystemSetting JSON) |
+388 -405
View File
@@ -1,15 +1,13 @@
"""Backfill historical earnings into a snapshot ``earnings_events`` table.
"""Bulk-only historical earnings backfill for a local SQLite snapshot.
Prefers FMP bulk date-range ``earnings-calendar`` (one request per window).
On free-tier 402/403, falls back to per-symbol ``/stable/earnings`` with
resume support and request counting (≈250 req/day free tier).
The job uses FMP's date-range earnings-calendar endpoint. One request covers all
symbols in a date window; per-symbol endpoints are intentionally not available
in this task runner. Successful windows are committed independently so a later
run resumes after a daily quota boundary without repeating completed windows.
Research only — writes to the local snapshot SQLite, never production Postgres.
Example
-------
python scripts/backfill_earnings_events.py \\
--snapshot backtest_snapshots/prod.sqlite --limit 250
Example:
python scripts/backfill_earnings_events.py --snapshot backtest_snapshots/prod.sqlite \
--from-date 2012-01-01 --window-days 30 --limit 250
"""
from __future__ import annotations
@@ -17,10 +15,11 @@ from __future__ import annotations
import argparse
import asyncio
import json
import math
import sys
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import create_engine, text
@@ -34,7 +33,7 @@ from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
FMP_STABLE = "https://financialmodelingprep.com/stable"
DDL = """
EVENTS_DDL = """
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
@@ -49,7 +48,6 @@ CREATE TABLE IF NOT EXISTS earnings_events (
UNIQUE(symbol, announce_date)
)
"""
# Side table tracks which symbols have been fully pulled (resume).
META_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_meta (
symbol TEXT PRIMARY KEY,
@@ -59,239 +57,238 @@ CREATE TABLE IF NOT EXISTS earnings_backfill_meta (
note TEXT
)
"""
WINDOW_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_windows (
from_date TEXT NOT NULL,
to_date TEXT NOT NULL,
status TEXT NOT NULL,
requests INTEGER NOT NULL DEFAULT 0,
rows_raw INTEGER NOT NULL DEFAULT 0,
rows_universe INTEGER NOT NULL DEFAULT 0,
duplicate_rows INTEGER NOT NULL DEFAULT 0,
restated_rows INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT,
PRIMARY KEY(from_date, to_date)
)
"""
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
p.add_argument(
"--from-date",
default="2020-01-01",
help="Bulk calendar window start (also filters per-symbol rows).",
)
p.add_argument(
"--to-date",
default=None,
help="Bulk calendar window end (default: today).",
)
p.add_argument(
"--limit",
type=int,
default=250,
help="Max FMP requests this run (free-tier cushion).",
)
p.add_argument("--sleep", type=float, default=0.35)
p.add_argument(
"--force-symbol",
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
parser.add_argument("--from-date", default="2012-01-01")
parser.add_argument("--to-date", default=None)
parser.add_argument("--window-days", type=int, default=30)
parser.add_argument("--limit", type=int, default=250)
parser.add_argument("--sleep", type=float, default=0.35)
parser.add_argument(
"--refetch-windows",
action="store_true",
help="Skip bulk attempt; go straight to per-symbol.",
help="Re-fetch date windows already logged as done.",
)
p.add_argument(
"--refetch-done",
action="store_true",
help="Re-fetch symbols already marked done.",
)
p.add_argument(
"--provider",
choices=("fmp", "alpha_vantage", "auto"),
default="auto",
help="Earnings provider. auto tries FMP bulk then FMP/AV per-symbol.",
)
return p.parse_args()
return parser.parse_args()
def _ensure_tables(engine) -> None:
with engine.begin() as conn:
conn.execute(text(DDL))
conn.execute(text(EVENTS_DDL))
conn.execute(text(META_DDL))
conn.execute(text(WINDOW_DDL))
def _upsert_events(conn, rows: list[dict], source: str) -> int:
if not rows:
return 0
now = datetime.now(timezone.utc).isoformat()
written = 0
for r in rows:
conn.execute(
text(
"""
INSERT INTO earnings_events (
symbol, announce_date, announce_time,
eps_estimate, eps_actual, revenue_estimate, revenue_actual,
source, fetched_at
) VALUES (
:symbol, :announce_date, :announce_time,
:eps_estimate, :eps_actual, :revenue_estimate, :revenue_actual,
:source, :fetched_at
)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=excluded.announce_time,
eps_estimate=excluded.eps_estimate,
eps_actual=excluded.eps_actual,
revenue_estimate=excluded.revenue_estimate,
revenue_actual=excluded.revenue_actual,
source=excluded.source,
fetched_at=excluded.fetched_at
"""
),
{
"symbol": r["symbol"],
"announce_date": r["announce_date"],
"announce_time": r.get("announce_time"),
"eps_estimate": r.get("eps_estimate"),
"eps_actual": r.get("eps_actual"),
"revenue_estimate": r.get("revenue_estimate"),
"revenue_actual": r.get("revenue_actual"),
"source": source,
"fetched_at": now,
},
)
written += 1
return written
def _number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _normalise_session(value: Any) -> str | None:
if value is None:
return None
cleaned = str(value).strip().lower().replace("_", " ").replace("-", " ")
aliases = {
"bmo": "bmo",
"before market open": "bmo",
"before open": "bmo",
"amc": "amc",
"after market close": "amc",
"after close": "amc",
"during market hours": "during",
"dmh": "during",
}
return aliases.get(cleaned, cleaned or None)
def _parse_bulk_item(item: dict) -> dict | None:
sym = (item.get("symbol") or "").strip().upper()
d = item.get("date") or item.get("earningsDate")
if not sym or not d:
symbol = str(item.get("symbol") or "").strip().upper().replace(".", "-")
raw_date = item.get("date") or item.get("earningsDate")
if not symbol or not raw_date:
return None
return {
"symbol": sym.replace(".", "-"),
"announce_date": str(d)[:10],
"announce_time": item.get("time") or item.get("announceTime"),
"eps_estimate": _f(item.get("epsEstimated") or item.get("estimatedEarning")),
"eps_actual": _f(item.get("epsActual") or item.get("eps")),
"revenue_estimate": _f(item.get("revenueEstimated")),
"revenue_actual": _f(item.get("revenueActual")),
"symbol": symbol,
"announce_date": str(raw_date)[:10],
"announce_time": _normalise_session(
item.get("time") or item.get("announceTime")
),
"eps_estimate": _number(
item.get("epsEstimated")
if item.get("epsEstimated") is not None
else item.get("estimatedEarning")
),
"eps_actual": _number(
item.get("epsActual")
if item.get("epsActual") is not None
else item.get("eps")
),
"revenue_estimate": _number(item.get("revenueEstimated")),
"revenue_actual": _number(item.get("revenueActual")),
}
def _parse_symbol_item(item: dict, symbol: str) -> dict | None:
d = item.get("date")
if not d:
return None
return {
"symbol": symbol.replace(".", "-").upper(),
"announce_date": str(d)[:10],
"announce_time": item.get("time"),
"eps_estimate": _f(item.get("epsEstimated")),
"eps_actual": _f(item.get("epsActual")),
"revenue_estimate": _f(item.get("revenueEstimated")),
"revenue_actual": _f(item.get("revenueActual")),
}
def _windows(start: date, end: date, window_days: int) -> list[tuple[date, date]]:
if window_days < 1:
raise ValueError("window_days must be positive")
result: list[tuple[date, date]] = []
cursor = start
while cursor <= end:
window_end = min(end, cursor + timedelta(days=window_days - 1))
result.append((cursor, window_end))
cursor = window_end + timedelta(days=1)
return result
def _f(v) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _dedupe_bulk_rows(rows: list[dict]) -> tuple[list[dict], int, int]:
"""Prefer the most complete duplicate; use the later row as the tie-break."""
fields = (
"announce_time",
"eps_estimate",
"eps_actual",
"revenue_estimate",
"revenue_actual",
)
chosen: dict[tuple[str, str], dict] = {}
duplicate_extras = 0
restated = 0
for row in rows:
key = (str(row["symbol"]), str(row["announce_date"]))
previous = chosen.get(key)
if previous is None:
chosen[key] = row
continue
duplicate_extras += 1
if any(
previous.get(field) is not None
and row.get(field) is not None
and previous.get(field) != row.get(field)
for field in fields
):
restated += 1
previous_score = sum(previous.get(field) is not None for field in fields)
new_score = sum(row.get(field) is not None for field in fields)
if new_score >= previous_score:
chosen[key] = row
return list(chosen.values()), duplicate_extras, restated
async def _try_bulk(
client: httpx.AsyncClient,
api_key: str,
def _upsert_events(conn, rows: list[dict]) -> int:
if not rows:
return 0
fetched_at = datetime.now(timezone.utc).isoformat()
statement = text(
"""
INSERT INTO earnings_events (
symbol, announce_date, announce_time, eps_estimate, eps_actual,
revenue_estimate, revenue_actual, source, fetched_at
) VALUES (
:symbol, :announce_date, :announce_time, :eps_estimate, :eps_actual,
:revenue_estimate, :revenue_actual, 'fmp_earnings_calendar', :fetched_at
)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=COALESCE(excluded.announce_time, earnings_events.announce_time),
eps_estimate=COALESCE(excluded.eps_estimate, earnings_events.eps_estimate),
eps_actual=COALESCE(excluded.eps_actual, earnings_events.eps_actual),
revenue_estimate=COALESCE(excluded.revenue_estimate, earnings_events.revenue_estimate),
revenue_actual=COALESCE(excluded.revenue_actual, earnings_events.revenue_actual),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
)
conn.execute(statement, [{**row, "fetched_at": fetched_at} for row in rows])
return len(rows)
async def _fetch_bulk_window(
client: httpx.AsyncClient, api_key: str, start: date, end: date
) -> tuple[list[dict], int, str | None]:
response = await client.get(
f"{FMP_STABLE}/earnings-calendar",
params={"from": start.isoformat(), "to": end.isoformat(), "apikey": api_key},
)
if response.status_code in (402, 403):
return [], response.status_code, "bulk_endpoint_unavailable"
if response.status_code == 429:
return [], response.status_code, "daily_limit_reached"
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
return [], response.status_code, f"unexpected_payload:{type(payload).__name__}"
rows = []
for item in payload:
if isinstance(item, dict):
parsed = _parse_bulk_item(item)
if parsed:
rows.append(parsed)
return rows, response.status_code, None
def _write_window_status(
engine,
*,
start: date,
end: date,
*,
window_days: int = 30,
) -> tuple[list[dict], int, str | None]:
"""Return (rows, requests_used, error_note)."""
rows: list[dict] = []
reqs = 0
cur = start
while cur <= end:
win_end = min(end, cur + timedelta(days=window_days - 1))
resp = await client.get(
f"{FMP_STABLE}/earnings-calendar",
params={
"from": cur.isoformat(),
"to": win_end.isoformat(),
"apikey": api_key,
status: str,
raw_n: int = 0,
universe_n: int = 0,
duplicate_n: int = 0,
restated_n: int = 0,
note: str | None = None,
) -> None:
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO earnings_backfill_windows(
from_date, to_date, status, requests, rows_raw, rows_universe,
duplicate_rows, restated_rows, updated_at, note
) VALUES (:a, :b, :status, 1, :raw, :uni, :dup, :rest, :now, :note)
ON CONFLICT(from_date, to_date) DO UPDATE SET
status=excluded.status,
requests=earnings_backfill_windows.requests + 1,
rows_raw=excluded.rows_raw,
rows_universe=excluded.rows_universe,
duplicate_rows=excluded.duplicate_rows,
restated_rows=excluded.restated_rows,
updated_at=excluded.updated_at,
note=excluded.note
"""
),
{
"a": start.isoformat(),
"b": end.isoformat(),
"status": status,
"raw": raw_n,
"uni": universe_n,
"dup": duplicate_n,
"rest": restated_n,
"now": datetime.now(timezone.utc).isoformat(),
"note": note,
},
)
reqs += 1
if resp.status_code in (402, 403):
return [], reqs, f"bulk_unavailable status={resp.status_code}"
if resp.status_code == 429:
return rows, reqs, "rate_limited"
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return [], reqs, f"unexpected bulk payload type={type(data)}"
for item in data:
if isinstance(item, dict):
parsed = _parse_bulk_item(item)
if parsed:
rows.append(parsed)
cur = win_end + timedelta(days=1)
return rows, reqs, None
async def _fetch_symbol(
client: httpx.AsyncClient, api_key: str, symbol: str
) -> list[dict]:
resp = await client.get(
f"{FMP_STABLE}/earnings",
params={"symbol": symbol, "apikey": api_key},
)
if resp.status_code == 429:
raise RuntimeError("rate_limited")
if resp.status_code == 402:
return []
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return []
out: list[dict] = []
for item in data:
if isinstance(item, dict):
parsed = _parse_symbol_item(item, symbol)
if parsed:
out.append(parsed)
return out
async def _fetch_symbol_alpha_vantage(
client: httpx.AsyncClient, api_key: str, symbol: str
) -> list[dict]:
"""Alpha Vantage EARNINGS — includes reportedDate (announce) + estimate/actual."""
resp = await client.get(
"https://www.alphavantage.co/query",
params={"function": "EARNINGS", "symbol": symbol, "apikey": api_key},
)
if resp.status_code == 429:
raise RuntimeError("rate_limited")
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
return []
note = str(data.get("Note") or data.get("Information") or "")
if "rate limit" in note.lower() or "Thank you for using Alpha Vantage" in note:
raise RuntimeError("rate_limited")
if data.get("Error Message"):
return []
quarterly = data.get("quarterlyEarnings") or []
out: list[dict] = []
for item in quarterly:
if not isinstance(item, dict):
continue
# Prefer announce (reportedDate); fall back to fiscal end (worse PIT).
ad = item.get("reportedDate") or item.get("fiscalDateEnding")
if not ad:
continue
out.append({
"symbol": symbol.replace(".", "-").upper(),
"announce_date": str(ad)[:10],
"announce_time": item.get("reportTime"),
"eps_estimate": _f(item.get("estimatedEPS")),
"eps_actual": _f(item.get("reportedEPS")),
"revenue_estimate": None,
"revenue_actual": None,
})
return out
async def _main() -> None:
@@ -304,228 +301,214 @@ async def _main() -> None:
if not settings.fmp_api_key:
raise SystemExit("FMP_API_KEY required")
start = date.fromisoformat(args.from_date)
end = date.fromisoformat(args.to_date) if args.to_date else date.today()
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
_ensure_tables(engine)
if start > end:
raise SystemExit("--from-date must not be after --to-date")
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
_ensure_tables(engine)
all_windows = _windows(start, end, int(args.window_days))
with engine.connect() as conn:
symbols = [
str(r[0]).upper().replace(".", "-")
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
str(row[0]).upper().replace(".", "-")
for row in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
]
done = set()
if not args.refetch_done:
done = {
str(r[0])
for r in conn.execute(
text(
"SELECT symbol FROM earnings_backfill_meta "
"WHERE status='done' AND n_events > 0"
)
completed = {
(str(row[0]), str(row[1]))
for row in conn.execute(
text(
"SELECT from_date, to_date FROM earnings_backfill_windows "
"WHERE status='done'"
)
}
pending = [s for s in symbols if s not in done]
print(f"Snapshot: {snapshot}")
print(f"Universe: {len(symbols)}; pending: {len(pending)}; done: {len(done)}")
print(f"Window filter: {start}{end}")
print(f"Provider: {args.provider}")
req_budget = int(args.limit)
reqs_used = 0
events_written = 0
mode = "per_symbol"
use_av = args.provider in ("alpha_vantage", "auto") and bool(
getattr(settings, "alpha_vantage_api_key", "")
)
use_fmp = args.provider in ("fmp", "auto") and bool(settings.fmp_api_key)
async with httpx.AsyncClient(timeout=60.0) as client:
if (
not args.force_symbol
and req_budget > 0
and use_fmp
and args.provider != "alpha_vantage"
):
print("Attempting bulk earnings-calendar…")
bulk_rows, bulk_reqs, err = await _try_bulk(
client, settings.fmp_api_key, start, end
)
reqs_used += bulk_reqs
if err:
print(f" Bulk unavailable: {err} (requests={bulk_reqs})")
else:
# Filter to universe.
uni = set(symbols)
bulk_rows = [r for r in bulk_rows if r["symbol"] in uni]
with engine.begin() as conn:
events_written += _upsert_events(conn, bulk_rows, "fmp_earnings_calendar")
for sym in symbols:
n = conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events WHERE symbol=:s"
),
{"s": sym},
).scalar_one()
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:s, 'done', :n, :t, 'bulk')
ON CONFLICT(symbol) DO UPDATE SET
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
"""
),
{
"s": sym,
"n": int(n),
"t": datetime.now(timezone.utc).isoformat(),
},
)
mode = "bulk"
print(f" Bulk wrote {events_written} events; requests={bulk_reqs}")
pending = []
}
pending = [
window
for window in all_windows
if args.refetch_windows
or (window[0].isoformat(), window[1].isoformat()) not in completed
]
universe = set(symbols)
print(f"Snapshot: {snapshot}")
print(f"Universe: {len(symbols)} symbols")
print(f"Window: {start} -> {end}")
print(
f"Bulk windows: {len(all_windows)} total; "
f"{len(all_windows) - len(pending)} done; {len(pending)} pending"
)
print("Provider: FMP bulk earnings-calendar only")
# Per-symbol fallback / completion.
fmp_limited = False
for sym in pending:
if reqs_used >= req_budget:
print(f"Request budget exhausted ({req_budget}). Resume later.")
requests_this_run = 0
rows_upserted = 0
duplicate_rows = 0
restated_rows = 0
stop_note: str | None = None
async with httpx.AsyncClient(timeout=60.0) as client:
for index, (window_start, window_end) in enumerate(pending, 1):
if requests_this_run >= int(args.limit):
stop_note = "request_budget_exhausted"
break
items: list[dict] = []
source = "fmp_earnings"
note = "per_symbol"
try:
if use_fmp and not fmp_limited and args.provider != "alpha_vantage":
items = await _fetch_symbol(client, settings.fmp_api_key, sym)
source = "fmp_earnings"
note = "fmp_per_symbol"
# Empty list may mean soft-limit or no data — try AV if available.
if not items and use_av:
items = await _fetch_symbol_alpha_vantage(
client, settings.alpha_vantage_api_key, sym
)
source = "alpha_vantage_earnings"
note = "av_after_fmp_empty"
reqs_used += 1 # count AV call separately below too
elif use_av:
items = await _fetch_symbol_alpha_vantage(
client, settings.alpha_vantage_api_key, sym
)
source = "alpha_vantage_earnings"
note = "av_per_symbol"
else:
raise RuntimeError("no provider available")
raw_rows, status_code, error = await _fetch_bulk_window(
client, settings.fmp_api_key, window_start, window_end
)
except Exception as exc:
msg = str(exc)
print(f" FAIL {sym}: {msg}")
reqs_used += 1
if "rate_limited" in msg and note.startswith("fmp"):
fmp_limited = True
with engine.begin() as conn:
raw_rows, status_code = [], 0
error = f"request_error:{type(exc).__name__}:{exc}"
requests_this_run += 1
if error:
_write_window_status(
engine,
start=window_start,
end=window_end,
status="error",
note=f"http={status_code} {error}"[:300],
)
stop_note = error
print(
f"STOP {window_start}..{window_end}: {error} "
f"(http={status_code}, request={requests_this_run})"
)
break
in_universe = [row for row in raw_rows if row["symbol"] in universe]
deduped, duplicate_n, restated_n = _dedupe_bulk_rows(in_universe)
with engine.begin() as conn:
rows_upserted += _upsert_events(conn, deduped)
_write_window_status(
engine,
start=window_start,
end=window_end,
status="done",
raw_n=len(raw_rows),
universe_n=len(deduped),
duplicate_n=duplicate_n,
restated_n=restated_n,
note="bulk",
)
duplicate_rows += duplicate_n
restated_rows += restated_n
if index == 1 or index % 10 == 0 or index == len(pending):
print(
f"progress windows={index}/{len(pending)} "
f"requests={requests_this_run}/{args.limit} "
f"last={window_start}..{window_end} rows={len(deduped)}"
)
if args.sleep > 0:
await asyncio.sleep(float(args.sleep))
with engine.begin() as conn:
windows_done = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_backfill_windows "
"WHERE status='done' AND from_date >= :a AND to_date <= :b"
),
{"a": start.isoformat(), "b": end.isoformat()},
).scalar_one()
)
complete = windows_done >= len(all_windows)
if complete:
now = datetime.now(timezone.utc).isoformat()
for symbol in symbols:
count = int(
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:s, 'error', 0, :t, :n)
ON CONFLICT(symbol) DO UPDATE SET
status='error', updated_at=excluded.updated_at, note=excluded.note
"""
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol=:symbol AND announce_date BETWEEN :a AND :b"
),
{
"s": sym,
"t": datetime.now(timezone.utc).isoformat(),
"n": msg[:200],
},
)
if args.sleep > 0:
await asyncio.sleep(args.sleep)
continue
reqs_used += 1
# Keep all rows with dates on/before end — SUE needs trailing history.
filtered = [
r for r in items if r["announce_date"] <= end.isoformat()
]
# Do NOT mark empty as done — leave pending for another provider/day.
status = "done" if filtered else "empty"
with engine.begin() as conn:
n_w = _upsert_events(conn, filtered, source) if filtered else 0
events_written += n_w
{"symbol": symbol, "a": start.isoformat(), "b": end.isoformat()},
).scalar_one()
)
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:s, :st, :n, :t, :note)
VALUES (:symbol, 'done', :count, :now, 'bulk_complete')
ON CONFLICT(symbol) DO UPDATE SET
status=excluded.status, n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
"""
),
{
"s": sym,
"st": status,
"n": len(filtered),
"t": datetime.now(timezone.utc).isoformat(),
"note": note,
},
{"symbol": symbol, "count": count, "now": now},
)
if reqs_used % 10 == 0 or reqs_used == 1:
print(
f" progress reqs={reqs_used}/{req_budget} last={sym} "
f"events_batch={len(filtered)} src={source}"
)
# AV free tier is ~5/min or 25/day — be polite when using it.
sleep_s = float(args.sleep)
if source.startswith("alpha_vantage"):
sleep_s = max(sleep_s, 12.0)
if sleep_s > 0:
await asyncio.sleep(sleep_s)
with engine.connect() as conn:
params = {"a": start.isoformat(), "b": end.isoformat()}
total_events = int(
conn.execute(text("SELECT COUNT(*) FROM earnings_events")).scalar_one()
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b"
),
params,
).scalar_one()
)
done_n = int(
paired_events = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b "
"AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL"
),
params,
).scalar_one()
)
date_range = conn.execute(
text(
"SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b"
),
params,
).fetchone()
done_symbols = int(
conn.execute(
text("SELECT COUNT(*) FROM earnings_backfill_meta WHERE status='done'")
).scalar_one()
)
d_range = conn.execute(
text("SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events")
totals = conn.execute(
text(
"SELECT COALESCE(SUM(requests),0), COALESCE(SUM(duplicate_rows),0), "
"COALESCE(SUM(restated_rows),0) FROM earnings_backfill_windows "
"WHERE from_date >= :a AND to_date <= :b"
),
params,
).fetchone()
with_actual = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE eps_actual IS NOT NULL AND eps_estimate IS NOT NULL"
)
).scalar_one()
)
summary = {
"mode": mode,
"fmp_requests": reqs_used,
"events_written_this_run": events_written,
"total_events": total_events,
"symbols_done": done_n,
"mode": "fmp_bulk_date_range_only",
"window": {"from": start.isoformat(), "to": end.isoformat()},
"window_days": int(args.window_days),
"bulk_windows_total": len(all_windows),
"bulk_windows_done": windows_done,
"bulk_requests_this_run": requests_this_run,
"bulk_requests_logged_total": int(totals[0]),
"rows_upserted_this_run": rows_upserted,
"duplicate_rows_this_run": duplicate_rows,
"restated_rows_this_run": restated_rows,
"duplicate_rows_logged_total": int(totals[1]),
"restated_rows_logged_total": int(totals[2]),
"dedupe_policy": (
"UNIQUE(symbol, announce_date); prefer more non-null fields, then "
"the provider's later occurrence; non-null bulk fields replace prior "
"values while null bulk fields retain existing values"
),
"events_in_window": total_events,
"events_with_actual_and_estimate": paired_events,
"symbols_done": done_symbols,
"symbols_universe": len(symbols),
"announce_date_range": {"min": d_range[0], "max": d_range[1]},
"events_with_actual_and_estimate": with_actual,
"budget": req_budget,
"complete": done_n >= len(symbols),
"announce_date_range": {"min": date_range[0], "max": date_range[1]},
"request_budget": int(args.limit),
"stop_note": stop_note,
"complete": complete,
}
output = Path("reports/earnings-backfill-status.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
out = Path("reports") / "earnings-backfill-status.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {out}")
print(f"Wrote {output}")
if __name__ == "__main__":
+60 -3
View File
@@ -97,6 +97,14 @@ def _parse_args() -> argparse.Namespace:
default=5,
help="Retries per symbol on RateLimitError.",
)
p.add_argument(
"--source-symbols-only",
action="store_true",
help=(
"Refresh only symbols present in --source. Useful for repairing "
"per-symbol depth without re-fetching the broad rank-only pool."
),
)
p.add_argument("--quiet", action="store_true")
return p.parse_args()
@@ -218,6 +226,15 @@ async def _main() -> None:
if not source.exists():
raise SystemExit(f"Source snapshot not found: {source}")
source_engine = create_engine(
f"sqlite:///{source.resolve().as_posix()}", future=True
)
with source_engine.connect() as conn:
source_symbols = {
str(row[0]) for row in conn.execute(text("SELECT symbol FROM tickers"))
}
source_engine.dispose()
# Any rebuild/update invalidates prior completion until we finish cleanly.
clear_manifest(output)
@@ -241,7 +258,12 @@ async def _main() -> None:
start = end - timedelta(days=int(args.history_days))
print("Resolving universe pool (nasdaq_all sp500)…")
pool, sources = await _resolve_pool()
if args.source_symbols_only:
pool = sorted(source_symbols)
sources = {"pool": "source_snapshot"}
print(" source snapshot: symbol pool selected")
else:
pool, sources = await _resolve_pool()
print(f"Pool size: {len(pool)} (sources={sources})")
# Sync sqlite via raw SQL — one short transaction per symbol so a failed
@@ -257,7 +279,7 @@ async def _main() -> None:
text("SELECT id, symbol FROM tickers")
).fetchall()
existing_ids = {str(sym): int(tid) for tid, sym in existing_rows}
prod_symbols = set(existing_ids)
prod_symbols = set(source_symbols)
bar_counts: dict[str, int] = {}
for sym, tid in existing_ids.items():
@@ -363,7 +385,7 @@ async def _main() -> None:
for b in bars
],
)
if is_new:
if is_new and sym not in prod_symbols:
write.execute(
text(
"INSERT OR REPLACE INTO research_rank_only "
@@ -385,6 +407,39 @@ async def _main() -> None:
f"elapsed={elapsed/60:.1f}m last={sym} bars={len(bars)}"
)
benchmark_rows = 0
try:
benchmark_bars = await _fetch_symbol_bars(
provider,
"SPY",
start,
end,
max_retries=args.max_retries,
sleep_s=args.sleep,
)
with engine.begin() as write:
write.execute(
text(
"DELETE FROM benchmark_prices WHERE symbol='SPY' "
"AND date >= :start AND date <= :end"
),
{"start": start.isoformat(), "end": end.isoformat()},
)
if benchmark_bars:
write.execute(
text(
"INSERT INTO benchmark_prices(symbol, date, close) "
"VALUES ('SPY', :date, :close)"
),
[
{"date": bar.date.isoformat(), "close": float(bar.close)}
for bar in benchmark_bars
],
)
benchmark_rows = len(benchmark_bars)
except Exception as exc:
print(f" benchmark SPY refresh FAIL {exc}")
rank_only_n = conn.execute(
text("SELECT COUNT(*) FROM research_rank_only")
).scalar_one()
@@ -409,6 +464,8 @@ async def _main() -> None:
"prod_symbols_at_start": len(prod_symbols),
"pool_size": len(pool),
"to_fetch": len(to_fetch),
"source_symbols_only": bool(args.source_symbols_only),
"benchmark_spy_rows": benchmark_rows,
},
)
+657
View File
@@ -0,0 +1,657 @@
"""Import the public post-no-preference/earnings DoltHub database.
The earnings calendar and EPS history are separate tables in the source. This
importer aligns them monotonically per symbol, keeps every calendar event for
the defensive gap study, and stores the longer EPS history separately for SUE
scaling. EPS history without an announcement date is never exposed as a live
signal event.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import sqlite3
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
EVENTS_DDL = """
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
announce_date TEXT NOT NULL,
announce_time TEXT,
eps_estimate REAL,
eps_actual REAL,
revenue_estimate REAL,
revenue_actual REAL,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL,
period_end_date TEXT,
UNIQUE(symbol, announce_date)
)
"""
META_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_meta (
symbol TEXT PRIMARY KEY,
status TEXT NOT NULL,
n_events INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT
)
"""
SURPRISE_HISTORY_DDL = """
CREATE TABLE IF NOT EXISTS earnings_surprise_history (
symbol TEXT NOT NULL,
period_end_date TEXT NOT NULL,
eps_estimate REAL,
eps_actual REAL,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL,
PRIMARY KEY(symbol, period_end_date)
)
"""
SKIP_EVENT_COST = 45.0
SKIP_PERIOD_COST = 45.0
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
parser.add_argument("--calendar-csv", required=True)
parser.add_argument("--history-csv", required=True)
parser.add_argument("--from-date", default="2020-01-22")
parser.add_argument("--to-date", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument(
"--source-url",
default="https://www.dolthub.com/repositories/post-no-preference/earnings",
)
parser.add_argument("--max-period-lag-days", type=int, default=90)
parser.add_argument("--max-period-lead-days", type=int, default=14)
parser.add_argument(
"--status-output", default="reports/earnings-backfill-status.json"
)
return parser.parse_args()
def _normalise_symbol(value: Any) -> str:
return str(value or "").strip().upper().replace(".", "-")
def _normalise_session(value: Any) -> str | None:
cleaned = str(value or "").strip().lower().replace("_", " ").replace("-", " ")
aliases = {
"before market open": "bmo",
"before open": "bmo",
"bmo": "bmo",
"after market close": "amc",
"after close": "amc",
"amc": "amc",
"during market hours": "during",
"dmh": "during",
}
return aliases.get(cleaned, cleaned or None)
def _number(value: Any) -> float | None:
if value is None or str(value).strip() == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _read_calendar(
path: Path,
universe: set[str],
start: date,
end: date,
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
by_key: dict[tuple[str, date], dict[str, Any]] = {}
raw_rows = 0
universe_rows = 0
duplicate_rows = 0
restated_rows = 0
with path.open(newline="", encoding="utf-8-sig") as handle:
for raw in csv.DictReader(handle):
raw_rows += 1
symbol = _normalise_symbol(raw.get("act_symbol"))
raw_date = str(raw.get("date") or "")[:10]
if symbol not in universe or not raw_date:
continue
event_date = date.fromisoformat(raw_date)
if not start <= event_date <= end:
continue
universe_rows += 1
row = {
"symbol": symbol,
"announce_date": event_date,
"announce_time": _normalise_session(raw.get("when")),
}
key = (symbol, event_date)
previous = by_key.get(key)
if previous is not None:
duplicate_rows += 1
if (
previous.get("announce_time") is not None
and row.get("announce_time") is not None
and previous["announce_time"] != row["announce_time"]
):
restated_rows += 1
if row.get("announce_time") is not None:
by_key[key] = row
else:
by_key[key] = row
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in by_key.values():
grouped[row["symbol"]].append(row)
for rows in grouped.values():
rows.sort(key=lambda item: item["announce_date"])
return grouped, {
"raw_rows": raw_rows,
"universe_rows_in_window": universe_rows,
"deduped_rows_in_window": len(by_key),
"duplicate_rows": duplicate_rows,
"restated_rows": restated_rows,
}
def _read_history(
path: Path, universe: set[str]
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
by_key: dict[tuple[str, date], dict[str, Any]] = {}
raw_rows = 0
universe_rows = 0
duplicate_rows = 0
restated_rows = 0
fields = ("eps_actual", "eps_estimate")
with path.open(newline="", encoding="utf-8-sig") as handle:
for raw in csv.DictReader(handle):
raw_rows += 1
symbol = _normalise_symbol(raw.get("act_symbol"))
raw_date = str(raw.get("period_end_date") or "")[:10]
if symbol not in universe or not raw_date:
continue
universe_rows += 1
period_end = date.fromisoformat(raw_date)
row = {
"symbol": symbol,
"period_end_date": period_end,
"eps_actual": _number(raw.get("reported")),
"eps_estimate": _number(raw.get("estimate")),
}
key = (symbol, period_end)
previous = by_key.get(key)
if previous is not None:
duplicate_rows += 1
if any(
previous.get(field) is not None
and row.get(field) is not None
and previous[field] != row[field]
for field in fields
):
restated_rows += 1
previous_score = sum(previous.get(field) is not None for field in fields)
row_score = sum(row.get(field) is not None for field in fields)
if row_score >= previous_score:
by_key[key] = row
else:
by_key[key] = row
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in by_key.values():
grouped[row["symbol"]].append(row)
for rows in grouped.values():
rows.sort(key=lambda item: item["period_end_date"])
return grouped, {
"raw_rows": raw_rows,
"universe_rows": universe_rows,
"deduped_rows": len(by_key),
"duplicate_rows": duplicate_rows,
"restated_rows": restated_rows,
}
def _match_cost(event: dict[str, Any], period: dict[str, Any]) -> float:
delta = (event["announce_date"] - period["period_end_date"]).days
missing_session_penalty = 3.0 if event.get("announce_time") is None else 0.0
return float(abs(delta - 30)) + missing_session_penalty
def _align_symbol(
events: list[dict[str, Any]],
periods: list[dict[str, Any]],
*,
max_lag_days: int,
max_lead_days: int,
) -> tuple[list[tuple[int, int]], list[int], list[int]]:
"""Return a minimum-cost monotonic calendar-to-period alignment."""
n_events = len(events)
n_periods = len(periods)
scores = [[0.0] * (n_periods + 1) for _ in range(n_events + 1)]
choices = [[""] * (n_periods + 1) for _ in range(n_events + 1)]
for event_index in range(n_events - 1, -1, -1):
scores[event_index][n_periods] = (
scores[event_index + 1][n_periods] + SKIP_EVENT_COST
)
choices[event_index][n_periods] = "event"
for period_index in range(n_periods - 1, -1, -1):
scores[n_events][period_index] = (
scores[n_events][period_index + 1] + SKIP_PERIOD_COST
)
choices[n_events][period_index] = "period"
for event_index in range(n_events - 1, -1, -1):
for period_index in range(n_periods - 1, -1, -1):
options = [
(
scores[event_index + 1][period_index] + SKIP_EVENT_COST,
2,
"event",
),
(
scores[event_index][period_index + 1] + SKIP_PERIOD_COST,
1,
"period",
),
]
delta = (
events[event_index]["announce_date"]
- periods[period_index]["period_end_date"]
).days
if -max_lead_days <= delta <= max_lag_days:
options.append(
(
scores[event_index + 1][period_index + 1]
+ _match_cost(events[event_index], periods[period_index]),
0,
"match",
)
)
score, _, choice = min(options)
scores[event_index][period_index] = score
choices[event_index][period_index] = choice
matches: list[tuple[int, int]] = []
unmatched_events: list[int] = []
unmatched_periods: list[int] = []
event_index = 0
period_index = 0
while event_index < n_events or period_index < n_periods:
if event_index >= n_events:
unmatched_periods.extend(range(period_index, n_periods))
break
if period_index >= n_periods:
unmatched_events.extend(range(event_index, n_events))
break
choice = choices[event_index][period_index]
if choice == "match":
matches.append((event_index, period_index))
event_index += 1
period_index += 1
elif choice == "period":
unmatched_periods.append(period_index)
period_index += 1
else:
unmatched_events.append(event_index)
event_index += 1
return matches, unmatched_events, unmatched_periods
def _ensure_schema(connection: sqlite3.Connection) -> None:
connection.execute(EVENTS_DDL)
columns = {
str(row[1])
for row in connection.execute("PRAGMA table_info(earnings_events)")
}
if "period_end_date" not in columns:
connection.execute("ALTER TABLE earnings_events ADD COLUMN period_end_date TEXT")
connection.execute(META_DDL)
connection.execute(SURPRISE_HISTORY_DDL)
def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
calendar_csv = Path(args.calendar_csv)
history_csv = Path(args.history_csv)
for path in (snapshot, calendar_csv, history_csv):
if not path.exists():
raise SystemExit(f"Missing input: {path}")
start = date.fromisoformat(args.from_date)
end = date.fromisoformat(args.to_date)
if start > end:
raise SystemExit("--from-date must not be after --to-date")
connection = sqlite3.connect(snapshot)
try:
universe = {
_normalise_symbol(row[0])
for row in connection.execute("SELECT symbol FROM tickers")
}
finally:
connection.close()
calendar, calendar_stats = _read_calendar(calendar_csv, universe, start, end)
history, history_stats = _read_history(history_csv, universe)
aligned_events: list[dict[str, Any]] = []
pairing_deltas: list[int] = []
unmatched_calendar = 0
unmatched_periods_in_pairing_window = 0
matched = 0
for symbol in sorted(universe):
events = calendar.get(symbol, [])
lower = start - timedelta(days=int(args.max_period_lag_days))
upper = end + timedelta(days=int(args.max_period_lead_days))
periods = [
row
for row in history.get(symbol, [])
if lower <= row["period_end_date"] <= upper
]
matches, unmatched_events, unmatched_periods = _align_symbol(
events,
periods,
max_lag_days=int(args.max_period_lag_days),
max_lead_days=int(args.max_period_lead_days),
)
matched_by_event = {event_index: period_index for event_index, period_index in matches}
matched += len(matches)
unmatched_calendar += len(unmatched_events)
unmatched_periods_in_pairing_window += len(unmatched_periods)
for event_index, event in enumerate(events):
row = dict(event)
period_index = matched_by_event.get(event_index)
if period_index is None:
row.update(
{
"period_end_date": None,
"eps_actual": None,
"eps_estimate": None,
}
)
else:
period = periods[period_index]
row.update(
{
"period_end_date": period["period_end_date"],
"eps_actual": period["eps_actual"],
"eps_estimate": period["eps_estimate"],
}
)
pairing_deltas.append(
(event["announce_date"] - period["period_end_date"]).days
)
aligned_events.append(row)
now = datetime.now(timezone.utc).isoformat()
source = f"dolthub_post_no_preference@{args.source_commit}"
conflicting_existing_rows = 0
conflicting_existing_fields = 0
preserved_existing_fields = 0
incoming_keys = {
(row["symbol"], row["announce_date"].isoformat()) for row in aligned_events
}
connection = sqlite3.connect(snapshot)
try:
_ensure_schema(connection)
existing = {
(str(row[0]), str(row[1])): row
for row in connection.execute(
"""
SELECT symbol, announce_date, announce_time, eps_estimate,
eps_actual, period_end_date, source
FROM earnings_events
WHERE announce_date BETWEEN ? AND ?
""",
(start.isoformat(), end.isoformat()),
)
}
upsert = """
INSERT INTO earnings_events(
symbol, announce_date, announce_time, eps_estimate, eps_actual,
revenue_estimate, revenue_actual, source, fetched_at, period_end_date
) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=COALESCE(earnings_events.announce_time, excluded.announce_time),
eps_estimate=COALESCE(earnings_events.eps_estimate, excluded.eps_estimate),
eps_actual=COALESCE(earnings_events.eps_actual, excluded.eps_actual),
period_end_date=COALESCE(excluded.period_end_date, earnings_events.period_end_date),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
for row in aligned_events:
key = (row["symbol"], row["announce_date"].isoformat())
old = existing.get(key)
retained = 0
conflicts = 0
if old is not None:
old_values = {
"announce_time": old[2],
"eps_estimate": old[3],
"eps_actual": old[4],
"period_end_date": old[5],
}
new_values = {
"announce_time": row.get("announce_time"),
"eps_estimate": row.get("eps_estimate"),
"eps_actual": row.get("eps_actual"),
"period_end_date": (
row["period_end_date"].isoformat()
if row.get("period_end_date")
else None
),
}
for field, new_value in new_values.items():
old_value = old_values[field]
if field != "period_end_date" and old_value is not None:
retained += 1
if new_value is not None and old_value is not None:
if field in {"eps_estimate", "eps_actual"}:
differs = not math.isclose(
float(new_value), float(old_value), rel_tol=0.0, abs_tol=1e-9
)
else:
differs = str(new_value) != str(old_value)
conflicts += int(differs)
conflicting_existing_rows += int(conflicts > 0)
conflicting_existing_fields += conflicts
preserved_existing_fields += retained
row_source = source
if retained and old is not None:
row_source = f"{old[6]}+calendar:{source}"
connection.execute(
upsert,
(
row["symbol"],
row["announce_date"].isoformat(),
row.get("announce_time"),
row.get("eps_estimate"),
row.get("eps_actual"),
row_source,
now,
(
row["period_end_date"].isoformat()
if row.get("period_end_date")
else None
),
),
)
history_upsert = """
INSERT INTO earnings_surprise_history(
symbol, period_end_date, eps_estimate, eps_actual, source, fetched_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, period_end_date) DO UPDATE SET
eps_estimate=COALESCE(excluded.eps_estimate, earnings_surprise_history.eps_estimate),
eps_actual=COALESCE(excluded.eps_actual, earnings_surprise_history.eps_actual),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
for symbol, rows in history.items():
connection.executemany(
history_upsert,
[
(
symbol,
row["period_end_date"].isoformat(),
row.get("eps_estimate"),
row.get("eps_actual"),
source,
now,
)
for row in rows
],
)
for symbol in sorted(universe):
count = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE symbol=? AND announce_date BETWEEN ? AND ?
""",
(symbol, start.isoformat(), end.isoformat()),
).fetchone()[0]
)
connection.execute(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (?, 'done', ?, ?, 'dolthub_bulk_complete')
ON CONFLICT(symbol) DO UPDATE SET
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
""",
(symbol, count, now),
)
connection.commit()
params = (start.isoformat(), end.isoformat())
total_events = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE symbol IN (SELECT symbol FROM tickers)
AND announce_date BETWEEN ? AND ?
""",
params,
).fetchone()[0]
)
paired_events = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE symbol IN (SELECT symbol FROM tickers)
AND announce_date BETWEEN ? AND ?
AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL
""",
params,
).fetchone()[0]
)
date_range = connection.execute(
"""
SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events
WHERE symbol IN (SELECT symbol FROM tickers)
AND announce_date BETWEEN ? AND ?
""",
params,
).fetchone()
source_symbols = set(calendar)
history_complete = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_surprise_history
WHERE symbol IN (SELECT symbol FROM tickers)
AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL
"""
).fetchone()[0]
)
finally:
connection.close()
deltas = sorted(pairing_deltas)
summary = {
"mode": "dolthub_public_bulk_clone",
"window": {"from": start.isoformat(), "to": end.isoformat()},
"coverage_amendment": {
"approved_by_user": True,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": start.isoformat(),
},
"source": {
"repository": args.source_url,
"commit": args.source_commit,
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": False,
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": calendar_stats,
"eps_history": {**history_stats, "complete_actual_and_estimate": history_complete},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-int(args.max_period_lead_days),
int(args.max_period_lag_days),
],
"matched_calendar_events": matched,
"unmatched_calendar_events": unmatched_calendar,
"unmatched_periods_in_pairing_window": unmatched_periods_in_pairing_window,
"announce_minus_period_end_days": {
"min": min(deltas) if deltas else None,
"median": deltas[len(deltas) // 2] if deltas else None,
"max": max(deltas) if deltas else None,
},
"pre_2020_eps_history_use": (
"trailing_surprise_stdev_only; never treated as an announcement "
"or live signal event"
),
},
"duplicate_rows_logged_total": (
calendar_stats["duplicate_rows"] + history_stats["duplicate_rows"]
),
"restated_rows_logged_total": (
calendar_stats["restated_rows"]
+ history_stats["restated_rows"]
+ conflicting_existing_rows
),
"conflicting_existing_rows": conflicting_existing_rows,
"conflicting_existing_fields": conflicting_existing_fields,
"preserved_existing_fields": preserved_existing_fields,
"existing_enrichment_events_not_in_dolthub_calendar": max(
0, total_events - len(incoming_keys)
),
"dedupe_policy": (
"UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one "
"calendar row per key; preserve existing non-null session/EPS values from "
"the prior FMP/Alpha Vantage partial backfill, then fill nulls and all "
"remaining symbols from DoltHub; attach DoltHub period-end alignment"
),
"events_in_window": total_events,
"events_with_actual_and_estimate": paired_events,
"symbols_done": len(universe),
"symbols_universe": len(universe),
"symbols_with_dolthub_calendar": len(source_symbols),
"symbols_without_dolthub_calendar": sorted(universe - source_symbols),
"announce_date_range": {"min": date_range[0], "max": date_range[1]},
"complete": True,
}
output = Path(args.status_output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
print(f"Wrote {output}")
if __name__ == "__main__":
_main()
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -120,12 +120,14 @@ case "$PHASE" in
ssl) ssl_check ;;
earnings)
need_file "$PROD_SNAP"
log "Earnings backfill + research (parked experiment)"
need_file "$RESEARCH_SNAP"
log "Earnings Task 2 bulk backfill + registered 2a/2b closeout"
"$PYTHON" scripts/backfill_earnings_events.py \
--snapshot "$PROD_SNAP" --provider fmp --force-symbol \
--snapshot "$PROD_SNAP" --from-date 2016-01-04 --window-days 30 \
--limit "$FMP_LIMIT" --sleep "$FMP_SLEEP"
"$PYTHON" scripts/run_earnings_research.py \
--snapshot "$PROD_SNAP" --workers "$WORKERS" --allow-spawn
--snapshot "$RESEARCH_SNAP" --universe-snapshot "$PROD_SNAP" \
--earnings-snapshot "$PROD_SNAP" --workers "$WORKERS" --allow-spawn
;;
prod_book)
need_file "$RESEARCH_SNAP"
+225
View File
@@ -0,0 +1,225 @@
from datetime import date, timedelta
from scripts.backfill_earnings_events import _dedupe_bulk_rows, _windows
from scripts.import_dolthub_earnings import _align_symbol
from scripts.run_earnings_research import (
_analyse_2a_trades,
_build_sue_series,
_mechanical_sue_grade,
)
def test_dolthub_alignment_is_monotonic_across_close_calendar_events() -> None:
events = [
{"announce_date": date(2020, 3, 17), "announce_time": "bmo"},
{"announce_date": date(2020, 4, 30), "announce_time": "bmo"},
]
periods = [
{"period_end_date": date(2019, 12, 31)},
{"period_end_date": date(2020, 3, 31)},
]
matches, unmatched_events, unmatched_periods = _align_symbol(
events, periods, max_lag_days=90, max_lead_days=14
)
assert matches == [(0, 0), (1, 1)]
assert unmatched_events == []
assert unmatched_periods == []
def test_dolthub_alignment_allows_fiscal_period_label_after_announcement() -> None:
events = [
{"announce_date": date(2023, 2, 28), "announce_time": "bmo"},
{"announce_date": date(2023, 5, 23), "announce_time": "bmo"},
]
periods = [
{"period_end_date": date(2023, 2, 28)},
{"period_end_date": date(2023, 5, 31)},
]
matches, _, _ = _align_symbol(
events, periods, max_lag_days=90, max_lead_days=14
)
assert matches == [(0, 0), (1, 1)]
def test_bulk_windows_cover_range_without_overlap() -> None:
result = _windows(date(2020, 1, 1), date(2020, 1, 10), 4)
assert result == [
(date(2020, 1, 1), date(2020, 1, 4)),
(date(2020, 1, 5), date(2020, 1, 8)),
(date(2020, 1, 9), date(2020, 1, 10)),
]
def test_bulk_dedupe_prefers_more_complete_and_counts_restatement() -> None:
rows = [
{
"symbol": "AAPL",
"announce_date": "2024-01-01",
"announce_time": None,
"eps_estimate": 1.0,
"eps_actual": 1.1,
"revenue_estimate": None,
"revenue_actual": None,
},
{
"symbol": "AAPL",
"announce_date": "2024-01-01",
"announce_time": "amc",
"eps_estimate": 1.0,
"eps_actual": 1.2,
"revenue_estimate": 10.0,
"revenue_actual": 11.0,
},
]
deduped, duplicates, restated = _dedupe_bulk_rows(rows)
assert duplicates == 1
assert restated == 1
assert deduped == [rows[1]]
def test_2a_uses_net_r_strict_hold_and_next_session_stop() -> None:
calendar = [
date(2024, 1, 2),
date(2024, 1, 3),
date(2024, 1, 4),
date(2024, 1, 5),
date(2024, 1, 8),
date(2024, 1, 9),
]
events = [
{
"symbol": "AAPL",
"announce_date": date(2024, 1, 5),
}
]
trades = [
{
"symbol": "AAPL",
"entry_date": "2024-01-03",
"exit_date": "2024-01-08",
"entry": 100.0,
"initial_stop": 90.0,
"fill": 90.0,
"r": -1.0,
"reason": "stop",
},
{
"symbol": "MSFT",
"entry_date": "2024-01-02",
"exit_date": "2024-01-09",
"entry": 100.0,
"initial_stop": 90.0,
"fill": 110.0,
"r": 1.0,
"reason": "time",
},
]
result = _analyse_2a_trades(
trades, events, calendar, cost_per_side=0.001
)
assert result["q1_loss_concentration"]["losses_count"] == 1
assert result["q1_loss_concentration"]["losses_with_announcement_count"] == 1
assert (
result["q2_entries_within_3_trading_days_before_announcement"][
"pre_earnings"
]["count"]
== 1
)
assert (
result["q3_stop_exits_within_1_trading_day_after_announcement"][
"stops_after_earnings"
]["count"]
== 1
)
assert result["q1_loss_concentration"]["loss_definition"] == (
"realized_net_R <= -1.0"
)
def test_sue_needs_four_prior_surprises_and_starts_next_trading_day() -> None:
dates = [date(2024, 1, 1) + timedelta(days=index) for index in range(100)]
columns = (
[value.toordinal() for value in dates],
[100.0] * len(dates),
[101.0] * len(dates),
[99.0] * len(dates),
[100.0] * len(dates),
[1_000_000] * len(dates),
)
event_dates = [date(2024, 1, 2) + timedelta(days=10 * index) for index in range(5)]
surprises = [0.1, -0.2, 0.3, -0.1, 0.4]
events = {
"AAPL": [
{
"announce_date": event_date,
"eps_actual": 1.0 + surprise,
"eps_estimate": 1.0,
}
for event_date, surprise in zip(event_dates, surprises)
]
}
series, counts = _build_sue_series(
events, {"AAPL": columns}, use_price_fallback=False
)
first_live = event_dates[-1] + timedelta(days=1)
assert first_live in series["AAPL"]
assert event_dates[-1] not in series["AAPL"]
assert counts["standard_scaled_events"] == 1
assert counts["price_fallback_events"] == 0
def test_sue_uses_period_history_only_for_scaling() -> None:
dates = [date(2020, 1, 1) + timedelta(days=index) for index in range(100)]
columns = (
[value.toordinal() for value in dates],
[100.0] * len(dates),
[101.0] * len(dates),
[99.0] * len(dates),
[100.0] * len(dates),
[1_000_000] * len(dates),
)
event_date = date(2020, 2, 3)
events = {
"AAPL": [
{
"announce_date": event_date,
"period_end_date": date(2019, 12, 31),
"eps_actual": 1.4,
"eps_estimate": 1.0,
}
]
}
history = {
"AAPL": [
{
"period_end_date": date(2018, 12, 31)
+ timedelta(days=90 * index),
"eps_actual": 1.0 + surprise,
"eps_estimate": 1.0,
}
for index, surprise in enumerate([0.1, -0.2, 0.3, -0.1])
]
}
series, counts = _build_sue_series(
events,
{"AAPL": columns},
use_price_fallback=False,
surprise_history_by_symbol=history,
)
assert event_date + timedelta(days=1) in series["AAPL"]
assert event_date not in series["AAPL"]
assert counts["events_scaled_from_period_history"] == 1
def test_sue_grade_requires_positive_both_eras() -> None:
full = {"mean_ic": 0.03, "reliable": True}
passed, stable = _mechanical_sue_grade(
full, {"mean_ic": 0.01}, {"mean_ic": 0.02}
)
assert passed is True
assert stable is True
failed, stable = _mechanical_sue_grade(
full, {"mean_ic": -0.01}, {"mean_ic": 0.02}
)
assert failed is False
assert stable is False