fix: serialize Decimal as float in API responses and fix transaction field names
All checks were successful
Deploy to Production / deploy (push) Successful in 1m33s

Pydantic v2's model_dump(mode="json") serializes Decimal as strings (e.g.,
"33.33" instead of 33.33), causing frontend crashes when calling .toFixed()
on string values. Introduced FloatDecimal type alias with PlainSerializer
to ensure Decimal fields are serialized as floats in JSON responses.

Also fixed frontend Transaction interface to match backend field names
(created_at → executed_at, transaction_type → tx_type).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-12 23:47:48 +09:00
parent 0a8d17a588
commit 752db2ef1a
4 changed files with 107 additions and 97 deletions

View File

@ -8,6 +8,8 @@ from enum import Enum
from pydantic import BaseModel, Field
from app.schemas.portfolio import FloatDecimal
class RebalancePeriod(str, Enum):
MONTHLY = "monthly"
@ -39,13 +41,13 @@ class BacktestCreate(BaseModel):
class BacktestMetrics(BaseModel):
"""Backtest result metrics."""
total_return: Decimal
cagr: Decimal
mdd: Decimal
sharpe_ratio: Decimal
volatility: Decimal
benchmark_return: Decimal
excess_return: Decimal
total_return: FloatDecimal
cagr: FloatDecimal
mdd: FloatDecimal
sharpe_ratio: FloatDecimal
volatility: FloatDecimal
benchmark_return: FloatDecimal
excess_return: FloatDecimal
class BacktestResponse(BaseModel):
@ -57,9 +59,9 @@ class BacktestResponse(BaseModel):
start_date: date
end_date: date
rebalance_period: str
initial_capital: Decimal
commission_rate: Decimal
slippage_rate: Decimal
initial_capital: FloatDecimal
commission_rate: FloatDecimal
slippage_rate: FloatDecimal
benchmark: str
status: str
created_at: datetime
@ -80,9 +82,9 @@ class BacktestListItem(BaseModel):
rebalance_period: str
status: str
created_at: datetime
total_return: Optional[Decimal] = None
cagr: Optional[Decimal] = None
mdd: Optional[Decimal] = None
total_return: Optional[FloatDecimal] = None
cagr: Optional[FloatDecimal] = None
mdd: Optional[FloatDecimal] = None
class Config:
from_attributes = True
@ -91,9 +93,9 @@ class BacktestListItem(BaseModel):
class EquityCurvePoint(BaseModel):
"""Single point in equity curve."""
date: date
portfolio_value: Decimal
benchmark_value: Decimal
drawdown: Decimal
portfolio_value: FloatDecimal
benchmark_value: FloatDecimal
drawdown: FloatDecimal
class Config:
from_attributes = True
@ -103,9 +105,9 @@ class HoldingItem(BaseModel):
"""Single holding at a rebalance date."""
ticker: str
name: str
weight: Decimal
weight: FloatDecimal
shares: int
price: Decimal
price: FloatDecimal
class RebalanceHoldings(BaseModel):
@ -121,8 +123,8 @@ class TransactionItem(BaseModel):
ticker: str
action: str
shares: int
price: Decimal
commission: Decimal
price: FloatDecimal
commission: FloatDecimal
class Config:
from_attributes = True

View File

