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