A note on language and vagueness
I write this series in English because Polymarket is not available in every jurisdiction, and writing in English is partly how I keep the audience self-selecting toward people who already understand what they are getting into. I also stay deliberately vague about anything that constitutes the actual edge — which signals trigger a trade, how positions are sized, which wallets the bot watches. That information stays private because it is the whole point of the exercise. What I share freely is the infrastructure: how the software is built, what broke, and what it taught me. This entry is entirely in that category.
The question the bot has to answer
At some point every trading system has to close the loop. A signal came in, a decision was made, an order went out, the market eventually resolved — did that sequence of events actually work? Answering that sounds like a statistics problem, but before you can do any math you have a bookkeeping problem. You need to know which fill belongs to which signal, whether every submitted order ever got a fill event at all, and whether running the analysis twice over the same raw data gives you the same answer both times. Getting that bookkeeping right took most of the work described in this entry.
Everything is an event, written once, never modified
The log the bot writes is append-only. A line is never edited, never deleted. Every meaningful moment in the bot’s life becomes one JSON object, stamped with a wall-clock time and pushed to the end of the file. The event types, simplified, look like this:
{
"event": "signal_detected",
"play_id": "a3f7...",
"ts": 1718200000
}
{
"event": "decision_accepted",
"play_id": "a3f7...",
"ts": 1718200001
}
{
"event": "order_submitted",
"play_id": "a3f7...",
"order_id": "ord_...",
"ts": 1718200003
}
{
"event": "order_filled",
"play_id": "a3f7...",
"order_id": "ord_...",
"ts": 1718200045
}
{
"event": "market_resolved",
"play_id": "a3f7...",
"outcome": "yes",
"ts": 1718291200
}
The play_id is the stable thread running through every stage. It is generated at signal detection and copied into every downstream event for that play. When you want to reconstruct the full story of a single trade, you group every event that shares a play_id and sort by timestamp. The result is a timeline for that play from first detection to final resolution.
The three hard parts
In theory this is simple. In practice three things complicate it.
First: events arrive out of order. The fill confirmation from the exchange API sometimes comes back before the internal “order submitted” event has been flushed to disk, especially if the order was very fast or the process was under load. The wall-clock timestamps are unreliable enough that you cannot just sort and trust them. The fix was to make the reconciler sort by a logical stage index — detected before decision, decision before submission, submission before fill, fill before resolution — and only fall back to wall-clock time as a tiebreaker within the same stage. Stage is derived from the event type, not from when the line was written.
Second: some events never arrive. The most common case is a process restart between submission and fill. The order goes out over the network; the bot crashes or is restarted; the fill event is never written. From the log’s perspective, the order was submitted and then nothing. The reconciler has to decide what that means. The choice I made was: an unmatched submission is treated as unresolved, not as a loss and not as a win. Unresolved plays sit in a separate bucket and get checked again on every reconciliation pass. If the fill event eventually arrives — written late, after a restart, once the bot polls the API and catches up — it lands in the log and the next pass picks it up, promoting that play out of the unresolved bucket. If an order is genuinely lost and no fill ever comes, the play stays unresolved indefinitely, which is the honest answer. Calling it a loss when you don’t actually know would corrupt the record.
Third: idempotency. Because the log is re-read constantly — the reconciler runs on a loop, not once — the matching logic has to produce exactly the same result every time it processes the same set of events. No double-counting, no state that accumulates across runs. The solution is to make the reconciler stateless with respect to the log: it reads all events, groups them by play_id, classifies each group, and writes its conclusions to a separate output structure that is always rebuilt from scratch. There is no “running total” that gets incremented. If you add one new event to the log and re-run, you get a fresh classification of every play, and the one new event changes only the plays it belongs to. The output is a pure function of the log.
What a completed play looks like in code
The reconciler loads events, groups them, and then classifies each group. The classification logic in rough pseudocode:
def classify_play(events):
by_type = group_by(events, key="event")
if "signal_detected" not in by_type:
return "malformed" # should never happen, logged as anomaly
if "decision_accepted" not in by_type:
return "rejected" # bot decided not to trade
if "order_submitted" not in by_type:
return "submission_failed" # accepted but never sent
if "order_filled" not in by_type:
return "unresolved" # submitted, no fill confirmed yet
if "market_resolved" not in by_type:
return "open" # filled, market still live
resolution = by_type["market_resolved"][0]["outcome"]
side = by_type["order_filled"][0]["side"]
if outcome_matches_side(resolution, side):
return "won"
else:
return "lost"
The function returns the same value every time for the same input. No mutation, no side effects on the log itself. Plays flow through the classification states as new events accumulate: unresolved becomes open when a fill event appears, open becomes won or lost when a resolution event appears. You can replay the entire log at any point in history and see exactly what the bot knew at that moment.
The lesson underneath the mechanics
What event sourcing buys you is auditability and correctness under failure. A system that mutates state in place — updating a database row from “submitted” to “filled” — loses the intermediate history. If there’s a bug in that transition, you cannot reconstruct what actually happened. With an append-only log, every intermediate state is preserved. The bug from Part 2, where a logging failure silently swallowed other errors, would have been caught much faster in this model: the absence of an expected event type in a play’s timeline is itself a signal that something went wrong at that stage.
The other lesson is about how to handle missing information honestly. It is tempting to make an assumption — unmatched submission equals lost order equals loss — because it keeps the books clean. But that assumption can be wrong, and wrong assumptions compound. Leaving a play in an unresolved state feels untidy. It is actually more correct, because it accurately represents what the system knows, which is nothing definitive yet. When the information arrives, the record corrects itself automatically, without any special-case repair code.
What comes next
The reconciler runs cleanly now, but it reads the entire log on every pass — a problem that sounds familiar from Part 3. The next entry will cover how I added a checkpoint mechanism so that reconciliation can resume from a known position rather than re-scanning from line one every cycle.