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
This commit is contained in:
parent
1c6c2fadf1
commit
1ea006e41f
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -8,6 +8,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 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)
|
- 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)
|
- JWT authentication with asymmetric RS256 signing algorithm [file:tradingagents/api/services/auth_service.py](tradingagents/api/services/auth_service.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')
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -5,5 +5,18 @@ from tradingagents.api.models.user import User
|
||||||
from tradingagents.api.models.strategy import Strategy
|
from tradingagents.api.models.strategy import Strategy
|
||||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||||
from tradingagents.api.models.settings import Settings, RiskProfile
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,12 @@ class Portfolio(Base, TimestampMixin):
|
||||||
back_populates="portfolios"
|
back_populates="portfolios"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
trades: Mapped[list["Trade"]] = relationship(
|
||||||
|
"Trade",
|
||||||
|
back_populates="portfolio",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
# Table-level constraints and indexes
|
# Table-level constraints and indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Unique constraint: user can't have duplicate portfolio names
|
# Unique constraint: user can't have duplicate portfolio names
|
||||||
|
|
|
||||||
|
|
@ -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"<Trade(id={self.id}, "
|
||||||
|
f"symbol='{self.symbol}', "
|
||||||
|
f"side={self.side.value}, "
|
||||||
|
f"quantity={self.quantity}, "
|
||||||
|
f"price={self.price}, "
|
||||||
|
f"status={self.status.value})>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue