TradingAgents/tradingagents/api/models/settings.py

289 lines
10 KiB
Python

"""Settings model for user risk profiles and alert preferences.
This module defines the Settings model for managing user trading preferences,
risk profiles, and alert configurations. Each user has exactly one Settings
record (one-to-one relationship).
Model Fields:
- id: Primary key
- user_id: Foreign key to users table (unique, one-to-one)
- risk_profile: User's risk tolerance (CONSERVATIVE, MODERATE, AGGRESSIVE)
- risk_score: Numeric risk score from 0 (very conservative) to 10 (very aggressive)
- max_position_pct: Maximum percentage of portfolio for single position (0-100)
- max_portfolio_risk_pct: Maximum portfolio-wide risk percentage (0-100)
- investment_horizon_years: Investment time horizon in years (>= 0)
- alert_preferences: JSON configuration for email/SMS/push notifications
- created_at, updated_at: Automatic timestamps
Relationships:
- user: One-to-one relationship with User model
- Cascade delete when user is deleted
Constraints:
- Unique constraint on user_id (one settings per user)
- Check constraint: risk_score >= 0 AND risk_score <= 10
- Check constraint: max_position_pct >= 0 AND max_position_pct <= 100
- Check constraint: max_portfolio_risk_pct >= 0 AND max_portfolio_risk_pct <= 100
- Check constraint: investment_horizon_years >= 0
Follows SQLAlchemy 2.0 patterns with Mapped[] and mapped_column().
"""
from enum import Enum as PyEnum
from typing import Optional, Dict, Any
from decimal import Decimal
from sqlalchemy import (
String,
Integer,
Numeric,
ForeignKey,
Index,
UniqueConstraint,
CheckConstraint,
Enum,
event,
JSON,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates, Session
from tradingagents.api.models.base import Base, TimestampMixin
class RiskProfile(str, PyEnum):
"""Enum for user risk tolerance profiles.
CONSERVATIVE: Low risk tolerance, focus on capital preservation
MODERATE: Balanced risk/reward approach (default)
AGGRESSIVE: High risk tolerance, focus on growth
"""
CONSERVATIVE = "CONSERVATIVE"
MODERATE = "MODERATE"
AGGRESSIVE = "AGGRESSIVE"
class Settings(Base, TimestampMixin):
"""Settings model for user preferences and risk management.
A settings record configures a user's trading preferences including
risk tolerance, position sizing limits, and alert configurations.
Each user has exactly one settings record (one-to-one relationship).
Attributes:
id: Primary key, auto-increment
user_id: Foreign key to users.id (cascade delete, unique)
risk_profile: Risk tolerance profile (CONSERVATIVE, MODERATE, AGGRESSIVE)
risk_score: Numeric risk score 0-10 (Decimal 5,2)
max_position_pct: Max % of portfolio for single position (Decimal 5,2)
max_portfolio_risk_pct: Max portfolio-wide risk % (Decimal 5,2)
investment_horizon_years: Investment time horizon in years
alert_preferences: JSON config for notifications (email, SMS, push)
user: Relationship to User model
created_at: Timestamp when created (auto)
updated_at: Timestamp when last updated (auto)
Constraints:
- user_id must be unique (one-to-one with User)
- risk_score must be between 0 and 10 (inclusive)
- max_position_pct must be between 0 and 100 (inclusive)
- max_portfolio_risk_pct must be between 0 and 100 (inclusive)
- investment_horizon_years must be >= 0
Example:
>>> from decimal import Decimal
>>> settings = Settings(
... user_id=1,
... risk_profile=RiskProfile.MODERATE,
... risk_score=Decimal("5.0"),
... max_position_pct=Decimal("10.0"),
... max_portfolio_risk_pct=Decimal("2.0"),
... investment_horizon_years=5,
... alert_preferences={
... "email": {
... "enabled": True,
... "address": "user@example.com",
... "alert_types": ["price_alert", "portfolio_alert"]
... }
... }
... )
>>> session.add(settings)
>>> await session.commit()
"""
__tablename__ = "settings"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Foreign key to user (cascade delete, unique for one-to-one)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
comment="User who owns these settings (one-to-one)"
)
# Risk profile (enum)
risk_profile: Mapped[RiskProfile] = mapped_column(
Enum(RiskProfile, native_enum=False, length=20),
nullable=False,
default=RiskProfile.MODERATE,
comment="Risk tolerance: CONSERVATIVE, MODERATE, or AGGRESSIVE"
)
# Risk score (0-10 scale with 2 decimal places)
risk_score: Mapped[Decimal] = mapped_column(
Numeric(precision=5, scale=2),
nullable=False,
default=Decimal("5.0"),
comment="Numeric risk score from 0 (conservative) to 10 (aggressive)"
)
# Position sizing limits (percentages with 2 decimal places)
max_position_pct: Mapped[Decimal] = mapped_column(
Numeric(precision=5, scale=2),
nullable=False,
default=Decimal("10.0"),
comment="Maximum percentage of portfolio for single position (0-100)"
)
max_portfolio_risk_pct: Mapped[Decimal] = mapped_column(
Numeric(precision=5, scale=2),
nullable=False,
default=Decimal("2.0"),
comment="Maximum portfolio-wide risk percentage (0-100)"
)
# Investment horizon
investment_horizon_years: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=5,
comment="Investment time horizon in years"
)
# Alert preferences (JSON)
alert_preferences: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="JSON configuration for email/SMS/push notifications"
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="settings"
)
# Table-level constraints and indexes
__table_args__ = (
# Unique constraint: one settings per user
UniqueConstraint(
"user_id",
name="uq_settings_user_id"
),
# Check constraints: valid numeric ranges
CheckConstraint(
"risk_score >= 0 AND risk_score <= 10",
name="ck_settings_risk_score_range"
),
CheckConstraint(
"max_position_pct >= 0 AND max_position_pct <= 100",
name="ck_settings_max_position_pct_range"
),
CheckConstraint(
"max_portfolio_risk_pct >= 0 AND max_portfolio_risk_pct <= 100",
name="ck_settings_max_portfolio_risk_pct_range"
),
CheckConstraint(
"investment_horizon_years >= 0",
name="ck_settings_investment_horizon_positive"
),
# Note: Index on user_id is auto-created by unique=True parameter above
)
@validates("risk_profile")
def validate_risk_profile(self, key: str, value) -> RiskProfile:
"""Validate and convert risk profile to RiskProfile enum.
Args:
key: Field name (risk_profile)
value: Risk profile value (str or RiskProfile)
Returns:
RiskProfile enum value
Raises:
ValueError: If value is not a valid risk profile
"""
# If already a RiskProfile, return it
if isinstance(value, RiskProfile):
return value
# Try to convert string to RiskProfile
if isinstance(value, str):
try:
return RiskProfile[value.upper()]
except KeyError:
raise ValueError(
f"Invalid risk profile '{value}'. "
f"Must be one of: {', '.join([p.value for p in RiskProfile])}"
)
# Invalid type
raise ValueError(
f"Risk profile must be string or RiskProfile enum, got {type(value)}"
)
def __repr__(self) -> str:
"""String representation of Settings.
Returns:
String showing settings ID, user ID, and risk profile
"""
return (
f"<Settings(id={self.id}, "
f"user_id={self.user_id}, "
f"risk_profile={self.risk_profile.value}, "
f"risk_score={self.risk_score})>"
)
# Event listener for before_flush validation
# This ensures business rules are validated before database commit
@event.listens_for(Session, "before_flush")
def validate_settings_before_flush(session, flush_context, instances):
"""Validate Settings objects before flushing to database.
This event listener checks business rules that cannot be enforced
by database constraints (data normalization, complex business logic).
Database-enforced constraints (CheckConstraints) will raise IntegrityError
from the database itself.
Args:
session: SQLAlchemy session
flush_context: Flush context
instances: Instances being flushed
Raises:
ValueError: If validation fails for business logic violations
"""
for obj in session.new | session.dirty:
if isinstance(obj, Settings):
# Ensure alert_preferences is never None (should default to empty dict)
if obj.alert_preferences is None:
obj.alert_preferences = {}
# Note: Numeric range validations (risk_score, max_position_pct, etc.)
# are handled by database CheckConstraints and will raise IntegrityError
# if violated. We don't duplicate those checks here.
# Note: Nested JSON mutations (e.g., modifying settings.alert_preferences["key"]["nested"] = value)
# are not automatically tracked by SQLAlchemy. Users should either:
# 1. Reassign the entire dict: settings.alert_preferences = {...}
# 2. Use flag_modified(settings, "alert_preferences") explicitly
# 3. Use a custom MutableDict implementation for nested tracking