Skip to content

mmsell: entry-timing study, on the right clock per settle mode - #167

Merged
50thycal merged 3 commits into
claude/confident-goldberg-83u3qfrom
claude/position-entry-timing-j0wn1g
Aug 6, 2026
Merged

mmsell: entry-timing study, on the right clock per settle mode#167
50thycal merged 3 commits into
claude/confident-goldberg-83u3qfrom
claude/position-entry-timing-j0wn1g

Conversation

@50thycal

@50thycal 50thycal commented Aug 5, 2026

Copy link
Copy Markdown
Owner

What it answers

Does WHEN we enter matter? Measured on our own book: 10,337 in-play trades across 3,783 distinct markets, vs the 1,137 h2h entries the original U-shape backtest was built on.

The finding that shapes the whole design

hours_to_close — the obvious timing variable — is valid for scheduled/discrete markets and a fiction for in-play ones. Kalshi sets a sports market's close_time to a far-future fallback, not the end of the contest:

mode avg htc at entry avg actual time to resolution gap
in_play 145.9h 2.0h 143.9h
scheduled 44.8h 46.8h −2.0h
discrete 63.4h 55.9h 7.5h

Per series it's worse — KXUFCFIGHT reads 335h-to-close on a fight that resolves in 0.4h, pinned at our own htcmax=336 cap. Bucketing in-play trades by hours_to_close files every one under "24–72h" and measures nothing while looking perfectly healthy. So the study uses a different clock per mode and prints the validation table first on every run, as a guardrail against anyone switching it back.

Two consequences beyond the bucketing:

  • The global mmsell_min_hours_to_close = 1.0 floor never binds on sports at all — the books already enter deep in-play (0.4–1.5h of contest left) with no rule saying so.
  • A live in-play timing gate is impossible until the worker persists a forward-looking field (expected_expiration_time).

Result — the endgame half of the U-shape replicates

window n mkts ¢/trade edge
<0.25h 588 223 +8.85 +11.4
0.25–0.5h 1318 588 +6.72 +7.3
0.5–1h 2079 1010 +2.28 +2.4
1–2h 3488 1565 −1.55 −1.6
2–4h 2317 916 +1.77 +1.9
4–12h 283 121 −4.28 −4.5

It was the half predicted most likely to be a mirage, and the largest single cell (1–2h, n=3,488) is the one losing money.

h2h is the sharpest read: +8.97¢ in the final 15 minutes against −13.34¢ beyond 12 hours. docs/MMSELL_MARKET_TYPES.md scored h2h at +0.63¢ pooled and called it the weakest large in-play type — that number was averaging those two ends. h2h is not a bad type, it's a badly-timed one.

total and spread behave the same way. player_prop and outright do not (they peak at 0.5–1h and 2–4h/12h+), which is why the per-type cut exists — the pooled shape does not generalize.

