diff --git a/alembic/versions/e3f4a5b6c7d8_mmsell_candidate_tick_expiry_and_strike.py b/alembic/versions/e3f4a5b6c7d8_mmsell_candidate_tick_expiry_and_strike.py new file mode 100644 index 00000000..aac5e6b7 --- /dev/null +++ b/alembic/versions/e3f4a5b6c7d8_mmsell_candidate_tick_expiry_and_strike.py @@ -0,0 +1,55 @@ +"""mmsell candidate ticks: expected-expiration clock + contract sub-structure. + +Two capture gaps this closes, both on the same row the entry scan already writes: + + * `hours_to_expiration` — the only FORWARD-LOOKING estimate of when an in-play contest + resolves. `hours_to_close` comes from Kalshi's `close_time`, which on sports is a + far-future fallback (KXUFCFIGHT reported 335h on a fight that resolved in 0.4h, measured + 2026-08-05). The timing study can score HISTORY on realized closed_at - created_at, but a + live entry gate cannot — see docs/MMSELL_TIMING_STUDY.md. + * `strike_type` / `floor_strike` / `cap_strike` / `yes_sub_title` — the contract's + sub-structure. The type taxonomy says a ticker is a `spread` or a `total`; these say which + LINE, so a book can be cut at "MLB spreads of 3+ runs" rather than all spreads. + * `depth_at_best_bid` / `depth_at_best_ask` — book depth at the touch. A TAKER entry (buy NO + == sell YES into the bid) consumes the first; a MAKER entry queues behind the second. The + endgame taker result is `paper - spread`, which silently assumes liquidity at the touch is + unlimited — these are what turn that into a measurable capacity ceiling. + +All are nullable and backfill-free by design: rows already captured keep NULL, and the +analysis scripts report coverage rather than assuming it. + +Revision ID: e3f4a5b6c7d8 +Revises: d2e3f4a5b6c7 +Create Date: 2026-08-05 +""" + +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision: str = 'e3f4a5b6c7d8' +down_revision: str | None = 'd2e3f4a5b6c7' +branch_labels = None +depends_on = None + +_COLUMNS = ( + ("hours_to_expiration", sa.Float()), + ("strike_type", sa.String(16)), + ("floor_strike", sa.Float()), + ("cap_strike", sa.Float()), + ("yes_sub_title", sa.String(64)), + ("depth_at_best_bid", sa.Integer()), + ("depth_at_best_ask", sa.Integer()), +) + + +def upgrade() -> None: + for name, type_ in _COLUMNS: + op.add_column("mmsell_candidate_ticks", sa.Column(name, type_, nullable=True)) + + +def downgrade() -> None: + for name, _type in reversed(_COLUMNS): + op.drop_column("mmsell_candidate_ticks", name) diff --git a/docs/MMSELL_MARKET_TYPES.md b/docs/MMSELL_MARKET_TYPES.md index 6e5ba79f..2f8ab9de 100644 --- a/docs/MMSELL_MARKET_TYPES.md +++ b/docs/MMSELL_MARKET_TYPES.md @@ -91,6 +91,37 @@ ordering. That is trap 2 in action: pooled, both types are dominated by 16¢ ent difference between them is entry price rather than structure. The cheap-band cut is the one to read. +## Next axis: sub-typing by the contract's LINE + +The taxonomy answers "is this a spread or a total". It does not answer "**which** spread" — a +1-run handicap and a 7-run handicap are the same `spread` to every book we run. Probed 2026-08-05 +by parsing the trailing integer off the ticker's outcome token, there is structure there: + +| series | line | n | ¢/trade | loss% | entry¢ | +|---|---|---|---|---|---| +| `KXMLBSPREAD` | 2 | 124 | **+7.89** | 4.8% | 14.0 | +| `KXMLBSPREAD` | 6 | 46 | −1.29 | 15.2% | 15.3 | +| `KXMLBSPREAD` | 7 | 16 | **−13.63** | 37.5% | 25.5 | +| `KXMLBTOTAL` | 10–11 | 452 | +4.64 / +3.53 | 9.7% | 15.7 / 14.5 | +| `KXMLBTOTAL` | 15 | 85 | **−7.68** | 22.4% | 16.0 | +| `KXMLBTOTAL` | 16 | 57 | **−10.63** | 26.3% | 17.1 | +| `KXWCTEAMTOTAL` | 2 | 67 | −9.73 | 25.4% | 17.0 | + +MLB totals at the 10–11 run line run a 9.7% loss rate; at 15–16 the same market type runs 22–26%. +That is a bigger spread than most of the type-level differences this whole doc is built on. + +**But do not build on the regex.** The ticker encoding is series-specific and silently wrong in +places — `KXWNBATOTAL` puts raw points in the suffix (151–213), `KXMLBTOTAL` puts runs (4–21), a +spread suffix embeds the line after a team code (`LAA3`), and an exact-score suffix +(`ARG1CPV0`) yields `0`, which is not a line at all. Entry price is also confounded across lines +(14.0¢ at MLB spread 2 vs 25.5¢ at 7), so some of the above is price, not structure. + +The durable fix shipped 2026-08-05: `mmsell_candidate_ticks` now captures `strike_type`, +`floor_strike`, `cap_strike` and `yes_sub_title` straight from the market payload +(`alembic e3f4a5b6c7d8`). Those are the line as Kalshi states it, uniform across series. Coverage +starts at deploy and is forward-only — the fields cannot be backfilled, since Kalshi drops settled +markets after ~70 days. + ## Usage ```jsonc diff --git a/docs/MMSELL_TIMING_STUDY.md b/docs/MMSELL_TIMING_STUDY.md new file mode 100644 index 00000000..015e716c --- /dev/null +++ b/docs/MMSELL_TIMING_STUDY.md @@ -0,0 +1,226 @@ +# mmsell entry-timing study + +**Command:** `{"type": "script", "name": "mmsell_timing_study"}` — `scripts/mmsell_timing_study.py` + +## What it answers + +Does WHEN we enter matter? A backtest over Kalshi's settled h2h history found a U-shape — selling +the cheap tail paid pre-game (+5.4¢) and in the final hour (+6.57¢) but lost through the 1–4h +in-play middle. That covered 1,137 h2h entries and nothing else. This measures it on our own book, +across every market type, on **10,337 in-play trades over 3,783 distinct markets**. + +## The trap that shapes the whole design + +`hours_to_close` — the obvious timing variable, captured per candidate in +`mmsell_candidate_ticks` — is **valid for scheduled and 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. The script prints this validation first, every run: + +| 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 is worse: `KXUFCFIGHT` reads 335h-to-close on a fight that resolves in 0.4h (pinned +at our own `htcmax=336` cap — the market's reported close is further out still). Bucketing in-play +trades by `hours_to_close` files every one of them under "24–72h" or "72h+" and measures nothing +while looking perfectly healthy. + +**Two consequences beyond the bucketing.** The global `mmsell_min_hours_to_close = 1.0` floor +never binds on sports at all, so the books are *already* entering deep in-play without any rule +saying so. And a live in-play timing gate is currently impossible — it needs a forward-looking +field (Kalshi's `expected_expiration_time`), which the worker does not persist. + +So the study uses a **different clock per mode**: realized time-to-resolution +(`closed_at − created_at`) for `in_play`, `hours_to_close` for `scheduled`/`discrete`. + +## Standing result (2026-08-05) + +### in_play — the endgame is the edge + +| window | n | mkts | ¢/trade | loss% | edge | +|---|---|---|---|---|---| +| **<0.25h** | 588 | 223 | **+8.85** | 3.1% | **+11.4** | +| **0.25–0.5h** | 1318 | 588 | **+6.72** | 7.1% | **+7.3** | +| 0.5–1h | 2079 | 1010 | +2.28 | 11.8% | +2.4 | +| **1–2h** | 3488 | 1565 | **−1.55** | 15.9% | **−1.6** | +| 2–4h | 2317 | 916 | +1.77 | 11.5% | +1.9 | +| 4–12h | 283 | 121 | −4.28 | 18.7% | −4.5 | +| 12h+ | 264 | 89 | +2.09 | 8.7% | +2.1 | + +The endgame half of the U-shape replicates, with far more power than the backtest that proposed +it — and it was the half predicted *most likely to be a mirage*. The largest single cell (1–2h, +n=3,488) is the one losing money. + +### h2h — not a bad type, a badly-timed one + +| window | n | ¢/trade | edge | +|---|---|---|---| +| <0.25h | 349 | **+8.97** | +12.3 | +| 0.25–0.5h | 582 | **+7.03** | +8.2 | +| 0.5–1h | 696 | −0.64 | −0.7 | +| 1–2h | 842 | **−3.87** | −4.0 | +| 2–4h | 339 | −1.55 | −1.7 | +| 4–12h | 64 | −11.62 | −13.2 | +| 12h+ | 32 | −13.34 | −13.0 | + +`docs/MMSELL_MARKET_TYPES.md` scored h2h at +0.63¢ pooled and called it the weakest large in-play +type. That number was averaging **+8.97¢ in the final 15 minutes against −13.34¢ beyond 12 hours**. +The type finding and the timing finding are the same finding seen from two angles. + +`total` behaves identically (+10.48¢ at <0.25h → −4.23¢ at 1–2h → −32.71¢ at 4–12h) and `spread` +similarly. **`player_prop` and `outright` do not** — player props peak at 0.5–1h (+7.79¢) and +outrights are strongest at 2–4h and 12h+. Late-entry is a contest-resolution effect, not a +universal law, and the per-type cut is what separates them. + +### scheduled / discrete + +`scheduled` runs a genuine U: +9.60¢ (<2h) and **+10.50¢ (72h+, n=210, edge +27.6)** against +−1.12¢ in the 8–24h trough. `discrete` peaks at 8–24h (+6.39¢) with a 24–72h trough. Both are +scored on `hours_to_close`, so coverage is limited to the candidate-tick window (736 of 1,528 +scheduled trades; 544 of 1,619 discrete) and grows daily. + +## VERDICT (2026-08-05): the timing edge does NOT survive the fill haircut + +The `REAL` column projects each window's own entry-price mix through the live maker-fill +calibration (`docs/MMSELL_FILL_MODEL.md`). It is the number to gate on. Paper vs realizable: + +| window | paper ¢/trade | **realizable** | coverage | +|---|---|---|---| +| <0.25h | +8.85 | **+0.50** | 63% | +| 0.25–0.5h | +6.72 | **+0.46** | 50% | +| 0.5–1h | +2.28 | +0.29 | 49% | +| 1–2h | **−1.55** | **+0.55** | 48% | +| 2–4h | +1.77 | −0.23 | 55% | +| 4–12h | −4.28 | −0.95 | 43% | +| 12h+ | +2.09 | −0.12 | 61% | + +**A 13.1¢ paper spread collapses to 1.45¢ realizable, and the ordering inverts where it matters:** +the 1–2h window that looked worst on paper (−1.55¢) is the *best* realizable cell (+0.55¢), while +the <0.25h endgame that looked best (+8.85¢) lands at +0.50¢ — indistinguishable from it. + +The endgame edge is composed almost entirely of fills a resting maker never gets. That is exactly +what was predicted before the test: the final in-play window is the thinnest, fastest book in the +universe, and it is where `mmsell7` (`htcmax=24`) and `mmsell11` (`htcmin=6`) both already died. +This is the third timing signal to die at the same step. + +`scheduled` tells the same story — its 72h+ cell, the largest paper edge anywhere in the study at ++10.50¢, reads **−0.87¢ realizable**. + +**No `.timeX` book should be built on this.** The pre-registered gates below are retained for any +future re-test (e.g. after a fill-model refresh on a larger live sample), but the current answer +to "does entry timing pay" is: on paper yes, in reality no. + +Two honest limits on the verdict. Coverage is 43–63% on the in-play cells — the live calibration +only spans the cheap price cells, so the realizable figure speaks for about half of each cell. And +the calibration is borrowed from live `mmsell3` (n=359), a different market mix. Neither changes +the direction, which is consistent across all seven windows. + +## Three caveats before anyone trades this + +1. **Long-game confound (the serious one).** Hold time is *entry → resolution*, so a game that runs + long lands in a later bucket. Games run long when they are close — and a close game is exactly + where a cheap tail is live. Some unknown share of the gradient is therefore "blowouts end on + schedule", not "entering late is safe". A rule keyed on *time remaining* only captures the part + that is genuinely about the clock. 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*, up to one management + cycle after resolution. The bias is constant in absolute terms, so it bites hardest in the + shortest cell — read `<0.25h` as "detected within a cycle", not a precise quarter-hour. +3. **Fill-everything paper.** The endgame is a thin, fast book: precisely where a resting maker is + picked off. Two prior timing signals died exactly here — `mmsell7` (`htcmax=24`) was the worst + variant of its cohort, and `mmsell11` (`htcmin=6`) went +2.38¢ paper → −0.86¢ realizable. + **Nothing here is promotable until it clears `mmsell fill model`.** + +## The TAKER route — why the verdict above is not the end of it + +The verdict kills timing **as a maker**. It does not kill timing, because the mechanism that +killed it is specific to resting: a maker only fills when someone crosses into them, and those are +the losers. A taker chooses the moment and keeps the whole distribution. + +And the arithmetic is exact. A maker rests at the no-bid and collects the **yes-ask**; a taker +crosses to the no-ask and collects the **yes-bid**. Whether the tail hits or misses, the +difference is the same: + +> **taker P&L = paper P&L − spread** + +Which makes paper's fill-everything number — the thing this whole doc discounts — *achievable*, +just at a worse price. Measured over the candidate-tick window: + +| in-play window | n | spread | maker paper | **TAKER** | maker realizable | +|---|---|---|---|---|---| +| **<15 min** | 170 | 2.50¢ | +6.56 | **+4.06** | +0.50 | +| **15–30 min** | 257 | 2.02¢ | +8.73 | **+6.71** | +0.46 | +| 30–60 min | 450 | 2.27¢ | +2.47 | +0.20 | +0.29 | +| 1–2h | 526 | 2.30¢ | −4.39 | **−6.68** | +0.55 | +| 2–4h | 392 | 2.31¢ | −4.94 | −7.24 | −0.23 | + +The spread does **not** widen in the endgame — it is flat at ~2¢, and tighter still on h2h (1.65¢ +at <15min → **+4.64¢ taker**). But taking is not better in general: pooled across all in-play +windows a taker runs **−2.64¢/trade**, and the 1–2h window is −6.68¢ taker vs +0.55¢ maker. + +**Timing alone (maker) is dead; taker alone is marginal (~+0.7¢); taker + endgame gate is +4 to ++6.7¢.** The two ideas only work together. + +### What the Kalshi API actually allows (checked against the docs, not inferred) + +* **There is no market order type.** Only limit orders. A "market order" is a marketable limit at + an aggressive price with `time_in_force: immediate_or_cancel` (or `fill_or_kill`). +* `post_only: true` is the maker-only flag — it is what the current mmsell entry sets. **It cannot + be combined with `immediate_or_cancel`** (400 `invalid_parameters`), and + `self_trade_prevention_type` is required (400 `missing_parameters` if omitted). Both already + learned live and annotated in `live/executor.py`. +* **The taker path already exists and is proven** — the closeout order is annotated as the "EXACT + field set of a recorded status-201 taker-IOC request". A taker entry is that payload with + `side: "ask"`, not new infrastructure. +* **Fees do not penalise taking at our size.** Taker is `ceil(0.07 × C × P × (1−P))`, maker + `ceil(0.0175 × C × P × (1−P))` — a 4× discount that the per-trade round-up to a cent erases + entirely at 1-contract clips in the cheap band (both charge 1¢ at yes ≤11¢). It only becomes + real above ~15¢ or at larger clips (~0.3¢/contract at 20-lots). So the taker's only real cost is + the spread, and `taker = paper − spread` needs no fee correction. + + This **contradicts `docs/MMSELL_ROADMAP.md`**, which claims paper overcharges makers ~1¢/contract + based on 492 measured contracts. If maker also ceils to 1¢ there is no correction owed. Either + that measurement predates Kalshi's July-2025 maker-fee change (it was a flat $0.0025/contract + before, now probability-scaled), or these series sit outside the schedule's "Maker Fees" section. + Unresolved, and worth a Kalshi statement — it moves maker realizable by a full cent, which is + most of the maker-vs-taker gap *outside* the endgame. + +### The gating unknown: depth + +`taker = paper − spread` silently assumes unlimited liquidity at the touch. 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. + +`mmsell_candidate_ticks` now captures `depth_at_best_bid` (contracts resting at the YES bid — what +a taker entry lifts) and `depth_at_best_ask` (the YES-ask queue a maker sits behind). The study +renders the median as a `takerQ` column with its coverage, e.g. `3(100%)`. **Capture is +forward-only from 2026-08-05**, so historical windows read `n/a` by design; the column becomes +meaningful as coverage accrues. No taker book should be sized above the median depth its window +actually shows. + +## Gates for a timing book + +Before building any `.timeX` book: + +- The window must show **edge ≥ +3.0pp above the adjacent window** at **n ≥ 300** and **≥ 150 + distinct markets** (trades within one contest are not independent). +- It must hold **within a single type**, not only pooled — `player_prop` and `outright` already + demonstrate the pooled shape does not generalize. +- **`mmsell fill model` realizable ¢/trade > 0** on the window's price mix. +- For in-play, the worker must first persist a forward-looking resolution estimate; until then an + in-play timing book cannot be gated live regardless of what this study says. + +## Usage + +```jsonc +{"type": "script", "name": "mmsell_timing_study"} +{"type": "script", "name": "mmsell_timing_study", "args": ["--maxyes", "7"]} // live band only +{"type": "script", "name": "mmsell_timing_study", "args": ["--book", "mmsell10"]} +{"type": "script", "name": "mmsell_timing_study", "args": ["--no-types"]} +``` + +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. Bucketing and the +clock mapping are unit-tested in `tests/test_mmsell_timing_study.py`. diff --git a/kalshi_bot/mmsell/tracker.py b/kalshi_bot/mmsell/tracker.py index 751c8401..0a89f713 100644 --- a/kalshi_bot/mmsell/tracker.py +++ b/kalshi_bot/mmsell/tracker.py @@ -471,6 +471,16 @@ def run_once(self, session) -> MmSellCycleSummary: continue htc_s = compute_time_to_close(market.get("close_time")) htc = htc_s / 3600.0 if htc_s is not None else None # seconds -> hours + # Forward-looking resolution clock, recorded (never gated on) alongside htc. + # For an in-play market `close_time` is a far-future fallback — KXUFCFIGHT + # reports ~335h to close on a fight that resolves in 0.4h — so htc cannot + # express "enter in the final 30 minutes" and the timing study has to score + # history on realized hold instead (docs/MMSELL_TIMING_STUDY.md). Kalshi's + # expected expiration is the only estimate available BEFORE the fact; capturing + # it now is what makes an in-play timing gate testable later. + exp_s = compute_time_to_close( + market.get("expected_expiration_time") or market.get("expiration_time")) + hte = exp_s / 3600.0 if exp_s is not None else None # control htc gate scopes the shared work + the skipped_htc counter; a variant # with a wider htc than the control is not supported (control is the widest). if htc is None or not (s.mmsell_min_hours_to_close <= htc @@ -552,7 +562,8 @@ def run_once(self, session) -> MmSellCycleSummary: and captured < s.mmsell_candidate_capture_max: try: repo.insert_mmsell_candidate_tick( - session, ticker, metrics, series=series, hours_to_close=htc) + session, ticker, metrics, series=series, hours_to_close=htc, + hours_to_expiration=hte, market=market) captured += 1 except Exception as exc: # noqa: BLE001 logger.warning( diff --git a/kalshi_bot/models.py b/kalshi_bot/models.py index 88187cde..64533e17 100644 --- a/kalshi_bot/models.py +++ b/kalshi_bot/models.py @@ -230,6 +230,35 @@ class MmSellCandidateTick(Base): captured_at: Mapped[datetime] = mapped_column(TS, default=utcnow, nullable=False) series: Mapped[str | None] = mapped_column(String(32)) hours_to_close: Mapped[float | None] = mapped_column(Float) + # Hours to Kalshi's EXPECTED EXPIRATION, which for an in-play sports market is the only + # forward-looking estimate of when the contest actually resolves. `hours_to_close` above is + # derived from `close_time`, which Kalshi sets to a far-future fallback on sports: measured + # 2026-08-05, KXUFCFIGHT reported 335h to close on a fight that resolved in 0.4h, so every + # in-play trade buckets as "24-72h+" and a timing study on that column measures nothing + # (docs/MMSELL_TIMING_STUDY.md). The timing study can score history on realized + # closed_at - created_at, but a LIVE entry gate cannot — it needs this, known in advance. + hours_to_expiration: Mapped[float | None] = mapped_column(Float) + # Contract sub-structure, straight from the market payload. The market TYPE taxonomy + # (kalshi_bot/mmsell/market_types.py) says a ticker is a `spread` or a `total`; these say + # WHICH ONE — the run line, the over/under number, the strike. That is the difference + # between "sell cheap tails on MLB spreads" and "sell them only at 3+ runs", and it cannot + # be recovered later: the ticker suffix encodes it inconsistently across series and the + # subtitle is truncated into fill_assumption. + strike_type: Mapped[str | None] = mapped_column(String(16)) # greater | less | between | ... + floor_strike: Mapped[float | None] = mapped_column(Float) + cap_strike: Mapped[float | None] = mapped_column(Float) + yes_sub_title: Mapped[str | None] = mapped_column(String(64)) # "Above 3.5%", "LAA by 3+" + # Book DEPTH at the touch, the missing input to the taker-vs-maker question. mmsell sells the + # YES tail by buying NO, so the two sides mean different things to it: + # depth_at_best_bid — contracts resting at the best YES bid. This is what a TAKER entry + # consumes (buy NO == sell YES into the bid), so it is the capacity ceiling: a book + # quoting 1 contract cannot fill a 20-lot no matter how good the edge looks. + # depth_at_best_ask — contracts resting at the best NO bid (== the YES-ask queue). This is + # what a MAKER entry joins, i.e. how many orders sit ahead of ours at our own price. + # Without these, `taker = paper - spread` silently assumes infinite liquidity at the touch, + # which is exactly the assumption that has to hold for the endgame result to be tradeable. + depth_at_best_bid: Mapped[int | None] = mapped_column(Integer) + depth_at_best_ask: Mapped[int | None] = mapped_column(Integer) yes_bid: Mapped[int | None] = mapped_column(Integer) yes_ask: Mapped[int | None] = mapped_column(Integer) no_bid: Mapped[int | None] = mapped_column(Integer) diff --git a/kalshi_bot/repository.py b/kalshi_bot/repository.py index bb618e2e..1a857c68 100644 --- a/kalshi_bot/repository.py +++ b/kalshi_bot/repository.py @@ -771,26 +771,58 @@ def insert_mmsell_tick( session.flush() +def _strike_value(raw) -> float | None: + """Kalshi strike fields arrive as numbers, numeric strings, or absent. Anything unparseable + stores NULL rather than 0.0 — a strike of zero is a real line (a 0-run handicap), so a + coerced default would be indistinguishable from data.""" + if raw is None or isinstance(raw, bool): + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + def insert_mmsell_candidate_tick( session, ticker: str, metrics: MarketMetrics, *, series: str | None = None, hours_to_close: float | None = None, captured_at: datetime | None = None, + hours_to_expiration: float | None = None, market: dict | None = None, ) -> None: """Record one orderbook tick for an IN-BAND mmsell CANDIDATE (opened this cycle or not) — the pre-entry price path a per-ticker fill replay needs ('would a resting buy-NO at the no-bid have been lifted before close?'). Complements insert_mmsell_tick (held positions only). Cheap: reuses the orderbook metrics the entry scan already fetched; deliberately NOT flushed per row - (committed with the cycle) so bulk candidate capture doesn't flush hundreds of times.""" + (committed with the cycle) so bulk candidate capture doesn't flush hundreds of times. + + `hours_to_expiration` is the forward-looking resolution clock (see the model docstring — for + in-play markets `hours_to_close` is a far-future fallback and measures nothing). `market` is + the raw payload the scan already holds; its strike fields are recorded so a book can later be + cut by the contract's LINE (a 3-run handicap vs a 1-run one) rather than only by its type.""" + mkt = market or {} session.add(m.MmSellCandidateTick( market_ticker=ticker, captured_at=captured_at or _now(), series=series, hours_to_close=hours_to_close, + hours_to_expiration=hours_to_expiration, + # Clamped to the column widths: a long subtitle must never abort a whole cycle's + # candidate capture (the tape is a diagnostic — it may lose detail, never rows). + strike_type=(str(mkt.get("strike_type"))[:16] if mkt.get("strike_type") else None), + floor_strike=_strike_value(mkt.get("floor_strike")), + cap_strike=_strike_value(mkt.get("cap_strike")), + yes_sub_title=(str(sub)[:64] if (sub := (mkt.get("yes_sub_title") + or mkt.get("subtitle"))) else None), yes_bid=metrics.best_yes_bid, yes_ask=metrics.best_yes_ask, no_bid=metrics.best_no_bid, no_ask=metrics.best_no_ask, mid=metrics.midpoint, volume=metrics.volume, + # Depth at the touch — the capacity ceiling a taker entry runs into. getattr-guarded + # because the only consequence of a metrics object without them is a NULL column, and a + # diagnostic tape must never be the thing that breaks an entry scan. + depth_at_best_bid=getattr(metrics, "depth_at_best_bid", None), + depth_at_best_ask=getattr(metrics, "depth_at_best_ask", None), )) diff --git a/scripts/mmsell_timing_study.py b/scripts/mmsell_timing_study.py new file mode 100644 index 00000000..fe652d69 --- /dev/null +++ b/scripts/mmsell_timing_study.py @@ -0,0 +1,379 @@ +"""mmsell ENTRY-TIMING study — does WHEN we enter matter, measured on the right clock per mode? + +THE QUESTION +------------ +A backtest over Kalshi's settled h2h history found a U-shape in entry timing: selling the cheap +tail paid pre-game (+5.4c) and in the final hour (+6.57c) but lost through the 1-4h in-play +middle (-5.75c / -1.9c). That study covered 1,137 h2h entries and nothing else, so "does this +apply to the rest of the book" was open. This script answers it on OUR trades. + +THE TRAP THAT SHAPES THE WHOLE DESIGN +------------------------------------- +`hours_to_close` (from Kalshi's `close_time`, captured per candidate in `mmsell_candidate_ticks`) +is a VALID clock for scheduled and discrete markets and a FICTION for in-play ones. Kalshi sets +`close_time` on a sports market to a far-future fallback, not the end of the contest. Measured +2026-08-05 over our own book: + + series htc at entry actual time to resolution + KXUFCFIGHT 335.0h 0.4h + KXITFMATCH 333.3h 0.4h + KXMLSGAME 334.3h 0.6h + KXMLBGAME 70.6h 1.5h + --------------------------------------------------------- + KXBTCD 31.0h 30.6h (gap 0.5) + KXTRUMPSAY 72.3h 70.6h (gap 1.7) + KXRT 90.8h 90.7h (gap 0.2) + +The sports numbers are pinned at ~335h because that is our own `htcmax=336` cap — the market's +reported close is even further out. Bucketing in-play trades by `hours_to_close` therefore files +every one of them under "24-72h" or "72h+" and measures nothing. It also means the global +`mmsell_min_hours_to_close = 1.0` floor NEVER BINDS on sports: the books already enter deep +in-play (0.4-1.5h of contest left) without any timing rule saying so. + +So this study uses a DIFFERENT CLOCK PER MODE: + + in_play realized time-to-resolution = closed_at - created_at. Ground truth for + "how much contest was left when we entered", available for the FULL trade + history rather than only the candidate-tick window. + scheduled/discrete hours_to_close at entry, from the candidate tick nearest the entry. + +CAVEATS ON THE IN-PLAY CLOCK (both real, both bounded) +------------------------------------------------------ +* It is measured at settlement DETECTION, not the instant of resolution, so it overstates the + true remaining time by up to one management cycle. That bias is constant in absolute terms and + therefore matters most in the shortest buckets — read `<0.25h` as "detected within a cycle", + not as a precise quarter-hour. +* It is only knowable AFTER the fact, so it cannot gate a live entry. A live in-play timing rule + needs a forward-looking field (Kalshi's `expected_expiration_time`), which the worker does not + yet persist. This script measures whether such a rule would be WORTH building. + +Read-only, self-contained (stdlib + psycopg); runs locally or via the ops channel: + + DATABASE_URL_RO=postgresql://... python scripts/mmsell_timing_study.py + # or: {"type": "script", "name": "mmsell_timing_study"} + # {"type": "script", "name": "mmsell_timing_study", "args": ["--maxyes", "7"]} + # {"type": "script", "name": "mmsell_timing_study", "args": ["--book", "mmsell10"]} + +Taxonomy and the per-cell statistics are imported from scripts/mmsell_market_types.py rather than +re-declared, so a type can never mean one thing in the census and another here. The bucketing is a +pure function unit-tested in tests/test_mmsell_timing_study.py. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict + +import mmsell_fill_model as fm +import mmsell_market_types as mt + +RO_OPTIONS = ( + "-c default_transaction_read_only=on " + "-c statement_timeout=180000 " + "-c idle_in_transaction_session_timeout=180000" +) + +# Per-mode window grids, as (label, lower_bound_hours) with the upper bound implied by the next +# entry. They differ because the mechanism differs: an in-play contest resolves over minutes to +# hours, while a scheduled print sits days out and only its final hours are interesting. +WINDOWS: dict[str, tuple[tuple[str, float], ...]] = { + mt.IN_PLAY: ( + ("<0.25h", 0.0), ("0.25-0.5h", 0.25), ("0.5-1h", 0.5), + ("1-2h", 1.0), ("2-4h", 2.0), ("4-12h", 4.0), ("12h+", 12.0), + ), + mt.SCHEDULED: ( + ("<2h", 0.0), ("2-4h", 2.0), ("4-8h", 4.0), + ("8-24h", 8.0), ("24-72h", 24.0), ("72h+", 72.0), + ), + mt.DISCRETE: ( + ("<2h", 0.0), ("2-4h", 2.0), ("4-8h", 4.0), + ("8-24h", 8.0), ("24-72h", 24.0), ("72h+", 72.0), + ), +} + +# Which clock each mode is scored on. See the module docstring — this mapping IS the finding. +CLOCK = { + mt.IN_PLAY: "hold", # realized time-to-resolution + mt.SCHEDULED: "htc", # hours_to_close at entry + mt.DISCRETE: "htc", +} + + +def bucket_of(hours: float | None, mode: str) -> str | None: + """Window label for `hours` under `mode`'s grid, or None when unbucketable. + + Negative hours cannot happen on a valid clock (an entry never post-dates its own + resolution); returning None rather than silently flooring them into the first bucket keeps a + clock/data fault visible as coverage loss instead of as a fake edge in the shortest cell.""" + grid = WINDOWS.get(mode) + if grid is None or hours is None or hours < 0: + return None + label = grid[0][0] + for lbl, lo in grid: + if hours >= lo: + label = lbl + else: + break + return label + + +def _to_libpq_url(url: str) -> str: + url = (url or "").strip() + if url.startswith("postgresql+"): + url = "postgresql://" + url.split("://", 1)[1] + elif url.startswith("postgres://"): + url = "postgresql://" + url[len("postgres://"):] + return url + + +def _has_column(cur, table: str, column: str) -> bool: + """Is `column` present on `table` right now? + + The depth columns ship with alembic e3f4a5b6c7d8, and this script is run through the ops + channel against whatever is deployed — which may be either side of that migration. Probing + beats hard-depending: an analysis script that crashes on a missing diagnostic column is + worse than one that reports the column as 0% covered, which is what it means anyway.""" + cur.execute( + "SELECT 1 FROM information_schema.columns" + " WHERE table_name = %s AND column_name = %s LIMIT 1", + (table, column), + ) + return cur.fetchone() is not None + + +def load_trades(cur, include_twins: bool = False) -> list[dict]: + """Every settled mmsell trade with BOTH clocks attached where available. + + The candidate tick is matched to the entry within +/-10 minutes (the entry scan captures the + tick off the same orderbook fetch in the same cycle, so a real match is near-simultaneous; + the window only absorbs cycle jitter). Trades predating the capture simply have htc=None, + which costs nothing for in-play rows since they are scored on the hold clock anyway.""" + twin_clause = "" if include_twins else \ + " AND p.strategy NOT IN (SELECT twin_tag FROM live_paper_twins)" + # NULL::int keeps the row shape identical pre-migration, so nothing downstream branches. + dep_expr = ("ct.depth_at_best_bid" if _has_column(cur, "mmsell_candidate_ticks", + "depth_at_best_bid") else "NULL::int") + cur.execute( + "SELECT p.market_ticker, p.strategy, p.assumed_price, p.quantity, p.pnl," + " extract(epoch FROM (p.closed_at - p.created_at))/3600.0 AS hold_h," + " c.htc, c.dep" + " FROM paper_trades p" + " LEFT JOIN LATERAL (" + " SELECT ct.hours_to_close AS htc, " + dep_expr + " AS dep" + " FROM mmsell_candidate_ticks ct" + " WHERE ct.market_ticker = p.market_ticker" + " AND ct.captured_at BETWEEN p.created_at - interval '10 minutes'" + " AND p.created_at + interval '10 minutes'" + " ORDER BY abs(extract(epoch FROM (ct.captured_at - p.created_at))) LIMIT 1" + " ) c ON true" + " WHERE p.strategy LIKE '%%mmsell%%' AND p.status IN ('settled','closed_sl')" + " AND NOT coalesce(p.legacy,false) AND p.pnl IS NOT NULL" + " AND p.quantity IS NOT NULL AND p.quantity > 0" + + twin_clause, + ) + out: list[dict] = [] + for tkr, book, px, qty, pnl, hold_h, htc, dep in cur.fetchall(): + series = mt.series_of(tkr) + mtype, mode = mt.classify(series) + out.append({ + "ticker": tkr, "series": series, "book": book, + "type": mtype, "mode": mode, + "entry_c": (100 - int(px)) if px is not None else None, + "pnl_c": float(pnl) / int(qty) * 100.0, + "hold_h": float(hold_h) if hold_h is not None else None, + "htc": float(htc) if htc is not None else None, + # Contracts resting at the YES bid = what a TAKER entry could actually lift here. + # NULL for every row captured before the depth column shipped, which is why the + # cell prints coverage rather than assuming the median speaks for the window. + "depth": int(dep) if dep is not None else None, + }) + return out + + +def clock_hours(row: dict) -> float | None: + """The hours-to-resolution this row is scored on, per its mode's clock.""" + return row["hold_h"] if CLOCK.get(row["mode"]) == "hold" else row["htc"] + + +# --- rendering ----------------------------------------------------------------------------- + +def _f(x, spec="{:+.2f}") -> str: + return spec.format(x) if x is not None else "n/a" + + +HDR = (f" {'window':>10} {'n':>6} {'mkts':>5} {'c/trade':>9} {'REAL':>8} {'cov':>5}" + f" {'loss%':>6} {'be%':>6} {'edge':>7} {'avgloss':>8} {'worst':>7} {'p5':>7}" + f" {'p50':>6} {'entry':>6} {'takerQ':>7}") + + +def realizable_of(rows: list[dict], calib: dict | None) -> tuple[float | None, float | None]: + """(realizable cents/contract, coverage) for one cell, projected through the LIVE maker-fill + calibration — or (None, None) without a calibration. + + This is the column a timing rule must be gated on, not `c/trade`. Paper assumes a resting + maker order always fills; live it fills ~70% and misses the winners. The endgame windows are + the thinnest, fastest books in the whole universe, which is exactly where that gap is widest + — and where the two previous timing signals (mmsell7's htcmax=24, mmsell11's htcmin=6) both + died. See docs/MMSELL_FILL_MODEL.md.""" + if not calib: + return (None, None) + hist: dict[int, list] = defaultdict(lambda: [0, 0.0]) + for r in rows: + if r["entry_c"] is None: + continue + cell = hist[int(r["entry_c"])] + cell[0] += 1 + cell[1] += r["pnl_c"] + if not hist: + return (None, None) + res = fm.project_realizable({k: tuple(v) for k, v in hist.items()}, calib) + cover = (res["covered_n"] / res["total_n"]) if res["total_n"] else 0.0 + return (res["est_realizable_cents"], cover) + + +def _taker_capacity(rows: list[dict]) -> str: + """Median contracts resting at the YES bid, over the rows that have it — i.e. the size a + TAKER entry could actually lift in this window, and the ceiling on the whole taker thesis. + + Rendered as `median(coverage%)`, and as "n/a" until the depth column has coverage: the + numbers above are per-CONTRACT, so a window can look excellent at 1 contract and be + untradeable at 20. Depth is captured forward-only, so historical rows read n/a by design.""" + vals = sorted(r["depth"] for r in rows if r.get("depth") is not None) + if not vals: + return "n/a" + med = mt.pctile([float(v) for v in vals], 0.50) + return f"{med:.0f}({100*len(vals)//len(rows)}%)" + + +def _print_cells(rows_by_bucket: dict, order: tuple[str, ...], calib: dict | None = None) -> None: + print(HDR) + for lbl in order: + rows = rows_by_bucket.get(lbl) + if not rows: + continue + s = mt.summarize(rows) + be = 100.0 * s["be_loss"] if s["be_loss"] is not None else None + real, cover = realizable_of(rows, calib) + print(f" {lbl:>10} {s['n']:6d} {s['mkts']:5d} {s['mean']:>+8.2f}c" + f" {_f(real, '{:+7.2f}'):>7}c {_f(100.0*cover if cover is not None else None, '{:4.0f}'):>4}%" + f" {100.0*s['loss_rate']:5.1f}% {_f(be, '{:5.1f}'):>5}% {_f(s['edge_pp'], '{:+6.1f}'):>7}" + f" {_f(s['avg_loss'], '{:+7.1f}'):>8} {s['worst']:+6.0f}c" + f" {_f(s['p5'], '{:+6.1f}'):>7} {_f(s['p50'], '{:+5.1f}'):>6}" + f" {_f(s['entry'], '{:5.1f}'):>6} {_taker_capacity(rows):>7}") + + +def print_clock_validation(trades: list[dict]) -> None: + """Why each mode is scored on the clock it is scored on — printed FIRST, every run. + + This is a guardrail, not decoration. `hours_to_close` looks like the obvious timing variable + and is wrong for in-play markets by two orders of magnitude; anyone who switches the clock + back should see the evidence against it in the same output.""" + print("=== CLOCK VALIDATION (why in-play cannot use hours_to_close) ===") + print(f" {'mode':>10} {'n_with_both':>12} {'avg_htc':>9} {'avg_hold':>9} {'gap':>9} clock used") + for mode in (mt.IN_PLAY, mt.SCHEDULED, mt.DISCRETE): + both = [t for t in trades + if t["mode"] == mode and t["htc"] is not None and t["hold_h"] is not None] + if not both: + print(f" {mode:>10} {0:>12} n/a n/a n/a {CLOCK[mode]}") + continue + a_htc = sum(t["htc"] for t in both) / len(both) + a_hold = sum(t["hold_h"] for t in both) / len(both) + print(f" {mode:>10} {len(both):>12} {a_htc:>8.1f}h {a_hold:>8.1f}h {a_htc-a_hold:>8.1f}h" + f" {CLOCK[mode]}") + print(" A gap near zero means close_time IS the resolution time and htc is trustworthy." + "\n A gap of hundreds of hours means Kalshi's close_time is a far-future fallback and" + "\n htc measures nothing — in-play is scored on realized time-to-resolution instead.") + + +def report(trades: list[dict], maxyes: int | None, by_type: bool, min_n: int, + calib: dict | None = None) -> None: + if not trades: + print("(no settled mmsell trades matched)") + return + if maxyes is not None: + trades = [t for t in trades if t["entry_c"] is not None and t["entry_c"] <= maxyes] + print(f"*** ENTRY-PRICE FILTER: yes <= {maxyes}c — {len(trades)} trades ***\n") + if not trades: + print("(nothing in band)") + return + + print_clock_validation(trades) + + for mode in (mt.IN_PLAY, mt.SCHEDULED, mt.DISCRETE): + rows = [t for t in trades if t["mode"] == mode] + if not rows: + continue + clock = CLOCK[mode] + scored = defaultdict(list) + unbucketed = 0 + for t in rows: + b = bucket_of(clock_hours(t), mode) + if b is None: + unbucketed += 1 + else: + scored[b].append(t) + n_scored = sum(len(v) for v in scored.values()) + src = ("realized time-to-resolution (closed_at - created_at)" if clock == "hold" + else "hours_to_close at entry (candidate tick)") + print(f"\n=== {mode.upper()} — by {src} ===") + print(f" {n_scored} of {len(rows)} trades scoreable" + f"{f' ({unbucketed} without a clock value)' if unbucketed else ''}") + _print_cells(scored, tuple(lbl for lbl, _ in WINDOWS[mode]), calib) + + # The h2h thesis is type-specific, so in-play also gets a per-type cut. Without it a + # real h2h effect can be masked by totals/props moving the other way in the same cell. + if by_type and mode == mt.IN_PLAY: + for mtype in sorted({t["type"] for t in rows}): + sub = [t for t in rows if t["type"] == mtype] + if len(sub) < min_n: + continue + cells = defaultdict(list) + for t in sub: + b = bucket_of(clock_hours(t), mode) + if b is not None: + cells[b].append(t) + print(f"\n --- in_play / {mtype} (n={len(sub)}) ---") + _print_cells(cells, tuple(lbl for lbl, _ in WINDOWS[mode]), calib) + + print("\n edge = be% - loss%, in percentage points: the loss rate this cell breaks even at" + "\n (given its own realized win/loss sizes) minus the one it actually ran. It is the" + "\n only column comparable ACROSS cells, because each window is entered at a different" + "\n premium. A timing rule is only worth building where edge separates cleanly AND the" + "\n cell has the n to support it — see docs/MMSELL_TIMING_STUDY.md for the gates.") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--maxyes", type=int, default=None, + help="restrict to entries at or below this yes price (e.g. 7 for the " + "mmsell10 live band); default: all prices") + ap.add_argument("--no-types", action="store_true", + help="skip the per-type breakdown inside in_play") + ap.add_argument("--min-n", type=int, default=40, + help="hide per-type in_play cuts below this n (default 40)") + ap.add_argument("--no-fill-model", action="store_true", + help="skip the REALIZABLE projection (paper numbers only)") + ap.add_argument("--include-twins", action="store_true", + help="include live/paper twin books (different entry convention)") + args = ap.parse_args(argv) + + url = _to_libpq_url(os.environ.get("DATABASE_URL_RO") or os.environ.get("DATABASE_URL") or "") + if not url: + print("DATABASE_URL_RO (or DATABASE_URL) is not set.", file=sys.stderr) + return 1 + + import psycopg + + with psycopg.connect(url, options=RO_OPTIONS, connect_timeout=15) as conn: + conn.read_only = True + with conn.cursor() as cur: + trades = load_trades(cur, args.include_twins) + calib = None if args.no_fill_model else fm._load_calibration(cur) + report(trades, args.maxyes, not args.no_types, args.min_n, calib) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ops_runner.py b/scripts/ops_runner.py index 38854646..99b8cff0 100644 --- a/scripts/ops_runner.py +++ b/scripts/ops_runner.py @@ -110,6 +110,7 @@ "mmsell_regime_backtest", "mmsell_history_status", "mmsell_market_types", + "mmsell_timing_study", "evo_digest", "evo_tree", "evo_selftest", diff --git a/tests/test_mmsell_timing_study.py b/tests/test_mmsell_timing_study.py new file mode 100644 index 00000000..32b37337 --- /dev/null +++ b/tests/test_mmsell_timing_study.py @@ -0,0 +1,223 @@ +"""The mmsell entry-timing study (scripts/mmsell_timing_study). + +The whole design rests on one finding: `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 UFC reads 335h-to-close on a fight that resolves in 0.4h). Get the clock wrong and +every in-play trade files under "24-72h" and the study measures nothing while looking fine. + +So the tests pin: + * the per-mode clock mapping — the finding itself; + * bucketing, including that a negative/None clock value drops out as coverage loss rather than + silently flooring into the shortest bucket, where it would read as a fake late-entry edge; + * that the grids are well-formed (ascending, first bucket open at zero), since a mis-ordered + grid mislabels every cell without erroring. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import sys + +SCRIPTS = pathlib.Path(__file__).resolve().parent.parent / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +_SPEC = importlib.util.spec_from_file_location( + "mmsell_timing_study", SCRIPTS / "mmsell_timing_study.py") +ts = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(ts) # type: ignore[union-attr] + + +def test_each_mode_is_scored_on_the_right_clock(): + """This mapping IS the finding. in_play must never be scored on hours_to_close.""" + assert ts.CLOCK[ts.mt.IN_PLAY] == "hold" + assert ts.CLOCK[ts.mt.SCHEDULED] == "htc" + assert ts.CLOCK[ts.mt.DISCRETE] == "htc" + + +def test_clock_hours_picks_the_field_the_mode_mapping_names(): + inplay = {"mode": ts.mt.IN_PLAY, "hold_h": 1.4, "htc": 335.0} + sched = {"mode": ts.mt.SCHEDULED, "hold_h": 30.6, "htc": 31.0} + # The in-play row must report 1.4, NOT the 335 that close_time claims. + assert ts.clock_hours(inplay) == 1.4 + assert ts.clock_hours(sched) == 31.0 + + +def test_window_grids_are_well_formed(): + for mode, grid in ts.WINDOWS.items(): + lows = [lo for _, lo in grid] + assert lows[0] == 0.0, f"{mode} grid must open at zero" + assert lows == sorted(lows), f"{mode} grid must ascend" + assert len(set(lows)) == len(lows), f"{mode} grid has duplicate bounds" + assert len({lbl for lbl, _ in grid}) == len(grid), f"{mode} grid has duplicate labels" + + +def test_bucketing_is_lower_bound_inclusive(): + m = ts.mt.SCHEDULED + assert ts.bucket_of(0.0, m) == "<2h" + assert ts.bucket_of(1.9, m) == "<2h" + assert ts.bucket_of(2.0, m) == "2-4h" # boundary belongs to the upper cell + assert ts.bucket_of(7.99, m) == "4-8h" + assert ts.bucket_of(8.0, m) == "8-24h" + assert ts.bucket_of(72.0, m) == "72h+" + assert ts.bucket_of(5000.0, m) == "72h+" + + +def test_in_play_grid_resolves_the_sub_hour_detail(): + m = ts.mt.IN_PLAY + # The in-play book actually enters with 0.4-1.5h of contest left, so the interesting + # structure is BELOW one hour — a scheduled-style grid would put it all in one cell. + assert ts.bucket_of(0.1, m) == "<0.25h" + assert ts.bucket_of(0.4, m) == "0.25-0.5h" + assert ts.bucket_of(0.6, m) == "0.5-1h" + assert ts.bucket_of(1.5, m) == "1-2h" + assert ts.bucket_of(20.0, m) == "12h+" + + +def test_missing_or_impossible_clock_drops_out_instead_of_flooring(): + """An entry cannot post-date its own resolution. Flooring a negative into the shortest + bucket would manufacture exactly the late-entry edge the study is trying to test.""" + m = ts.mt.IN_PLAY + assert ts.bucket_of(None, m) is None + assert ts.bucket_of(-0.5, m) is None + assert ts.bucket_of(1.0, "no_such_mode") is None + assert ts.clock_hours({"mode": ts.mt.SCHEDULED, "hold_h": 3.0, "htc": None}) is None + + +def test_taxonomy_is_imported_not_redeclared(): + """A third copy of the type table would let a type mean one thing in the census and another + here. The study must use the census module's classifier.""" + import mmsell_market_types as canonical + assert ts.mt is canonical + assert ts.mt.classify("KXUFCFIGHT") == ("h2h", ts.mt.IN_PLAY) + assert ts.mt.classify("KXBTCD") == ("price_strike", ts.mt.SCHEDULED) + + +# --------------------------------------------------- the forward-looking capture this needs + +def test_candidate_tick_captures_the_forward_looking_clock_and_strike(): + """The study can score HISTORY on realized hold, but a live in-play gate cannot — it needs a + clock known before the fact. And the strike fields are what let a book be cut at "MLB + spreads of 3+ runs" rather than all spreads; neither is recoverable after the fact.""" + import types + + from kalshi_bot import repository as repo + + captured = {} + + class _Session: + def add(self, obj): + captured["obj"] = obj + + metrics = types.SimpleNamespace( + best_yes_bid=6, best_yes_ask=8, best_no_bid=92, best_no_ask=94, + midpoint=7.0, volume=1234, depth_at_best_bid=3, depth_at_best_ask=40) + market = { + "strike_type": "greater", "floor_strike": "3", "cap_strike": None, + "yes_sub_title": "LAA by 3+", + } + repo.insert_mmsell_candidate_tick( + _Session(), "KXMLBSPREAD-26AUG021515MILLAA-LAA3", metrics, + series="KXMLBSPREAD", hours_to_close=70.4, hours_to_expiration=1.4, market=market) + + row = captured["obj"] + assert row.hours_to_close == 70.4 + assert row.hours_to_expiration == 1.4 # the clock that is actually usable in-play + assert row.strike_type == "greater" + assert row.floor_strike == 3.0 # numeric string coerced + assert row.cap_strike is None + assert row.yes_sub_title == "LAA by 3+" + # Depth at the touch: 3 contracts resting at the YES bid is the TAKER capacity ceiling + # (buy NO == sell YES into that bid), while 40 at the YES-ask queue is what a MAKER must + # sit behind. A 20-lot taker entry is impossible here regardless of how good the edge is. + assert row.depth_at_best_bid == 3 + assert row.depth_at_best_ask == 40 + + +def test_candidate_tick_strike_parsing_never_fakes_a_line(): + """A strike of 0 is a real line (a 0-run handicap), so an unparseable value must store NULL + rather than coerce to 0.0 — otherwise the two are indistinguishable in the study.""" + from kalshi_bot.repository import _strike_value + + assert _strike_value(0) == 0.0 + assert _strike_value("3.5") == 3.5 + assert _strike_value(None) is None + assert _strike_value("") is None + assert _strike_value("n/a") is None + assert _strike_value(True) is None # bool is an int subclass; not a strike + + +def test_candidate_tick_clamps_to_column_widths(): + """The tape is a diagnostic: it may lose detail, never rows. An over-long subtitle must not + abort a whole cycle's candidate capture.""" + import types + + from kalshi_bot import repository as repo + + captured = {} + + class _Session: + def add(self, obj): + captured["obj"] = obj + + metrics = types.SimpleNamespace( + best_yes_bid=1, best_yes_ask=2, best_no_bid=98, best_no_ask=99, + midpoint=1.5, volume=0) + repo.insert_mmsell_candidate_tick( + _Session(), "T", metrics, + market={"yes_sub_title": "x" * 200, "strike_type": "y" * 40}) + row = captured["obj"] + assert len(row.yes_sub_title) == 64 + assert len(row.strike_type) == 16 + + +def test_candidate_tick_survives_metrics_without_depth(): + """The tape is a diagnostic and must never break an entry scan. A metrics object lacking the + depth attributes stores NULL rather than raising — NULL is honest (the analysis reports + coverage), an exception would cost the whole cycle's candidate capture.""" + import types + + from kalshi_bot import repository as repo + + captured = {} + + class _Session: + def add(self, obj): + captured["obj"] = obj + + bare = types.SimpleNamespace( + best_yes_bid=6, best_yes_ask=7, best_no_bid=93, best_no_ask=94, + midpoint=6.5, volume=10) + repo.insert_mmsell_candidate_tick(_Session(), "T", bare) + row = captured["obj"] + assert row.depth_at_best_bid is None + assert row.depth_at_best_ask is None + + +def test_study_runs_against_a_database_without_the_depth_column(): + """The ops channel runs this against whatever is deployed, which may be either side of the + migration that adds the depth columns. A crash on a missing DIAGNOSTIC column is worse than + reporting it as uncovered — which is what its absence means anyway.""" + class _Cur: + def __init__(self, has_col): + self.has_col = has_col + self.sql = None + + def execute(self, sql, params=None): + self.sql = sql + self._rows = [(1,)] if (self.has_col and "information_schema" in sql) else [] + + def fetchone(self): + return self._rows[0] if self._rows else None + + def fetchall(self): + return [] + + absent = _Cur(has_col=False) + ts.load_trades(absent) + assert "NULL::int AS dep" in absent.sql + assert "depth_at_best_bid" not in absent.sql + + present = _Cur(has_col=True) + ts.load_trades(present) + assert "ct.depth_at_best_bid AS dep" in present.sql