What the Kalshi Order Book Actually Looks Like at 2am (And Why That Matters)
TL;DR / Key Takeaways
- Kalshi spreads on temperature markets routinely widen to 0.15-0.25 during overnight hours, making limit order placement materially harder.
- Thin liquidity at 2am is not just inconvenient, it changes the math on whether a trade is worth taking at all.
- IOC (immediate-or-cancel) orders are the right tool for automated execution in volatile-spread environments, not market orders.
- The Weather Bot v2.1 includes a spread filter and an early-exit module that both skip trades when the book is too thin to execute cleanly.
Most people trade Kalshi during business hours. They open the app, look at the market, see reasonable bid-ask spreads, and make a decision. They are not thinking about what that same market looks like at 2am on a Tuesday.
I am thinking about it. Because my bot is trading then.
What follows is a real look at Kalshi market structure across the day, specifically temperature markets, with numbers I have actually observed. Not theoretical. Not from a whitepaper. From watching the bot's logs and the order book while I was up late debugging things I should have fixed during daylight hours.
What a Healthy Kalshi Temperature Market Looks Like
During peak hours (roughly 9am to 6pm Eastern), active temperature markets on Kalshi look reasonable. Spreads on the most liquid contracts sit around 0.03 to 0.07 cents on the dollar. Meaning if the fair value of a contract is roughly 0.60, you might see a bid at 0.57 and an ask at 0.63.
That is workable. Not great. Not like trading SPY options. But workable.
Volume is uneven across strikes. The contracts near the market consensus pull most of the activity. The tail strikes (contracts well above or below the forecast median) can sit with no activity for hours even during peak hours.
The order book depth is shallow by traditional finance standards. You might see 200-500 contracts on the best bid, and the next level drops off fast. There is no dark pool. There is no hidden institutional order flow. What you see is what exists.
What Happens After Hours
This is where it gets interesting for automated systems.
After about 8pm Eastern, liquidity on most temperature markets drops noticeably. By midnight it is thin. At 2am it is sometimes just a few resting orders with wide spreads.
Here are real spread observations I logged on Dallas high-temperature markets over several weeks:
| Time Window | Observed Spread Range | Notes | |---|---|---| | 9am - 6pm ET | 0.03 - 0.08 | Active, multiple levels visible | | 6pm - 10pm ET | 0.07 - 0.14 | Trailing off, still tradeable | | 10pm - 2am ET | 0.12 - 0.22 | Thin, spreads widen significantly | | 2am - 6am ET | 0.15 - 0.28 | Worst liquidity, sometimes one-sided | | 6am - 9am ET | 0.08 - 0.15 | Recovering as traders wake up |
These are not edge cases. This is the pattern. Night after night.
A 0.25 spread on a binary contract that resolves at 0 or 1 is brutal. If you are buying a contract at 0.60 fair value but the ask is 0.70 because the book is empty, you have already given away a large fraction of your theoretical edge before the trade settles.
Why This Matters for Automated Execution
A human trader sees a wide spread and waits. They check back in an hour. They have judgment and patience and caffeine.
A bot does not have any of that unless you build it in.
Early versions of the Weather Bot did not handle thin books well. The bot would identify a high-confidence edge, look at the order book, and place a limit order at what it calculated as the fair entry price. If the spread was wide and nobody was at that level, the order would sit unexecuted. Or worse, it would partially fill at a bad price.
Partial fills in a thin book are painful. You get 40 contracts filled at one price, then the ask jumps, and your remaining 60 contracts either sit or fill at a worse level. Your average entry is now ugly and the effective edge is smaller than the bot calculated.
The fix required thinking carefully about three separate problems: when to skip the trade entirely, how to place the order when you do trade, and how to handle exits in the same thin environment.
The Spread Filter
The first line of defense is simple. If the spread is too wide, do not trade.
The bot now checks spread width as part of the trade filter pipeline. The threshold I settled on is 0.20. If the bid-ask spread on the target contract exceeds 0.20, the trade is skipped. This is logged as a skip with reason, which feeds into the trade_decisions table for later analysis.
def check_spread_width(bid: float, ask: float, max_spread: float = 0.20) -> bool:
"""
Returns True if spread is acceptable for execution.
Returns False if spread is too wide to trade safely.
"""
if bid is None or ask is None:
return False
spread = ask - bid
if spread > max_spread:
logger.warning(
f"SPREAD_FILTER spread={spread:.3f} bid={bid:.3f} ask={ask:.3f} "
f"max={max_spread:.3f} SKIP"
)
return False
return True
This is deliberately simple. I considered weighting the spread filter by expected edge (wider spread tolerated if edge is larger), and I may add that later. For now, the flat cutoff is working. The 0.20 threshold was chosen by looking at the spread distribution in the logs and picking the point where execution quality falls off a cliff.
IOC Orders and Why Market Orders Are Wrong Here
When the bot does decide to execute, it uses limit orders placed as IOC: immediate-or-cancel.
IOC means: try to fill this order right now at this price or better. If you cannot fill it completely, cancel the unfilled portion. Do not leave a resting order in the book.
This matters for two reasons.
First, prediction market prices move. If the bot places a resting limit order at 0.58 and the market moves to 0.65 overnight, that order might fill at the old price hours later when the context has changed completely. The edge calculation at 11pm may be wrong by 6am. IOC prevents stale fills.
Second, resting orders in a thin book can get filled against informed traders. If someone with better information than me decides the contract is worth 0.45 and my resting 0.58 buy order is sitting there, I get filled and they are right and I am wrong. IOC limits the window for that to happen.
The Kalshi API supports IOC via the time_in_force parameter on order creation. Setting it to "ioc" is straightforward:
def place_ioc_order(
client,
ticker: str,
side: str,
count: int,
limit_price: int, # Kalshi prices in cents, integer
) -> dict:
"""
Place an IOC limit order on Kalshi.
Returns the API response or raises on failure.
limit_price is in cents (e.g., 58 = $0.58 per contract).
"""
order_payload = {
"ticker": ticker,
"client_order_id": generate_order_id(),
"type": "limit",
"action": side, # "buy" or "sell"
"count": count,
"limit_price": limit_price,
"time_in_force": "ioc",
}
response = client.create_order(**order_payload)
filled = response.get("order", {}).get("filled_count", 0)
remaining = count - filled
if remaining > 0:
logger.info(
f"IOC_PARTIAL ticker={ticker} requested={count} "
f"filled={filled} cancelled={remaining}"
)
return response
The partial fill handling is important. When an IOC order only partially fills, the bot logs it and moves on. It does not chase the remaining contracts with another order. Chasing is how you turn a thin book into your enemy.
The Early Exit Problem in Thin Markets
The early exit module adds a complication. When the bot wants to close a winning position before settlement (taking profit at 70% of maximum gain), it needs to sell into the same thin book it bought from.
This is where overnight liquidity becomes actively problematic.
If the bot entered a position at noon when the spread was 0.05 and now wants to exit at 2am when the spread is 0.22, the exit price it can actually get is much worse than what the marked position suggests. The theoretical gain looks good. The actual execution will be ugly.
The early exit module handles this with two checks. First, it skips the early exit entirely if the current spread exceeds 0.20. Better to hold to settlement than to sell into a 0.22 spread and give away the profit trying to exit. Second, it skips early exit within one hour of settlement regardless of spread, because at that point holding to settlement is cleaner than executing a messy exit.
def should_attempt_early_exit(
current_bid: float,
current_ask: float,
hours_to_settlement: float,
max_spread: float = 0.20,
min_hours_remaining: float = 1.0,
) -> tuple[bool, str]:
"""
Returns (should_exit, reason_string).
"""
if hours_to_settlement <= min_hours_remaining:
return False, f"too_close_to_settlement hours={hours_to_settlement:.2f}"
spread = current_ask - current_bid
if spread > max_spread:
return False, f"spread_too_wide spread={spread:.3f} max={max_spread:.3f}"
if current_bid is None or current_bid <= 0:
return False, "no_liquidity_bid_missing"
return True, "ok"
The reason_string goes into the log and into the database. I want to know how often the early exit is skipped due to liquidity versus skipped for other reasons. That data matters for tuning.
What the Logs Actually Show
Over a couple months of watching the overnight behavior, a few patterns stand out.
The bot skips a meaningful number of potential trades during overnight hours purely on spread width. Some of these are trades that would have been profitable if the execution had been better. I am okay with that tradeoff. A skipped trade costs nothing. A bad fill in a thin book can cost real money and distort the edge calculation for future analysis.
Early exits almost never fire between midnight and 6am. The spread filter blocks them consistently. This means the bot holds more positions to settlement overnight than it does during market hours. Settlement resolves those cleanly regardless of liquidity, so that is fine.
The IOC approach does produce partial fills, maybe 15-20% of overnight orders only partially fill. The bot handles these gracefully. It logs them, records the actual fill count, and the position size in the database reflects what actually got filled, not what was requested.
The Bigger Point
Kalshi is not a deep liquid market. It is a growing prediction market platform and the liquidity reflects that. The temperature market books are better than they were a year ago. They will probably be better a year from now, especially as Robinhood routing brings more retail flow onto the same infrastructure.
But right now, if you are running an automated bot that trades around the clock, you cannot pretend the market is the same at 2am as it is at noon. It is not.
The bot has to know when to trade aggressively, when to back off, and when to hold positions rather than execute a messy exit. That awareness does not come from the forecast models. It comes from reading the actual order book state and making decisions based on what is there, not what you wish was there.
Liquidity is a variable, not a constant. Build your execution layer like it is.
The Weather Bot v2.1 has all of this built in, including the spread filter, IOC order placement, and the early exit liquidity checks. If you are building your own Kalshi bot from scratch, these are not optional features to add later. They are things you need before you run real capital overnight. The source code is at predictandprofit.gumroad.com if you want to see exactly how it is wired together.
Thin books are a fact of life on prediction markets right now. The edge is in knowing that and trading accordingly.