""" Portfolio related Pydantic schemas. """ from datetime import datetime, date from decimal import Decimal from typing import Annotated, Optional, List from pydantic import BaseModel, Field, PlainSerializer # Decimal type that serializes as float in JSON responses (not string) FloatDecimal = Annotated[ Decimal, PlainSerializer(lambda v: float(v), return_type=float, when_used='json'), ] # Target schemas class TargetBase(BaseModel): ticker: str target_ratio: FloatDecimal = Field(..., ge=0, le=100) class TargetCreate(TargetBase): pass class TargetResponse(TargetBase): class Config: from_attributes = True # Holding schemas class HoldingBase(BaseModel): ticker: str quantity: int = Field(..., ge=0) avg_price: FloatDecimal = Field(..., ge=0) class HoldingCreate(HoldingBase): pass class HoldingResponse(HoldingBase): class Config: from_attributes = True class HoldingWithValue(HoldingResponse): """Holding with calculated values.""" name: str | None = None current_price: FloatDecimal | None = None value: FloatDecimal | None = None current_ratio: FloatDecimal | None = None profit_loss: FloatDecimal | None = None profit_loss_ratio: FloatDecimal | None = None # Transaction schemas class TransactionBase(BaseModel): ticker: str tx_type: str # "buy" or "sell" quantity: int = Field(..., gt=0) price: FloatDecimal = Field(..., gt=0) executed_at: datetime memo: Optional[str] = None class TransactionCreate(TransactionBase): pass class TransactionResponse(TransactionBase): id: int name: str | None = None class Config: from_attributes = True # Portfolio schemas class PortfolioBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) portfolio_type: str = "general" # "pension" or "general" class PortfolioCreate(PortfolioBase): pass class PortfolioUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) portfolio_type: Optional[str] = None class PortfolioResponse(PortfolioBase): id: int user_id: int created_at: datetime updated_at: datetime class Config: from_attributes = True class PortfolioDetail(PortfolioResponse): """Portfolio with targets and holdings.""" targets: List[TargetResponse] = [] holdings: List[HoldingWithValue] = [] total_value: FloatDecimal | None = None total_invested: FloatDecimal | None = None total_profit_loss: FloatDecimal | None = None risk_asset_ratio: FloatDecimal | None = None # Snapshot schemas class SnapshotHoldingResponse(BaseModel): ticker: str name: str | None = None quantity: int price: FloatDecimal value: FloatDecimal current_ratio: FloatDecimal class Config: from_attributes = True class SnapshotListItem(BaseModel): """Snapshot list item (without holdings).""" id: int portfolio_id: int total_value: FloatDecimal snapshot_date: date class Config: from_attributes = True class SnapshotResponse(BaseModel): """Snapshot detail with holdings.""" id: int portfolio_id: int total_value: FloatDecimal snapshot_date: date holdings: List[SnapshotHoldingResponse] = [] class Config: from_attributes = True class ReturnDataPoint(BaseModel): """Single data point for returns chart.""" date: date total_value: FloatDecimal daily_return: FloatDecimal | None = None cumulative_return: FloatDecimal | None = None benchmark_return: FloatDecimal | None = None class ReturnsResponse(BaseModel): """Portfolio returns over time.""" portfolio_id: int start_date: date | None = None end_date: date | None = None total_return: FloatDecimal | None = None cagr: FloatDecimal | None = None benchmark_total_return: FloatDecimal | None = None data: List[ReturnDataPoint] = [] # Rebalancing schemas class RebalanceItem(BaseModel): ticker: str name: str | None = None target_ratio: FloatDecimal current_ratio: FloatDecimal current_quantity: int current_value: FloatDecimal target_value: FloatDecimal diff_value: FloatDecimal diff_quantity: int action: str # "buy", "sell", or "hold" class RebalanceResponse(BaseModel): portfolio_id: int total_value: FloatDecimal items: List[RebalanceItem] class RebalanceSimulationRequest(BaseModel): additional_amount: Decimal = Field(..., gt=0) class RebalanceSimulationResponse(BaseModel): portfolio_id: int current_total: FloatDecimal additional_amount: FloatDecimal new_total: FloatDecimal items: List[RebalanceItem] class RebalanceCalculateRequest(BaseModel): """Request for manual rebalance calculation.""" strategy: str = Field(..., pattern="^(full_rebalance|additional_buy)$") prices: Optional[dict[str, Decimal]] = None additional_amount: Optional[Decimal] = Field(None, ge=0) class RebalanceCalculateItem(BaseModel): """Extended rebalance item with price change info.""" ticker: str name: str | None = None target_ratio: FloatDecimal current_ratio: FloatDecimal current_quantity: int current_value: FloatDecimal current_price: FloatDecimal target_value: FloatDecimal diff_ratio: FloatDecimal diff_quantity: int action: str # "buy", "sell", or "hold" change_vs_prev_month: FloatDecimal | None = None change_vs_start: FloatDecimal | None = None class RebalanceCalculateResponse(BaseModel): """Response for manual rebalance calculation.""" portfolio_id: int total_assets: FloatDecimal available_to_buy: FloatDecimal | None = None items: List[RebalanceCalculateItem] # Rebalance apply schemas class RebalanceApplyItem(BaseModel): ticker: str action: str # "buy" or "sell" quantity: int = Field(..., gt=0) price: FloatDecimal = Field(..., gt=0) class RebalanceApplyRequest(BaseModel): items: List[RebalanceApplyItem] class RebalanceApplyResponse(BaseModel): transactions: List[TransactionResponse] holdings_updated: int