Day 65 — CSV Writer: Appending Fills to the Master Trade Log
Your engine got OOM-killed at 09:31:14 mid-append to
master_trade_log.csv. The last fill’s row never got its trailing newline. The next fill wrote right after it, on the same line.
The Naive Approach
Every trade log starts the same way: a list of fills, a CSV writer, and open(path, "a").
# WRONG — buffered append with no completion guarantee
def log_fill(path, fill):
with open(path, "a", newline="") as f:
writer = csv.writer(f)
writer.writerow([fill.ts, fill.symbol, fill.side, fill.qty, fill.price])
This looks fine because every manual test runs to completion.
open(..., "a")doesn’t truncate anything — it just seeks to end-of-file and starts writing, which is why it feels safer than the overwrite case from path management. The mental model most engineers carry over is “append mode is additive, so the worst case is a missing row, not a corrupted one.” That model is wrong, and it’s wrong in a way that’s easy to miss because it only breaks under load.
csv.writer(f).writerow([...])doesn’t write your five fields as one atomic unit. It serializes them, joins them with commas, appends a line terminator, and callsTextIOWrapper.write()— which itself can issue more than one underlyingwrite()syscall depending on buffering and encoding. Nothing here guarantees the row you just wrote is a complete row before the process disappears. “Safer than overwrite” isn’t “safe.”
Github Link :
https://github.com/sysdr/quantpython-p/tree/main/day65/day_65/scripts
The Failure Mode in Detail
writer.writerow() issues a buffered write() call. If the process is killed between “wrote 40 of the 52 bytes in this row” and the OS actually committing those bytes, the file on disk ends mid-row — no trailing \n. This isn’t rare: it’s most likely during the exact moment your trade log is busiest, market open or a volatility spike, when memory pressure is highest and the orchestrator is most likely to send a SIGKILL rather than let the process exit cleanly. A graceful shutdown gives you a chance to flush; an OOM kill or a hard container restart gives you none.
The observable symptom isn’t a crash on restart — it’s silence. The next fill appends immediately after those orphaned bytes, so two fills now occupy one physical line. csv.DictReader either raises csv.Error: field larger than field limit, or — if the column count happens to divide evenly across the two merged rows — it parses successfully with price landing in the qty column for that row. Nothing alerts you, because nothing failed. The corruption sits quietly in your audit trail until someone reconciles P&L against the broker’s statement weeks later and the numbers don’t add up, by which point you’re debugging a stale file instead of a live system.
This never shows up in backtesting because backtests write their fills once, sequentially, at the end of a run, to a fresh file with no other process touching it. They never get killed mid-stream by a container orchestrator, and they never have two concurrent writers racing for the same file handle the way a live async engine — receiving fills from multiple symbols’ WebSocket streams at once — does.
The Shape of the Fix
The fix is to make each row’s write indivisible from the engine’s perspective: build the complete line — fields plus the trailing newline — as a single string, then write it in one call, then flush.
# CORRECT — write the complete row as one buffer, including the newline
def log_fill(path: Path, fill: Fill) -> None:
row = f"{fill.ts},{fill.symbol},{fill.side},{fill.qty},{fill.price}\n"
with open(path, "a", newline="") as f:
f.write(row)
f.flush()
Building the full row first means there’s no window where csv.writer is mid-row when the process dies — either the entire line is in the write buffer or none of it is. f.flush() then pushes that buffer from Python’s userspace buffer into the OS page cache, which is enough to survive a process crash (the OS still has the bytes), though not enough to survive a power loss (the page cache itself can still be lost before the disk controller commits it).
This snippet does not cover what happens when two coroutines call log_fill concurrently — their writes can still interleave at the OS level if the file is opened separately or the calls aren’t serialized, splicing two rows’ bytes together even though each row was built as a single string. It does not call os.fsync(), so a power loss between the flush and the disk write can still lose a row that the engine believes was persisted. And it does not check on load whether a previous crash left a trailing partial line sitting in the file, which means a loader written against this fix alone will happily hand a corrupted row to FillAuditLog as if it were valid. Those three gaps — locking, fsync durability, and load-time quarantine — are what the paid workspace closes.
Gate Check
assert row.endswith("\n")— every row written ends in a newline before the write call returnsassert isinstance(loaded_row["price"], str)— price hits disk as a Decimal-safe string, never a float reprassert load_fills(corrupted_path)raises or quarantines the trailing row instead of returning it as valid data
What’s in the Paid Post
This week’s paid post delivers the complete AppendOnlyCSVWriter workspace: an asyncio.Lock-serialized append path with fsync-before-return, a quarantine step on load that detects and isolates any newline-less trailing row, and integration with Day 64’s PathStorage for directory resolution — plus all 14 pytest gates green.




