---
name: Paper market maker — three real bugs inflated every result today; corrected pipeline shows the naive design barely fills and earns near zero
description: 2026-07-26. main/mm_quoter.py + mm_settle.py run paper-only. THREE bugs found and fixed in one session, each making results look better than they were — state only tracking competitive legs (completion rule fired 0 times), timestamp STRINGS compared directly (silently dropped ~all trades), and no kickoff cutoff (counted in-play trades as fills, 83% vs a real 13-19%). The historical +30%-first-decile finding (project_market_making_early_edge) was independently re-derived from scratch today with sanity checks and DOES replicate exactly — it is unaffected, since it reads real trades' own maker/taker flag rather than simulating our order. What does NOT survive: every "complete books are riskless arbitrage, 8-for-8" claim from earlier today, which was built on the in-play-fill bug. Corrected quoting (cap ask at the market's own ask, floor at fair) raised fill rate from ~14% competitive / ~19% real fills to ~61%, but the corrected backtest replay shows COMPLETE books earn ~$0 once capped at the market's own ask — there is no overround left. Whether the historical edge is capturable this way is now the fully open question.
metadata:
  type: project
---

Built after [[project_market_making_early_edge]] established that the passive side
of Kalshi soccer earns +30% in a market's first volume decile (real trades,
Kalshi's own maker/taker flag) and that **makers pay no fee** while takers pay
4.39% of stake. This note exists to separate what survived a day of self-inflicted
bugs from what didn't — read the whole thing before trusting any number from this
project, including numbers reported to the user earlier the same day.

## THE THREE BUGS, in the order they were found (all in the SIMULATION, none in
## the underlying data)

1. **State only tracked competitive legs.** `mm_quoter`'s state dict only got an
   entry when a quote was competitive (~14% of legs), so `complete_book()` — which
   requires all 3 legs of an event in state — essentially never saw a 3-leg book.
   The completion rule was correct code that had fired **zero times** in a full
   day of live paper running. Fixed by recording every quoted leg, competitive or
   not (a real maker posts all three; the uncompetitive ones just rest unfilled).

2. **Timestamps compared as raw strings.** Quote times carry `+00:00`, kickoffs
   carry `-04:00`, Kalshi trade times carry `Z`. Lexicographic string comparison
   across three different suffix formats is not meaningful — it silently
   filtered out effectively all real trades in `mm_settle`'s first fill test
   (`trades_after`'s `t > after_iso` and the original `paper_filled`'s
   `t <= since_iso`). This produced a headline "0 completions ever" result before
   being found. Fixed by parsing every timestamp with
   `dt.datetime.fromisoformat(str(s).replace('Z', '+00:00'))` before any
   comparison, in both files (`_pt()`).

3. **No kickoff cutoff — in-play trades counted as fills.** Even after fixing (2),
   the fill test had no upper time bound, so a trade printed in the 70th minute
   (price run up after a goal, a stale resting ask getting lifted by someone
   watching the match) counted as a legitimate pre-match fill. This is what
   produced the **83% fill rate** and the **"8 complete books, all +$0.70-0.80,
   8-for-8 with incomplete books all negative"** finding reported to the user
   earlier the same day. Restricting to strictly pre-kickoff trades dropped the
   real fill rate to **13%** on the same data. **None of that "8-for-8 arbitrage"
   result should be trusted — it never happened before kickoff.**

Each bug independently made the paper maker look more successful. That pattern —
not any one bug — is the thing worth remembering: validate a headline number
(90% fill rate on orders only competitive 14% of the time was implausible on its
face) before reporting it.

## What survived, fully independently re-verified today

[[project_market_making_early_edge]]'s **opening-decile +30% finding is real**.
Rebuilt from scratch in `research/mm_decile_by_volume.py` (a NEW script, not the
paper-quoter code path) directly from `mm_trades.csv`'s 2,280,414 real trades /
641 events, with explicit sanity checks:
- taker buy:sell ratio 2.3:1 (retail backs teams — confirms the maker/taker
  mapping is the right way round, not inverted)
- decile-0 event count = 637, matching the total events with any pre-kickoff
  volume (every event contributes exactly one opening trade, as it must)
- **result: opening decile +30.32% notional-weighted / +35.43% median event /
  t=+11.58, decaying monotonically to significantly negative by decile 80-90%
  (t=-5.57)** — matching the original numbers to the decimal.

