Files
2026-10-31 08:53:30 -04:00

5.1 KiB

Answer Key: SPOILERS

Bug 1: Log levels are compared as strings (logger.py, _enabled)

return level >= self.level

The levels are compared alphabetically, which gives DEBUG < ERROR < INFO < WARNING. At level INFO, "ERROR" >= "INFO" is False, so every error is dropped. DEBUG is still suppressed and WARNING still gets through, so the logger looks correct in normal use. This is why Ops sees "no errors".

Fix:

return LEVELS.index(level) >= LEVELS.index(self.level)

An IntEnum or a level-to-number mapping also works.

Bug 2: Records keep a reference to the live context dict (logger.py, _log)

Record(time.time(), level, msg, self._context)

Each record stores a reference to the shared dict, not a copy of it. Records are only turned into text in flush(), after later push() calls have changed that dict. So every buffered line shows the context of the last trade (in main.py, all lines say trade_id=T7). If someone calls clear() before flushing, every line loses its context.

Fix: take a snapshot when the record is created:

Record(time.time(), level, msg, dict(self._context))

Another option is to render the line when it is logged.

Bug 3: signed_qty() changes the trade (ledger.py, Trade.signed_qty)

This is the state-change bug.

if self.side == "SELL" and self.qty > 0:
    self.qty = -self.qty

Calling signed_qty() flips qty to negative on the shared Trade object that is stored in history. During the EOD rebuild, _validate sees qty <= 0 and raises. rebuild swallows the exception, and Bug 1 throws away the error log. The result is that every sell silently disappears from the rebuild, which causes the reconcile breaks. The self.qty > 0 guard makes the code look safe because calling it twice gives the same result, but it still changes the trade.

Fix:

def signed_qty(self):
    return -self.qty if self.side == "SELL" else self.qty

Ideally also use @dataclass(frozen=True).

Bug 4: avg_price is wrong after a position flips (ledger.py, Position.update)

When a single trade moves a position through zero (for example, long 100 then sell 150 to end short 50), the code books P&L on the 100 shares it closed but keeps the old avg_price for the new short position. The correct cost basis for the new short is the trade price.

Fix: add a branch:

elif (new_qty > 0) != (self.qty > 0):
    self.avg_price = price

The test suite: why CI is green

Test Problem
test_position_flip assertTrue(pos.avg_price, 105.0) only checks that the first argument is truthy. The second argument is the failure message. This should be assertAlmostEqual.
test_rejection_is_logged Because of Bug 1 there are no ERROR lines, so the for/if body never runs. The test passes without making any assertion. It should first assert that an ERROR line exists.
test_context_attached It only logs one trade, so a stale context can't be seen. It needs two or more trades before the flush.
test_rebuild_matches_live It only uses buys, so the code that changes trades never runs. It needs a sell in the history.
test_debug_suppressed It is correct, but it gives false confidence. Alphabetical order puts DEBUG lowest, so this case happens to work.

Tests the candidate should write (each fails before the fix and passes after)

def test_error_logged_at_info(self):
    _, log, out = make_ledger("INFO")
    log.error("boom"); log.flush()
    self.assertIn("boom", out.getvalue())

def test_context_snapshot_per_record(self):
    ledger, log, out = make_ledger()
    ledger.apply(Trade("T1", "AAPL", "BUY", 10, 100.0))
    ledger.apply(Trade("T2", "MSFT", "BUY", 10, 100.0))
    log.flush()
    first, second = out.getvalue().splitlines()
    self.assertIn("trade_id=T1", first)
    self.assertIn("trade_id=T2", second)

def test_apply_does_not_mutate_trade(self):
    ledger, _, _ = make_ledger()
    t = Trade("T1", "AAPL", "SELL", 10, 100.0)
    ledger.apply(t)
    self.assertEqual(t.qty, 10)

def test_rebuild_with_sells(self):
    ledger, _, _ = make_ledger()
    ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0))
    ledger.apply(Trade("T2", "AAPL", "SELL", 40, 110.0))
    rebuilt = Ledger.rebuild(ledger.history, BufferedLogger("eod", stream=io.StringIO()))
    self.assertEqual(ledger.reconcile(rebuilt), [])

def test_flip_sets_new_cost_basis(self):
    ledger, _, _ = make_ledger()
    ledger.apply(Trade("T1", "AAPL", "BUY", 100, 100.0))
    ledger.apply(Trade("T2", "AAPL", "SELL", 150, 105.0))
    self.assertAlmostEqual(ledger.position("AAPL").avg_price, 105.0)

How the bugs chain together

Bug 3 makes the rebuild reject every sell. The rebuild swallows the exception, and Bug 1 throws away the error log. Bug 2 means the log lines that remain point at the wrong trade. Each bug hides the others, so no single fix fully clears the ticket.

Expected main.py output after all fixes

AAPL  qty=  -50 avg= 201.0000 realized=   1170.00
IBM   qty=  -30 avg= 225.0000 realized=      0.00
MSFT  qty=  200 avg= 410.0000 realized=      0.00
reconcile: OK