# How fast +EV flags die: results (study #1)

**Finding.** The median moneyline +EV flag our model raised at FanDuel and DraftKings lasted
between **15 minutes and about an hour**. Our polling is too coarse to say which side of an hour
the median falls on, so the primary hypothesis is **inconclusive**. Flags raised within two hours
of kickoff looked shorter-lived than flags raised more than a day out, but only 31 near-kickoff
flags remain, the difference passed on only one of the two bounds, and polling cadence explains
much of it, so the second hypothesis is **inconclusive** too.

**Correction before publication: moneyline only.** We found a bug in our own odds scanner
while preparing these results. It priced spreads and totals against consensus books quoting a
different line, so most spread and total "flags" were artifacts of the line gap. Those markets
are dropped. Note 11 has the bug, how it was found, what it did to the data, and why the fix is
to drop the markets rather than repair them. An earlier version of these results, on all three
markets (3,287 flags across 764 games, with H2 supported), was never published.

- Data: 728 moneyline flags across 454 games starting 2026-10-15 to 2026-11-07 (odds polled
  from 2026-10-08), DraftKings and FanDuel, seven sports.
- Pre-registered in `PREREGISTRATION.md`, sha256
  `5c12ce4efd66aae2bd3bcda4870bd41766d5416205900959e012f31b764c3224`, commit `5070bbf0`. The
  pre-registration covers all three markets; restricting to moneyline is a deviation (note 11).
- **The timestamp is private.** The pre-registration file is published with these results, but
  the commit that dates it is in a private repository, so a reader has to take its date on
  trust. Git shows the pre-registration (22:23:47 on 2026-11-08) committed before the flag
  definitions (22:26:10) and before the results (22:31:21). It does not show the order of the
  first run: `run.py` was first committed with the results (note 9).
- A "flag" is the model's output, not a recommendation to bet. Nothing here says a flag was
  worth betting.

## The two hypotheses

| | rule (pre-registered) | result | verdict |
|---|---|---|---|
| **H1** "Most flags are gone within an hour" | Supported if the upper-bound median's 95% CI upper limit is under 60 min; refuted if the lower-bound median's 95% CI lower limit is over 60 min | Upper-bound median **about 60 min** [60, 90]; lower-bound median **15 min** [10, 25] | **Inconclusive** |
| **H2** "Flags near kickoff die faster than flags a day out" | Far minus near median, CI above 0 on **both** bounds | Lower bound **+5 min** [−10, +20]; upper bound **about +75 min** [+45, +105] | **Inconclusive** |

Each flag's true lifetime lies in an interval:

- its *lower* end is how long the flag was seen alive;
- its *upper* end runs from the poll *before* the flag to the poll where it was gone.

CIs come from a bootstrap that resamples whole games (2,000 resamples, seed 20261108). Figures
are rounded to poll resolution. Exact values are in `output/summary.json`.

**Why H1 can't be decided.** Flags tend to appear after a longer-than-usual gap between polls.
The median gap before a flag was 29.8 minutes, and before the poll where it was gone 25.0. For
comparison, the median gap between all consecutive polls was 5–10 minutes. So a typical died
flag's lifetime is known only to within a window about an hour wide (median width 55.0 min).
The data puts the median between 15 and about 60 minutes, and an hour sits at the edge of that
range: the upper-bound median is 60.5 minutes, with a CI of [60.1, 89.3]. Finer polling would
be needed to settle it; an interval-censored estimator might narrow it somewhat.

**H2 in plain numbers.**
- *Near:* only 31 flags were raised ≤2h before kickoff. Half were gone within **15 minutes**
  on the upper bound (5 on the lower), and 84–90% within 30.
- *Far:* for flags raised >24h out (n=423), the lower-bound median is **10 minutes** and the
  upper-bound median 90.
- *Why it fails the rule:* the lower-bound difference is +5 minutes with a CI from −10 to +20,
  so it doesn't exclude zero. The upper-bound difference clears zero, but it mostly reflects
  polling cadence (note 10).
- *Who the near flags are:* the first flag a side ever received. A ≤2h flag is therefore a side
  that was never flagged earlier in the game's polling, a selected group.

