Files
stonks-oracle/tests/test_pbt_tax_lots.py
T
Celes Renata 4ffde8cc06 feat: autonomous trading engine — full implementation
- Database migration 018 with 13 tables for trading engine state
- Trading engine service (services/trading/) with 12 pure computation modules:
  position sizer, stop-loss manager, reserve pool, circuit breaker,
  risk tier controller, correlation matrix, tax lots, trading window,
  gradual entry, notifications, micro-trading, backtester
- Core TradingEngine with pre-trade evaluation pipeline and integration wiring
- FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest)
- Performance tracker with Sharpe ratio, drawdown, profit factor computation
- 194 Python tests (165 property-based + 29 integration)
- Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page
- Helm chart entry, network policy, nginx proxy, ingress for trading-engine
- Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
2026-04-15 16:12:22 +00:00

292 lines
8.8 KiB
Python

"""Property-based tests for the Tax Lot Tracker.
Feature: autonomous-trading-engine
Tests properties 22 and 23 from the design specification,
covering FIFO lot ordering and wash sale detection.
"""
from __future__ import annotations
from datetime import date, timedelta
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.tax_lots import ClosedLot, TaxLot, TaxLotTracker
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _tax_lot_strategy(
ticker: str = "AAPL",
status: str = "open",
) -> st.SearchStrategy[TaxLot]:
"""Generate a random TaxLot with a fixed ticker and status."""
return st.builds(
TaxLot,
ticker=st.just(ticker),
quantity=st.integers(min_value=1, max_value=100),
cost_basis_per_share=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
acquisition_date=st.dates(
min_value=date(2023, 1, 1),
max_value=date(2025, 6, 1),
),
status=st.just(status),
)
# ---------------------------------------------------------------------------
# Property 22: Tax lot FIFO ordering
# **Validates: Requirements 12.4**
# ---------------------------------------------------------------------------
class TestProperty22TaxLotFIFOOrdering:
"""Property 22: Tax lot FIFO ordering.
For any sequence of buy transactions, closing lots SHALL follow
FIFO order — the earliest-acquired open lot is closed first.
The realized P&L for each closed lot SHALL equal
(exit_price - cost_basis_per_share) * quantity.
**Validates: Requirements 12.4**
"""
@given(
lots=st.lists(
_tax_lot_strategy(),
min_size=1,
max_size=10,
),
exit_price=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
exit_date=st.dates(
min_value=date(2025, 1, 1),
max_value=date(2025, 12, 31),
),
)
@settings(max_examples=100)
def test_lots_closed_in_fifo_order(
self,
lots: list[TaxLot],
exit_price: float,
exit_date: date,
) -> None:
"""Closed lots are ordered by acquisition_date ascending (FIFO)."""
total_available = sum(lot.quantity for lot in lots)
assume(total_available > 0)
tracker = TaxLotTracker()
closed = tracker.close_lots_fifo(
lots=lots,
quantity=total_available,
exit_price=exit_price,
exit_date=exit_date,
)
# Verify FIFO: each closed lot's acquisition_date is <= the next
for i in range(len(closed) - 1):
assert closed[i].acquisition_date <= closed[i + 1].acquisition_date, (
f"FIFO violated: lot {i} acquired {closed[i].acquisition_date} "
f"but lot {i+1} acquired {closed[i+1].acquisition_date}"
)
@given(
lots=st.lists(
_tax_lot_strategy(),
min_size=1,
max_size=10,
),
exit_price=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
exit_date=st.dates(
min_value=date(2025, 1, 1),
max_value=date(2025, 12, 31),
),
)
@settings(max_examples=100)
def test_realized_pnl_formula(
self,
lots: list[TaxLot],
exit_price: float,
exit_date: date,
) -> None:
"""Realized P&L = (exit_price - cost_basis_per_share) * quantity per lot."""
total_available = sum(lot.quantity for lot in lots)
assume(total_available > 0)
tracker = TaxLotTracker()
closed = tracker.close_lots_fifo(
lots=lots,
quantity=total_available,
exit_price=exit_price,
exit_date=exit_date,
)
for cl in closed:
expected_pnl = (cl.exit_price - cl.cost_basis_per_share) * cl.quantity
assert abs(cl.realized_pnl - expected_pnl) < 1e-6, (
f"P&L mismatch: got {cl.realized_pnl}, "
f"expected {expected_pnl} for lot acquired {cl.acquisition_date}"
)
@given(
lots=st.lists(
_tax_lot_strategy(),
min_size=2,
max_size=10,
),
exit_price=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
exit_date=st.dates(
min_value=date(2025, 1, 1),
max_value=date(2025, 12, 31),
),
)
@settings(max_examples=100)
def test_partial_close_respects_fifo(
self,
lots: list[TaxLot],
exit_price: float,
exit_date: date,
) -> None:
"""Closing fewer shares than available still follows FIFO order."""
total_available = sum(lot.quantity for lot in lots)
assume(total_available >= 2)
# Close only half the shares
close_qty = total_available // 2
assume(close_qty > 0)
tracker = TaxLotTracker()
closed = tracker.close_lots_fifo(
lots=lots,
quantity=close_qty,
exit_price=exit_price,
exit_date=exit_date,
)
# Total closed quantity should not exceed requested
total_closed = sum(cl.quantity for cl in closed)
assert total_closed <= close_qty
# FIFO order maintained
for i in range(len(closed) - 1):
assert closed[i].acquisition_date <= closed[i + 1].acquisition_date
# ---------------------------------------------------------------------------
# Property 23: Wash sale detection within 30-day window
# **Validates: Requirements 12.2, 12.3**
# ---------------------------------------------------------------------------
class TestProperty23WashSaleDetection:
"""Property 23: Wash sale detection within 30-day window.
For any position closed at a loss, the Tax Lot Tracker SHALL flag
it as a potential wash sale if the same ticker was purchased within
30 days before or after the loss date.
**Validates: Requirements 12.2, 12.3**
"""
@given(
loss_date=st.dates(
min_value=date(2024, 6, 1),
max_value=date(2025, 6, 1),
),
days_offset=st.integers(min_value=-30, max_value=30),
)
@settings(max_examples=100)
def test_wash_sale_flagged_within_30_days(
self,
loss_date: date,
days_offset: int,
) -> None:
"""Purchase within 30 days of loss triggers wash sale flag."""
purchase_date = loss_date + timedelta(days=days_offset)
purchase = TaxLot(
ticker="AAPL",
quantity=10,
cost_basis_per_share=100.0,
acquisition_date=purchase_date,
)
tracker = TaxLotTracker()
result = tracker.check_wash_sale(
loss_date=loss_date,
purchases=[purchase],
)
assert result is True, (
f"Expected wash sale flag for purchase {days_offset} days "
f"from loss date {loss_date}"
)
@given(
loss_date=st.dates(
min_value=date(2024, 6, 1),
max_value=date(2025, 6, 1),
),
days_offset=st.integers(min_value=31, max_value=365),
direction=st.sampled_from([-1, 1]),
)
@settings(max_examples=100)
def test_no_wash_sale_outside_30_days(
self,
loss_date: date,
days_offset: int,
direction: int,
) -> None:
"""Purchase outside 30-day window does not trigger wash sale."""
purchase_date = loss_date + timedelta(days=days_offset * direction)
purchase = TaxLot(
ticker="AAPL",
quantity=10,
cost_basis_per_share=100.0,
acquisition_date=purchase_date,
)
tracker = TaxLotTracker()
result = tracker.check_wash_sale(
loss_date=loss_date,
purchases=[purchase],
)
assert result is False, (
f"Unexpected wash sale flag for purchase {days_offset * direction} "
f"days from loss date {loss_date}"
)
@given(
loss_date=st.dates(
min_value=date(2024, 6, 1),
max_value=date(2025, 6, 1),
),
)
@settings(max_examples=100)
def test_no_wash_sale_with_no_purchases(
self,
loss_date: date,
) -> None:
"""No purchases means no wash sale."""
tracker = TaxLotTracker()
result = tracker.check_wash_sale(
loss_date=loss_date,
purchases=[],
)
assert result is False