From 752db2ef1a72b8001bd71998ec30c22384c70b75 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Thu, 12 Feb 2026 23:47:48 +0900 Subject: [PATCH] fix: serialize Decimal as float in API responses and fix transaction field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/schemas/backtest.py | 42 +++++------ backend/app/schemas/portfolio.py | 90 +++++++++++++----------- backend/app/schemas/strategy.py | 60 ++++++++-------- frontend/src/app/portfolio/[id]/page.tsx | 12 ++-- 4 files changed, 107 insertions(+), 97 deletions(-) diff --git a/backend/app/schemas/backtest.py b/backend/app/schemas/backtest.py index 546b217..6d057d4 100644 --- a/backend/app/schemas/backtest.py +++ b/backend/app/schemas/backtest.py @@ -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 diff --git a/backend/app/schemas/portfolio.py b/backend/app/schemas/portfolio.py index 5ae8eb4..0762dce 100644 --- a/backend/app/schemas/portfolio.py +++ b/backend/app/schemas/portfolio.py @@ -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] diff --git a/backend/app/schemas/strategy.py b/backend/app/schemas/strategy.py index 23de58f..1646c57 100644 --- a/backend/app/schemas/strategy.py +++ b/backend/app/schemas/strategy.py @@ -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: diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index c5140f6..1394428 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -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) => ( - {new Date(tx.created_at).toLocaleString('ko-KR')} + {new Date(tx.executed_at).toLocaleString('ko-KR')} {tx.ticker} - {tx.transaction_type === 'buy' ? '매수' : '매도'} + {tx.tx_type === 'buy' ? '매수' : '매도'}