From 1ea006e41f653a4841387ed89b1db6657b9e77de Mon Sep 17 00:00:00 2001 From: Andrew Kaszubski Date: Fri, 26 Dec 2025 14:46:06 +1100 Subject: [PATCH] feat(db): add Trade model with CGT tracking and Australian FY support (#6) - TradeSide/TradeStatus/TradeOrderType enums, 50% discount for >12mo holdings, multi-currency FX, 87 tests --- CHANGELOG.md | 25 + migrations/versions/005_add_trade_model.py | 385 +++ .../integration/api/test_trade_integration.py | 1235 ++++++++++ tests/unit/api/test_trade_model.py | 2054 +++++++++++++++++ tradingagents/api/models/__init__.py | 15 +- tradingagents/api/models/portfolio.py | 6 + tradingagents/api/models/trade.py | 664 ++++++ 7 files changed, 4383 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/005_add_trade_model.py create mode 100644 tests/integration/api/test_trade_integration.py create mode 100644 tests/unit/api/test_trade_model.py create mode 100644 tradingagents/api/models/trade.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 593d12fc..05638424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Trade model for execution history with CGT tracking (Issue #6: DB-5) + - Trade model with BUY/SELL sides and execution status tracking (PENDING, FILLED, PARTIAL, CANCELLED, REJECTED) [file:tradingagents/api/models/trade.py](tradingagents/api/models/trade.py) + - TradeSide, TradeStatus, TradeOrderType enums for type-safe trade operations [file:tradingagents/api/models/trade.py:86-137](tradingagents/api/models/trade.py) + - Order type support (MARKET, LIMIT, STOP, STOP_LIMIT) with signal source and confidence tracking + - Capital Gains Tax (CGT) support for Australian tax compliance [file:tradingagents/api/models/trade.py:201-305](tradingagents/api/models/trade.py) + - 50% CGT discount eligibility for holdings >12 months (cgt_discount_eligible field) + - Australian financial year (FY) calculation (July-June) via tax_year property [file:tradingagents/api/models/trade.py:418-441](tradingagents/api/models/trade.py) + - CGT gain/loss tracking with gross_gain, gross_loss, and net_gain after discount + - Multi-currency support with FX rate to AUD conversion [file:tradingagents/api/models/trade.py:306-325](tradingagents/api/models/trade.py) + - High-precision decimal arithmetic (19,4) for monetary values and (19,8) for FX rates + - Automatic acquisition_date and timestamp management via default values and validators + - Check constraints for positive values (quantity, price, total_value, fx_rate_to_aud) + - Signal confidence validation (0-100 range) and holding period non-negative constraint + - Many-to-one relationship with Portfolio model with cascade delete behavior [file:tradingagents/api/models/portfolio.py:202-205](tradingagents/api/models/portfolio.py) + - Properties for convenient trade type checking (is_buy, is_sell, is_filled) [file:tradingagents/api/models/trade.py:443-475](tradingagents/api/models/trade.py) + - Comprehensive validators for enum normalization (side, status, order_type) and symbol/currency normalization [file:tradingagents/api/models/trade.py:477-585](tradingagents/api/models/trade.py) + - Event listener validation (before_flush) for cross-field business rule enforcement [file:tradingagents/api/models/trade.py:596-665](tradingagents/api/models/trade.py) + - Composite indexes for efficient queries: (portfolio_id, symbol) and (portfolio_id, side) and (status, executed_at) + - Database migration 005_add_trade_model.py with comprehensive schema definition [file:migrations/versions/005_add_trade_model.py](migrations/versions/005_add_trade_model.py) + - Migration with proper upgrade and downgrade functions for reversible schema changes + - Comprehensive unit test suite covering field validation, defaults, constraints, enums, and CGT calculations [file:tests/unit/api/test_trade_model.py](tests/unit/api/test_trade_model.py) (2054 lines, 65 tests) + - Integration test suite for relationships, cascade delete, and concurrent operations [file:tests/integration/api/test_trade_integration.py](tests/integration/api/test_trade_integration.py) (1235 lines, 22 tests) + - Comprehensive docstrings with examples for all Trade model methods and fields + - Total: 87 tests added for Trade model feature + - FastAPI backend with JWT authentication and strategies CRUD (Issue #48) - FastAPI application with async/await support and health check endpoints [file:tradingagents/api/main.py](tradingagents/api/main.py) - JWT authentication with asymmetric RS256 signing algorithm [file:tradingagents/api/services/auth_service.py](tradingagents/api/services/auth_service.py) diff --git a/migrations/versions/005_add_trade_model.py b/migrations/versions/005_add_trade_model.py new file mode 100644 index 00000000..b7712896 --- /dev/null +++ b/migrations/versions/005_add_trade_model.py @@ -0,0 +1,385 @@ +"""Add Trade model for execution history with CGT tracking + +Revision ID: 005 +Revises: 004 +Create Date: 2025-12-26 15:00:00.000000 + +This migration adds the trades table for tracking buy/sell trade executions +with full capital gains tax (CGT) support for Australian tax compliance. +Each trade belongs to a portfolio and includes acquisition details, cost basis, +holding period calculations, and CGT discount eligibility. + +Table: trades +Columns: + Core Trade: + - id: Primary key, auto-increment + - portfolio_id: Foreign key to portfolios.id (cascade delete) + - symbol: Stock/asset symbol (STRING 20, uppercase) + - side: Trade side (ENUM: BUY, SELL) + - quantity: Number of units traded (NUMERIC 19,4) + - price: Price per unit (NUMERIC 19,4) + - total_value: Total trade value (NUMERIC 19,4) + - order_type: Order type (ENUM: MARKET, LIMIT, STOP, STOP_LIMIT) + - status: Trade status (ENUM: PENDING, FILLED, PARTIAL, CANCELLED, REJECTED) + - executed_at: Timestamp when executed (DATETIME, nullable) + + Signal: + - signal_source: Source of trading signal (STRING 100, nullable) + - signal_confidence: Confidence score 0-100 (NUMERIC 5,2, nullable) + + CGT (Australian Tax): + - acquisition_date: Date asset acquired (DATE) + - cost_basis_per_unit: Purchase price per unit (NUMERIC 19,4, nullable) + - cost_basis_total: Total purchase cost (NUMERIC 19,4, nullable) + - holding_period_days: Days held (INTEGER, nullable) + - cgt_discount_eligible: Eligible for 50% CGT discount (BOOLEAN, default: false) + - cgt_gross_gain: Gross capital gain (NUMERIC 19,4, nullable) + - cgt_gross_loss: Gross capital loss (NUMERIC 19,4, nullable) + - cgt_net_gain: Net capital gain after discount (NUMERIC 19,4, nullable) + + Currency: + - currency: 3-letter currency code (STRING 3, default: AUD) + - fx_rate_to_aud: FX rate to AUD (NUMERIC 19,8, default: 1.0) + - total_value_aud: Total value in AUD (NUMERIC 19,4, nullable) + + Timestamps: + - created_at: Timestamp when created (auto) + - updated_at: Timestamp when last updated (auto) + +Constraints: +- CHECK quantity > 0 +- CHECK price > 0 +- CHECK total_value > 0 +- CHECK signal_confidence >= 0 AND signal_confidence <= 100 +- CHECK holding_period_days >= 0 OR holding_period_days IS NULL +- CHECK fx_rate_to_aud > 0 + +Indexes: +- ix_trades_portfolio_id: Index on portfolio_id +- ix_trades_symbol: Index on symbol +- ix_trades_side: Index on side +- ix_trades_status: Index on status +- ix_trades_acquisition_date: Index on acquisition_date +- ix_trade_portfolio_symbol: Composite index on (portfolio_id, symbol) +- ix_trade_portfolio_side: Composite index on (portfolio_id, side) +- ix_trade_status_executed: Composite index on (status, executed_at) + +Relationships: +- trades.portfolio_id -> portfolios.id (CASCADE DELETE) +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '005' +down_revision: Union[str, None] = '004' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create trades table with all constraints and indexes. + + Creates the trades table for tracking trade executions with CGT support + and proper foreign keys, constraints, and indexes. + """ + # Create trades table + op.create_table( + 'trades', + # Primary key + sa.Column( + 'id', + sa.Integer(), + nullable=False, + primary_key=True, + autoincrement=True + ), + + # Foreign key to portfolios (cascade delete) + sa.Column( + 'portfolio_id', + sa.Integer(), + sa.ForeignKey('portfolios.id', ondelete='CASCADE'), + nullable=False, + comment='Portfolio this trade belongs to' + ), + + # Core trade fields + sa.Column( + 'symbol', + sa.String(length=20), + nullable=False, + comment='Stock/asset symbol (uppercase)' + ), + + sa.Column( + 'side', + sa.String(length=10), + nullable=False, + comment='Trade side: BUY or SELL' + ), + + sa.Column( + 'quantity', + sa.Numeric(precision=19, scale=4), + nullable=False, + comment='Number of units traded' + ), + + sa.Column( + 'price', + sa.Numeric(precision=19, scale=4), + nullable=False, + comment='Price per unit' + ), + + sa.Column( + 'total_value', + sa.Numeric(precision=19, scale=4), + nullable=False, + comment='Total trade value (quantity * price)' + ), + + sa.Column( + 'order_type', + sa.String(length=20), + nullable=False, + comment='Order type: MARKET, LIMIT, STOP, STOP_LIMIT' + ), + + sa.Column( + 'status', + sa.String(length=20), + nullable=False, + server_default='PENDING', + comment='Trade status: PENDING, FILLED, PARTIAL, CANCELLED, REJECTED' + ), + + sa.Column( + 'executed_at', + sa.DateTime(timezone=True), + nullable=True, + comment='Timestamp when trade was executed (nullable for pending)' + ), + + # Signal fields + sa.Column( + 'signal_source', + sa.String(length=100), + nullable=True, + comment='Source of trading signal (e.g., RSI_DIVERGENCE)' + ), + + sa.Column( + 'signal_confidence', + sa.Numeric(precision=5, scale=2), + nullable=True, + comment='Signal confidence score 0-100' + ), + + # CGT (Capital Gains Tax) fields + sa.Column( + 'acquisition_date', + sa.Date(), + nullable=False, + comment='Date asset was acquired (for CGT)' + ), + + sa.Column( + 'cost_basis_per_unit', + sa.Numeric(precision=19, scale=4), + nullable=True, + comment='Purchase price per unit for CGT calculation' + ), + + sa.Column( + 'cost_basis_total', + sa.Numeric(precision=19, scale=4), + nullable=True, + comment='Total purchase cost for CGT calculation' + ), + + sa.Column( + 'holding_period_days', + sa.Integer(), + nullable=True, + comment='Days held (for 50% CGT discount eligibility)' + ), + + sa.Column( + 'cgt_discount_eligible', + sa.Boolean(), + nullable=False, + server_default='false', + comment='Eligible for 50% CGT discount (held >365 days)' + ), + + sa.Column( + 'cgt_gross_gain', + sa.Numeric(precision=19, scale=4), + nullable=True, + comment='Gross capital gain before discount' + ), + + sa.Column( + 'cgt_gross_loss', + sa.Numeric(precision=19, scale=4), + nullable=True, + comment='Gross capital loss' + ), + + sa.Column( + 'cgt_net_gain', + sa.Numeric(precision=19, scale=4), + nullable=True, + comment='Net capital gain after 50% discount' + ), + + # Currency fields + sa.Column( + 'currency', + sa.String(length=3), + nullable=False, + server_default='AUD', + comment='Currency code (ISO 4217, e.g., AUD, USD)' + ), + + sa.Column( + 'fx_rate_to_aud', + sa.Numeric(precision=19, scale=8), + nullable=False, + server_default='1.0', + comment='Foreign exchange rate to AUD' + ), + + sa.Column( + 'total_value_aud', + sa.Numeric(precision=19, scale=4), + nullable=True, + comment='Total value in AUD (for multi-currency portfolios)' + ), + + # Timestamps (from TimestampMixin) + sa.Column( + 'created_at', + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text('CURRENT_TIMESTAMP'), + comment='Timestamp when trade was created' + ), + + sa.Column( + 'updated_at', + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text('CURRENT_TIMESTAMP'), + comment='Timestamp when trade was last updated' + ), + + # Check constraints: positive values + sa.CheckConstraint( + 'quantity > 0', + name='ck_trade_quantity_positive' + ), + + sa.CheckConstraint( + 'price > 0', + name='ck_trade_price_positive' + ), + + sa.CheckConstraint( + 'total_value > 0', + name='ck_trade_total_value_positive' + ), + + # Check constraint: signal confidence range + sa.CheckConstraint( + 'signal_confidence >= 0 AND signal_confidence <= 100', + name='ck_trade_signal_confidence_range' + ), + + # Check constraint: holding period non-negative + sa.CheckConstraint( + 'holding_period_days >= 0 OR holding_period_days IS NULL', + name='ck_trade_holding_period_positive' + ), + + # Check constraint: FX rate positive + sa.CheckConstraint( + 'fx_rate_to_aud > 0', + name='ck_trade_fx_rate_positive' + ), + ) + + # Create indexes for efficient queries + # Single column indexes + op.create_index( + 'ix_trades_portfolio_id', + 'trades', + ['portfolio_id'] + ) + + op.create_index( + 'ix_trades_symbol', + 'trades', + ['symbol'] + ) + + op.create_index( + 'ix_trades_side', + 'trades', + ['side'] + ) + + op.create_index( + 'ix_trades_status', + 'trades', + ['status'] + ) + + op.create_index( + 'ix_trades_acquisition_date', + 'trades', + ['acquisition_date'] + ) + + # Composite indexes for common query patterns + op.create_index( + 'ix_trade_portfolio_symbol', + 'trades', + ['portfolio_id', 'symbol'] + ) + + op.create_index( + 'ix_trade_portfolio_side', + 'trades', + ['portfolio_id', 'side'] + ) + + op.create_index( + 'ix_trade_status_executed', + 'trades', + ['status', 'executed_at'] + ) + + +def downgrade() -> None: + """Drop trades table and all associated indexes and constraints. + + WARNING: This will permanently delete all trade execution data! + """ + # Drop all indexes + op.drop_index('ix_trade_status_executed', 'trades') + op.drop_index('ix_trade_portfolio_side', 'trades') + op.drop_index('ix_trade_portfolio_symbol', 'trades') + op.drop_index('ix_trades_acquisition_date', 'trades') + op.drop_index('ix_trades_status', 'trades') + op.drop_index('ix_trades_side', 'trades') + op.drop_index('ix_trades_symbol', 'trades') + op.drop_index('ix_trades_portfolio_id', 'trades') + + # Drop the table (constraints are dropped automatically) + op.drop_table('trades') diff --git a/tests/integration/api/test_trade_integration.py b/tests/integration/api/test_trade_integration.py new file mode 100644 index 00000000..a601834e --- /dev/null +++ b/tests/integration/api/test_trade_integration.py @@ -0,0 +1,1235 @@ +"""Integration tests for Trade model (Issue #6: DB-5). + +Tests for Trade model integration with: +- Portfolio model relationships +- CGT calculation workflows (FIFO matching) +- Multi-currency trade scenarios +- Trade lifecycle management +- Complex query scenarios +- Tax year reporting +- Position tracking + +Follows TDD principles - tests written BEFORE implementation. +""" + +import pytest +from decimal import Decimal +from datetime import datetime, date, timedelta +from sqlalchemy import select, func, and_, or_ +from sqlalchemy.exc import IntegrityError + +# Mark all tests in this module as asyncio +pytestmark = pytest.mark.asyncio + + +class TestTradePortfolioIntegration: + """Integration tests for Trade-Portfolio relationships.""" + + @pytest.mark.asyncio + async def test_create_trade_for_portfolio(self, db_session, test_portfolio): + """Should create trade linked to portfolio.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + await db_session.refresh(test_portfolio, ["trades"]) + + # Verify both sides of relationship + assert trade.portfolio_id == test_portfolio.id + assert trade in test_portfolio.trades + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_portfolio_with_multiple_trades(self, db_session, test_portfolio): + """Should allow portfolio to have multiple trades.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trades_data = [ + ("AAPL", Decimal("100"), Decimal("150.00")), + ("TSLA", Decimal("50"), Decimal("200.00")), + ("GOOGL", Decimal("25"), Decimal("120.00")), + ] + + for symbol, quantity, price in trades_data: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol=symbol, + side=TradeSide.BUY, + quantity=quantity, + price=price, + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + db_session.add(trade) + + await db_session.commit() + + # Refresh and verify + await db_session.refresh(test_portfolio, ["trades"]) + + assert len(test_portfolio.trades) == 3 + + symbols = {t.symbol for t in test_portfolio.trades} + assert "AAPL" in symbols + assert "TSLA" in symbols + assert "GOOGL" in symbols + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trades_deleted_with_portfolio(self, db_session, test_user): + """Should cascade delete trades when portfolio is deleted.""" + try: + from tradingagents.api.models.portfolio import Portfolio, PortfolioType + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create a temporary portfolio + portfolio = Portfolio( + user_id=test_user.id, + name="Temp Portfolio", + portfolio_type=PortfolioType.PAPER, + initial_capital=Decimal("10000.00"), + ) + + db_session.add(portfolio) + await db_session.commit() + await db_session.refresh(portfolio) + + # Create trades for portfolio + trade1 = Trade( + portfolio_id=portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + trade2 = Trade( + portfolio_id=portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add_all([trade1, trade2]) + await db_session.commit() + + trade1_id = trade1.id + trade2_id = trade2.id + + # Delete the portfolio + await db_session.delete(portfolio) + await db_session.commit() + + # Verify trades are deleted + result = await db_session.execute( + select(Trade).where( + Trade.id.in_([trade1_id, trade2_id]) + ) + ) + remaining = result.scalars().all() + + assert len(remaining) == 0 + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_multiple_portfolios_separate_trades(self, db_session, test_user, another_user): + """Should isolate trades between different portfolios.""" + try: + from tradingagents.api.models.portfolio import Portfolio, PortfolioType + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create portfolios for different users + portfolio1 = Portfolio( + user_id=test_user.id, + name="User 1 Portfolio", + portfolio_type=PortfolioType.PAPER, + initial_capital=Decimal("10000.00"), + ) + + portfolio2 = Portfolio( + user_id=another_user.id, + name="User 2 Portfolio", + portfolio_type=PortfolioType.PAPER, + initial_capital=Decimal("10000.00"), + ) + + db_session.add_all([portfolio1, portfolio2]) + await db_session.commit() + + # Create trades for each portfolio + trade1 = Trade( + portfolio_id=portfolio1.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + trade2 = Trade( + portfolio_id=portfolio2.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("200"), + price=Decimal("155.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add_all([trade1, trade2]) + await db_session.commit() + + # Query trades for each portfolio + result1 = await db_session.execute( + select(Trade).where(Trade.portfolio_id == portfolio1.id) + ) + trades1 = result1.scalars().all() + + result2 = await db_session.execute( + select(Trade).where(Trade.portfolio_id == portfolio2.id) + ) + trades2 = result2.scalars().all() + + assert len(trades1) == 1 + assert len(trades2) == 1 + assert trades1[0].quantity == Decimal("100") + assert trades2[0].quantity == Decimal("200") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeCGTEndToEnd: + """Integration tests for full CGT calculation lifecycle.""" + + @pytest.mark.asyncio + async def test_simple_buy_sell_cgt_workflow(self, db_session, test_portfolio): + """Should calculate CGT for simple buy-then-sell scenario.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Buy 100 shares @ $40 + acquisition = date(2023, 1, 15) + buy_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("40.00"), + total_value=Decimal("4000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(acquisition, datetime.min.time()), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + ) + + db_session.add(buy_trade) + await db_session.commit() + + # Sell 100 shares @ $50 (200 days later, no CGT discount) + sell_date = date(2023, 8, 3) + holding_days = (sell_date - acquisition).days + + sell_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("50.00"), + total_value=Decimal("5000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + holding_period_days=holding_days, + cgt_discount_eligible=False, # < 367 days + cgt_gross_gain=Decimal("1000.00"), # 100 * ($50 - $40) + cgt_gross_loss=Decimal("0.00"), + cgt_net_gain=Decimal("1000.00"), # No discount + ) + + db_session.add(sell_trade) + await db_session.commit() + + # Verify CGT calculation + await db_session.refresh(sell_trade) + + assert sell_trade.holding_period_days == holding_days + assert sell_trade.cgt_discount_eligible is False + assert sell_trade.cgt_gross_gain == Decimal("1000.00") + assert sell_trade.cgt_net_gain == Decimal("1000.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_long_term_hold_cgt_discount(self, db_session, test_portfolio): + """Should apply 50% CGT discount for >367 day hold.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Buy 100 shares @ $40 + acquisition = date(2022, 1, 1) + buy_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("40.00"), + total_value=Decimal("4000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(acquisition, datetime.min.time()), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + ) + + db_session.add(buy_trade) + await db_session.commit() + + # Sell 100 shares @ $50 (400 days later, eligible for discount) + sell_date = date(2023, 2, 5) + holding_days = (sell_date - acquisition).days + + sell_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("50.00"), + total_value=Decimal("5000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + holding_period_days=holding_days, + cgt_discount_eligible=True, # >= 367 days + cgt_gross_gain=Decimal("1000.00"), # 100 * ($50 - $40) + cgt_gross_loss=Decimal("0.00"), + cgt_net_gain=Decimal("500.00"), # 50% discount applied + ) + + db_session.add(sell_trade) + await db_session.commit() + + # Verify CGT discount applied + await db_session.refresh(sell_trade) + + assert sell_trade.holding_period_days >= 367 + assert sell_trade.cgt_discount_eligible is True + assert sell_trade.cgt_gross_gain == Decimal("1000.00") + assert sell_trade.cgt_net_gain == Decimal("500.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_capital_loss_scenario(self, db_session, test_portfolio): + """Should calculate capital loss correctly.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Buy 100 shares @ $50 + acquisition = date(2023, 3, 1) + buy_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("50.00"), + total_value=Decimal("5000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(acquisition, datetime.min.time()), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("50.00"), + cost_basis_total=Decimal("5000.00"), + ) + + db_session.add(buy_trade) + await db_session.commit() + + # Sell 100 shares @ $30 (100 days later, loss) + sell_date = date(2023, 6, 9) + holding_days = (sell_date - acquisition).days + + sell_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("30.00"), + total_value=Decimal("3000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("50.00"), + cost_basis_total=Decimal("5000.00"), + holding_period_days=holding_days, + cgt_discount_eligible=False, + cgt_gross_gain=Decimal("0.00"), + cgt_gross_loss=Decimal("2000.00"), # 100 * ($50 - $30) + cgt_net_gain=Decimal("-2000.00"), + ) + + db_session.add(sell_trade) + await db_session.commit() + + # Verify capital loss + await db_session.refresh(sell_trade) + + assert sell_trade.cgt_gross_gain == Decimal("0.00") + assert sell_trade.cgt_gross_loss == Decimal("2000.00") + assert sell_trade.cgt_net_gain == Decimal("-2000.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeFIFOMatching: + """Integration tests for FIFO parcel matching.""" + + @pytest.mark.asyncio + async def test_fifo_single_parcel_full_sale(self, db_session, test_portfolio): + """Should match sell to single buy parcel (FIFO).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Single buy + buy_date = date(2023, 1, 1) + buy = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("100.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(buy_date, datetime.min.time()), + acquisition_date=buy_date, + cost_basis_per_unit=Decimal("100.00"), + cost_basis_total=Decimal("10000.00"), + ) + + db_session.add(buy) + await db_session.commit() + + # Sell entire position + sell_date = date(2023, 6, 1) + sell = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("120.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + acquisition_date=buy_date, # Matched to first buy + cost_basis_per_unit=Decimal("100.00"), + cost_basis_total=Decimal("10000.00"), + holding_period_days=(sell_date - buy_date).days, + cgt_gross_gain=Decimal("2000.00"), + cgt_net_gain=Decimal("2000.00"), + ) + + db_session.add(sell) + await db_session.commit() + + # Verify FIFO matching + await db_session.refresh(sell) + + assert sell.acquisition_date == buy_date + assert sell.cost_basis_per_unit == Decimal("100.00") + assert sell.cgt_gross_gain == Decimal("2000.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_fifo_multiple_parcels_oldest_first(self, db_session, test_portfolio): + """Should match sell to oldest parcel first (FIFO).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # First buy (oldest) + buy1_date = date(2023, 1, 1) + buy1 = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("40.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(buy1_date, datetime.min.time()), + acquisition_date=buy1_date, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("2000.00"), + ) + + # Second buy (newer) + buy2_date = date(2023, 3, 1) + buy2 = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("45.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(buy2_date, datetime.min.time()), + acquisition_date=buy2_date, + cost_basis_per_unit=Decimal("45.00"), + cost_basis_total=Decimal("2250.00"), + ) + + db_session.add_all([buy1, buy2]) + await db_session.commit() + + # Sell 50 shares - should match to buy1 (FIFO) + sell_date = date(2023, 6, 1) + sell = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("50"), + price=Decimal("50.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + acquisition_date=buy1_date, # Matched to oldest buy + cost_basis_per_unit=Decimal("40.00"), # From buy1 + cost_basis_total=Decimal("2000.00"), + holding_period_days=(sell_date - buy1_date).days, + cgt_gross_gain=Decimal("500.00"), # 50 * ($50 - $40) + cgt_net_gain=Decimal("500.00"), + ) + + db_session.add(sell) + await db_session.commit() + + # Verify matched to oldest parcel + await db_session.refresh(sell) + + assert sell.acquisition_date == buy1_date + assert sell.cost_basis_per_unit == Decimal("40.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_fifo_partial_parcel_matching(self, db_session, test_portfolio): + """Should handle partial parcel matching across multiple buys.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Buy 100 @ $40 + buy1_date = date(2023, 1, 1) + buy1 = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("40.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(buy1_date, datetime.min.time()), + acquisition_date=buy1_date, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + ) + + # Buy 100 @ $45 + buy2_date = date(2023, 2, 1) + buy2 = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("45.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(buy2_date, datetime.min.time()), + acquisition_date=buy2_date, + cost_basis_per_unit=Decimal("45.00"), + cost_basis_total=Decimal("4500.00"), + ) + + db_session.add_all([buy1, buy2]) + await db_session.commit() + + # Sell 150 @ $50 - should consume all of buy1 + 50 from buy2 + sell_date = date(2023, 6, 1) + + # In real implementation, this might be split into 2 trade records + # For this test, we'll use weighted average cost basis + # 100 @ $40 + 50 @ $45 = $6250 / 150 = $41.67 avg + + sell = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.SELL, + quantity=Decimal("150"), + price=Decimal("50.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + acquisition_date=buy1_date, # Earliest acquisition + cost_basis_per_unit=Decimal("41.67"), # Weighted average + cost_basis_total=Decimal("6250.50"), + holding_period_days=(sell_date - buy1_date).days, + cgt_gross_gain=Decimal("1249.50"), # 150*$50 - $6250.50 + cgt_net_gain=Decimal("1249.50"), + ) + + db_session.add(sell) + await db_session.commit() + + # Verify FIFO matching + await db_session.refresh(sell) + + assert sell.quantity == Decimal("150") + assert sell.acquisition_date == buy1_date + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeMultiCurrency: + """Integration tests for multi-currency trade scenarios.""" + + @pytest.mark.asyncio + async def test_foreign_stock_with_fx_conversion(self, db_session, test_portfolio): + """Should handle foreign stock trades with FX conversion.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Buy US stock in USD + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), # USD + total_value=Decimal("15000.00"), # USD + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency="USD", + fx_rate_to_aud=Decimal("1.50"), # 1 USD = 1.50 AUD + total_value_aud=Decimal("22500.00"), # AUD equivalent + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Verify currency conversion + assert trade.currency == "USD" + assert trade.total_value == Decimal("15000.00") + assert trade.total_value_aud == Decimal("22500.00") + assert trade.fx_rate_to_aud == Decimal("1.50") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_fx_gain_loss_in_cgt_calculation(self, db_session, test_portfolio): + """Should account for FX gains/losses in CGT.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Buy @ 1.50 FX rate + buy_date = date(2023, 1, 1) + buy = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("100.00"), # USD + total_value=Decimal("10000.00"), # USD + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(buy_date, datetime.min.time()), + currency="USD", + fx_rate_to_aud=Decimal("1.50"), + total_value_aud=Decimal("15000.00"), # AUD + acquisition_date=buy_date, + cost_basis_per_unit=Decimal("150.00"), # AUD per share + cost_basis_total=Decimal("15000.00"), # AUD + ) + + db_session.add(buy) + await db_session.commit() + + # Sell @ 1.40 FX rate (AUD strengthened) + sell_date = date(2023, 6, 1) + sell = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("110.00"), # USD + total_value=Decimal("11000.00"), # USD + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.combine(sell_date, datetime.min.time()), + currency="USD", + fx_rate_to_aud=Decimal("1.40"), + total_value_aud=Decimal("15400.00"), # AUD + acquisition_date=buy_date, + cost_basis_per_unit=Decimal("150.00"), # AUD + cost_basis_total=Decimal("15000.00"), # AUD + holding_period_days=(sell_date - buy_date).days, + # Small gain in AUD despite USD price increase due to FX + cgt_gross_gain=Decimal("400.00"), + cgt_net_gain=Decimal("400.00"), + ) + + db_session.add(sell) + await db_session.commit() + + # Verify FX impact on CGT + await db_session.refresh(sell) + + assert sell.total_value_aud == Decimal("15400.00") + assert sell.cgt_gross_gain == Decimal("400.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_mixed_currency_portfolio(self, db_session, test_portfolio): + """Should support mixed currency trades in same portfolio.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # AUD trade + aud_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("45.00"), + total_value=Decimal("4500.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency="AUD", + fx_rate_to_aud=Decimal("1.0"), + total_value_aud=Decimal("4500.00"), + ) + + # USD trade + usd_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + total_value=Decimal("15000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency="USD", + fx_rate_to_aud=Decimal("1.50"), + total_value_aud=Decimal("22500.00"), + ) + + db_session.add_all([aud_trade, usd_trade]) + await db_session.commit() + + # Calculate total portfolio value in AUD + result = await db_session.execute( + select(func.sum(Trade.total_value_aud)).where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.side == TradeSide.BUY, + Trade.status == TradeStatus.FILLED + ) + ) + ) + total_aud = result.scalar() + + assert total_aud == Decimal("27000.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeComplexQueries: + """Integration tests for complex trade queries.""" + + @pytest.mark.asyncio + async def test_aggregate_position_by_symbol(self, db_session, test_portfolio): + """Should calculate current position for a symbol.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Multiple buys and sells for AAPL + trades = [ + (TradeSide.BUY, Decimal("100")), + (TradeSide.BUY, Decimal("50")), + (TradeSide.SELL, Decimal("30")), + (TradeSide.BUY, Decimal("20")), + ] + + for side, quantity in trades: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=side, + quantity=quantity, + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + db_session.add(trade) + + await db_session.commit() + + # Calculate net position (buys - sells) using case() from sqlalchemy + from sqlalchemy import case + result = await db_session.execute( + select( + func.sum( + case( + (Trade.side == TradeSide.BUY, Trade.quantity), + else_=-Trade.quantity + ) + ) + ).where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.symbol == "AAPL", + Trade.status == TradeStatus.FILLED + ) + ) + ) + net_position = result.scalar() + + # 100 + 50 - 30 + 20 = 140 + assert net_position == Decimal("140") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_query_trades_by_tax_year(self, db_session, test_portfolio): + """Should filter trades by Australian tax year.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # FY2024 trades (July 2023 - June 2024) + fy2024_dates = [ + datetime(2023, 7, 1), + datetime(2023, 12, 15), + datetime(2024, 6, 30), + ] + + # FY2025 trade + fy2025_date = datetime(2024, 7, 1) + + for exec_date in fy2024_dates: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=exec_date, + ) + db_session.add(trade) + + trade_fy2025 = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=fy2025_date, + ) + db_session.add(trade_fy2025) + + await db_session.commit() + + # Query FY2024 trades using tax_year property + # Note: This requires the model to have a hybrid_property or similar + # For now, we'll query by date range + fy2024_start = datetime(2023, 7, 1) + fy2024_end = datetime(2024, 6, 30, 23, 59, 59) + + result = await db_session.execute( + select(Trade).where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.executed_at >= fy2024_start, + Trade.executed_at <= fy2024_end + ) + ) + ) + fy2024_trades = result.scalars().all() + + assert len(fy2024_trades) == 3 + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_query_cgt_eligible_for_discount(self, db_session, test_portfolio): + """Should filter trades eligible for CGT discount.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Short hold (no discount) + short_hold = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + holding_period_days=200, + cgt_discount_eligible=False, + ) + + # Long hold (eligible for discount) + long_hold = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("45.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + holding_period_days=400, + cgt_discount_eligible=True, + ) + + db_session.add_all([short_hold, long_hold]) + await db_session.commit() + + # Query eligible trades + result = await db_session.execute( + select(Trade).where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.cgt_discount_eligible == True + ) + ) + ) + eligible_trades = result.scalars().all() + + assert len(eligible_trades) == 1 + assert eligible_trades[0].symbol == "BHP.AX" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_calculate_total_cgt_for_year(self, db_session, test_portfolio): + """Should calculate total CGT for tax year.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Multiple sales with CGT + trades = [ + (Decimal("1000.00"), Decimal("500.00")), # Gain with discount + (Decimal("500.00"), Decimal("500.00")), # Gain no discount + (Decimal("0.00"), Decimal("-300.00")), # Loss + ] + + for gross_gain, net_gain in trades: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime(2024, 3, 15), + cgt_gross_gain=gross_gain, + cgt_net_gain=net_gain, + ) + db_session.add(trade) + + await db_session.commit() + + # Calculate total net CGT + result = await db_session.execute( + select(func.sum(Trade.cgt_net_gain)).where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.side == TradeSide.SELL, + Trade.status == TradeStatus.FILLED + ) + ) + ) + total_cgt = result.scalar() + + # $500 + $500 - $300 = $700 + assert total_cgt == Decimal("700.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeLifecycle: + """Integration tests for trade lifecycle management.""" + + @pytest.mark.asyncio + async def test_trade_status_progression(self, db_session, test_portfolio): + """Should support trade status transitions.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create pending order + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.PENDING + + # Partially fill + trade.status = TradeStatus.PARTIAL + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.PARTIAL + + # Complete fill + trade.status = TradeStatus.FILLED + trade.executed_at = datetime.utcnow() + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.FILLED + assert trade.executed_at is not None + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cancel_pending_order(self, db_session, test_portfolio): + """Should support cancelling pending orders.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Cancel order + trade.status = TradeStatus.CANCELLED + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.CANCELLED + assert trade.executed_at is None + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_reject_invalid_order(self, db_session, test_portfolio): + """Should support rejecting invalid orders.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="INVALID", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("0.01"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.REJECTED, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.REJECTED + assert trade.executed_at is None + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeReporting: + """Integration tests for trade reporting scenarios.""" + + @pytest.mark.asyncio + async def test_portfolio_performance_report(self, db_session, test_portfolio): + """Should generate portfolio performance metrics.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create trades with gains and losses + trades = [ + (TradeSide.BUY, Decimal("100"), Decimal("40.00"), None, None), + (TradeSide.SELL, Decimal("100"), Decimal("50.00"), Decimal("1000.00"), Decimal("1000.00")), + (TradeSide.BUY, Decimal("50"), Decimal("60.00"), None, None), + (TradeSide.SELL, Decimal("50"), Decimal("55.00"), Decimal("0.00"), Decimal("-250.00")), + ] + + for side, quantity, price, gross_gain, net_gain in trades: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=side, + quantity=quantity, + price=price, + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + cgt_gross_gain=gross_gain or Decimal("0.00"), + cgt_net_gain=net_gain or Decimal("0.00"), + ) + db_session.add(trade) + + await db_session.commit() + + # Calculate metrics + result = await db_session.execute( + select( + func.sum(Trade.cgt_gross_gain), + func.sum(Trade.cgt_net_gain), + func.count(Trade.id) + ).where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.side == TradeSide.SELL + ) + ) + ) + gross_total, net_total, sell_count = result.one() + + assert gross_total == Decimal("1000.00") + assert net_total == Decimal("750.00") # 1000 - 250 + assert sell_count == 2 + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_symbol_trading_history(self, db_session, test_portfolio): + """Should retrieve complete trading history for a symbol.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create trading history for AAPL + trade_dates = [ + datetime(2023, 1, 15), + datetime(2023, 3, 20), + datetime(2023, 6, 10), + datetime(2023, 9, 5), + ] + + for i, exec_date in enumerate(trade_dates): + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY if i % 2 == 0 else TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal(f"{140 + i*10}.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=exec_date, + ) + db_session.add(trade) + + await db_session.commit() + + # Query trading history ordered by date + result = await db_session.execute( + select(Trade) + .where( + and_( + Trade.portfolio_id == test_portfolio.id, + Trade.symbol == "AAPL" + ) + ) + .order_by(Trade.executed_at.asc()) + ) + history = result.scalars().all() + + assert len(history) == 4 + assert history[0].executed_at == trade_dates[0] + assert history[-1].executed_at == trade_dates[-1] + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") diff --git a/tests/unit/api/test_trade_model.py b/tests/unit/api/test_trade_model.py new file mode 100644 index 00000000..737a55f8 --- /dev/null +++ b/tests/unit/api/test_trade_model.py @@ -0,0 +1,2054 @@ +"""Unit tests for Trade model (Issue #6: DB-5). + +Tests for Trade model fields including: +- TradeSide enum (BUY, SELL) +- TradeStatus enum (PENDING, FILLED, PARTIAL, CANCELLED, REJECTED) +- TradeOrderType enum (MARKET, LIMIT, STOP, STOP_LIMIT) +- Basic trade fields (symbol, quantity, price, etc.) +- Signal fields (signal_source, signal_confidence) +- CGT (Capital Gains Tax) fields and calculations +- Currency support (currency, fx_rate_to_aud, total_value_aud) +- Tax year calculation (Australian FY: July-June) +- Decimal precision for monetary and quantity values +- Check constraints (quantity > 0, price > 0, etc.) +- Properties (is_buy, is_sell, is_filled) +- Relationship with Portfolio + +Follows TDD principles with comprehensive coverage. +Tests written BEFORE implementation (RED phase). +""" + +import pytest +from decimal import Decimal +from datetime import datetime, date, timedelta +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +# Mark all tests in this module as asyncio +pytestmark = pytest.mark.asyncio + + +class TestTradeBasicFields: + """Tests for basic Trade model fields.""" + + @pytest.mark.asyncio + async def test_create_trade_with_required_fields(self, db_session, test_portfolio): + """Should create trade with only required fields.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert + assert trade.id is not None + assert trade.portfolio_id == test_portfolio.id + assert trade.symbol == "AAPL" + assert trade.side == TradeSide.BUY + assert trade.quantity == Decimal("100") + assert trade.price == Decimal("150.00") + assert trade.order_type == TradeOrderType.MARKET + assert trade.status == TradeStatus.FILLED + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_defaults(self, db_session, test_portfolio): + """Should apply default values to optional fields.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Check defaults + assert trade.currency == "AUD" + assert trade.fx_rate_to_aud == Decimal("1.0000") + assert trade.created_at is not None + assert trade.updated_at is not None + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_with_all_fields(self, db_session, test_portfolio): + """Should create trade with all fields specified.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + executed_time = datetime(2024, 3, 15, 10, 30, 0) + acquisition = date(2023, 6, 1) + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("500"), + price=Decimal("45.50"), + total_value=Decimal("22750.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.FILLED, + executed_at=executed_time, + signal_source="TechnicalAnalysis", + signal_confidence=Decimal("85.50"), + acquisition_date=acquisition, + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("20000.00"), + holding_period_days=288, + cgt_discount_eligible=False, + cgt_gross_gain=Decimal("2750.00"), + cgt_gross_loss=Decimal("0.00"), + cgt_net_gain=Decimal("2750.00"), + currency="AUD", + fx_rate_to_aud=Decimal("1.0000"), + total_value_aud=Decimal("22750.00"), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert all fields + assert trade.id is not None + assert trade.symbol == "BHP.AX" + assert trade.side == TradeSide.SELL + assert trade.quantity == Decimal("500") + assert trade.price == Decimal("45.50") + assert trade.total_value == Decimal("22750.00") + assert trade.signal_source == "TechnicalAnalysis" + assert trade.signal_confidence == Decimal("85.50") + assert trade.acquisition_date == acquisition + assert trade.cgt_discount_eligible is False + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_timestamps_auto_populate(self, db_session, test_portfolio): + """Should auto-populate created_at and updated_at timestamps.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("95.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert timestamps exist and are recent + assert trade.created_at is not None + assert trade.updated_at is not None + assert isinstance(trade.created_at, datetime) + assert isinstance(trade.updated_at, datetime) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeSideEnum: + """Tests for TradeSide enum validation.""" + + @pytest.mark.asyncio + async def test_trade_side_buy(self, db_session, test_portfolio): + """Should create trade with BUY side.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.side == TradeSide.BUY + assert trade.side.value == "BUY" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_side_sell(self, db_session, test_portfolio): + """Should create trade with SELL side.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.SELL, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.side == TradeSide.SELL + assert trade.side.value == "SELL" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_side_invalid_value(self, db_session, test_portfolio): + """Should reject invalid trade side.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Try to create with invalid string + with pytest.raises((ValueError, AttributeError)): + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side="INVALID_SIDE", + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + db_session.add(trade) + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeStatusEnum: + """Tests for TradeStatus enum validation.""" + + @pytest.mark.asyncio + async def test_trade_status_pending(self, db_session, test_portfolio): + """Should create trade with PENDING status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PENDING, + executed_at=None, # Not executed yet + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.PENDING + assert trade.status.value == "PENDING" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_status_filled(self, db_session, test_portfolio): + """Should create trade with FILLED status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.FILLED + assert trade.status.value == "FILLED" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_status_partial(self, db_session, test_portfolio): + """Should create trade with PARTIAL status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PARTIAL, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.PARTIAL + assert trade.status.value == "PARTIAL" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_status_cancelled(self, db_session, test_portfolio): + """Should create trade with CANCELLED status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.CANCELLED, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.CANCELLED + assert trade.status.value == "CANCELLED" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_status_rejected(self, db_session, test_portfolio): + """Should create trade with REJECTED status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.REJECTED, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.status == TradeStatus.REJECTED + assert trade.status.value == "REJECTED" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeOrderTypeEnum: + """Tests for TradeOrderType enum validation.""" + + @pytest.mark.asyncio + async def test_order_type_market(self, db_session, test_portfolio): + """Should create trade with MARKET order type.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.order_type == TradeOrderType.MARKET + assert trade.order_type.value == "MARKET" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_order_type_limit(self, db_session, test_portfolio): + """Should create trade with LIMIT order type.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.order_type == TradeOrderType.LIMIT + assert trade.order_type.value == "LIMIT" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_order_type_stop(self, db_session, test_portfolio): + """Should create trade with STOP order type.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("140.00"), + order_type=TradeOrderType.STOP, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.order_type == TradeOrderType.STOP + assert trade.order_type.value == "STOP" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_order_type_stop_limit(self, db_session, test_portfolio): + """Should create trade with STOP_LIMIT order type.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("140.00"), + order_type=TradeOrderType.STOP_LIMIT, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.order_type == TradeOrderType.STOP_LIMIT + assert trade.order_type.value == "STOP_LIMIT" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeDecimalPrecision: + """Tests for Decimal precision on trade fields.""" + + @pytest.mark.asyncio + async def test_quantity_decimal_precision(self, db_session, test_portfolio): + """Should store quantity with 4 decimal places (PreciseNumeric standard).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BTC", + side=TradeSide.BUY, + quantity=Decimal("0.1234"), # 4 decimal places (per model spec) + price=Decimal("45000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert decimal precision maintained (4 decimals per PreciseNumeric) + assert trade.quantity == Decimal("0.1234") + assert isinstance(trade.quantity, Decimal) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_price_decimal_precision(self, db_session, test_portfolio): + """Should store price with 4 decimal places.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.1234"), # 4 decimal places + order_type=TradeOrderType.LIMIT, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert decimal precision maintained + assert trade.price == Decimal("150.1234") + assert isinstance(trade.price, Decimal) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_total_value_decimal_precision(self, db_session, test_portfolio): + """Should store total_value with 4 decimal places.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + total_value=Decimal("10000.5678"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert decimal precision maintained + assert trade.total_value == Decimal("10000.5678") + assert isinstance(trade.total_value, Decimal) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_fields_decimal_precision(self, db_session, test_portfolio): + """Should store CGT fields with 4 decimal places.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("500"), + price=Decimal("45.50"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + cost_basis_per_unit=Decimal("40.1234"), + cost_basis_total=Decimal("20061.7000"), + cgt_gross_gain=Decimal("2688.3000"), + cgt_gross_loss=Decimal("0.0000"), + cgt_net_gain=Decimal("2688.3000"), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert CGT decimal precision + assert trade.cost_basis_per_unit == Decimal("40.1234") + assert trade.cost_basis_total == Decimal("20061.7000") + assert trade.cgt_gross_gain == Decimal("2688.3000") + assert trade.cgt_net_gain == Decimal("2688.3000") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_fx_rate_decimal_precision(self, db_session, test_portfolio): + """Should store fx_rate_to_aud with 6 decimal places.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency="USD", + fx_rate_to_aud=Decimal("1.523456"), # 6 decimal places + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert FX rate precision + assert trade.fx_rate_to_aud == Decimal("1.523456") + assert isinstance(trade.fx_rate_to_aud, Decimal) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_signal_confidence_decimal_precision(self, db_session, test_portfolio): + """Should store signal_confidence with 2 decimal places (0-100).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + signal_source="ML_Model", + signal_confidence=Decimal("87.65"), # 2 decimal places + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Assert signal confidence precision + assert trade.signal_confidence == Decimal("87.65") + assert isinstance(trade.signal_confidence, Decimal) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeTaxYear: + """Tests for Australian tax year calculation (July-June).""" + + @pytest.mark.asyncio + async def test_tax_year_fy2024_start(self, db_session, test_portfolio): + """Should calculate tax year FY2024 for trade on July 1, 2023.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # July 1, 2023 starts FY2024 + executed_time = datetime(2023, 7, 1, 10, 0, 0) + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("95.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=executed_time, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # FY2024 = July 1, 2023 to June 30, 2024 + assert trade.tax_year == "FY2024" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_tax_year_fy2024_end(self, db_session, test_portfolio): + """Should calculate tax year FY2024 for trade on June 30, 2024.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # June 30, 2024 ends FY2024 + executed_time = datetime(2024, 6, 30, 23, 59, 59) + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.BUY, + quantity=Decimal("200"), + price=Decimal("45.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=executed_time, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.tax_year == "FY2024" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_tax_year_fy2025_start(self, db_session, test_portfolio): + """Should calculate tax year FY2025 for trade on July 1, 2024.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # July 1, 2024 starts FY2025 + executed_time = datetime(2024, 7, 1, 0, 0, 0) + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="WES.AX", + side=TradeSide.BUY, + quantity=Decimal("150"), + price=Decimal("55.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=executed_time, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.tax_year == "FY2025" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_tax_year_before_fy_transition(self, db_session, test_portfolio): + """Should calculate tax year FY2024 for trade in June 2024.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # June 15, 2024 is before FY transition + executed_time = datetime(2024, 6, 15, 14, 30, 0) + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="NAB.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("30.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=executed_time, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.tax_year == "FY2024" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_tax_year_after_fy_transition(self, db_session, test_portfolio): + """Should calculate tax year FY2025 for trade in July 2024.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # July 15, 2024 is after FY transition + executed_time = datetime(2024, 7, 15, 9, 0, 0) + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="ANZ.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("25.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=executed_time, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.tax_year == "FY2025" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeCGTDiscount: + """Tests for CGT discount eligibility (367+ days).""" + + @pytest.mark.asyncio + async def test_cgt_discount_not_eligible_short_hold(self, db_session, test_portfolio): + """Should not be eligible for CGT discount with <367 days holding.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("160.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + acquisition_date=date.today() - timedelta(days=200), + holding_period_days=200, + cgt_discount_eligible=False, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.holding_period_days == 200 + assert trade.cgt_discount_eligible is False + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_discount_eligible_367_days(self, db_session, test_portfolio): + """Should be eligible for CGT discount with exactly 367 days holding.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("500"), + price=Decimal("45.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + acquisition_date=date.today() - timedelta(days=367), + holding_period_days=367, + cgt_discount_eligible=True, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.holding_period_days == 367 + assert trade.cgt_discount_eligible is True + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_discount_eligible_long_hold(self, db_session, test_portfolio): + """Should be eligible for CGT discount with >367 days holding.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.SELL, + quantity=Decimal("200"), + price=Decimal("100.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + acquisition_date=date.today() - timedelta(days=500), + holding_period_days=500, + cgt_discount_eligible=True, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.holding_period_days == 500 + assert trade.cgt_discount_eligible is True + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_discount_boundary_366_days(self, db_session, test_portfolio): + """Should not be eligible with 366 days (boundary test).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="WES.AX", + side=TradeSide.SELL, + quantity=Decimal("150"), + price=Decimal("55.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + acquisition_date=date.today() - timedelta(days=366), + holding_period_days=366, + cgt_discount_eligible=False, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.holding_period_days == 366 + assert trade.cgt_discount_eligible is False + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeCGTCalculations: + """Tests for CGT calculation fields.""" + + @pytest.mark.asyncio + async def test_cgt_gross_gain_calculation(self, db_session, test_portfolio): + """Should calculate gross gain correctly.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Sell at $50, cost basis $40 = $10 gain per unit + # 100 units = $1000 gross gain + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("50.00"), + total_value=Decimal("5000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + cgt_gross_gain=Decimal("1000.00"), + cgt_gross_loss=Decimal("0.00"), + cgt_net_gain=Decimal("1000.00"), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.cgt_gross_gain == Decimal("1000.00") + assert trade.cgt_gross_loss == Decimal("0.00") + assert trade.cgt_net_gain == Decimal("1000.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_gross_loss_calculation(self, db_session, test_portfolio): + """Should calculate gross loss correctly.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Sell at $30, cost basis $40 = $10 loss per unit + # 100 units = $1000 gross loss + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("30.00"), + total_value=Decimal("3000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + cgt_gross_gain=Decimal("0.00"), + cgt_gross_loss=Decimal("1000.00"), + cgt_net_gain=Decimal("-1000.00"), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.cgt_gross_gain == Decimal("0.00") + assert trade.cgt_gross_loss == Decimal("1000.00") + assert trade.cgt_net_gain == Decimal("-1000.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_net_gain_with_discount(self, db_session, test_portfolio): + """Should calculate net gain with CGT discount applied.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # $1000 gross gain, eligible for 50% discount = $500 net gain + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("50.00"), + total_value=Decimal("5000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + acquisition_date=date.today() - timedelta(days=400), + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + holding_period_days=400, + cgt_discount_eligible=True, + cgt_gross_gain=Decimal("1000.00"), + cgt_gross_loss=Decimal("0.00"), + cgt_net_gain=Decimal("500.00"), # 50% discount applied + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.cgt_gross_gain == Decimal("1000.00") + assert trade.cgt_discount_eligible is True + assert trade.cgt_net_gain == Decimal("500.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cgt_no_gain_or_loss(self, db_session, test_portfolio): + """Should handle breakeven trades (no gain or loss).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Sell at cost basis = no gain or loss + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="CBA.AX", + side=TradeSide.SELL, + quantity=Decimal("100"), + price=Decimal("40.00"), + total_value=Decimal("4000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + cost_basis_per_unit=Decimal("40.00"), + cost_basis_total=Decimal("4000.00"), + cgt_gross_gain=Decimal("0.00"), + cgt_gross_loss=Decimal("0.00"), + cgt_net_gain=Decimal("0.00"), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.cgt_gross_gain == Decimal("0.00") + assert trade.cgt_gross_loss == Decimal("0.00") + assert trade.cgt_net_gain == Decimal("0.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeCurrencySupport: + """Tests for multi-currency support.""" + + @pytest.mark.asyncio + async def test_default_currency_aud(self, db_session, test_portfolio): + """Should default to AUD currency.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BHP.AX", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("45.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.currency == "AUD" + assert trade.fx_rate_to_aud == Decimal("1.0000") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_usd_currency_with_fx_rate(self, db_session, test_portfolio): + """Should support USD with FX rate conversion.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # $100 USD @ 1.50 FX rate = $150 AUD + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + total_value=Decimal("15000.00"), # USD + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency="USD", + fx_rate_to_aud=Decimal("1.50"), + total_value_aud=Decimal("22500.00"), # AUD + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.currency == "USD" + assert trade.fx_rate_to_aud == Decimal("1.50") + assert trade.total_value == Decimal("15000.00") + assert trade.total_value_aud == Decimal("22500.00") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_common_currencies(self, db_session, test_portfolio): + """Should accept common 3-letter currency codes.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + currencies = ["USD", "EUR", "GBP", "JPY", "CNY", "AUD"] + + for currency in currencies: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol=f"STOCK.{currency}", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("100.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency=currency, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.currency == currency + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_currency_uppercase_enforced(self, db_session, test_portfolio): + """Should store currency in uppercase.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + currency="usd", # lowercase input + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Should be stored in uppercase + assert trade.currency == "USD" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeConstraints: + """Tests for CheckConstraints on trade fields.""" + + @pytest.mark.asyncio + async def test_quantity_must_be_positive(self, db_session, test_portfolio): + """Should reject negative quantity.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("-100"), # Negative! + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + + with pytest.raises((IntegrityError, ValueError)): + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_quantity_cannot_be_zero(self, db_session, test_portfolio): + """Should reject zero quantity.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("0"), # Zero! + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + + with pytest.raises((IntegrityError, ValueError)): + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_price_must_be_positive(self, db_session, test_portfolio): + """Should reject negative price.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("-150.00"), # Negative! + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + + with pytest.raises((IntegrityError, ValueError)): + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_price_cannot_be_zero(self, db_session, test_portfolio): + """Should reject zero price.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("0.00"), # Zero! + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + + with pytest.raises((IntegrityError, ValueError)): + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_signal_confidence_range_0_to_100(self, db_session, test_portfolio): + """Should accept signal_confidence between 0 and 100.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Test boundary values + for confidence in [Decimal("0.00"), Decimal("50.00"), Decimal("100.00")]: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + signal_source="Test", + signal_confidence=confidence, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.signal_confidence == confidence + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_signal_confidence_above_100_rejected(self, db_session, test_portfolio): + """Should reject signal_confidence > 100.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + signal_source="Test", + signal_confidence=Decimal("101.00"), # > 100! + ) + + db_session.add(trade) + + with pytest.raises((IntegrityError, ValueError)): + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_signal_confidence_negative_rejected(self, db_session, test_portfolio): + """Should reject negative signal_confidence.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + signal_source="Test", + signal_confidence=Decimal("-10.00"), # Negative! + ) + + db_session.add(trade) + + with pytest.raises((IntegrityError, ValueError)): + await db_session.commit() + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeSignalFields: + """Tests for signal tracking fields.""" + + @pytest.mark.asyncio + async def test_signal_source_stored(self, db_session, test_portfolio): + """Should store signal source string.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + signal_source="TechnicalAnalysis", + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.signal_source == "TechnicalAnalysis" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_signal_confidence_stored(self, db_session, test_portfolio): + """Should store signal confidence value.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + signal_source="ML_Model", + signal_confidence=Decimal("92.50"), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.signal_confidence == Decimal("92.50") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_signal_fields_optional(self, db_session, test_portfolio): + """Should allow trades without signal fields.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + # No signal_source or signal_confidence + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.signal_source is None + assert trade.signal_confidence is None + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeProperties: + """Tests for trade model properties.""" + + @pytest.mark.asyncio + async def test_is_buy_property(self, db_session, test_portfolio): + """Should return True for BUY side.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.is_buy is True + assert trade.is_sell is False + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_is_sell_property(self, db_session, test_portfolio): + """Should return True for SELL side.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.SELL, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.is_buy is False + assert trade.is_sell is True + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_is_filled_property(self, db_session, test_portfolio): + """Should return True for FILLED status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.is_filled is True + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_is_filled_false_for_pending(self, db_session, test_portfolio): + """Should return False for PENDING status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.is_filled is False + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradePortfolioRelationship: + """Tests for Trade-Portfolio relationship.""" + + @pytest.mark.asyncio + async def test_trade_belongs_to_portfolio(self, db_session, test_portfolio): + """Should establish relationship from trade to portfolio.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + # Load the relationship + await db_session.refresh(trade, ["portfolio"]) + + assert trade.portfolio is not None + assert trade.portfolio.id == test_portfolio.id + assert trade.portfolio.name == test_portfolio.name + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_portfolio_has_many_trades(self, db_session, test_portfolio): + """Should establish relationship from portfolio to trades.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade1 = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + trade2 = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade1) + db_session.add(trade2) + await db_session.commit() + + # Refresh portfolio with trades relationship + await db_session.refresh(test_portfolio, ["trades"]) + + assert len(test_portfolio.trades) == 2 + symbols = [t.symbol for t in test_portfolio.trades] + assert "AAPL" in symbols + assert "TSLA" in symbols + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_cascade_delete_when_portfolio_deleted(self, db_session, test_user): + """Should delete trades when portfolio is deleted (cascade).""" + try: + from tradingagents.api.models.portfolio import Portfolio, PortfolioType + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create a temporary portfolio + portfolio = Portfolio( + user_id=test_user.id, + name="Temp Portfolio", + portfolio_type=PortfolioType.PAPER, + initial_capital=Decimal("10000.00"), + ) + + db_session.add(portfolio) + await db_session.commit() + await db_session.refresh(portfolio) + + # Create trades + trade = Trade( + portfolio_id=portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + trade_id = trade.id + + # Delete the portfolio + await db_session.delete(portfolio) + await db_session.commit() + + # Check trade is also deleted + result = await db_session.execute( + select(Trade).where(Trade.id == trade_id) + ) + deleted_trade = result.scalar_one_or_none() + + assert deleted_trade is None + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.asyncio + async def test_very_long_symbol(self, db_session, test_portfolio): + """Should handle symbol names up to 20 chars (model limit).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + long_symbol = "A" * 20 # 20 char limit per model spec + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol=long_symbol, + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.symbol == long_symbol + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_fractional_shares(self, db_session, test_portfolio): + """Should handle fractional share quantities.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("0.5"), # Half a share + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.quantity == Decimal("0.5") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_very_small_quantity(self, db_session, test_portfolio): + """Should handle small quantities within 4 decimal precision.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="BTC", + side=TradeSide.BUY, + quantity=Decimal("0.0001"), # Smallest with 4 decimal precision + price=Decimal("45000.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.quantity == Decimal("0.0001") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_very_large_quantity(self, db_session, test_portfolio): + """Should handle large quantities.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="PENNY_STOCK", + side=TradeSide.BUY, + quantity=Decimal("1000000"), # 1 million shares + price=Decimal("0.01"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + assert trade.quantity == Decimal("1000000") + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_trade_repr(self, db_session, test_portfolio): + """Should have meaningful string representation.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + await db_session.refresh(trade) + + repr_str = repr(trade) + assert "Trade" in repr_str + assert "AAPL" in repr_str or str(trade.id) in repr_str + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + +class TestTradeQueryOperations: + """Tests for querying Trade records.""" + + @pytest.mark.asyncio + async def test_query_trade_by_id(self, db_session, test_portfolio): + """Should retrieve trade by ID.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add(trade) + await db_session.commit() + trade_id = trade.id + + # Query by ID + result = await db_session.execute( + select(Trade).where(Trade.id == trade_id) + ) + found = result.scalar_one() + + assert found.id == trade_id + assert found.symbol == "AAPL" + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_query_trades_by_symbol(self, db_session, test_portfolio): + """Should filter trades by symbol.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create trades for different symbols + symbols = ["AAPL", "TSLA", "AAPL", "GOOGL"] + + for symbol in symbols: + trade = Trade( + portfolio_id=test_portfolio.id, + symbol=symbol, + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + db_session.add(trade) + + await db_session.commit() + + # Query for AAPL trades + result = await db_session.execute( + select(Trade).where(Trade.symbol == "AAPL") + ) + aapl_trades = result.scalars().all() + + assert len(aapl_trades) == 2 + assert all(t.symbol == "AAPL" for t in aapl_trades) + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_query_trades_by_side(self, db_session, test_portfolio): + """Should filter trades by side (BUY/SELL).""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create mix of BUY and SELL trades + buy_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + sell_trade = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.SELL, + quantity=Decimal("50"), + price=Decimal("160.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + db_session.add_all([buy_trade, sell_trade]) + await db_session.commit() + + # Query for BUY trades + result = await db_session.execute( + select(Trade).where(Trade.side == TradeSide.BUY) + ) + buy_trades = result.scalars().all() + + assert len(buy_trades) == 1 + assert buy_trades[0].side == TradeSide.BUY + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") + + @pytest.mark.asyncio + async def test_query_trades_by_status(self, db_session, test_portfolio): + """Should filter trades by status.""" + try: + from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType + + # Create trades with different statuses + filled = Trade( + portfolio_id=test_portfolio.id, + symbol="AAPL", + side=TradeSide.BUY, + quantity=Decimal("100"), + price=Decimal("150.00"), + order_type=TradeOrderType.MARKET, + status=TradeStatus.FILLED, + executed_at=datetime.utcnow(), + ) + + pending = Trade( + portfolio_id=test_portfolio.id, + symbol="TSLA", + side=TradeSide.BUY, + quantity=Decimal("50"), + price=Decimal("200.00"), + order_type=TradeOrderType.LIMIT, + status=TradeStatus.PENDING, + executed_at=None, + ) + + db_session.add_all([filled, pending]) + await db_session.commit() + + # Query for PENDING trades + result = await db_session.execute( + select(Trade).where(Trade.status == TradeStatus.PENDING) + ) + pending_trades = result.scalars().all() + + assert len(pending_trades) == 1 + assert pending_trades[0].status == TradeStatus.PENDING + + except ImportError: + pytest.skip("Trade model not yet implemented (TDD RED phase)") diff --git a/tradingagents/api/models/__init__.py b/tradingagents/api/models/__init__.py index b8fb23d5..f8870159 100644 --- a/tradingagents/api/models/__init__.py +++ b/tradingagents/api/models/__init__.py @@ -5,5 +5,18 @@ from tradingagents.api.models.user import User from tradingagents.api.models.strategy import Strategy from tradingagents.api.models.portfolio import Portfolio, PortfolioType from tradingagents.api.models.settings import Settings, RiskProfile +from tradingagents.api.models.trade import Trade, TradeSide, TradeStatus, TradeOrderType -__all__ = ["Base", "User", "Strategy", "Portfolio", "PortfolioType", "Settings", "RiskProfile"] +__all__ = [ + "Base", + "User", + "Strategy", + "Portfolio", + "PortfolioType", + "Settings", + "RiskProfile", + "Trade", + "TradeSide", + "TradeStatus", + "TradeOrderType", +] diff --git a/tradingagents/api/models/portfolio.py b/tradingagents/api/models/portfolio.py index bfe4a70c..8ca4b5ea 100644 --- a/tradingagents/api/models/portfolio.py +++ b/tradingagents/api/models/portfolio.py @@ -199,6 +199,12 @@ class Portfolio(Base, TimestampMixin): back_populates="portfolios" ) + trades: Mapped[list["Trade"]] = relationship( + "Trade", + back_populates="portfolio", + cascade="all, delete-orphan" + ) + # Table-level constraints and indexes __table_args__ = ( # Unique constraint: user can't have duplicate portfolio names diff --git a/tradingagents/api/models/trade.py b/tradingagents/api/models/trade.py new file mode 100644 index 00000000..1d328a37 --- /dev/null +++ b/tradingagents/api/models/trade.py @@ -0,0 +1,664 @@ +"""Trade model for execution history with CGT tracking. + +This module defines the Trade model for tracking buy/sell trade executions +with full capital gains tax (CGT) support for Australian tax compliance. +Each trade belongs to a portfolio and includes acquisition details, +cost basis, holding period, and CGT calculations. + +Model Fields: + Core Trade Fields: + - id: Primary key + - portfolio_id: Foreign key to portfolios table + - symbol: Stock/asset symbol (uppercase) + - side: Trade side (BUY, SELL) + - quantity: Number of units traded + - price: Price per unit + - total_value: Total trade value (quantity * price) + - order_type: Order type (MARKET, LIMIT, STOP, STOP_LIMIT) + - status: Trade status (PENDING, FILLED, PARTIAL, CANCELLED, REJECTED) + - executed_at: When trade was executed (nullable for pending) + + Signal Fields: + - signal_source: Source of trading signal (e.g., "RSI_DIVERGENCE") + - signal_confidence: Confidence score 0-100 + + CGT Fields (Australian Tax): + - acquisition_date: Date asset was acquired + - cost_basis_per_unit: Purchase price per unit (for CGT) + - cost_basis_total: Total purchase cost (for CGT) + - holding_period_days: Days held (for 50% discount eligibility) + - cgt_discount_eligible: Whether eligible for 50% CGT discount (>365 days) + - cgt_gross_gain: Gross capital gain before discount + - cgt_gross_loss: Gross capital loss + - cgt_net_gain: Net capital gain after discount + + Currency Fields: + - currency: 3-letter currency code (default: AUD) + - fx_rate_to_aud: Foreign exchange rate to AUD + - total_value_aud: Total value in AUD + - created_at, updated_at: Automatic timestamps + +Relationships: + - portfolio: Many-to-one relationship with Portfolio model + - Cascade delete when portfolio is deleted + +Constraints: + - quantity > 0 + - price > 0 + - total_value > 0 + - signal_confidence >= 0 AND signal_confidence <= 100 + - holding_period_days >= 0 OR NULL + - fx_rate_to_aud > 0 + +Properties: + - tax_year: Australian FY (July-June) in format "FY2024" + - is_buy: True if BUY trade + - is_sell: True if SELL trade + - is_filled: True if FILLED status + +Follows SQLAlchemy 2.0 patterns with Mapped[] and mapped_column(). +""" + +from enum import Enum as PyEnum +from typing import Optional +from decimal import Decimal +from datetime import datetime, date + +from sqlalchemy import ( + String, + Boolean, + Integer, + Numeric, + ForeignKey, + Index, + CheckConstraint, + Enum, + DateTime, + Date, + event, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates, Session + +from tradingagents.api.models.base import Base, TimestampMixin +from tradingagents.api.models.portfolio import PreciseNumeric + + +class TradeSide(str, PyEnum): + """Enum for trade side (buy/sell). + + BUY: Purchase of asset + SELL: Sale of asset + """ + + BUY = "BUY" + SELL = "SELL" + + +class TradeStatus(str, PyEnum): + """Enum for trade execution status. + + PENDING: Trade submitted but not yet executed + FILLED: Trade fully executed + PARTIAL: Trade partially executed + CANCELLED: Trade cancelled before full execution + REJECTED: Trade rejected by broker/exchange + """ + + PENDING = "PENDING" + FILLED = "FILLED" + PARTIAL = "PARTIAL" + CANCELLED = "CANCELLED" + REJECTED = "REJECTED" + + +class TradeOrderType(str, PyEnum): + """Enum for order types. + + MARKET: Execute at current market price + LIMIT: Execute at specified price or better + STOP: Trigger market order at stop price + STOP_LIMIT: Trigger limit order at stop price + """ + + MARKET = "MARKET" + LIMIT = "LIMIT" + STOP = "STOP" + STOP_LIMIT = "STOP_LIMIT" + + +class Trade(Base, TimestampMixin): + """Trade model for execution history with CGT tracking. + + A trade represents a buy or sell execution with full capital gains tax + tracking for Australian compliance. Includes acquisition details, cost basis, + holding period calculations, and CGT discount eligibility. + + Attributes: + Core Trade: + id: Primary key, auto-increment + portfolio_id: Foreign key to portfolios.id (cascade delete) + symbol: Stock/asset symbol (uppercase) + side: Trade side (BUY or SELL) + quantity: Number of units traded (Decimal 19,4) + price: Price per unit (Decimal 19,4) + total_value: Total trade value (Decimal 19,4) + order_type: Order type (MARKET, LIMIT, STOP, STOP_LIMIT) + status: Trade status (PENDING, FILLED, etc.) + executed_at: Timestamp when trade executed (nullable) + + Signal: + signal_source: Source of trading signal (nullable) + signal_confidence: Confidence score 0-100 (Decimal 5,2, nullable) + + CGT (Australian Tax): + acquisition_date: Date asset acquired + cost_basis_per_unit: Purchase price per unit (Decimal 19,4, nullable) + cost_basis_total: Total purchase cost (Decimal 19,4, nullable) + holding_period_days: Days held (nullable) + cgt_discount_eligible: Eligible for 50% CGT discount (>365 days) + cgt_gross_gain: Gross capital gain (Decimal 19,4, nullable) + cgt_gross_loss: Gross capital loss (Decimal 19,4, nullable) + cgt_net_gain: Net capital gain after discount (Decimal 19,4, nullable) + + Currency: + currency: 3-letter currency code (e.g., AUD, USD) + fx_rate_to_aud: FX rate to AUD (Decimal 19,8) + total_value_aud: Total value in AUD (Decimal 19,4, nullable) + + Relationships: + portfolio: Relationship to Portfolio model + created_at: Timestamp when created (auto) + updated_at: Timestamp when last updated (auto) + + Constraints: + - quantity must be > 0 + - price must be > 0 + - total_value must be > 0 + - signal_confidence must be 0-100 (if set) + - holding_period_days must be >= 0 (if set) + - fx_rate_to_aud must be > 0 + + Example: + >>> from decimal import Decimal + >>> from datetime import datetime, date + >>> trade = Trade( + ... portfolio_id=1, + ... symbol="BHP", + ... side=TradeSide.BUY, + ... quantity=Decimal("100.0000"), + ... price=Decimal("45.5000"), + ... total_value=Decimal("4550.0000"), + ... order_type=TradeOrderType.MARKET, + ... status=TradeStatus.FILLED, + ... executed_at=datetime.now(), + ... acquisition_date=date.today(), + ... currency="AUD" + ... ) + >>> session.add(trade) + >>> await session.commit() + """ + + __tablename__ = "trades" + + # Primary key + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + # Foreign key to portfolio (cascade delete) + portfolio_id: Mapped[int] = mapped_column( + ForeignKey("portfolios.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Portfolio this trade belongs to" + ) + + # Core trade fields + symbol: Mapped[str] = mapped_column( + String(20), + nullable=False, + index=True, + comment="Stock/asset symbol (uppercase)" + ) + + side: Mapped[TradeSide] = mapped_column( + Enum(TradeSide, native_enum=False, length=10), + nullable=False, + index=True, + comment="Trade side: BUY or SELL" + ) + + quantity: Mapped[Decimal] = mapped_column( + PreciseNumeric, + nullable=False, + comment="Number of units traded" + ) + + price: Mapped[Decimal] = mapped_column( + PreciseNumeric, + nullable=False, + comment="Price per unit" + ) + + total_value: Mapped[Decimal] = mapped_column( + PreciseNumeric, + nullable=False, + default=lambda context: ( + context.get_current_parameters()['quantity'] * + context.get_current_parameters()['price'] + ), + comment="Total trade value (quantity * price)" + ) + + order_type: Mapped[TradeOrderType] = mapped_column( + Enum(TradeOrderType, native_enum=False, length=20), + nullable=False, + comment="Order type: MARKET, LIMIT, STOP, STOP_LIMIT" + ) + + status: Mapped[TradeStatus] = mapped_column( + Enum(TradeStatus, native_enum=False, length=20), + nullable=False, + default=TradeStatus.PENDING, + index=True, + comment="Trade status: PENDING, FILLED, PARTIAL, CANCELLED, REJECTED" + ) + + executed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="Timestamp when trade was executed (nullable for pending)" + ) + + # Signal fields + signal_source: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="Source of trading signal (e.g., RSI_DIVERGENCE)" + ) + + signal_confidence: Mapped[Optional[Decimal]] = mapped_column( + Numeric(precision=5, scale=2), + nullable=True, + comment="Signal confidence score 0-100" + ) + + # CGT (Capital Gains Tax) fields for Australian tax compliance + acquisition_date: Mapped[date] = mapped_column( + Date, + nullable=False, + index=True, + default=lambda context: ( + context.get_current_parameters().get('executed_at').date() + if context.get_current_parameters().get('executed_at') + else date.today() + ), + comment="Date asset was acquired (for CGT)" + ) + + cost_basis_per_unit: Mapped[Optional[Decimal]] = mapped_column( + PreciseNumeric, + nullable=True, + comment="Purchase price per unit for CGT calculation" + ) + + cost_basis_total: Mapped[Optional[Decimal]] = mapped_column( + PreciseNumeric, + nullable=True, + comment="Total purchase cost for CGT calculation" + ) + + holding_period_days: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + comment="Days held (for 50% CGT discount eligibility)" + ) + + cgt_discount_eligible: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="Eligible for 50% CGT discount (held >365 days)" + ) + + cgt_gross_gain: Mapped[Optional[Decimal]] = mapped_column( + PreciseNumeric, + nullable=True, + comment="Gross capital gain before discount" + ) + + cgt_gross_loss: Mapped[Optional[Decimal]] = mapped_column( + PreciseNumeric, + nullable=True, + comment="Gross capital loss" + ) + + cgt_net_gain: Mapped[Optional[Decimal]] = mapped_column( + PreciseNumeric, + nullable=True, + comment="Net capital gain after 50% discount" + ) + + # Currency fields + currency: Mapped[str] = mapped_column( + String(3), + nullable=False, + default="AUD", + comment="Currency code (ISO 4217, e.g., AUD, USD)" + ) + + fx_rate_to_aud: Mapped[Decimal] = mapped_column( + Numeric(precision=19, scale=8), + nullable=False, + default=Decimal("1.0"), + comment="Foreign exchange rate to AUD" + ) + + total_value_aud: Mapped[Optional[Decimal]] = mapped_column( + PreciseNumeric, + nullable=True, + comment="Total value in AUD (for multi-currency portfolios)" + ) + + # Relationships + portfolio: Mapped["Portfolio"] = relationship( + "Portfolio", + back_populates="trades" + ) + + # Table-level constraints and indexes + __table_args__ = ( + # Check constraints: positive values + CheckConstraint( + "quantity > 0", + name="ck_trade_quantity_positive" + ), + CheckConstraint( + "price > 0", + name="ck_trade_price_positive" + ), + CheckConstraint( + "total_value > 0", + name="ck_trade_total_value_positive" + ), + # Check constraint: signal confidence range + CheckConstraint( + "signal_confidence >= 0 AND signal_confidence <= 100", + name="ck_trade_signal_confidence_range" + ), + # Check constraint: holding period non-negative + CheckConstraint( + "holding_period_days >= 0 OR holding_period_days IS NULL", + name="ck_trade_holding_period_positive" + ), + # Check constraint: FX rate positive + CheckConstraint( + "fx_rate_to_aud > 0", + name="ck_trade_fx_rate_positive" + ), + # Composite indexes for common queries + Index("ix_trade_portfolio_symbol", "portfolio_id", "symbol"), + Index("ix_trade_portfolio_side", "portfolio_id", "side"), + Index("ix_trade_status_executed", "status", "executed_at"), + ) + + @property + def tax_year(self) -> str: + """Calculate Australian financial year (July-June). + + Australian tax year runs from July 1 to June 30. + Returns format "FY2024" for year ending June 30, 2024. + + Returns: + String in format "FY2024" representing the Australian tax year + """ + if not self.executed_at: + # Use acquisition_date if no execution date + ref_date = self.acquisition_date + else: + ref_date = self.executed_at.date() + + # Australian FY: July 1 to June 30 + # If month is Jan-Jun (1-6), FY is current year + # If month is Jul-Dec (7-12), FY is next year + if ref_date.month >= 7: + fy_year = ref_date.year + 1 + else: + fy_year = ref_date.year + + return f"FY{fy_year}" + + @property + def is_buy(self) -> bool: + """Check if trade is a BUY. + + Returns: + True if trade side is BUY, False otherwise + """ + return self.side == TradeSide.BUY + + @property + def is_sell(self) -> bool: + """Check if trade is a SELL. + + Returns: + True if trade side is SELL, False otherwise + """ + return self.side == TradeSide.SELL + + @property + def is_filled(self) -> bool: + """Check if trade is fully filled. + + Returns: + True if trade status is FILLED, False otherwise + """ + return self.status == TradeStatus.FILLED + + @validates("side") + def validate_side(self, key: str, value) -> TradeSide: + """Validate and convert trade side to TradeSide enum. + + Args: + key: Field name (side) + value: Trade side value (str or TradeSide) + + Returns: + TradeSide enum value + + Raises: + ValueError: If value is not a valid trade side + """ + # If already a TradeSide, return it + if isinstance(value, TradeSide): + return value + + # Try to convert string to TradeSide + if isinstance(value, str): + try: + return TradeSide[value.upper()] + except KeyError: + raise ValueError( + f"Invalid trade side '{value}'. " + f"Must be one of: {', '.join([s.value for s in TradeSide])}" + ) + + # Invalid type + raise ValueError( + f"Trade side must be string or TradeSide enum, got {type(value)}" + ) + + @validates("status") + def validate_status(self, key: str, value) -> TradeStatus: + """Validate and convert trade status to TradeStatus enum. + + Args: + key: Field name (status) + value: Trade status value (str or TradeStatus) + + Returns: + TradeStatus enum value + + Raises: + ValueError: If value is not a valid trade status + """ + # If already a TradeStatus, return it + if isinstance(value, TradeStatus): + return value + + # Try to convert string to TradeStatus + if isinstance(value, str): + try: + return TradeStatus[value.upper()] + except KeyError: + raise ValueError( + f"Invalid trade status '{value}'. " + f"Must be one of: {', '.join([s.value for s in TradeStatus])}" + ) + + # Invalid type + raise ValueError( + f"Trade status must be string or TradeStatus enum, got {type(value)}" + ) + + @validates("order_type") + def validate_order_type(self, key: str, value) -> TradeOrderType: + """Validate and convert order type to TradeOrderType enum. + + Args: + key: Field name (order_type) + value: Order type value (str or TradeOrderType) + + Returns: + TradeOrderType enum value + + Raises: + ValueError: If value is not a valid order type + """ + # If already a TradeOrderType, return it + if isinstance(value, TradeOrderType): + return value + + # Try to convert string to TradeOrderType + if isinstance(value, str): + try: + return TradeOrderType[value.upper()] + except KeyError: + raise ValueError( + f"Invalid order type '{value}'. " + f"Must be one of: {', '.join([t.value for t in TradeOrderType])}" + ) + + # Invalid type + raise ValueError( + f"Order type must be string or TradeOrderType enum, got {type(value)}" + ) + + @validates("currency") + def validate_currency(self, key: str, value: str) -> str: + """Normalize currency code to uppercase. + + Args: + key: Field name (currency) + value: Currency code to normalize + + Returns: + Uppercase currency code + """ + if value is None: + return "AUD" # Default currency + + # Convert to uppercase for consistency + return value.upper() + + @validates("symbol") + def validate_symbol(self, key: str, value: str) -> str: + """Normalize symbol to uppercase. + + Args: + key: Field name (symbol) + value: Symbol to normalize + + Returns: + Uppercase symbol + """ + if value is None: + raise ValueError("Symbol cannot be None") + + # Convert to uppercase for consistency + return value.upper() + + def __repr__(self) -> str: + """String representation of Trade. + + Returns: + String showing trade ID, symbol, side, quantity, and status + """ + return ( + f"" + ) + + +# Event listener for before_flush validation +# This ensures constraints are checked before database commit +@event.listens_for(Session, "before_flush") +def validate_trade_before_flush(session, flush_context, instances): + """Validate Trade objects before flushing to database. + + This event listener checks business rules that may not be enforced + by the database (especially in SQLite which is permissive). + + Args: + session: SQLAlchemy session + flush_context: Flush context + instances: Instances being flushed + + Raises: + ValueError: If validation fails + """ + for obj in session.new | session.dirty: + if isinstance(obj, Trade): + # Validate symbol + if not obj.symbol or not obj.symbol.strip(): + raise ValueError("Trade symbol cannot be empty") + + if len(obj.symbol) > 20: + raise ValueError( + f"Trade symbol too long: {len(obj.symbol)} characters (max 20)" + ) + + # Validate currency code length + if obj.currency and len(obj.currency) != 3: + raise ValueError( + f"Currency code must be exactly 3 characters, got {len(obj.currency)}" + ) + + # Validate signal_source length + if obj.signal_source and len(obj.signal_source) > 100: + raise ValueError( + f"Signal source too long: {len(obj.signal_source)} characters (max 100)" + ) + + # Validate positive values + if obj.quantity is not None and obj.quantity <= 0: + raise ValueError( + f"quantity must be positive, got {obj.quantity}" + ) + + if obj.price is not None and obj.price <= 0: + raise ValueError( + f"price must be positive, got {obj.price}" + ) + + if obj.total_value is not None and obj.total_value <= 0: + raise ValueError( + f"total_value must be positive, got {obj.total_value}" + ) + + if obj.fx_rate_to_aud is not None and obj.fx_rate_to_aud <= 0: + raise ValueError( + f"fx_rate_to_aud must be positive, got {obj.fx_rate_to_aud}" + )