## Survival by time since the flag (Kaplan–Meier)

Each cell is the share of flags still flagged, written as lower-bound estimate – upper-bound
estimate:

| | n | 15 min | 30 min | 60 min | 2 h | 4 h | 12 h |
|---|---:|---|---|---|---|---|---|
| All flags (moneyline) | 728 | 50–90% | 43–77% | 33–59% | 26–37% | 18–22% | 11% |
| ≤2h before kickoff | 31 | 31–53% | 10–16% | 0–4% | 0% | 0% | 0% |
| 2–24h before | 274 | 53–89% | 45–75% | 34–58% | 29–34% | 21–23% | 10–11% |
| >24h before | 423 | 49–94% | 43–82% | 34–64% | 25–41% | 17–22% | 11–12% |
| EV 0–3% at flag | 419 | 48–89% | 40–75% | 29–59% | 21–35% | 13–17% | 7% |
| EV ≥3% at flag | 309 | 52–92% | 46–80% | 38–60% | 32–39% | 25–28% | 16–17% |

- Full tables, including each sport and the secondary definition, are in
  `output/km_by_stratum.csv`. Under the secondary definition a flag dies the moment the book's
  price or point changes, and the median is 5–60 minutes.
- The ≤2h row rests on 31 flags; read it as a sketch.
- The EV-band rows are descriptive only. There was no hypothesis about EV size, and the ≥3%
  group lasting longer shouldn't be read as evidence of anything.

## What moved when a flag died

Of the 678 flags that died (50 were censored):

| | consensus moved, book didn't | book moved, consensus didn't | both | neither |
|---|---:|---:|---:|---:|
| Moneyline | 59% | 15% | 24% | 2% |

When a flag dies, the consensus price has usually moved, and in most deaths the sportsbook's
price held. The book's price moved in about 4 of every 10 deaths (alone or with the
consensus). A post-hoc reference point (`output/posthoc.json`) puts that in context: on polls
where a flag survived, the consensus moved 6.7% of the time and the book 2.7%. That reference
is conditioned on the flag surviving, so it is context, not a test.

This table records *whether* each side moved, not which way. Part 4 of the 2026-11-03 audit
measured the direction for next-poll moneyline deaths and found the consensus moving toward the
sportsbook (−3.9pp on average).

## Method

- **Flag:** the first poll at which a side was the model's best bet with EV above 0, where that
  side wasn't flagged at the previous poll.
- **Markets:** moneyline only (note 11).
- **Excluded:** 342 flags that appeared on a series' first poll, because the flag may have
  predated polling.
- **Censored:** 41 flags were still alive at the last poll before kickoff (5.6%), and 9 hit a
  gap of more than 120 minutes. Censored flags are kept, not dropped.
- **No NULL rows:** the window has no NULL EV values, so the null rules never applied
  (`null_ev`: 0).
- **No filters on consensus quality.** Breadth at flag time:

  | books in consensus | flags |
  |---:|---:|
  | 1 | 136 |
  | 2 | 124 |
  | 3 | 262 |
  | 4 | 206 |

  `line_is_outlier` was false for 2 flags and not recorded for 726; the scanner doesn't set it
  for moneyline.
- **Code:** `edges_die/`, with tests in `tests/`, run by `run.py`. Post-hoc checks are in
  `output/posthoc.json`, kept separate from the registered outputs.

## Limitations

- **One month, two books, one market, the October–November sport mix.** Per-sport curves are in
  the CSV.
- **Poll resolution.** No statement here is finer than "by the next poll".
- **Small near-kickoff group.** 31 flags, 27 with a next poll. H2 has little power.
- **Not out-of-sample on next-poll persistence.** Part 4 had already measured it on
  overlapping games. Of the flags that had a next poll, 56.3% were still alive at it and 43.7%
  were gone, against about 60% alive in Part 4, which covered all three markets. The full curve
  and H2 are new.
- **Bounds, not an estimate.** Kaplan–Meier is run on each bound separately, as
  pre-registered, which brackets the answer. An interval-censored estimator (Turnbull) would
  estimate the distribution itself.
