galaxis-po/backend/app/schemas/portfolio.py
zephyrdark de77d5b2aa feat: add rebalance calculate schemas and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:27:47 +09:00

221 lines
5.0 KiB
Python

"""
Portfolio related Pydantic schemas.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel, Field
# Target schemas
class TargetBase(BaseModel):
ticker: str
target_ratio: Decimal = 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: Decimal = Field(..., ge=0)
class HoldingCreate(HoldingBase):
pass
class HoldingResponse(HoldingBase):
class Config:
from_attributes = True
class HoldingWithValue(HoldingResponse):
"""Holding with calculated values."""
current_price: Decimal | None = None
value: Decimal | None = None
current_ratio: Decimal | None = None
profit_loss: Decimal | None = None
profit_loss_ratio: Decimal | None = None
# Transaction schemas
class TransactionBase(BaseModel):
ticker: str
tx_type: str # "buy" or "sell"
quantity: int = Field(..., gt=0)
price: Decimal = Field(..., gt=0)
executed_at: datetime
memo: Optional[str] = None
class TransactionCreate(TransactionBase):
pass
class TransactionResponse(TransactionBase):
id: int
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: Decimal | None = None
total_invested: Decimal | None = None
total_profit_loss: Decimal | None = None
# Snapshot schemas
class SnapshotHoldingResponse(BaseModel):
ticker: str
quantity: int
price: Decimal
value: Decimal
current_ratio: Decimal
class Config:
from_attributes = True
class SnapshotListItem(BaseModel):
"""Snapshot list item (without holdings)."""
id: int
portfolio_id: int
total_value: Decimal
snapshot_date: date
class Config:
from_attributes = True
class SnapshotResponse(BaseModel):
"""Snapshot detail with holdings."""
id: int
portfolio_id: int
total_value: Decimal
snapshot_date: date
holdings: List[SnapshotHoldingResponse] = []
class Config:
from_attributes = True
class ReturnDataPoint(BaseModel):
"""Single data point for returns chart."""
date: date
total_value: Decimal
daily_return: Decimal | None = None
cumulative_return: Decimal | None = None
class ReturnsResponse(BaseModel):
"""Portfolio returns over time."""
portfolio_id: int
start_date: date | None = None
end_date: date | None = None
total_return: Decimal | None = None
cagr: Decimal | None = None
data: List[ReturnDataPoint] = []
# Rebalancing schemas
class RebalanceItem(BaseModel):
ticker: str
name: str | None = None
target_ratio: Decimal
current_ratio: Decimal
current_quantity: int
current_value: Decimal
target_value: Decimal
diff_value: Decimal
diff_quantity: int
action: str # "buy", "sell", or "hold"
class RebalanceResponse(BaseModel):
portfolio_id: int
total_value: Decimal
items: List[RebalanceItem]
class RebalanceSimulationRequest(BaseModel):
additional_amount: Decimal = Field(..., gt=0)
class RebalanceSimulationResponse(BaseModel):
portfolio_id: int
current_total: Decimal
additional_amount: Decimal
new_total: Decimal
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: Decimal
current_ratio: Decimal
current_quantity: int
current_value: Decimal
current_price: Decimal
target_value: Decimal
diff_ratio: Decimal
diff_quantity: int
action: str # "buy", "sell", or "hold"
change_vs_prev_month: Decimal | None = None
change_vs_start: Decimal | None = None
class RebalanceCalculateResponse(BaseModel):
"""Response for manual rebalance calculation."""
portfolio_id: int
total_assets: Decimal
available_to_buy: Decimal | None = None
items: List[RebalanceCalculateItem]