galaxis-po/backend/app/schemas/portfolio.py
zephyrdark 0cd1e931b0
All checks were successful
Deploy to Production / deploy (push) Successful in 1m35s
feat: display Korean stock names in portfolio views
The portfolio API was returning only ticker symbols (e.g., "095570")
without stock names. The Stock table already has Korean names
(e.g., "AJ네트웍스") from data collection.

Backend: Add name field to HoldingWithValue schema, fetch stock names
via RebalanceService.get_stock_names() in the portfolio detail endpoint.

Frontend: Show Korean stock name as primary label with ticker as
subtitle in portfolio detail, donut charts, and target vs actual
comparison. Dashboard donut chart also shows names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:22:48 +09:00

228 lines
5.4 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
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
# Snapshot schemas
class SnapshotHoldingResponse(BaseModel):
ticker: str
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
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
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]