Weather Bot — Episode 8: FOK Ghost Orders, Orderbook Lies, and the UTC Bug That Broke My Polymarket Bot

The bot was making money. Resolution holds were working. The strategy felt solid. And then four bugs showed up in the span of a week, each one capable of quietly draining my account if I hadn't caught it.

None of these appeared during DRY RUN. Every single one only showed up when real money was moving through real APIs. That's the part nobody tells you about building trading bots — the code that works perfectly in testing can break in ways you never imagined once it's live.

What This Post Covers

Four bugs, found in order, each one weirder than the last. If you're building anything on Polymarket's API, this episode might save you some money. If you're not, it's at least a window into what "running an automated trading bot" actually looks like day to day.

The Orderbook That Lied

I lost an entire day of trading opportunities because of this one.

My buy logic checked get_order_book().asks to see if I could buy at my target price. Every single query came back with the same answer:

Best Ask: $0.990

For every token. YES and NO. Every city. My bot saw $0.990 and thought "nobody's selling below 99 cents" and skipped every opportunity. An entire day of zero trades while perfectly good markets were sitting right there.

The problem is Polymarket's unified orderbook. A YES BUY at $0.30 is simultaneously a NO SELL at $0.70. The asks field in get_order_book() returns values from this unified math, not what you'd actually pay. The number is technically correct in some abstract sense, but completely useless for deciding whether to buy.

The fix was one line:

# Always returned $0.990 — useless
book = client.get_order_book(token_id)
best_ask = float(book.asks[0].price)

# Returns the actual executable price — $0.12
price_info = client.get_price(token_id, "BUY")
best_price = float(price_info['price'])

get_price() instead of get_order_book(). Works for both YES and NO. After switching, the bot immediately started finding opportunities again.

The Ghost Orders

Fill-Or-Kill orders are supposed to either fill completely or cancel entirely. That's the whole point. In practice, the Polymarket API sometimes returns something like this:

{
    "orderID": "0x831680...",
    "success": true,
    "status": "delayed",
    "takingAmount": "0",
    "makingAmount": "0"
}

success: true. An orderID exists. But takingAmount: 0 — nothing actually filled. My bot looked at the first two fields and registered it as a real position. Eight fake NO positions in one day. Each tracked as $2 spent. The daily loss limit hit. Bot stopped trading.

Eight phantom positions. Zero actual shares. All because I trusted success without checking takingAmount.

# Before — trusted the orderID
success = result and (
    result.get("orderID")
    or result.get("success")
)

# After — verify actual fill
success = (
    result
    and result.get("success") is True
    and result.get("status")
        in ("matched", "delayed")
    and float(result.get("takingAmount", 0)) > 0
)

That status field matters more than I realized. "matched" means filled immediately. "delayed" means filled but settlement is pending on-chain — it's a real fill, money is deducted. Anything else means nothing happened.

I found out about "delayed" the hard way too. A Toronto NO order came back as delayed, my bot ignored it because it only checked for matched, but Polymarket actually filled it. $2 gone, no tracking. I only noticed when I manually checked the portfolio the next morning. Invisible loss.

The UTC Midnight Disaster

This one sent false alerts for six cities simultaneously.

My time functions compared dates like this:

if target_date != now_utc.date():
    return False  # "Not today, skip"

At UTC midnight (9 AM Korean time), the date flips. Suddenly every US city's March 16 market became "today." And my is_past_peak function calculated:

hours_since_peak = 0 - 20  # UTC 00:00 minus peak at UTC 20:00
hours_since_peak += 24      # = 4 hours
# 4 > 1 → "Peak has passed!"

Six cities got "peak passed" alerts at 9 AM Korean time. Their actual peaks were 17-21 hours away. My phone blew up with Telegram messages telling me to check positions that hadn't even started trading yet.

The root cause was using date comparison for something that needed absolute time comparison. The fix:

def is_past_peak(city, target_date):
    peak_dt = datetime(
        target.year, target.month, target.day,
        peak_hour, tzinfo=timezone.utc
    )
    diff_hours = (
        (now_utc - peak_dt).total_seconds() / 3600
    )
    return 1 < diff_hours < 36

Pure time difference. No date comparison. Works regardless of timezone or what happens at midnight.

The Win That Disappeared

Remember the Ankara +$5.89 from Episode 7? My biggest win, the trade that proved the resolution strategy worked?

The bot never recorded it.

After I manually claimed the payout on Polymarket's website, the shares in my wallet went to zero. My bot ran its position check, saw zero shares, and labeled it "fake position" — the same cleanup logic I'd built to handle the FOK ghost orders from earlier.

The problem was the order of operations in my code:

# WRONG ORDER
if actual_shares == 0:
    close_as_fake()      # ← catches claimed wins!
    continue

if market.get("closed"):
    process_resolution()  # ← never reached

Fake position check ran first. Resolution check came second. But after a manual claim, the shares are zero AND the market is closed. The code hit the first condition, called it fake, and never got to the second one. My +$5.89 profit was never logged.

# RIGHT ORDER
if market.get("closed"):
    process_resolution()  # ← catches wins first
    continue

if actual_shares == 0:
    close_as_fake()       # ← only real fakes get here

Two lines swapped. That's it. Check for resolution first, then check for fakes. The lesson was obvious in hindsight — resolved positions and fake positions both show zero shares, but for completely different reasons.

Key Takeaways

  • Use get_price(), not get_order_book() on Polymarket. The unified orderbook makes ask prices meaningless for buy decisions.
  • Never trust success: true alone on FOK orders. Check takingAmount > 0 and know the difference between matched and delayed status.
  • Don't compare dates when you need to compare times. UTC midnight will break your timezone logic every single day.
  • Check for resolution before checking for zero shares. Claimed wins and fake positions look identical to code that doesn't check in the right order.

What's Next

Four bugs found, four bugs fixed. The bot was running cleaner now, and the resolution strategy was holding up. In Episode 9, I'll cover the push from 5 cities to 15 — the API optimization that made it possible, the same-day trading ban I discovered the hard way, and the two-week live results that showed where things actually stood.

← Previous: Episode 7: The $0.45 Exit Strategy       Next: Episode 9: Scaling to 15 Cities


More updates on the way. If you're working on something similar or found a smarter way to do it, drop it in the comments — the more we share, the faster we all move.

Disclaimer: This blog documents my personal learning journey. Nothing here is financial advice.

Comments