This uses REAL trades between REAL counterparties via Kalshi's own
`taker_book_side` flag. It cannot inherit the paper-quoter's fill-simulation
bugs because it never simulates our own order. **This is the credible evidence
that a genuine opportunity exists.** Everything below is about whether *we* can
capture it, which is a completely different and still-open question.

## The corrected quoting rule

Old: `ask = ceil(fair*100 + 2c)/100`, uncapped. Verified on the historical log:
this sat above the market's own ask on **86% of legs** — the order essentially
never had a chance to fill, which is *why* the completion rule (bug #1) mattered
so little in practice and why real fill rate was so low.

New (`mm_quoter.cycle`): **`ask = min(ceil(fair*100 + 2c), market_ask)`, floored
at fair** (if the market's ask is already below our fair value, skip quoting that
leg entirely — selling below your own fair estimate in isolation is a
guaranteed-negative-EV trade and a stale/bad Pinnacle read should not be able to
force one). Verified live: first cycle after the fix hit **61% competitive**
(22/36 legs), matching a backtest replay of the same rule on historical data.

## The "be first" redesign (2026-07-26, later same day)

Motivated by the uncomfortable finding below: capping at the market's own ask
means complete books collect ~100c and earn nothing. The historical edge may
therefore require being the **first** resting order in a still-wide, newly-listed
book, rather than joining one other makers have already tightened to the 1c tick.
Three changes, all traceable to `research/mm_decile_by_volume.py`:

1. **Persistent process, not a 5-minute cron.** `mm_quoter.py` now runs as a loop
   at `POLL_SECS=20`; the `SoccerMMQuoter` task runs `mm_quoter_guard.ps1`, which
   only restarts it if dead (modelled on `monitor_guard.ps1`). 20s vs 300s is a
   15x improvement in reach-time. A 5-min cron structurally cannot be first
   against makers who rest continuously. Guard verified **idempotent** (running it
   against a live process does not duplicate — duplicates would double-count
   collateral and corrupt the shared `mm_paper_state.json`) and verified to
   **recover a killed process** (PID 8012 → 8380).
2. **`WINDOW_H` 3.0 → 5.0 → 12.0h.** Measured maker ROI and volume share by
   hours-to-kickoff across the same 637 events (pre-KO, 5c-95c):

   | window | % of pre-KO volume | maker ROI |
   |---|---|---|
   | 0-0.5h | 0.8% | +7.24% |
   | 0.5-1.5h | **52.5%** | +0.56% |
   | 1.5-2.5h | 30.4% | +5.04% |
   | 2.5-5h | 11.8% | +10.58% |
   | **5-12h** | 2.1% | **+15.85%** ← richest window in the dataset |
   | 12-24h | 2.0% | +5.95% |
   | 24-72h | 0.3% | **−8.78%** ← loses money |
   | >72h | 0.1% | +27.40% (only $36k — noise) |

   **ROI rises monotonically the earlier you go, while volume collapses.** 12h
   captures ~97.6% of pre-KO volume INCLUDING the richest slice. Deliberately NOT
   scanning 24/7: 24-72h is genuinely negative, >72h is a rounding error that
   could flip on a few matches, and each extra in-window match multiplies Kalshi +
   API-Football calls per cycle (API-Football at 9,009/75,000 daily after the
   widening — plenty of headroom, but the **plan expires 2026-08-07**, which is
   the real constraint on all of this).
3. **Staged margin `margin_for(hrs_to_ko)`** replacing flat `MARGIN_C=2.0`:
   `>=5h → 6.0c`, `2.5-5h → 5.0c`, `1.5-2.5h → 3.0c`, `0.5-1.5h → 2.0c`,
   `<0.5h → 1.0c`. Read off the decile boundaries (first three deciles
   t=+11.6/+7.2/+5.2; significantly NEGATIVE from decile 60% at t=-1.5 to -5.6)
   plus the hours-bucket ROI table above, mapped onto hours-to-kickoff because
   "% of eventual volume" is unobservable live. The `<0.5h → 1.0c` stage is
   deliberate — the historical edge is negative there, so quote thin mainly to
   let `complete_book()` finish an already-2/3-filled book, not to invite fresh
   naked exposure. **FIRST CUT, not fitted** — nowhere near enough paper legs to
   fit a schedule to.

   **Verified live and it does what the flat rule never could:** an NWSL home leg
   at 2.02h to KO, Pinnacle fair 62.6c, our ask **66c** (fair+3.4c), market ask
   67c — competitive while asking a genuine premium over fair, rather than being
   capped at the market. Under the old flat 2c that ask would have been 65c and
   the premium mostly surrendered. Boundaries verified at 13/11.9/5.1/4.9/2.6/
   2.4/1.6/1.4/0.6/0.4h. The 6c stage is correct-by-code but had no 5h+ match in
   window at test time, so it has not yet been observed firing.

**REST, DON'T CHASE was already true and must not be broken:** once a leg is
posted, `cycle()` never rewrites its price in state, so the ask that decides a
fill is always the originally-posted one (a fresh `our` is still computed and
logged each cycle for visibility). Time-priority is the entire point of arriving
early; re-quoting down to chase the market would discard the priority this
redesign exists to buy.

**Caveat visible immediately:** the market's own ask still caps us on most legs
(we ask 33c, market is already 33c), so the staged margin frequently does not
bite. Whether the 5c early stage ever matters depends on whether persistent
polling actually arrives before the book tightens — the open question this
redesign exists to ANSWER, not one it has answered.

## The uncomfortable finding from the CORRECTED backtest replay

Re-scored the historical quote log with the corrected rule AND the corrected
(managed-order, cancel-at-kickoff, pre-kickoff-only) fill test:

| category | books | P&L |
|---|---|---|
| 3/3 complete (the "riskless arbitrage") | 5 | **$0.00** |
| 2/3 hedged at the bid | 7 | **−$0.04** |
| 1/3 naked single legs | 4 | **+$12.40** (all of it) |

**Capping our ask at the market's own ask means a complete book collects
~100c, not ~107c — there is no overround left once you stop asking above the
market.** The entire apparent profit was four single-leg directional trades that
happened to win; that is variance, not a business, and it is far too few legs to
mean anything (t on the per-leg P&L was +0.49 in an earlier pass of the same
data).

**This is the actual open design tension, unresolved:** ask above the market and
you rarely fill (14-19%) but collect real margin when you do; ask at the market
and you fill often (61%) but collect nothing on the books that complete. The
historical +30% decile edge may specifically require being the FIRST resting
order in a still-wide, newly-listed book — not joining a book that other makers
have already tightened to the 1c tick by the time our 5-minute cycle checks it.
Our quoter has never been first; it has always been checking in on an
already-established market. Whether faster/earlier posting recovers the edge is
untested and is the natural next question, not something to build yet.

## Fee and capital facts (verified on real fills — untouched by any of today's bugs)

- Taker fee `0.07*P*(1-P)` charged to **4 decimal places, NOT rounded up to the
  cent** (8 fills checked, all within 0.00005). Small orders pay the same rate as
  large ones — SIZE=2 is not fee-penalised.
- Maker fee **$0**.
- Collateral per 3-leg book: **~$19.30 at SIZE=10 with ALL legs reserved**
  (corrected from an earlier undercount that only reserved competitive legs —
  real number is ~4.3x higher than first reported). $100 supports ~5 concurrent
  matches at SIZE=10, ~25 at SIZE=2.

## To go LIVE (three real blockers, unaffected by today's bugs)

1. `kalshi_trader.place_limit_order` **rejects `side='no'` and hardcodes
   `side: "bid"`** — needs `side: "ask"` to sell.
2. Hardcodes `time_in_force: "immediate_or_cancel"` — making needs **GTC** resting.
3. **The `$1` exposure cap is wrong for sells** — computes `count × price`
   (correct for buying); selling risks `count × (1 − price)`. Must be rewritten
   for the sell direction before any live order.

Plus order management (cancel/replace as fair moves) — the actual work, and now
also: figure out how to post EARLIER/faster than a 5-minute cycle if the edge
really does live in being first.

## The open question, sharpened rather than resolved

**Capture was always the open question; today added a second one on top of it:
even PERFECT capture at the market's own ask may not be worth anything**, because
capping there removes the margin. The historical edge might only be available to
whoever gets there first. Neither is answerable from history — only a live test,
or a much faster/earlier-posting paper test, resolves it.

Also still untested: using xlow **defensively** (it fires when the market is
BELOW fair — a maker should step away, not chase). Mechanically the maker and
xlow still select disjoint situations by construction.

Related: [[project_market_making_early_edge]], [[project_xlow_spread_verdict]],
[[project_no_subask_limits]], [[project_nonprice_features_dead]].
