All checks were successful
Deploy to Production / deploy (push) Successful in 1m35s
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>
228 lines
5.4 KiB
Python
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]
|