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
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:
parent
0a8d17a588
commit
752db2ef1a
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user