galaxis-po/backend/app/schemas/portfolio.py
머니페니 9249821a25 feat: add realized/unrealized PnL tracking and position sizing guide
- Add realized_pnl column to transactions table with alembic migration
- Calculate realized PnL on sell transactions: (sell_price - avg_price) * quantity
- Show total realized/unrealized PnL in portfolio detail summary cards
- Show per-transaction realized PnL in transaction history table
- Add position sizing API endpoint (GET /portfolios/{id}/position-size)
- Show position sizing guide in signal execution modal for buy signals
- 8 new E2E tests, all 88 tests passing

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

267 lines
6.5 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
realized_pnl: FloatDecimal | 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
total_realized_pnl: FloatDecimal | None = None
total_unrealized_pnl: 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
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