@ -3,15 +3,21 @@ Portfolio related Pydantic schemas.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List
from typing import Annotated, Optional, List
from pydantic import BaseModel, Field
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: Decimal = Field(..., ge=0, le=100)
target_ratio: FloatDecimal = Field(..., ge=0, le=100)
class TargetCreate(TargetBase):
@ -27,7 +33,7 @@ class TargetResponse(TargetBase):
class HoldingBase(BaseModel):
ticker: str
quantity: int = Field(..., ge=0)
avg_price: Decimal = Field(..., ge=0)
avg_price: FloatDecimal = Field(..., ge=0)
class HoldingCreate(HoldingBase):
@ -41,11 +47,11 @@ class HoldingResponse(HoldingBase):
class HoldingWithValue(HoldingResponse):
"""Holding with calculated values."""
current_price: Decimal | None = None
value: Decimal | None = None
current_ratio: Decimal | None = None
profit_loss: Decimal | None = None
profit_loss_ratio: Decimal | 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
@ -53,7 +59,7 @@ class TransactionBase(BaseModel):
ticker: str
tx_type: str # "buy" or "sell"
quantity: int = Field(..., gt=0)
price: Decimal = Field(..., gt=0)
price: FloatDecimal = Field(..., gt=0)
executed_at: datetime
memo: Optional[str] = None
@ -98,18 +104,18 @@ class PortfolioDetail(PortfolioResponse):
"""Portfolio with targets and holdings."""
targets: List[TargetResponse] = []
holdings: List[HoldingWithValue] = []
total_value: Decimal | None = None
total_invested: Decimal | None = None
total_profit_loss: Decimal | None = None
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: Decimal
value: Decimal
current_ratio: Decimal
price: FloatDecimal
value: FloatDecimal
current_ratio: FloatDecimal
class Config:
from_attributes = True
@ -119,7 +125,7 @@ class SnapshotListItem(BaseModel):
"""Snapshot list item (without holdings)."""
id: int
portfolio_id: int
total_value: Decimal
total_value: FloatDecimal
snapshot_date: date
class Config:
@ -130,7 +136,7 @@ class SnapshotResponse(BaseModel):
"""Snapshot detail with holdings."""
id: int
portfolio_id: int
total_value: Decimal
total_value: FloatDecimal
snapshot_date: date
holdings: List[SnapshotHoldingResponse] = []
@ -141,9 +147,9 @@ class SnapshotResponse(BaseModel):
class ReturnDataPoint(BaseModel):
"""Single data point for returns chart."""
date: date
total_value: Decimal
daily_return: Decimal | None = None
cumulative_return: Decimal | None = None
total_value: FloatDecimal
daily_return: FloatDecimal | None = None
cumulative_return: FloatDecimal | None = None
class ReturnsResponse(BaseModel):
@ -151,8 +157,8 @@ class ReturnsResponse(BaseModel):
portfolio_id: int
start_date: date | None = None
end_date: date | None = None
total_return: Decimal | None = None
cagr: Decimal | None = None
total_return: FloatDecimal | None = None
cagr: FloatDecimal | None = None
data: List[ReturnDataPoint] = []
@ -160,19 +166,19 @@ class ReturnsResponse(BaseModel):
class RebalanceItem(BaseModel):
ticker: str
name: str | None = None
target_ratio: Decimal
current_ratio: Decimal
target_ratio: FloatDecimal
current_ratio: FloatDecimal
current_quantity: int
current_value: Decimal
target_value: Decimal
diff_value: Decimal
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: Decimal
total_value: FloatDecimal
items: List[RebalanceItem]
@ -182,9 +188,9 @@ class RebalanceSimulationRequest(BaseModel):
class RebalanceSimulationResponse(BaseModel):
portfolio_id: int
current_total: Decimal
additional_amount: Decimal
new_total: Decimal
current_total: FloatDecimal
additional_amount: FloatDecimal
new_total: FloatDecimal
items: List[RebalanceItem]
@ -199,22 +205,22 @@ class RebalanceCalculateItem(BaseModel):
"""Extended rebalance item with price change info."""
ticker: str
name: str | None = None
target_ratio: Decimal
current_ratio: Decimal
target_ratio: FloatDecimal
current_ratio: FloatDecimal
current_quantity: int
current_value: Decimal
current_price: Decimal
target_value: Decimal
diff_ratio: Decimal
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: Decimal | None = None
change_vs_start: Decimal | None = None
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: Decimal
available_to_buy: Decimal | None = None
total_assets: FloatDecimal
available_to_buy: FloatDecimal | None = None
items: List[RebalanceCalculateItem]

View File

