diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ce2a40..a16b12c0 100644 --- a/CHANGELOG.md +++ b/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/) diff --git a/migrations/versions/003_add_portfolio_model.py b/migrations/versions/003_add_portfolio_model.py new file mode 100644 index 00000000..3132b729 --- /dev/null +++ b/migrations/versions/003_add_portfolio_model.py @@ -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') diff --git a/tests/api/conftest.py b/tests/api/conftest.py index b9eac5dd..2aee4b80 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -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 + ] diff --git a/tests/integration/api/conftest.py b/tests/integration/api/conftest.py new file mode 100644 index 00000000..82f68a43 --- /dev/null +++ b/tests/integration/api/conftest.py @@ -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"] diff --git a/tests/integration/api/test_portfolio_integration.py b/tests/integration/api/test_portfolio_integration.py new file mode 100644 index 00000000..d54323d3 --- /dev/null +++ b/tests/integration/api/test_portfolio_integration.py @@ -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)") diff --git a/tests/unit/api/conftest.py b/tests/unit/api/conftest.py new file mode 100644 index 00000000..ec883f18 --- /dev/null +++ b/tests/unit/api/conftest.py @@ -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"] diff --git a/tests/unit/api/test_portfolio_model.py b/tests/unit/api/test_portfolio_model.py new file mode 100644 index 00000000..6870ebd9 --- /dev/null +++ b/tests/unit/api/test_portfolio_model.py @@ -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)") diff --git a/tradingagents/api/models/__init__.py b/tradingagents/api/models/__init__.py index b10ed2e7..fb5264f1 100644 --- a/tradingagents/api/models/__init__.py +++ b/tradingagents/api/models/__init__.py @@ -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"] diff --git a/tradingagents/api/models/portfolio.py b/tradingagents/api/models/portfolio.py new file mode 100644 index 00000000..bfe4a70c --- /dev/null +++ b/tradingagents/api/models/portfolio.py @@ -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"" + ) + + +# 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}" + ) diff --git a/tradingagents/api/models/user.py b/tradingagents/api/models/user.py index be353c2e..c4f76c0a 100644 --- a/tradingagents/api/models/user.py +++ b/tradingagents/api/models/user.py @@ -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""