galaxis-po/backend/app/schemas/portfolio.py

268 lines
6.6 KiB
Python
Raw Normal View History

2026-02-03 07:06:22 +09:00
"""
Portfolio related Pydantic schemas.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Annotated, Optional, List
2026-02-03 07:06:22 +09:00
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'),
]
2026-02-03 07:06:22 +09:00
# Target schemas
class TargetBase(BaseModel):
ticker: str
target_ratio: FloatDecimal = Field(..., ge=0, le=100)
2026-02-03 07:06:22 +09:00
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)
2026-02-03 07:06:22 +09:00
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
2026-02-03 07:06:22 +09:00
# Transaction schemas
class TransactionBase(BaseModel):
ticker: str
tx_type: str # "buy" or "sell"
quantity: int = Field(..., gt=0)
price: FloatDecimal = Field(..., gt=0)
2026-02-03 07:06:22 +09:00
executed_at: datetime
memo: Optional[str] = None
class TransactionCreate(TransactionBase):
pass
class TransactionResponse(TransactionBase):
id: int
name: str | None = None
realized_pnl: FloatDecimal | None = None
2026-02-03 07:06:22 +09:00
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
total_realized_pnl: FloatDecimal | None = None
total_unrealized_pnl: FloatDecimal | None = None
risk_asset_ratio: FloatDecimal | None = None
2026-02-03 07:06:22 +09:00
# Snapshot schemas
class SnapshotHoldingResponse(BaseModel):
ticker: str
name: str | None = None
2026-02-03 07:06:22 +09:00
quantity: int
price: FloatDecimal
value: FloatDecimal
current_ratio: FloatDecimal
2026-02-03 07:06:22 +09:00
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
2026-02-03 07:06:22 +09:00
class SnapshotResponse(BaseModel):
"""Snapshot detail with holdings."""
2026-02-03 07:06:22 +09:00
id: int
portfolio_id: int
total_value: FloatDecimal
2026-02-03 07:06:22 +09:00
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] = []
2026-02-03 07:06:22 +09:00
# Rebalancing schemas
class RebalanceItem(BaseModel):
ticker: str
name: str | None = None
target_ratio: FloatDecimal
current_ratio: FloatDecimal
2026-02-03 07:06:22 +09:00
current_quantity: int
current_value: FloatDecimal
target_value: FloatDecimal
diff_value: FloatDecimal
2026-02-03 07:06:22 +09:00
diff_quantity: int
action: str # "buy", "sell", or "hold"
class RebalanceResponse(BaseModel):
portfolio_id: int
total_value: FloatDecimal
2026-02-03 07:06:22 +09:00
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
2026-02-03 07:06:22 +09:00
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)
min_trade_amount: Optional[Decimal] = Field(default=Decimal("10000"), 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
class PositionSizeResponse(BaseModel):
ticker: str
price: FloatDecimal
total_portfolio_value: FloatDecimal
current_holding_quantity: int
current_holding_value: FloatDecimal
current_ratio: FloatDecimal
target_ratio: FloatDecimal | None = None
recommended_quantity: int
max_quantity: int
recommended_value: FloatDecimal
max_value: FloatDecimal