@ -7,13 +7,15 @@ from typing import Optional, List, Dict
from pydantic import BaseModel, Field
from app.schemas.portfolio import FloatDecimal
class FactorWeights(BaseModel):
"""Factor weights for multi-factor strategy."""
value: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
quality: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
momentum: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
low_vol: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
value: FloatDecimal = Field(default=Decimal("0.25"), ge=0, le=1)
quality: FloatDecimal = Field(default=Decimal("0.25"), ge=0, le=1)
momentum: FloatDecimal = Field(default=Decimal("0.25"), ge=0, le=1)
low_vol: FloatDecimal = Field(default=Decimal("0.25"), ge=0, le=1)
class UniverseFilter(BaseModel):
@ -44,8 +46,8 @@ class QualityRequest(StrategyRequest):
class ValueMomentumRequest(StrategyRequest):
"""Value-Momentum strategy request."""
value_weight: Decimal = Field(default=Decimal("0.5"), ge=0, le=1)
momentum_weight: Decimal = Field(default=Decimal("0.5"), ge=0, le=1)
value_weight: FloatDecimal = Field(default=Decimal("0.5"), ge=0, le=1)
momentum_weight: FloatDecimal = Field(default=Decimal("0.5"), ge=0, le=1)
class StockFactor(BaseModel):
@ -55,23 +57,23 @@ class StockFactor(BaseModel):
market: str
sector_name: Optional[str] = None
market_cap: Optional[int] = None
close_price: Optional[Decimal] = None
close_price: Optional[FloatDecimal] = None
# Raw metrics
per: Optional[Decimal] = None
pbr: Optional[Decimal] = None
psr: Optional[Decimal] = None
pcr: Optional[Decimal] = None
dividend_yield: Optional[Decimal] = None
roe: Optional[Decimal] = None
per: Optional[FloatDecimal] = None
pbr: Optional[FloatDecimal] = None
psr: Optional[FloatDecimal] = None
pcr: Optional[FloatDecimal] = None
dividend_yield: Optional[FloatDecimal] = None
roe: Optional[FloatDecimal] = None
# Factor scores (z-scores)
value_score: Optional[Decimal] = None
quality_score: Optional[Decimal] = None
momentum_score: Optional[Decimal] = None
value_score: Optional[FloatDecimal] = None
quality_score: Optional[FloatDecimal] = None
momentum_score: Optional[FloatDecimal] = None
# Composite
total_score: Optional[Decimal] = None
total_score: Optional[FloatDecimal] = None
rank: Optional[int] = None
fscore: Optional[int] = None
@ -92,19 +94,19 @@ class StockInfo(BaseModel):
market: str
sector_name: Optional[str] = None
stock_type: str
close_price: Optional[Decimal] = None
close_price: Optional[FloatDecimal] = None
market_cap: Optional[int] = None
# Valuation
per: Optional[Decimal] = None
pbr: Optional[Decimal] = None
psr: Optional[Decimal] = None
pcr: Optional[Decimal] = None
dividend_yield: Optional[Decimal] = None
per: Optional[FloatDecimal] = None
pbr: Optional[FloatDecimal] = None
psr: Optional[FloatDecimal] = None
pcr: Optional[FloatDecimal] = None
dividend_yield: Optional[FloatDecimal] = None
# Per-share data
eps: Optional[Decimal] = None
bps: Optional[Decimal] = None
eps: Optional[FloatDecimal] = None
bps: Optional[FloatDecimal] = None
base_date: Optional[date] = None
@ -122,10 +124,10 @@ class StockSearchResult(BaseModel):
class PriceData(BaseModel):
"""Price data point."""
date: date
open: Decimal
high: Decimal
low: Decimal
close: Decimal
open: FloatDecimal
high: FloatDecimal
low: FloatDecimal
close: FloatDecimal
volume: int
class Config:

View File

@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TradingViewChart } from '@/components/charts/trading-view-chart';
import { DonutChart } from '@/components/charts/donut-chart';
import { api } from '@/lib/api';
import { AreaData, Time } from 'lightweight-charts';
import type { AreaData, Time } from 'lightweight-charts';
interface HoldingWithValue {
ticker: string;
@ -31,10 +31,10 @@ interface Target {
interface Transaction {
id: number;
ticker: string;
transaction_type: string;
tx_type: string;
quantity: number;
price: number;
created_at: string;
executed_at: string;
}
interface PortfolioDetail {
@ -432,18 +432,18 @@ export default function PortfolioDetailPage() {
{transactions.map((tx) => (
<tr key={tx.id}>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(tx.created_at).toLocaleString('ko-KR')}
{new Date(tx.executed_at).toLocaleString('ko-KR')}
</td>
<td className="px-4 py-3 text-sm font-medium">{tx.ticker}</td>
<td className="px-4 py-3 text-sm text-center">
<span
className={`px-2 py-1 rounded text-xs font-medium ${
tx.transaction_type === 'buy'
tx.tx_type === 'buy'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
}`}
>
{tx.transaction_type === 'buy' ? '매수' : '매도'}
{tx.tx_type === 'buy' ? '매수' : '매도'}
</span>
</td>
<td className="px-4 py-3 text-sm text-right">