Add trade ledger debugging exercise
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0cd0fdb641
6 files changed
+358
No files matched your search
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.answer_key/
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user