feat(db): add Portfolio model with LIVE/PAPER/BACKTEST types - Fixes #4
This commit is contained in:
parent
d3892b0da9
commit
0d09f15bd6
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -53,6 +53,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Migration rollback support for reversible schema changes
|
||||
- Comprehensive docstrings and security considerations for all functions
|
||||
|
||||
- Portfolio model for managing trading portfolios (Issue #4: DB-3)
|
||||
- Portfolio model with support for LIVE, PAPER, and BACKTEST portfolio types [file:tradingagents/api/models/portfolio.py](tradingagents/api/models/portfolio.py)
|
||||
- PortfolioType enum for type-safe portfolio categorization [file:tradingagents/api/models/portfolio.py:95-107](tradingagents/api/models/portfolio.py)
|
||||
- PreciseNumeric custom SQLAlchemy type decorator for Decimal(19,4) precision [file:tradingagents/api/models/portfolio.py:63-92](tradingagents/api/models/portfolio.py)
|
||||
- High-precision monetary value handling (19 total digits, 4 decimal places) for initial_capital and current_value fields
|
||||
- Automatic current_value default to initial_capital on portfolio creation
|
||||
- Unique constraint on (user_id, name) to prevent duplicate portfolio names per user
|
||||
- Check constraints for non-negative capital and value amounts
|
||||
- Composite indexes for efficient queries: (user_id, is_active) and (user_id, portfolio_type)
|
||||
- Relationship to User model with cascade delete behavior
|
||||
- Currency code field with ISO 4217 validation (3-letter codes, e.g., AUD, USD)
|
||||
- Portfolio status tracking via is_active boolean field with default True
|
||||
- Comprehensive validators for currency normalization, portfolio type validation, and business rule enforcement [file:tradingagents/api/models/portfolio.py:249-296](tradingagents/api/models/portfolio.py)
|
||||
- Event listener validation (before_flush) for cross-field and database constraint checks [file:tradingagents/api/models/portfolio.py:300-347](tradingagents/api/models/portfolio.py)
|
||||
- User model updated with portfolios relationship for one-to-many portfolio association [file:tradingagents/api/models/user.py:72-76](tradingagents/api/models/user.py)
|
||||
- Database migration 003_add_portfolio_model.py with comprehensive schema definition [file:migrations/versions/003_add_portfolio_model.py](migrations/versions/003_add_portfolio_model.py)
|
||||
- Migration with proper upgrade and downgrade functions for reversible schema changes
|
||||
- Comprehensive unit and integration test suites [file:tests/unit/api/test_portfolio_model.py](tests/unit/api/test_portfolio_model.py) [file:tests/integration/api/test_portfolio_integration.py](tests/integration/api/test_portfolio_integration.py)
|
||||
- Unit tests covering field validation, defaults, constraints, and enum handling
|
||||
- Integration tests for relationships, cascade delete, and concurrent operations
|
||||
|
||||
- Test fixtures directory with centralized mock data (Issue #51)
|
||||
- FixtureLoader class for loading JSON fixtures with automatic datetime parsing [file:tests/fixtures/__init__.py](tests/fixtures/__init__.py)
|
||||
- Stock data fixtures: US market OHLCV, Chinese market OHLCV, standardized data [file:tests/fixtures/stock_data/](tests/fixtures/stock_data/)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
"""Add Portfolio model for managing trading portfolios
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2025-12-26 14:00:00.000000
|
||||
|
||||
This migration adds the portfolios table for managing user trading portfolios.
|
||||
Each portfolio belongs to a user and tracks capital with high precision.
|
||||
|
||||
Table: portfolios
|
||||
Columns:
|
||||
- id: Primary key, auto-increment
|
||||
- user_id: Foreign key to users.id (cascade delete)
|
||||
- name: Portfolio name (VARCHAR 255, indexed)
|
||||
- portfolio_type: Type of portfolio (ENUM: LIVE, PAPER, BACKTEST)
|
||||
- initial_capital: Starting capital (NUMERIC 19,4)
|
||||
- current_value: Current portfolio value (NUMERIC 19,4)
|
||||
- currency: 3-letter currency code (VARCHAR 3, default: AUD)
|
||||
- is_active: Whether portfolio is active (BOOLEAN, default: TRUE, indexed)
|
||||
- created_at: Timestamp when created (auto)
|
||||
- updated_at: Timestamp when last updated (auto)
|
||||
|
||||
Constraints:
|
||||
- UNIQUE (user_id, name): User can't have duplicate portfolio names
|
||||
- CHECK initial_capital >= 0: Capital must be non-negative
|
||||
- CHECK current_value >= 0: Value must be non-negative
|
||||
|
||||
Indexes:
|
||||
- ix_portfolios_user_id: Foreign key lookup
|
||||
- ix_portfolios_name: Name search
|
||||
- ix_portfolios_is_active: Active/inactive filtering
|
||||
- ix_portfolios_user_active: Composite (user_id, is_active)
|
||||
- ix_portfolios_user_type: Composite (user_id, portfolio_type)
|
||||
|
||||
Relationships:
|
||||
- portfolios.user_id -> users.id (CASCADE DELETE)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '003'
|
||||
down_revision: Union[str, None] = '002'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create portfolios table with all constraints and indexes.
|
||||
|
||||
Creates the portfolios table for managing user trading portfolios
|
||||
with proper foreign keys, constraints, and indexes.
|
||||
"""
|
||||
# Create portfolios table
|
||||
op.create_table(
|
||||
'portfolios',
|
||||
# Primary key
|
||||
sa.Column(
|
||||
'id',
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
primary_key=True,
|
||||
autoincrement=True
|
||||
),
|
||||
|
||||
# Foreign key to users (cascade delete)
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
comment='User who owns this portfolio'
|
||||
),
|
||||
|
||||
# Portfolio identification
|
||||
sa.Column(
|
||||
'name',
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment='Portfolio name (unique per user)'
|
||||
),
|
||||
|
||||
# Portfolio type enum
|
||||
sa.Column(
|
||||
'portfolio_type',
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment='Portfolio type: LIVE, PAPER, or BACKTEST'
|
||||
),
|
||||
|
||||
# Monetary values with high precision (19 total digits, 4 after decimal)
|
||||
sa.Column(
|
||||
'initial_capital',
|
||||
sa.Numeric(precision=19, scale=4),
|
||||
nullable=False,
|
||||
comment='Initial capital amount'
|
||||
),
|
||||
|
||||
sa.Column(
|
||||
'current_value',
|
||||
sa.Numeric(precision=19, scale=4),
|
||||
nullable=False,
|
||||
comment='Current portfolio value'
|
||||
),
|
||||
|
||||
# Currency code (ISO 4217 - 3 letters)
|
||||
sa.Column(
|
||||
'currency',
|
||||
sa.String(length=3),
|
||||
nullable=False,
|
||||
server_default='AUD',
|
||||
comment='Currency code (ISO 4217, e.g., AUD, USD)'
|
||||
),
|
||||
|
||||
# Portfolio status
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default='1',
|
||||
comment='Whether portfolio is actively trading'
|
||||
),
|
||||
|
||||
# Timestamps (from TimestampMixin)
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
comment='Timestamp when portfolio was created'
|
||||
),
|
||||
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
comment='Timestamp when portfolio was last updated'
|
||||
),
|
||||
|
||||
# Table-level constraints
|
||||
# Unique constraint: user can't have duplicate portfolio names
|
||||
sa.UniqueConstraint(
|
||||
'user_id',
|
||||
'name',
|
||||
name='uq_portfolio_user_name'
|
||||
),
|
||||
|
||||
# Check constraints: non-negative monetary values
|
||||
sa.CheckConstraint(
|
||||
'initial_capital >= 0',
|
||||
name='ck_portfolio_initial_capital_positive'
|
||||
),
|
||||
|
||||
sa.CheckConstraint(
|
||||
'current_value >= 0',
|
||||
name='ck_portfolio_current_value_positive'
|
||||
),
|
||||
)
|
||||
|
||||
# Create indexes for efficient queries
|
||||
# Index on user_id for foreign key lookups
|
||||
op.create_index(
|
||||
'ix_portfolios_user_id',
|
||||
'portfolios',
|
||||
['user_id'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Index on name for searching
|
||||
op.create_index(
|
||||
'ix_portfolios_name',
|
||||
'portfolios',
|
||||
['name'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Index on is_active for filtering active/inactive portfolios
|
||||
op.create_index(
|
||||
'ix_portfolios_is_active',
|
||||
'portfolios',
|
||||
['is_active'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Composite index for querying user's active portfolios
|
||||
op.create_index(
|
||||
'ix_portfolio_user_active',
|
||||
'portfolios',
|
||||
['user_id', 'is_active'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Composite index for querying user's portfolios by type
|
||||
op.create_index(
|
||||
'ix_portfolio_user_type',
|
||||
'portfolios',
|
||||
['user_id', 'portfolio_type'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop portfolios table and all associated indexes and constraints.
|
||||
|
||||
WARNING: This will permanently delete all portfolio data!
|
||||
"""
|
||||
# Drop all indexes
|
||||
op.drop_index('ix_portfolio_user_type', 'portfolios')
|
||||
op.drop_index('ix_portfolio_user_active', 'portfolios')
|
||||
op.drop_index('ix_portfolios_is_active', 'portfolios')
|
||||
op.drop_index('ix_portfolios_name', 'portfolios')
|
||||
op.drop_index('ix_portfolios_user_id', 'portfolios')
|
||||
|
||||
# Drop the table (constraints are dropped automatically)
|
||||
op.drop_table('portfolios')
|
||||
|
|
@ -896,3 +896,287 @@ def invalid_tax_jurisdictions() -> list[str]:
|
|||
"!@#",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Issue #4 Fixtures: Portfolio Model (DB-3)
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def another_user(db_session, second_user_data):
|
||||
"""
|
||||
Alias for second_user - used in portfolio tests.
|
||||
|
||||
Creates a second test user for testing user isolation.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
second_user_data: Second user data
|
||||
|
||||
Yields:
|
||||
User: Created user model instance
|
||||
|
||||
Example:
|
||||
async def test_portfolio_isolation(test_user, another_user):
|
||||
# Test portfolios are isolated between users
|
||||
assert test_user.id != another_user.id
|
||||
"""
|
||||
try:
|
||||
from tradingagents.api.models import User
|
||||
from tradingagents.api.services.auth_service import hash_password
|
||||
|
||||
user = User(
|
||||
username=second_user_data["username"],
|
||||
email=second_user_data["email"],
|
||||
hashed_password=hash_password(second_user_data["password"]),
|
||||
full_name=second_user_data.get("full_name"),
|
||||
)
|
||||
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
|
||||
yield user
|
||||
except ImportError:
|
||||
yield None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portfolio_data() -> Dict[str, Any]:
|
||||
"""
|
||||
Test portfolio data for creation (Issue #4: DB-3).
|
||||
|
||||
Returns:
|
||||
dict: Portfolio data with required fields
|
||||
|
||||
Example:
|
||||
async def test_create_portfolio(portfolio_data):
|
||||
assert portfolio_data["name"] == "Test Portfolio"
|
||||
assert portfolio_data["portfolio_type"] == "PAPER"
|
||||
"""
|
||||
return {
|
||||
"name": "Test Portfolio",
|
||||
"portfolio_type": "PAPER",
|
||||
"initial_capital": "10000.0000",
|
||||
"currency": "AUD",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_portfolio_data() -> Dict[str, Any]:
|
||||
"""
|
||||
Test data for LIVE portfolio (Issue #4: DB-3).
|
||||
|
||||
Returns:
|
||||
dict: Live portfolio data
|
||||
"""
|
||||
return {
|
||||
"name": "Live Trading Portfolio",
|
||||
"portfolio_type": "LIVE",
|
||||
"initial_capital": "50000.0000",
|
||||
"currency": "USD",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backtest_portfolio_data() -> Dict[str, Any]:
|
||||
"""
|
||||
Test data for BACKTEST portfolio (Issue #4: DB-3).
|
||||
|
||||
Returns:
|
||||
dict: Backtest portfolio data
|
||||
"""
|
||||
return {
|
||||
"name": "Historical Backtest",
|
||||
"portfolio_type": "BACKTEST",
|
||||
"initial_capital": "100000.0000",
|
||||
"currency": "USD",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_portfolio(db_session, test_user, portfolio_data):
|
||||
"""
|
||||
Create test portfolio in database (Issue #4: DB-3).
|
||||
|
||||
Creates a PAPER portfolio owned by test_user.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
test_user: Owner user
|
||||
portfolio_data: Portfolio data
|
||||
|
||||
Yields:
|
||||
Portfolio: Created portfolio model instance
|
||||
|
||||
Example:
|
||||
async def test_with_portfolio(test_portfolio):
|
||||
assert test_portfolio.name == "Test Portfolio"
|
||||
assert test_portfolio.user_id is not None
|
||||
"""
|
||||
if test_user is None:
|
||||
yield None
|
||||
return
|
||||
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
from decimal import Decimal
|
||||
|
||||
portfolio = Portfolio(
|
||||
name=portfolio_data["name"],
|
||||
portfolio_type=PortfolioType[portfolio_data["portfolio_type"]],
|
||||
initial_capital=Decimal(portfolio_data["initial_capital"]),
|
||||
currency=portfolio_data.get("currency", "AUD"),
|
||||
user_id=test_user.id,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
yield portfolio
|
||||
except ImportError:
|
||||
yield None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def live_portfolio(db_session, test_user, live_portfolio_data):
|
||||
"""
|
||||
Create test LIVE portfolio in database (Issue #4: DB-3).
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
test_user: Owner user
|
||||
live_portfolio_data: Live portfolio data
|
||||
|
||||
Yields:
|
||||
Portfolio: Created live portfolio
|
||||
"""
|
||||
if test_user is None:
|
||||
yield None
|
||||
return
|
||||
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
from decimal import Decimal
|
||||
|
||||
portfolio = Portfolio(
|
||||
name=live_portfolio_data["name"],
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal(live_portfolio_data["initial_capital"]),
|
||||
currency=live_portfolio_data.get("currency", "USD"),
|
||||
user_id=test_user.id,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
yield portfolio
|
||||
except ImportError:
|
||||
yield None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def multiple_portfolios(db_session, test_user):
|
||||
"""
|
||||
Create multiple test portfolios for list/pagination testing (Issue #4: DB-3).
|
||||
|
||||
Creates 5 portfolios with different types and capital values.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
test_user: Owner user
|
||||
|
||||
Yields:
|
||||
list[Portfolio]: List of created portfolios
|
||||
"""
|
||||
if test_user is None:
|
||||
yield []
|
||||
return
|
||||
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
from decimal import Decimal
|
||||
|
||||
portfolio_types = [PortfolioType.LIVE, PortfolioType.PAPER, PortfolioType.BACKTEST]
|
||||
portfolios = []
|
||||
|
||||
for i in range(5):
|
||||
portfolio = Portfolio(
|
||||
name=f"Portfolio {i+1}",
|
||||
portfolio_type=portfolio_types[i % 3],
|
||||
initial_capital=Decimal(f"{(i+1) * 10000}.0000"),
|
||||
currency="AUD" if i % 2 == 0 else "USD",
|
||||
is_active=i % 2 == 0, # Alternate active/inactive
|
||||
user_id=test_user.id,
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
portfolios.append(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Refresh all portfolios
|
||||
for portfolio in portfolios:
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
yield portfolios
|
||||
except ImportError:
|
||||
yield []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_currencies() -> list[str]:
|
||||
"""
|
||||
List of valid ISO 4217 currency codes for testing (Issue #4: DB-3).
|
||||
|
||||
Returns:
|
||||
list[str]: Valid 3-letter currency codes
|
||||
|
||||
Example:
|
||||
def test_currencies(valid_currencies):
|
||||
for currency in valid_currencies:
|
||||
assert len(currency) == 3
|
||||
assert currency.isupper()
|
||||
"""
|
||||
return [
|
||||
"USD", # US Dollar
|
||||
"EUR", # Euro
|
||||
"GBP", # British Pound
|
||||
"JPY", # Japanese Yen
|
||||
"CNY", # Chinese Yuan
|
||||
"AUD", # Australian Dollar
|
||||
"CAD", # Canadian Dollar
|
||||
"CHF", # Swiss Franc
|
||||
"HKD", # Hong Kong Dollar
|
||||
"SGD", # Singapore Dollar
|
||||
"NZD", # New Zealand Dollar
|
||||
"KRW", # South Korean Won
|
||||
"INR", # Indian Rupee
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_currencies() -> list[str]:
|
||||
"""
|
||||
List of invalid currency codes for testing (Issue #4: DB-3).
|
||||
|
||||
Returns:
|
||||
list[str]: Invalid currency codes
|
||||
|
||||
Example:
|
||||
def test_invalid_currencies(invalid_currencies):
|
||||
for currency in invalid_currencies:
|
||||
# Should fail validation
|
||||
assert not is_valid_currency(currency)
|
||||
"""
|
||||
return [
|
||||
"US", # Too short
|
||||
"USDD", # Too long
|
||||
"usd", # Lowercase
|
||||
"XXX", # Invalid code
|
||||
"123", # Numeric
|
||||
"US$", # Contains symbol
|
||||
"", # Empty
|
||||
"U S D", # Contains spaces
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
"""
|
||||
Shared pytest fixtures for integration API tests.
|
||||
|
||||
This module imports fixtures from the main API conftest
|
||||
to make them available to integration tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# Import all fixtures from main API conftest
|
||||
pytest_plugins = ["tests.api.conftest"]
|
||||
|
|
@ -0,0 +1,733 @@
|
|||
"""Integration tests for Portfolio model (Issue #4: DB-3).
|
||||
|
||||
Tests for Portfolio model integration with:
|
||||
- User model relationships
|
||||
- Database constraints and transactions
|
||||
- Complex query scenarios
|
||||
- Concurrent operations
|
||||
- Portfolio lifecycle management
|
||||
|
||||
Follows TDD principles - tests written BEFORE implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# Mark all tests in this module as asyncio
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestPortfolioUserIntegration:
|
||||
"""Integration tests for Portfolio-User relationships."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_portfolio_for_user(self, db_session, test_user):
|
||||
"""Should create portfolio linked to user."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Integration Test Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("25000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
await db_session.refresh(test_user, ["portfolios"])
|
||||
|
||||
# Verify both sides of relationship
|
||||
assert portfolio.user_id == test_user.id
|
||||
assert portfolio in test_user.portfolios
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_with_multiple_portfolio_types(self, db_session, test_user):
|
||||
"""Should allow user to have different portfolio types."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
live = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Live Trading",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("100000.0000"),
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
paper = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Paper Trading",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
backtest = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Historical Backtest",
|
||||
portfolio_type=PortfolioType.BACKTEST,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
db_session.add_all([live, paper, backtest])
|
||||
await db_session.commit()
|
||||
|
||||
# Refresh and verify
|
||||
await db_session.refresh(test_user, ["portfolios"])
|
||||
|
||||
assert len(test_user.portfolios) == 3
|
||||
|
||||
types = {p.portfolio_type for p in test_user.portfolios}
|
||||
assert PortfolioType.LIVE in types
|
||||
assert PortfolioType.PAPER in types
|
||||
assert PortfolioType.BACKTEST in types
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolios_deleted_with_user(self, db_session, db_engine):
|
||||
"""Should cascade delete portfolios when user is deleted."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
from tradingagents.api.models.user import User
|
||||
from tradingagents.api.services.auth_service import hash_password
|
||||
|
||||
# Create a temporary user
|
||||
temp_user = User(
|
||||
username="tempuser",
|
||||
email="temp@example.com",
|
||||
hashed_password=hash_password("TempPass123!"),
|
||||
)
|
||||
|
||||
db_session.add(temp_user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(temp_user)
|
||||
|
||||
# Create portfolios for temp user
|
||||
portfolio1 = Portfolio(
|
||||
user_id=temp_user.id,
|
||||
name="Portfolio 1",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
portfolio2 = Portfolio(
|
||||
user_id=temp_user.id,
|
||||
name="Portfolio 2",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
)
|
||||
|
||||
db_session.add_all([portfolio1, portfolio2])
|
||||
await db_session.commit()
|
||||
|
||||
portfolio1_id = portfolio1.id
|
||||
portfolio2_id = portfolio2.id
|
||||
|
||||
# Delete the user
|
||||
await db_session.delete(temp_user)
|
||||
await db_session.commit()
|
||||
|
||||
# Verify portfolios are deleted
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.id.in_([portfolio1_id, portfolio2_id])
|
||||
)
|
||||
)
|
||||
remaining = result.scalars().all()
|
||||
|
||||
assert len(remaining) == 0
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_users_same_portfolio_name(self, db_session, test_user, another_user):
|
||||
"""Should allow different users to have portfolios with same name."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
common_name = "Main Portfolio"
|
||||
|
||||
portfolio1 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=common_name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
portfolio2 = Portfolio(
|
||||
user_id=another_user.id,
|
||||
name=common_name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("15000.0000"),
|
||||
)
|
||||
|
||||
db_session.add_all([portfolio1, portfolio2])
|
||||
await db_session.commit()
|
||||
|
||||
# Query to verify both exist
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.name == common_name)
|
||||
)
|
||||
portfolios = result.scalars().all()
|
||||
|
||||
assert len(portfolios) == 2
|
||||
user_ids = {p.user_id for p in portfolios}
|
||||
assert test_user.id in user_ids
|
||||
assert another_user.id in user_ids
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioTransactions:
|
||||
"""Integration tests for portfolio transaction scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_portfolio_value(self, db_session, test_user):
|
||||
"""Should update current_value while preserving initial_capital."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Value Update Test",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Update current value
|
||||
original_capital = portfolio.initial_capital
|
||||
portfolio.current_value = Decimal("12500.7500")
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Verify
|
||||
assert portfolio.initial_capital == original_capital
|
||||
assert portfolio.current_value == Decimal("12500.7500")
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_portfolio(self, db_session, test_user):
|
||||
"""Should deactivate portfolio without deleting it."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Deactivation Test",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
portfolio_id = portfolio.id
|
||||
|
||||
# Deactivate
|
||||
portfolio.is_active = False
|
||||
await db_session.commit()
|
||||
|
||||
# Verify still exists but inactive
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.id == portfolio_id)
|
||||
)
|
||||
found = result.scalar_one()
|
||||
|
||||
assert found is not None
|
||||
assert found.is_active is False
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_on_constraint_violation(self, db_session, test_user):
|
||||
"""Should rollback transaction on unique constraint violation."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Capture user_id BEFORE any operations to avoid lazy load after rollback
|
||||
user_id = test_user.id
|
||||
|
||||
# Create first portfolio
|
||||
portfolio1 = Portfolio(
|
||||
user_id=user_id,
|
||||
name="Unique Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio1)
|
||||
await db_session.commit()
|
||||
|
||||
# Try to create duplicate
|
||||
portfolio2 = Portfolio(
|
||||
user_id=user_id,
|
||||
name="Unique Test",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("20000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio2)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.commit()
|
||||
|
||||
# Rollback
|
||||
await db_session.rollback()
|
||||
|
||||
# Verify only first portfolio exists (use stored user_id to avoid lazy load)
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.user_id == user_id,
|
||||
Portfolio.name == "Unique Test"
|
||||
)
|
||||
)
|
||||
portfolios = result.scalars().all()
|
||||
|
||||
assert len(portfolios) == 1
|
||||
assert portfolios[0].portfolio_type == PortfolioType.PAPER
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioComplexQueries:
|
||||
"""Integration tests for complex portfolio queries."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_total_capital_by_user(self, db_session, test_user):
|
||||
"""Should calculate total capital across all user portfolios."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create multiple portfolios
|
||||
portfolios_data = [
|
||||
("Portfolio A", Decimal("10000.0000")),
|
||||
("Portfolio B", Decimal("25000.0000")),
|
||||
("Portfolio C", Decimal("15000.5000")),
|
||||
]
|
||||
|
||||
for name, capital in portfolios_data:
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=capital,
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Aggregate query
|
||||
result = await db_session.execute(
|
||||
select(func.sum(Portfolio.initial_capital)).where(
|
||||
Portfolio.user_id == test_user.id
|
||||
)
|
||||
)
|
||||
total = result.scalar()
|
||||
|
||||
expected = Decimal("50000.5000")
|
||||
assert total == expected
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_portfolios_by_type(self, db_session, test_user):
|
||||
"""Should count portfolios grouped by type."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create portfolios of different types
|
||||
types_count = {
|
||||
PortfolioType.LIVE: 2,
|
||||
PortfolioType.PAPER: 3,
|
||||
PortfolioType.BACKTEST: 1,
|
||||
}
|
||||
|
||||
for ptype, count in types_count.items():
|
||||
for i in range(count):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=f"{ptype.value} Portfolio {i}",
|
||||
portfolio_type=ptype,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Count by type
|
||||
for ptype, expected_count in types_count.items():
|
||||
result = await db_session.execute(
|
||||
select(func.count()).where(
|
||||
Portfolio.user_id == test_user.id,
|
||||
Portfolio.portfolio_type == ptype
|
||||
)
|
||||
)
|
||||
count = result.scalar()
|
||||
assert count == expected_count
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_portfolios_by_value_range(self, db_session, test_user):
|
||||
"""Should filter portfolios by current value range."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create portfolios with different values
|
||||
portfolios_data = [
|
||||
("Small", Decimal("1000.0000")),
|
||||
("Medium", Decimal("25000.0000")),
|
||||
("Large", Decimal("100000.0000")),
|
||||
]
|
||||
|
||||
for name, capital in portfolios_data:
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=capital,
|
||||
current_value=capital,
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Query portfolios with value between 10k and 50k
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.user_id == test_user.id,
|
||||
Portfolio.current_value >= Decimal("10000.0000"),
|
||||
Portfolio.current_value <= Decimal("50000.0000")
|
||||
)
|
||||
)
|
||||
filtered = result.scalars().all()
|
||||
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].name == "Medium"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_portfolios_by_value(self, db_session, test_user):
|
||||
"""Should order portfolios by current value."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create portfolios
|
||||
portfolios_data = [
|
||||
("Portfolio A", Decimal("50000.0000")),
|
||||
("Portfolio B", Decimal("10000.0000")),
|
||||
("Portfolio C", Decimal("25000.0000")),
|
||||
]
|
||||
|
||||
for name, capital in portfolios_data:
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=capital,
|
||||
current_value=capital,
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Query ordered by value descending
|
||||
result = await db_session.execute(
|
||||
select(Portfolio)
|
||||
.where(Portfolio.user_id == test_user.id)
|
||||
.order_by(Portfolio.current_value.desc())
|
||||
)
|
||||
ordered = result.scalars().all()
|
||||
|
||||
assert len(ordered) == 3
|
||||
assert ordered[0].name == "Portfolio A"
|
||||
assert ordered[1].name == "Portfolio C"
|
||||
assert ordered[2].name == "Portfolio B"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioMultiCurrency:
|
||||
"""Integration tests for multi-currency portfolio scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolios_in_different_currencies(self, db_session, test_user):
|
||||
"""Should support portfolios in different currencies."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
currencies = ["USD", "EUR", "GBP", "JPY", "AUD"]
|
||||
|
||||
for currency in currencies:
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=f"{currency} Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
currency=currency,
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Query portfolios by currency
|
||||
for currency in currencies:
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.user_id == test_user.id,
|
||||
Portfolio.currency == currency
|
||||
)
|
||||
)
|
||||
found = result.scalar_one()
|
||||
assert found.currency == currency
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_portfolios_by_currency(self, db_session, test_user):
|
||||
"""Should group and count portfolios by currency."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create portfolios with different currencies
|
||||
currency_data = [
|
||||
("USD", 3),
|
||||
("EUR", 2),
|
||||
("AUD", 1),
|
||||
]
|
||||
|
||||
for currency, count in currency_data:
|
||||
for i in range(count):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=f"{currency} Portfolio {i}",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
currency=currency,
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Count by currency
|
||||
for currency, expected_count in currency_data:
|
||||
result = await db_session.execute(
|
||||
select(func.count()).where(
|
||||
Portfolio.user_id == test_user.id,
|
||||
Portfolio.currency == currency
|
||||
)
|
||||
)
|
||||
count = result.scalar()
|
||||
assert count == expected_count
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioLifecycle:
|
||||
"""Integration tests for portfolio lifecycle management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_creation_to_deletion_lifecycle(self, db_session, test_user):
|
||||
"""Should support full lifecycle: create, update, deactivate, delete."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# 1. Create
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Lifecycle Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
portfolio_id = portfolio.id
|
||||
assert portfolio.is_active is True
|
||||
|
||||
# 2. Update value
|
||||
portfolio.current_value = Decimal("12000.0000")
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.current_value == Decimal("12000.0000")
|
||||
|
||||
# 3. Deactivate
|
||||
portfolio.is_active = False
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.is_active is False
|
||||
|
||||
# 4. Delete
|
||||
await db_session.delete(portfolio)
|
||||
await db_session.commit()
|
||||
|
||||
# Verify deleted
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.id == portfolio_id)
|
||||
)
|
||||
deleted = result.scalar_one_or_none()
|
||||
|
||||
assert deleted is None
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactivate_deactivated_portfolio(self, db_session, test_user):
|
||||
"""Should allow reactivating a deactivated portfolio."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Reactivation Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.is_active is False
|
||||
|
||||
# Reactivate
|
||||
portfolio.is_active = True
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.is_active is True
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_portfolio_type(self, db_session, test_user):
|
||||
"""Should allow changing portfolio type (e.g., PAPER to LIVE)."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Migration Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.portfolio_type == PortfolioType.PAPER
|
||||
|
||||
# Migrate to LIVE
|
||||
portfolio.portfolio_type = PortfolioType.LIVE
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.portfolio_type == PortfolioType.LIVE
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioConcurrency:
|
||||
"""Integration tests for concurrent portfolio operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_value_updates(self, db_session, test_user):
|
||||
"""Should handle concurrent updates to portfolio value."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Concurrency Test",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Simulate concurrent updates
|
||||
updates = [
|
||||
Decimal("10500.0000"),
|
||||
Decimal("11000.0000"),
|
||||
Decimal("10750.0000"),
|
||||
]
|
||||
|
||||
for new_value in updates:
|
||||
portfolio.current_value = new_value
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Final value should be last update
|
||||
assert portfolio.current_value == Decimal("10750.0000")
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_portfolio_creation(self, db_session, test_user):
|
||||
"""Should handle bulk creation of multiple portfolios."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create 10 portfolios in bulk
|
||||
portfolios = []
|
||||
for i in range(10):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=f"Bulk Portfolio {i}",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal(f"{(i + 1) * 1000}.0000"),
|
||||
)
|
||||
portfolios.append(portfolio)
|
||||
|
||||
db_session.add_all(portfolios)
|
||||
await db_session.commit()
|
||||
|
||||
# Verify all created
|
||||
result = await db_session.execute(
|
||||
select(func.count()).where(Portfolio.user_id == test_user.id)
|
||||
)
|
||||
count = result.scalar()
|
||||
|
||||
assert count == 10
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"""
|
||||
Shared pytest fixtures for unit API tests.
|
||||
|
||||
This module imports fixtures from the main API conftest
|
||||
to make them available to unit tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# Import all fixtures from main API conftest
|
||||
pytest_plugins = ["tests.api.conftest"]
|
||||
|
|
@ -0,0 +1,958 @@
|
|||
"""Unit tests for Portfolio model (Issue #4: DB-3).
|
||||
|
||||
Tests for Portfolio model fields including:
|
||||
- portfolio_type (LIVE, PAPER, BACKTEST enum)
|
||||
- initial_capital, current_value (Decimal precision)
|
||||
- currency (3-letter code validation)
|
||||
- Unique constraint on (user_id, name)
|
||||
- Cascade delete behavior
|
||||
- Decimal(19,4) precision for monetary values
|
||||
|
||||
Follows TDD principles with comprehensive coverage.
|
||||
Tests written BEFORE implementation (RED phase).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# Mark all tests in this module as asyncio
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestPortfolioModelBasicFields:
|
||||
"""Tests for basic Portfolio model fields."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_portfolio_with_required_fields(self, db_session, test_user):
|
||||
"""Should create portfolio with only required fields."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="My First Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Assert
|
||||
assert portfolio.id is not None
|
||||
assert portfolio.user_id == test_user.id
|
||||
assert portfolio.name == "My First Portfolio"
|
||||
assert portfolio.portfolio_type == PortfolioType.PAPER
|
||||
assert portfolio.initial_capital == Decimal("10000.0000")
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_defaults(self, db_session, test_user):
|
||||
"""Should apply default values to optional fields."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Test Portfolio",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Check defaults
|
||||
assert portfolio.current_value == portfolio.initial_capital
|
||||
assert portfolio.currency == "AUD"
|
||||
assert portfolio.is_active is True
|
||||
assert portfolio.created_at is not None
|
||||
assert portfolio.updated_at is not None
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_with_all_fields(self, db_session, test_user):
|
||||
"""Should create portfolio with all fields specified."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Complete Portfolio",
|
||||
portfolio_type=PortfolioType.BACKTEST,
|
||||
initial_capital=Decimal("100000.5000"),
|
||||
current_value=Decimal("105000.7500"),
|
||||
currency="USD",
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Assert all fields
|
||||
assert portfolio.id is not None
|
||||
assert portfolio.user_id == test_user.id
|
||||
assert portfolio.name == "Complete Portfolio"
|
||||
assert portfolio.portfolio_type == PortfolioType.BACKTEST
|
||||
assert portfolio.initial_capital == Decimal("100000.5000")
|
||||
assert portfolio.current_value == Decimal("105000.7500")
|
||||
assert portfolio.currency == "USD"
|
||||
assert portfolio.is_active is False
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_timestamps_auto_populate(self, db_session, test_user):
|
||||
"""Should auto-populate created_at and updated_at timestamps."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
from datetime import datetime
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Timestamp Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Assert timestamps exist and are recent
|
||||
assert portfolio.created_at is not None
|
||||
assert portfolio.updated_at is not None
|
||||
assert isinstance(portfolio.created_at, datetime)
|
||||
assert isinstance(portfolio.updated_at, datetime)
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioTypeEnum:
|
||||
"""Tests for PortfolioType enum validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_type_live(self, db_session, test_user):
|
||||
"""Should create portfolio with LIVE type."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Live Portfolio",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.portfolio_type == PortfolioType.LIVE
|
||||
assert portfolio.portfolio_type.value == "LIVE"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_type_paper(self, db_session, test_user):
|
||||
"""Should create portfolio with PAPER type."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Paper Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.portfolio_type == PortfolioType.PAPER
|
||||
assert portfolio.portfolio_type.value == "PAPER"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_type_backtest(self, db_session, test_user):
|
||||
"""Should create portfolio with BACKTEST type."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Backtest Portfolio",
|
||||
portfolio_type=PortfolioType.BACKTEST,
|
||||
initial_capital=Decimal("100000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.portfolio_type == PortfolioType.BACKTEST
|
||||
assert portfolio.portfolio_type.value == "BACKTEST"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_type_invalid_value(self, db_session, test_user):
|
||||
"""Should reject invalid portfolio type."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Try to create with invalid string
|
||||
with pytest.raises((ValueError, AttributeError)):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Invalid Portfolio",
|
||||
portfolio_type="INVALID_TYPE",
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioDecimalPrecision:
|
||||
"""Tests for Decimal(19,4) precision on monetary values."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_capital_decimal_precision(self, db_session, test_user):
|
||||
"""Should store initial_capital with 4 decimal places."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Precision Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("12345.6789"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Assert decimal precision maintained
|
||||
assert portfolio.initial_capital == Decimal("12345.6789")
|
||||
assert isinstance(portfolio.initial_capital, Decimal)
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_value_decimal_precision(self, db_session, test_user):
|
||||
"""Should store current_value with 4 decimal places."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Value Test",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
current_value=Decimal("10523.4567"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Assert decimal precision maintained
|
||||
assert portfolio.current_value == Decimal("10523.4567")
|
||||
assert isinstance(portfolio.current_value, Decimal)
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_capital_value(self, db_session, test_user):
|
||||
"""Should handle large capital values (up to 15 digits before decimal)."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Test with 14 digits before decimal point (SQLite has limited precision)
|
||||
# PostgreSQL can handle 15 digits, but SQLite rounds at ~15 significant figures
|
||||
large_value = Decimal("99999999999999.9999")
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Large Portfolio",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=large_value,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Allow small precision loss for SQLite compatibility
|
||||
assert abs(portfolio.initial_capital - large_value) < Decimal("1.0")
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_capital_value(self, db_session, test_user):
|
||||
"""Should handle small capital values with precision."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
small_value = Decimal("0.0001")
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Small Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=small_value,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.initial_capital == small_value
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_values_rejected(self, db_session, test_user):
|
||||
"""Should reject negative capital values (business rule)."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Negative Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("-1000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
|
||||
# Should raise constraint violation or validation error
|
||||
with pytest.raises((IntegrityError, ValueError)):
|
||||
await db_session.commit()
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioUniqueConstraint:
|
||||
"""Tests for unique constraint on (user_id, name)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_can_have_multiple_portfolios(self, db_session, test_user):
|
||||
"""Should allow user to create multiple portfolios with different names."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio1 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Portfolio 1",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
portfolio2 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Portfolio 2",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio1)
|
||||
db_session.add(portfolio2)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio1)
|
||||
await db_session.refresh(portfolio2)
|
||||
|
||||
assert portfolio1.id != portfolio2.id
|
||||
assert portfolio1.user_id == portfolio2.user_id
|
||||
assert portfolio1.name != portfolio2.name
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_name_same_user_rejected(self, db_session, test_user):
|
||||
"""Should reject duplicate portfolio name for same user."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio1 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="My Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio1)
|
||||
await db_session.commit()
|
||||
|
||||
# Try to create another portfolio with same name for same user
|
||||
portfolio2 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="My Portfolio",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("20000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio2)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.commit()
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_name_different_users_allowed(self, db_session, test_user, another_user):
|
||||
"""Should allow different users to have portfolios with same name."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio1 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Main Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
portfolio2 = Portfolio(
|
||||
user_id=another_user.id,
|
||||
name="Main Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("15000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio1)
|
||||
db_session.add(portfolio2)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio1)
|
||||
await db_session.refresh(portfolio2)
|
||||
|
||||
assert portfolio1.id != portfolio2.id
|
||||
assert portfolio1.name == portfolio2.name
|
||||
assert portfolio1.user_id != portfolio2.user_id
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioCurrencyValidation:
|
||||
"""Tests for currency field validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_currency_aud(self, db_session, test_user):
|
||||
"""Should default to AUD currency."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Default Currency",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.currency == "AUD"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_currencies(self, db_session, test_user):
|
||||
"""Should accept common 3-letter currency codes."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
currencies = ["USD", "EUR", "GBP", "JPY", "CNY", "AUD", "CAD"]
|
||||
|
||||
for i, currency in enumerate(currencies):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=f"Portfolio {currency}",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.currency == currency
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_currency_uppercase_enforced(self, db_session, test_user):
|
||||
"""Should store currency in uppercase."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Currency Case Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
currency="usd", # lowercase input
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Should be stored in uppercase
|
||||
assert portfolio.currency == "USD"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_currency_length(self, db_session, test_user):
|
||||
"""Should reject currency codes that aren't 3 characters."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Test with 2-character code
|
||||
with pytest.raises((ValueError, IntegrityError)):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Invalid Currency 1",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
currency="US",
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioRelationships:
|
||||
"""Tests for Portfolio relationships with User."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_belongs_to_user(self, db_session, test_user):
|
||||
"""Should establish relationship from portfolio to user."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="User Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
# Load the relationship
|
||||
await db_session.refresh(portfolio, ["user"])
|
||||
|
||||
assert portfolio.user is not None
|
||||
assert portfolio.user.id == test_user.id
|
||||
assert portfolio.user.username == test_user.username
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_many_portfolios(self, db_session, test_user):
|
||||
"""Should establish relationship from user to portfolios."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio1 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Portfolio A",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
portfolio2 = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Portfolio B",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio1)
|
||||
db_session.add(portfolio2)
|
||||
await db_session.commit()
|
||||
|
||||
# Refresh user with portfolios relationship
|
||||
await db_session.refresh(test_user, ["portfolios"])
|
||||
|
||||
assert len(test_user.portfolios) == 2
|
||||
portfolio_names = [p.name for p in test_user.portfolios]
|
||||
assert "Portfolio A" in portfolio_names
|
||||
assert "Portfolio B" in portfolio_names
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_delete_when_user_deleted(self, db_session, test_user):
|
||||
"""Should delete portfolios when user is deleted (cascade)."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Will Be Deleted",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
portfolio_id = portfolio.id
|
||||
|
||||
# Delete the user
|
||||
await db_session.delete(test_user)
|
||||
await db_session.commit()
|
||||
|
||||
# Check portfolio is also deleted
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.id == portfolio_id)
|
||||
)
|
||||
deleted_portfolio = result.scalar_one_or_none()
|
||||
|
||||
assert deleted_portfolio is None
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_very_long_portfolio_name(self, db_session, test_user):
|
||||
"""Should handle long portfolio names within limits."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
long_name = "A" * 255 # Assume 255 char limit
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=long_name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.name == long_name
|
||||
assert len(portfolio.name) == 255
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_name_too_long(self, db_session, test_user):
|
||||
"""Should reject portfolio names exceeding max length."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
too_long_name = "A" * 256 # Exceed 255 char limit
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=too_long_name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
|
||||
with pytest.raises((ValueError, IntegrityError)):
|
||||
await db_session.commit()
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unicode_in_portfolio_name(self, db_session, test_user):
|
||||
"""Should handle unicode characters in portfolio name."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
unicode_name = "我的投资组合 🚀 Portfolio Émigré"
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=unicode_name,
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.name == unicode_name
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_portfolio_name(self, db_session, test_user):
|
||||
"""Should reject empty portfolio name."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
|
||||
with pytest.raises((ValueError, IntegrityError)):
|
||||
await db_session.commit()
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_initial_capital(self, db_session, test_user):
|
||||
"""Should handle zero initial capital."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Zero Capital",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("0.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
assert portfolio.initial_capital == Decimal("0.0000")
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_repr(self, db_session, test_user):
|
||||
"""Should have meaningful string representation."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Repr Test",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(portfolio)
|
||||
|
||||
repr_str = repr(portfolio)
|
||||
assert "Portfolio" in repr_str
|
||||
assert str(portfolio.id) in repr_str
|
||||
assert portfolio.name in repr_str
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
|
||||
class TestPortfolioQueryOperations:
|
||||
"""Tests for querying Portfolio records."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_portfolio_by_id(self, db_session, test_user):
|
||||
"""Should retrieve portfolio by ID."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Query Test",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
portfolio_id = portfolio.id
|
||||
|
||||
# Query by ID
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.id == portfolio_id)
|
||||
)
|
||||
found = result.scalar_one_or_none()
|
||||
|
||||
assert found is not None
|
||||
assert found.id == portfolio_id
|
||||
assert found.name == "Query Test"
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_portfolios_by_user(self, db_session, test_user, another_user):
|
||||
"""Should retrieve all portfolios for a user."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create portfolios for test_user
|
||||
for i in range(3):
|
||||
portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name=f"User1 Portfolio {i}",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
|
||||
# Create portfolio for another_user
|
||||
portfolio = Portfolio(
|
||||
user_id=another_user.id,
|
||||
name="User2 Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("15000.0000"),
|
||||
)
|
||||
db_session.add(portfolio)
|
||||
await db_session.commit()
|
||||
|
||||
# Query portfolios for test_user
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.user_id == test_user.id)
|
||||
)
|
||||
user_portfolios = result.scalars().all()
|
||||
|
||||
assert len(user_portfolios) == 3
|
||||
for p in user_portfolios:
|
||||
assert p.user_id == test_user.id
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_portfolios_by_type(self, db_session, test_user):
|
||||
"""Should retrieve portfolios filtered by type."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create portfolios of different types
|
||||
live_portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Live Portfolio",
|
||||
portfolio_type=PortfolioType.LIVE,
|
||||
initial_capital=Decimal("50000.0000"),
|
||||
)
|
||||
|
||||
paper_portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Paper Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
)
|
||||
|
||||
backtest_portfolio = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Backtest Portfolio",
|
||||
portfolio_type=PortfolioType.BACKTEST,
|
||||
initial_capital=Decimal("100000.0000"),
|
||||
)
|
||||
|
||||
db_session.add_all([live_portfolio, paper_portfolio, backtest_portfolio])
|
||||
await db_session.commit()
|
||||
|
||||
# Query PAPER portfolios
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(Portfolio.portfolio_type == PortfolioType.PAPER)
|
||||
)
|
||||
paper_portfolios = result.scalars().all()
|
||||
|
||||
assert len(paper_portfolios) >= 1
|
||||
for p in paper_portfolios:
|
||||
assert p.portfolio_type == PortfolioType.PAPER
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_active_portfolios(self, db_session, test_user):
|
||||
"""Should retrieve only active portfolios."""
|
||||
try:
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
# Create active and inactive portfolios
|
||||
active = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Active Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
inactive = Portfolio(
|
||||
user_id=test_user.id,
|
||||
name="Inactive Portfolio",
|
||||
portfolio_type=PortfolioType.PAPER,
|
||||
initial_capital=Decimal("10000.0000"),
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
db_session.add_all([active, inactive])
|
||||
await db_session.commit()
|
||||
|
||||
# Query active portfolios
|
||||
result = await db_session.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.user_id == test_user.id,
|
||||
Portfolio.is_active == True
|
||||
)
|
||||
)
|
||||
active_portfolios = result.scalars().all()
|
||||
|
||||
assert len(active_portfolios) >= 1
|
||||
for p in active_portfolios:
|
||||
assert p.is_active is True
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Portfolio model not yet implemented (TDD RED phase)")
|
||||
|
|
@ -3,5 +3,6 @@
|
|||
from tradingagents.api.models.base import Base
|
||||
from tradingagents.api.models.user import User
|
||||
from tradingagents.api.models.strategy import Strategy
|
||||
from tradingagents.api.models.portfolio import Portfolio, PortfolioType
|
||||
|
||||
__all__ = ["Base", "User", "Strategy"]
|
||||
__all__ = ["Base", "User", "Strategy", "Portfolio", "PortfolioType"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
"""Portfolio model for managing trading portfolios.
|
||||
|
||||
This module defines the Portfolio model for tracking live, paper trading,
|
||||
and backtesting portfolios. Each portfolio belongs to a user and tracks
|
||||
monetary values with high precision using Decimal.
|
||||
|
||||
Model Fields:
|
||||
- id: Primary key
|
||||
- user_id: Foreign key to users table
|
||||
- name: Portfolio name (unique per user)
|
||||
- portfolio_type: Type of portfolio (LIVE, PAPER, BACKTEST)
|
||||
- initial_capital: Starting capital with Decimal(19,4) precision
|
||||
- current_value: Current portfolio value with Decimal(19,4) precision
|
||||
- currency: 3-letter currency code (default: AUD)
|
||||
- is_active: Whether portfolio is active
|
||||
- created_at, updated_at: Automatic timestamps
|
||||
|
||||
Relationships:
|
||||
- user: Many-to-one relationship with User model
|
||||
- Cascade delete when user is deleted
|
||||
|
||||
Constraints:
|
||||
- Unique constraint on (user_id, name)
|
||||
- Check constraint: initial_capital >= 0
|
||||
- Check constraint: current_value >= 0
|
||||
|
||||
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 sqlalchemy import (
|
||||
String,
|
||||
Boolean,
|
||||
Numeric,
|
||||
ForeignKey,
|
||||
Index,
|
||||
UniqueConstraint,
|
||||
CheckConstraint,
|
||||
Enum,
|
||||
event,
|
||||
TypeDecorator,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates, Session
|
||||
|
||||
from tradingagents.api.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class PreciseNumeric(TypeDecorator):
|
||||
"""Custom type for high-precision numeric values.
|
||||
|
||||
Stores Decimal with proper precision in all databases.
|
||||
For SQLite, uses NUMERIC (stored as REAL) but processes to/from Decimal
|
||||
to maintain Python-side precision even though DB storage is approximate.
|
||||
For PostgreSQL, uses true NUMERIC(19,4) type.
|
||||
|
||||
Note: SQLite stores NUMERIC as REAL (float) which has precision limits.
|
||||
Very large values (>15 significant digits) will lose precision in SQLite.
|
||||
For production, use PostgreSQL which has true arbitrary precision NUMERIC.
|
||||
"""
|
||||
|
||||
impl = Numeric(19, 4)
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
"""Ensure value is Decimal before storing."""
|
||||
if value is None:
|
||||
return value
|
||||
if not isinstance(value, Decimal):
|
||||
return Decimal(str(value))
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
"""Convert result to Decimal with proper precision."""
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
# Convert from float/int to Decimal
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
class PortfolioType(str, PyEnum):
|
||||
"""Enum for portfolio types.
|
||||
|
||||
LIVE: Real money trading portfolio
|
||||
PAPER: Virtual/simulated trading portfolio
|
||||
BACKTEST: Historical backtesting portfolio
|
||||
"""
|
||||
|
||||
LIVE = "LIVE"
|
||||
PAPER = "PAPER"
|
||||
BACKTEST = "BACKTEST"
|
||||
|
||||
|
||||
class Portfolio(Base, TimestampMixin):
|
||||
"""Portfolio model for managing trading portfolios.
|
||||
|
||||
A portfolio represents a collection of positions and tracks capital
|
||||
allocation. Users can have multiple portfolios of different types.
|
||||
|
||||
Attributes:
|
||||
id: Primary key, auto-increment
|
||||
user_id: Foreign key to users.id (cascade delete)
|
||||
name: Portfolio name, unique per user
|
||||
portfolio_type: Type of portfolio (LIVE, PAPER, BACKTEST)
|
||||
initial_capital: Starting capital amount (Decimal 19,4)
|
||||
current_value: Current portfolio value (Decimal 19,4)
|
||||
currency: 3-letter currency code (e.g., AUD, USD)
|
||||
is_active: Whether portfolio is actively trading
|
||||
user: Relationship to User model
|
||||
created_at: Timestamp when created (auto)
|
||||
updated_at: Timestamp when last updated (auto)
|
||||
|
||||
Constraints:
|
||||
- (user_id, name) must be unique
|
||||
- initial_capital must be >= 0
|
||||
- current_value must be >= 0
|
||||
- currency must be exactly 3 uppercase characters
|
||||
|
||||
Example:
|
||||
>>> from decimal import Decimal
|
||||
>>> portfolio = Portfolio(
|
||||
... user_id=1,
|
||||
... name="My Trading Portfolio",
|
||||
... portfolio_type=PortfolioType.PAPER,
|
||||
... initial_capital=Decimal("10000.0000")
|
||||
... )
|
||||
>>> session.add(portfolio)
|
||||
>>> await session.commit()
|
||||
"""
|
||||
|
||||
__tablename__ = "portfolios"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign key to user (cascade delete)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="User who owns this portfolio"
|
||||
)
|
||||
|
||||
# Portfolio identification
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Portfolio name (unique per user)"
|
||||
)
|
||||
|
||||
# Portfolio type (enum)
|
||||
portfolio_type: Mapped[PortfolioType] = mapped_column(
|
||||
Enum(PortfolioType, native_enum=False, length=20),
|
||||
nullable=False,
|
||||
comment="Portfolio type: LIVE, PAPER, or BACKTEST"
|
||||
)
|
||||
|
||||
# Monetary values with high precision (19 total digits, 4 after decimal)
|
||||
# Using PreciseNumeric to preserve decimal precision in SQLite
|
||||
initial_capital: Mapped[Decimal] = mapped_column(
|
||||
PreciseNumeric,
|
||||
nullable=False,
|
||||
comment="Initial capital amount"
|
||||
)
|
||||
|
||||
current_value: Mapped[Decimal] = mapped_column(
|
||||
PreciseNumeric,
|
||||
nullable=False,
|
||||
default=lambda context: context.get_current_parameters()['initial_capital'],
|
||||
comment="Current portfolio value"
|
||||
)
|
||||
|
||||
# Currency code (ISO 4217 - 3 letters)
|
||||
currency: Mapped[str] = mapped_column(
|
||||
String(3),
|
||||
nullable=False,
|
||||
default="AUD",
|
||||
comment="Currency code (ISO 4217, e.g., AUD, USD)"
|
||||
)
|
||||
|
||||
# Portfolio status
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
index=True,
|
||||
comment="Whether portfolio is actively trading"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="portfolios"
|
||||
)
|
||||
|
||||
# Table-level constraints and indexes
|
||||
__table_args__ = (
|
||||
# Unique constraint: user can't have duplicate portfolio names
|
||||
UniqueConstraint(
|
||||
"user_id",
|
||||
"name",
|
||||
name="uq_portfolio_user_name"
|
||||
),
|
||||
# Check constraints: non-negative monetary values
|
||||
CheckConstraint(
|
||||
"initial_capital >= 0",
|
||||
name="ck_portfolio_initial_capital_positive"
|
||||
),
|
||||
CheckConstraint(
|
||||
"current_value >= 0",
|
||||
name="ck_portfolio_current_value_positive"
|
||||
),
|
||||
# Composite index for common queries
|
||||
Index("ix_portfolio_user_active", "user_id", "is_active"),
|
||||
Index("ix_portfolio_user_type", "user_id", "portfolio_type"),
|
||||
)
|
||||
|
||||
@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("portfolio_type")
|
||||
def validate_portfolio_type(self, key: str, value) -> PortfolioType:
|
||||
"""Validate and convert portfolio type to PortfolioType enum.
|
||||
|
||||
Args:
|
||||
key: Field name (portfolio_type)
|
||||
value: Portfolio type value (str or PortfolioType)
|
||||
|
||||
Returns:
|
||||
PortfolioType enum value
|
||||
|
||||
Raises:
|
||||
ValueError: If value is not a valid portfolio type
|
||||
"""
|
||||
# If already a PortfolioType, return it
|
||||
if isinstance(value, PortfolioType):
|
||||
return value
|
||||
|
||||
# Try to convert string to PortfolioType
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return PortfolioType[value.upper()]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Invalid portfolio type '{value}'. "
|
||||
f"Must be one of: {', '.join([t.value for t in PortfolioType])}"
|
||||
)
|
||||
|
||||
# Invalid type
|
||||
raise ValueError(
|
||||
f"Portfolio type must be string or PortfolioType enum, got {type(value)}"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Portfolio.
|
||||
|
||||
Returns:
|
||||
String showing portfolio ID, name, type, and value
|
||||
"""
|
||||
return (
|
||||
f"<Portfolio(id={self.id}, "
|
||||
f"name='{self.name}', "
|
||||
f"type={self.portfolio_type.value}, "
|
||||
f"value={self.current_value} {self.currency})>"
|
||||
)
|
||||
|
||||
|
||||
# Event listener for before_flush validation
|
||||
# This ensures constraints are checked before database commit
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def validate_portfolio_before_flush(session, flush_context, instances):
|
||||
"""Validate Portfolio 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, Portfolio):
|
||||
# Validate portfolio name
|
||||
if not obj.name or not obj.name.strip():
|
||||
raise ValueError("Portfolio name cannot be empty")
|
||||
|
||||
if len(obj.name) > 255:
|
||||
raise ValueError(
|
||||
f"Portfolio name too long: {len(obj.name)} characters (max 255)"
|
||||
)
|
||||
|
||||
# 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 monetary values are non-negative
|
||||
if obj.initial_capital is not None and obj.initial_capital < 0:
|
||||
raise ValueError(
|
||||
f"initial_capital cannot be negative, got {obj.initial_capital}"
|
||||
)
|
||||
|
||||
if obj.current_value is not None and obj.current_value < 0:
|
||||
raise ValueError(
|
||||
f"current_value cannot be negative, got {obj.current_value}"
|
||||
)
|
||||
|
|
@ -23,6 +23,7 @@ class User(Base, TimestampMixin):
|
|||
api_key_hash: Bcrypt hash of API key (if user has API key)
|
||||
is_verified: Whether user email is verified
|
||||
strategies: Related Strategy objects owned by this user
|
||||
portfolios: Related Portfolio objects owned by this user
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
|
@ -72,5 +73,12 @@ class User(Base, TimestampMixin):
|
|||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Relationship to portfolios (Issue #4: DB-3)
|
||||
portfolios: Mapped[List["Portfolio"]] = relationship(
|
||||
"Portfolio",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"
|
||||
|
|
|
|||
Loading…
Reference in New Issue