feat(db): add Portfolio model with LIVE/PAPER/BACKTEST types - Fixes #4

This commit is contained in:
Andrew Kaszubski 2025-12-26 13:46:39 +11:00
parent d3892b0da9
commit 0d09f15bd6
10 changed files with 2578 additions and 1 deletions

View File

@ -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/)

View File

@ -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')

View File

@ -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
]

View File

@ -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"]

View File

@ -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)")

View File

@ -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"]

View File

@ -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)")

View File

@ -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"]

View File

@ -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}"
)

View File

@ -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}')>"