From 0cd0fdb64168219388e40b0aaa41109726499ed1 Mon Sep 17 00:00:00 2001 From: SirBlob Date: Sat, 31 Oct 2026 08:53:30 -0400 Subject: [PATCH] Add trade ledger debugging exercise Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 ++ README.md | 62 ++++++++++++++++++++++++++++++++ ledger.py | 86 ++++++++++++++++++++++++++++++++++++++++++++ logger.py | 68 +++++++++++++++++++++++++++++++++++ main.py | 41 +++++++++++++++++++++ test_ledger.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ledger.py create mode 100644 logger.py create mode 100644 main.py create mode 100644 test_ledger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7744f62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.answer_key/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1433862 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Trade Ledger: Debugging Exercise + +**Time:** 60 minutes **Language:** Python 3.10+ (standard library only) **AI:** Allowed (Codex in VS Code) + +## Files + +| File | Purpose | +|---|---| +| `logger.py` | A small logger that buffers records and writes them to a stream | +| `ledger.py` | Trades, positions, and the ledger that applies trades, rebuilds from history, and reconciles | +| `main.py` | Replays a sample trade list (a "blotter") through a live ledger, then does an end-of-day (EOD) rebuild and reconcile | +| `test_ledger.py` | The existing test suite | + +```bash +python3 main.py +python3 -m unittest -v +``` + +## The ticket + +> **From:** Trading Ops +> **Priority:** High +> +> The end-of-day reconcile is reporting breaks between the live ledger and the EOD rebuild, +> and some desks say their average prices look wrong after big sells. We checked the logs +> and found no errors, and the log lines don't seem to match the trades they're about. +> CI is green. + +## Your tasks + +1. **Clarify first.** Before you change any code, tell the interviewer which assumptions you are making, and ask about any that are unclear. +2. **Reproduce** the reported behaviour. +3. **Find and fix the 4 bugs** in the codebase. For each one, explain the root cause, not only the symptom. +4. **Review the test suite.** CI is green even though the code is broken. Explain why. +5. **Write your own tests.** Each bug needs at least one test that fails before your fix and passes after it. +6. **Talk through your trade-offs** as you go. + +## Given assumptions + +1. A `Trade` has `trade_id`, `symbol`, `side` (`"BUY"` or `"SELL"`), `qty`, and `price`. +2. `qty` on a trade is always entered as a positive whole number. The side gives the direction. +3. A trade with a qty or price of zero or less, or an unknown side, is invalid. It must be rejected and logged at `ERROR`. +4. Positions use **weighted-average cost**. +5. Realized P&L is booked only when a position is reduced. +6. `Ledger.history` is the record of truth for every accepted trade. The EOD job rebuilds a fresh ledger from it. +7. The two ledgers reconcile when every symbol has the same `qty` and `avg_price`. +8. Every log line must name the trade it is about. +9. A logger only writes messages at or above its configured level. +10. Single-threaded. You don't need to handle persistence, partial fills, or fees. + +## Assumptions you should clarify with the interviewer + +These are left open on purpose. Asking about them is part of the exercise. + +- Is short selling allowed? Can one trade move a position from long, through zero, to short? +- When a position flips from long to short, or short to long, what should `avg_price` be on the new side? +- Is a `Trade` object safe to modify once it has been submitted, or should it be treated as immutable? +- Should a rejected trade appear in `history`? +- Should the EOD rebuild stop on the first bad trade, or skip it and carry on? Either way, how should it report the problem? +- Can records be lost if the process exits before `flush()` is called? Does that matter here? +- Can level names be given in lowercase? Should custom levels be supported? +- Is a tolerance of `1e-9` right for comparing prices? diff --git a/ledger.py b/ledger.py new file mode 100644 index 0000000..6416db7 --- /dev/null +++ b/ledger.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +SIDES = ("BUY", "SELL") + + +@dataclass +class Trade: + trade_id: str + symbol: str + side: str + qty: int + price: float + + def signed_qty(self): + if self.side == "SELL" and self.qty > 0: + self.qty = -self.qty + return self.qty + + +@dataclass +class Position: + symbol: str + qty: int = 0 + avg_price: float = 0.0 + realized: float = 0.0 + + def update(self, qty, price): + new_qty = self.qty + qty + if self.qty == 0 or (self.qty > 0) == (qty > 0): + self.avg_price = (self.avg_price * self.qty + price * qty) / new_qty + else: + closed = min(abs(qty), abs(self.qty)) + direction = 1 if self.qty > 0 else -1 + self.realized += closed * (price - self.avg_price) * direction + if new_qty == 0: + self.avg_price = 0.0 + self.qty = new_qty + + +class Ledger: + def __init__(self, logger): + self.log = logger + self.positions = {} + self.history = [] + + def _validate(self, trade): + if trade.side not in SIDES: + raise ValueError(f"bad side {trade.side!r}") + if trade.qty <= 0: + raise ValueError(f"bad qty {trade.qty!r}") + if trade.price <= 0: + raise ValueError(f"bad price {trade.price!r}") + + def apply(self, trade): + self.log.push(trade_id=trade.trade_id, symbol=trade.symbol) + try: + self._validate(trade) + except ValueError as exc: + self.log.error(f"rejected: {exc}") + raise + pos = self.positions.setdefault(trade.symbol, Position(trade.symbol)) + pos.update(trade.signed_qty(), trade.price) + self.history.append(trade) + self.log.info(f"applied {trade.side} {abs(trade.qty)}@{trade.price:.2f} -> {pos.qty}") + return pos + + def position(self, symbol): + return self.positions.get(symbol, Position(symbol)) + + @classmethod + def rebuild(cls, trades, logger): + ledger = cls(logger) + for trade in trades: + try: + ledger.apply(trade) + except ValueError: + continue + return ledger + + def reconcile(self, other): + breaks = [] + for symbol in sorted(set(self.positions) | set(other.positions)): + mine, theirs = self.position(symbol), other.position(symbol) + if mine.qty != theirs.qty or abs(mine.avg_price - theirs.avg_price) > 1e-9: + breaks.append((symbol, mine.qty, theirs.qty)) + return breaks diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..45a79f9 --- /dev/null +++ b/logger.py @@ -0,0 +1,68 @@ +import sys +import time + +LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR") + + +class Record: + __slots__ = ("ts", "level", "msg", "context") + + def __init__(self, ts, level, msg, context): + self.ts = ts + self.level = level + self.msg = msg + self.context = context + + def render(self): + ctx = " ".join(f"{k}={v}" for k, v in sorted(self.context.items())) + return f"{self.ts:.6f} {self.level:<7} {self.msg} {ctx}".rstrip() + + +class BufferedLogger: + def __init__(self, name, level="INFO", stream=None, capacity=64): + self.name = name + self.stream = stream if stream is not None else sys.stderr + self.capacity = capacity + self._context = {} + self._buffer = [] + self.set_level(level) + + def set_level(self, level): + level = level.upper() + if level not in LEVELS: + raise ValueError(f"unknown level {level!r}") + self.level = level + + def push(self, **fields): + self._context.update(fields) + + def clear(self): + self._context.clear() + + def _enabled(self, level): + return level >= self.level + + def _log(self, level, msg): + if not self._enabled(level): + return + self._buffer.append(Record(time.time(), level, msg, self._context)) + if len(self._buffer) >= self.capacity: + self.flush() + + def debug(self, msg): + self._log("DEBUG", msg) + + def info(self, msg): + self._log("INFO", msg) + + def warning(self, msg): + self._log("WARNING", msg) + + def error(self, msg): + self._log("ERROR", msg) + + def flush(self): + for rec in self._buffer: + self.stream.write(f"[{self.name}] {rec.render()}\n") + self.stream.flush() + self._buffer.clear() diff --git a/main.py b/main.py new file mode 100644 index 0000000..2a0068e --- /dev/null +++ b/main.py @@ -0,0 +1,41 @@ +import sys + +from ledger import Ledger, Trade +from logger import BufferedLogger + +BLOTTER = [ + Trade("T1", "AAPL", "BUY", 100, 190.00), + Trade("T2", "AAPL", "BUY", 50, 196.00), + Trade("T3", "MSFT", "BUY", 200, 410.00), + Trade("T4", "AAPL", "SELL", 120, 199.50), + Trade("T5", "MSFT", "SELL", 0, 412.00), + Trade("T6", "AAPL", "SELL", 80, 201.00), + Trade("T7", "IBM", "SELL", 30, 225.00), +] + + +def main(): + live_log = BufferedLogger("live", level="INFO", stream=sys.stdout) + live = Ledger(live_log) + for trade in BLOTTER: + try: + live.apply(trade) + except ValueError: + pass + live_log.flush() + + eod_log = BufferedLogger("eod", level="INFO", stream=sys.stdout) + eod = Ledger.rebuild(live.history, eod_log) + eod_log.flush() + + print() + for symbol in sorted(live.positions): + p = live.positions[symbol] + print(f"{symbol:<5} qty={p.qty:>5} avg={p.avg_price:>9.4f} realized={p.realized:>10.2f}") + + breaks = live.reconcile(eod) + print("reconcile:", "OK" if not breaks else breaks) + + +if __name__ == "__main__": + main() diff --git a/test_ledger.py b/test_ledger.py new file mode 100644 index 0000000..256137d --- /dev/null +++ b/test_ledger.py @@ -0,0 +1,98 @@ +import io +import unittest + +from ledger import Ledger, Trade +from logger import BufferedLogger + + +def make_ledger(level="INFO"): + out = io.StringIO() + log = BufferedLogger("test", level=level, stream=out, capacity=1000) + return Ledger(log), log, out + + +class PositionTests(unittest.TestCase): + def test_buys_average_in(self): + ledger, _, _ = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0)) + ledger.apply(Trade("T2", "AAPL", "BUY", 100, 110.0)) + pos = ledger.position("AAPL") + self.assertEqual(pos.qty, 200) + self.assertAlmostEqual(pos.avg_price, 105.0) + + def test_partial_sell_realizes_pnl(self): + ledger, _, _ = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0)) + ledger.apply(Trade("T2", "AAPL", "SELL", 40, 110.0)) + pos = ledger.position("AAPL") + self.assertEqual(pos.qty, 60) + self.assertAlmostEqual(pos.avg_price, 100.0) + self.assertAlmostEqual(pos.realized, 400.0) + + def test_close_out(self): + ledger, _, _ = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0)) + ledger.apply(Trade("T2", "AAPL", "SELL", 100, 90.0)) + pos = ledger.position("AAPL") + self.assertEqual(pos.qty, 0) + self.assertAlmostEqual(pos.realized, -1000.0) + + def test_position_flip(self): + ledger, _, _ = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0)) + ledger.apply(Trade("T2", "AAPL", "SELL", 150, 105.0)) + pos = ledger.position("AAPL") + self.assertEqual(pos.qty, -50) + self.assertAlmostEqual(pos.realized, 500.0) + self.assertTrue(pos.avg_price, 105.0) + + +class ValidationTests(unittest.TestCase): + def test_zero_qty_rejected(self): + ledger, _, _ = make_ledger() + with self.assertRaises(ValueError): + ledger.apply(Trade("T1", "AAPL", "BUY", 0, 100.0)) + + def test_rejection_is_logged(self): + ledger, log, out = make_ledger() + with self.assertRaises(ValueError): + ledger.apply(Trade("T9", "AAPL", "BUY", 0, 100.0)) + log.flush() + for line in out.getvalue().splitlines(): + if "ERROR" in line: + self.assertIn("trade_id=T9", line) + + +class LoggerTests(unittest.TestCase): + def test_info_written(self): + ledger, log, out = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 10, 100.0)) + log.flush() + self.assertIn("applied BUY 10@100.00", out.getvalue()) + + def test_debug_suppressed(self): + _, log, out = make_ledger() + log.debug("noisy") + log.flush() + self.assertNotIn("noisy", out.getvalue()) + + def test_context_attached(self): + ledger, log, out = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 10, 100.0)) + log.flush() + self.assertIn("trade_id=T1", out.getvalue()) + self.assertIn("symbol=AAPL", out.getvalue()) + + +class RebuildTests(unittest.TestCase): + def test_rebuild_matches_live(self): + ledger, _, _ = make_ledger() + ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0)) + ledger.apply(Trade("T2", "MSFT", "BUY", 50, 400.0)) + ledger.apply(Trade("T3", "AAPL", "BUY", 20, 101.0)) + rebuilt = Ledger.rebuild(ledger.history, BufferedLogger("eod", stream=io.StringIO())) + self.assertEqual(ledger.reconcile(rebuilt), []) + + +if __name__ == "__main__": + unittest.main()