Add trade ledger debugging exercise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SirBlobandClaude Opus 5.5 committed 2026-10-31 09:03:25 -04:00
commit 0cd0fdb641
6 files changed
+358

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.answer_key/
+62
View File
@@ -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?
+86
View File
@@ -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
+68
View File
@@ -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()
+41
View File
@@ -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()
+98
View File
@@ -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()