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

139 lines
5.1 KiB
Markdown

# Answer Key: SPOILERS
## Bug 1: Log levels are compared as strings (`logger.py`, `_enabled`)
```python
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:**
```python
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`)
```python
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:
```python
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.
```python
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:**
```python
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:
```python
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)
```python
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
```