- **Kickoff times move.** 245 of the 454 study games carry more than one `commence_time`
  (usually a few minutes apart). Rows are filtered against their own `commence_time`, as
  pre-registered, so a few flags would change time-to-kickoff band under the final kickoff time.
- **The consensus is our own blend.** "Consensus moved" means our reference price moved, not
  that the market as a whole did.

## Deviations and interpretation notes (logged as the pre-registration requires)

1. **Interpretation.** If a side's first alive poll fails the flag rule (it's the series' first
   poll, or the poll before it has a null EV), that side gets no flag. It doesn't fall back to a
   later re-entry. That is the reading of "re-entries are not counted".
2. **Parsing bug fixed between runs.** The first run read `line_is_outlier` (`t`/`f` in the
   export) as text and reported a 70% outlier share. The field is descriptive only.
3. **Output added between runs.** The near and far medians behind H2's difference are now
   written out. No rule changed.
4. **H2's guard against polling cadence was incomplete.**
   - What the pre-registration claimed: requiring both bounds rules out an artefact of
     near-kickoff polls being denser.
   - What it misses: dense polling also catches brief drop-outs that 30-minute polling would
     miss, and under a poll-based definition a brief drop-out ends the flag.
   - **Post-hoc check, not pre-registered:** read each flag's state at the same checkpoints, as
     if every series were thinned to one grid.
     - At the first poll 25–40 minutes after the flag, the side was flagged for 12% of near
       flags (n=17) and 40% of far flags (n=285).
     - At both that checkpoint and the first poll 55–70 minutes after, it was flagged for 0% of
       near flags (n=11) and 29% of far flags (n=277).
   - Drop-outs between checkpoints are invisible to both groups alike, so this check removes the
     detection asymmetry. The direction matches H2, but 17 and 11 near flags are too few to
     rely on.
   - An elapsed-time check that keeps every poll (continuous survival: 12% vs 38%, and 0% vs
     38%) agrees. It isn't cadence-neutral, so it is reported only as a cross-check.
5. **Sanity count against Part 4.** On Part 4's window and rules, this archive gives 763
   moneyline flags on 432 games. Part 4 reported 3,115 flags on 609 games across all three
   markets, so the two counts no longer measure the same thing (on all three markets this
   archive gave 3,162 on 605). Reported, not reconciled.
6. **A pre-registered premise was false.** The 7-day buffer was meant to exclude games whose
   polling began before the archive did.
   - It doesn't do that. Polling starts a median 31.5 hours before kickoff, but up to 316 hours
     (p99 287), past the 168-hour buffer.
   - As a result, 9 games in the window (17 flags, 2.3%) were first polled in the archive's
     first hour, so some of their "first" flags may be re-entries.
   - *Sensitivity (post-hoc):* dropping those games leaves the H1 upper-bound median at about
     60 minutes [60, 85], so H1 stays inconclusive. H2 stays inconclusive: +5 [−10, +20] on the
     lower bound, about +75 [+40, +105] on the upper.
7. **The gap before a flag is uncapped.** The pre-registration caps only the gap *after* the
   last alive poll. One flag's previous poll was 95 hours earlier.
   - *Sensitivity (post-hoc):* dropping the 18 flags whose previous poll was over 120 minutes
     earlier leaves the H1 upper-bound median at 60 minutes [60, 80] and moves the lower-bound
     median to 10. H2's differences become +5 [−10, +20] and about +65 [+40, +105]. Both
     verdicts are unchanged.
   - The upper-bound median sits on a cluster of values near 60 that comes from the polling
     interval. That is why this write-up says "about an hour".
8. **Rounding fixed in `died_by`.** Consensus probabilities are stored to 4 decimals, and binary
   floating point lost one exact 0.1pp move. The difference is now rounded before comparing,
   which moved one moneyline flag from `book` to `both`. No share changed at the precision
   shown.
9. **Order of commits.** The analysis runner (`run.py`) was first committed together with the
   results, not before the run, and the first run's outputs weren't kept. The definitions
   (`edges_die/`, with tests) were committed before any run.
