4ffde8cc06
- 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
292 lines
8.8 KiB
Python
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
|