galaxis-po/backend/app/schemas/portfolio.py
zephyrdark eb06dfc48b
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
feat: implement scenario gap analysis - core loop completion
Phase 1 (Critical):
- Add bulk rebalance apply API + UI with confirmation modal
- Add strategy results to portfolio targets flow (shared component)

Phase 2 (Important):
- Show current holdings in signal execute modal with auto-fill
- Add DC pension risk asset ratio warning (70% limit)
- Add KOSPI benchmark comparison to portfolio returns
- Track signal execution details (price, quantity, timestamp)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:18:15 +09:00

250 lines
6.0 KiB
Python

"""
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