10. **Both H2 differences mostly measure polling cadence (found in review, post-hoc).**
    - Near kickoff, the lower-bound median (5.5 minutes) is the polling interval: the median
      gap before a near flag was 5.0 minutes, and for flags that died at the next poll the gap
      to it was 5.1, matching the overall 5.0 in `poll_gaps.csv`.
    - A day out, the gaps around flags are wider: 30.0 minutes before a flag and 30.0 to a
      next-poll death, three times the overall 10.1, because flags tend to appear after longer
      gaps. That is what stretches the far upper-bound median to 90.
    - Counted in polls, near flags did no worse: 59.3% of near flags with a next poll (n=27)
      and 53.3% of far flags (n=422) were alive at it.
    - So the upper-bound difference that clears zero is largely cadence, the failure deviation
      4 describes. The clock-time evidence is the same-checkpoint check in deviation 4, which
      points the same way on 17 and 11 near flags. Figures: `output/posthoc_cadence.json`,
      written by `posthoc_cadence.py`.
11. **Spreads and totals dropped: a bug in our scanner (found 2026-11-09, before
    publication).**
    - *The bug.* For spreads and totals, the scanner (`_calculate_spread_total_consensus` in
      our odds poller) averaged each consensus book's no-vig probability at *that book's own
      line*, then applied the blend to the sportsbook's price at a *different* line. Example:
      FanDuel 49ers −4.5 at +100, priced with the consensus books' coin flip at −4, read as +EV
      when it is about −3%. The bug was there from the scanner's first commit (2025-12-06).
      Moneyline has no line and is unaffected.
    - *How it was found.* A screenshot showed a FanDuel spread flagged +EV at a line the
      consensus wasn't on. We then measured the archive at each flag's first poll.
    - *What it did to this study.* 85% of spread flags (1,157 of 1,357) and 94% of total flags
      (1,132 of 1,202) were priced at a line the consensus wasn't on, and 97% and 95% of those
      were at a line worse for the bettor. Flags were enriched for the mismatch: across all
      polls, 36% of spread and 25% of total rows were at the consensus line, against 15% and 6%
      of flags. So most spread and total flags were the line gap, not a price.
    - *Graded outcomes agree.* In our graded-outcomes table (joinable to the archive from
      2026-10-08), mismatched-line spread bets claimed +11.5% EV on average and returned −9.6%
      ROI (n=1,198).
    - *Why moneyline only, not exact-line spreads and totals.* The flags at the consensus line
      (200 spreads, 70 totals) are too few to analyse, and per-book lines were never stored,
      only the blended consensus line, so even those can't be checked book by book.
    - *The re-run.* Same code and archive; `edges_die.restrict_markets` drops every
      non-moneyline row after the archive's integrity check. Series are keyed by game, book and
      market, so whole series drop and no other rule changes. Before the fix: 3,287 flags on
      764 games, H1 inconclusive, H2 supported. After: 728 flags on 454 games, both
      inconclusive.
    - The fix to the scanner was written but not deployed, because the service has closed. A
      clean spread and total study is not possible from this archive.

## Outputs

`output/summary.json`, `output/km_by_stratum.csv`, `output/died_by.csv`, `output/poll_gaps.csv`
(pre-registered), and `output/posthoc.json` and `output/posthoc_cadence.json` (not
pre-registered; the second is written by `posthoc_cadence.py` for note 10). All are
aggregates, with no game ids, teams, prices or links. Times are in minutes; survival values
and shares are fractions from 0 to 1. On plusevbets.com they are published under the same
names, in /data/how-fast-edges-die/.

Reproduce (moneyline only, as published):

```
uv run --no-project --with pandas --with numpy python research/edges-die/run.py \
  --archive ~/data/plusevbets-archive/ev_snapshots_through_2026-11-07.csv.gz \
  --out research/edges-die/output
uv run --no-project --with pandas --with numpy python research/edges-die/posthoc_cadence.py \
  --archive ~/data/plusevbets-archive/ev_snapshots_through_2026-11-07.csv.gz \
  --out research/edges-die/output
```