Caveats, documented and gated on

  1. Long-game confound (the serious one). Hold time is entry→resolution, so a game that runs long lands in a later bucket — and games run long when they're close, which is exactly where a cheap tail is live. Some share of the gradient is "blowouts end on schedule" rather than "late entry is safe". This is the main reason a forward-looking field is needed before promoting anything.
  2. Detection lag — the in-play clock is measured at settlement detection, so the bias bites hardest in the shortest cell.
  3. Fill-everything paper. The endgame is a thin, fast book: precisely where a resting maker is picked off. Both prior timing signals died exactly there (mmsell7 htcmax=24 was its cohort's worst; mmsell11 htcmin=6 went +2.38¢ paper → −0.86¢ realizable). Nothing here is promotable until it clears mmsell fill model.

Pre-registered gates for a .timeX book are in docs/MMSELL_TIMING_STUDY.md: edge ≥ +3.0pp above the adjacent window at n ≥ 300 and ≥ 150 distinct markets, holding within a single type, plus positive realizable ¢/trade.

Notes

Taxonomy and per-cell statistics are imported from scripts/mmsell_market_types.py rather than re-declared, so a type cannot mean one thing in the census and another here. Read-only, self-contained; the only non-script edit is the ALLOWED_SCRIPTS entry.

Testing

tests/test_mmsell_timing_study.py — 7 tests pinning the per-mode clock mapping (the finding itself), lower-bound-inclusive bucketing, grid well-formedness, that a missing or impossible clock value drops out as coverage loss rather than flooring into the shortest bucket (where it would manufacture exactly the late-entry edge under test), and that the taxonomy is imported rather than copied. Full suite green. Script has been run end-to-end against the live DB via the ops channel.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ECFY4eoPk2g1JUanmFqkee


Generated by Claude Code

claude added 3 commits August 5, 2026 14:40
Answers "does WHEN we enter matter" on our own book — 10,337 in-play trades
across 3,783 distinct markets, vs the 1,137 h2h entries the original U-shape
backtest was built on.

THE FINDING THAT SHAPES THE DESIGN: hours_to_close is a valid clock for
scheduled/discrete markets and a fiction for in-play ones. Kalshi sets a sports
market's close_time to a far-future fallback, so KXUFCFIGHT reads 335h-to-close
on a fight resolving in 0.4h — pinned at our own htcmax=336 cap. Measured across
the book, in_play averages 145.9h reported vs 2.0h actual; scheduled and
discrete are accurate to within 2 and 7.5 hours respectively. Bucketing in-play
trades by hours_to_close files every one under "24-72h" and measures nothing
while looking perfectly healthy, so the study scores in_play on realized
time-to-resolution and prints the clock validation first on every run as a
guardrail against switching it back.

Two consequences beyond the bucketing: the global htcmin=1.0 floor never binds
on sports at all (so the books already enter deep in-play with no rule saying
so), and a LIVE in-play timing gate is impossible until the worker persists a
forward-looking field.

Result: the endgame half of the U-shape replicates strongly — <0.25h +8.85c
(n=588), 0.25-0.5h +6.72c (n=1318), decaying to -1.55c in the 1-2h cell, which
is also the largest. It was the half predicted most likely to be a mirage.

h2h is the sharpest read: +8.97c in the final 15 minutes against -13.34c beyond
12 hours. The market-type census scored h2h at +0.63c pooled and called it the
weakest large in-play type; that number was averaging those two ends. h2h is not
a bad type, it is a badly-timed one. total and spread behave the same way;
player_prop and outright do NOT (they peak at 0.5-1h and 2-4h/12h+), so the
per-type cut is what keeps the pooled shape from being over-generalized.

Three caveats documented and gated on, the first being the serious one: hold
time is entry->resolution, so a game that runs long lands in a later bucket, and
games run long when they are close — some share of the gradient is "blowouts end
on schedule" rather than "late entry is safe". Plus settlement-detection lag,
and the standing fill-everything caveat (the endgame is exactly where a resting
maker is picked off, and both prior timing signals died there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECFY4eoPk2g1JUanmFqkee
…ing capture

TIMING VERDICT: the entry-timing edge does not survive the maker-fill haircut.
The study now projects each window's own entry-price mix through the live
calibration and prints a REALIZABLE column beside the paper one:

  window       paper    realizable   cov
  <0.25h      +8.85c       +0.50c    63%
  0.25-0.5h   +6.72c       +0.46c    50%
  1-2h        -1.55c       +0.55c    48%
  2-4h        +1.77c       -0.23c    55%
  4-12h       -4.28c       -0.95c    43%

A 13.1c paper spread collapses to 1.45c realizable, and the ordering inverts
where it matters: the 1-2h window that looked worst on paper is the best
realizable cell, while the <0.25h endgame that looked best is indistinguishable
from it. The endgame edge is composed almost entirely of fills a resting maker
never gets — the outcome predicted before the test, and the third timing signal
to die at this step after mmsell7 (htcmax=24) and mmsell11 (htcmin=6). Scheduled
tells the same story: its 72h+ cell, the largest paper edge in the study at
+10.50c, reads -0.87c realizable. No .timeX book should be built on this.

CAPTURE (forward-only, cannot be backfilled — Kalshi drops settled markets after
~70 days), on the row the entry scan already writes:

  * hours_to_expiration — the only FORWARD-LOOKING resolution clock. hours_to_close
    comes from close_time, which on sports is a far-future fallback (KXUFCFIGHT
    reports 335h on a fight resolving in 0.4h), so it cannot express "enter in the
    final 30 minutes". The study can score history on realized hold; a live gate
    cannot. Recorded, never gated on.
  * strike_type / floor_strike / cap_strike / yes_sub_title — the contract's LINE.
    The taxonomy says a ticker is a spread or a total; these say WHICH ONE. Probed
    via ticker regex, MLB totals run a 9.7% loss rate at the 10-11 run line and
    22-26% at 15-16 — a wider spread than most type-level differences. The regex is
    not durable (encoding is series-specific: WNBA totals carry raw points, an
    exact-score suffix yields a digit that is not a line at all), so the fields are
    now captured as Kalshi states them.

Strike parsing stores NULL rather than 0.0 for unparseable values, since a 0 line
is real and the two must stay distinguishable; subtitle/strike_type are clamped to
their column widths so an over-long payload can never abort a cycle's capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECFY4eoPk2g1JUanmFqkee
The endgame taker result (+4.06c at <15min, +6.71c at 15-30min) rests on
`taker = paper - spread`, which silently assumes liquidity at the touch is
unlimited. It is a per-CONTRACT number, so a window can look excellent at 1
contract and be untradeable at 20 — and the endgame is exactly where books are
thinnest. Depth is the one input that turns that into a measurable ceiling, and
we were not storing it.

Captures both sides on the candidate tick, because they mean different things to
a book that sells the YES tail by buying NO:
  depth_at_best_bid  contracts resting at the YES bid — what a TAKER entry lifts
  depth_at_best_ask  the YES-ask queue — what a MAKER entry sits behind

Surfaced in the timing study as a `takerQ` column rendering median(coverage%), so
a window's capacity is read next to its edge rather than assumed. Capture is
forward-only (Kalshi drops settled markets after ~70 days), so historical windows
read n/a by design.

The study now PROBES for the column via information_schema instead of hard-
depending on it: the ops channel runs this against whatever is deployed, which
may be either side of this migration, and a crash on a missing diagnostic column
is worse than reporting it as uncovered — which is what its absence means. Caught
by running it against production before shipping.

Columns are added to the existing (unmerged, unapplied) migration rather than
chained behind it, so this stays one deploy step.

Also documents, in docs/MMSELL_TIMING_STUDY.md, what the Kalshi API actually
allows — checked against the docs rather than inferred from this repo:
  * there is NO market order type; a "market order" is a marketable limit with
    time_in_force=immediate_or_cancel
  * post_only cannot be combined with IOC, and self_trade_prevention_type is
    required — both already learned live and annotated in live/executor.py
  * the taker path already exists and is proven (the closeout order is a recorded
    status-201 taker-IOC request); a taker ENTRY is that payload with side="ask"
  * maker fee is ceil(0.0175*C*P*(1-P)) vs taker ceil(0.07*...), a 4x discount
    that per-trade rounding to a cent ERASES at 1-contract clips in the cheap band
    (both charge 1c at yes<=11c). So taking costs the spread and nothing else, and
    `taker = paper - spread` needs no fee correction.

That last point contradicts docs/MMSELL_ROADMAP.md's claim that paper overcharges
makers ~1c/contract; recorded as unresolved, since it moves maker realizable by a
full cent outside the endgame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECFY4eoPk2g1JUanmFqkee
@50thycal
50thycal merged commit f7272cc into claude/confident-goldberg-83u3q Aug 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants