- Add realized_pnl column to transactions table with alembic migration
- Calculate realized PnL on sell transactions: (sell_price - avg_price) * quantity
- Show total realized/unrealized PnL in portfolio detail summary cards
- Show per-transaction realized PnL in transaction history table
- Add position sizing API endpoint (GET /portfolios/{id}/position-size)
- Show position sizing guide in signal execution modal for buy signals
- 8 new E2E tests, all 88 tests passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
267 lines
6.5 KiB
Python
267 lines
6.5 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
|
|
name: str | None = None
|
|
realized_pnl: FloatDecimal | None = None
|
|
|
|
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
|
|
total_realized_pnl: FloatDecimal | None = None
|
|
total_unrealized_pnl: FloatDecimal | None = None
|
|
risk_asset_ratio: FloatDecimal | None = None
|
|
|
|
|
|
# Snapshot schemas
|
|
class SnapshotHoldingResponse(BaseModel):
|
|
ticker: str
|
|
name: str | None = None
|
|
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
|
|
benchmark_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
|
|
benchmark_total_return: 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]
|
|
|
|
|
|
# Rebalance apply schemas
|
|
class RebalanceApplyItem(BaseModel):
|
|
ticker: str
|
|
action: str # "buy" or "sell"
|
|
quantity: int = Field(..., gt=0)
|
|
price: FloatDecimal = Field(..., gt=0)
|
|
|
|
|
|
class RebalanceApplyRequest(BaseModel):
|
|
items: List[RebalanceApplyItem]
|
|
|
|
|
|
class RebalanceApplyResponse(BaseModel):
|
|
transactions: List[TransactionResponse]
|
|
holdings_updated: int
|
|
|
|
|
|
class PositionSizeResponse(BaseModel):
|
|
ticker: str
|
|
price: FloatDecimal
|
|
total_portfolio_value: FloatDecimal
|
|
current_holding_quantity: int
|
|
current_holding_value: FloatDecimal
|
|
current_ratio: FloatDecimal
|
|
target_ratio: FloatDecimal | None = None
|
|
recommended_quantity: int
|
|
max_quantity: int
|
|
recommended_value: FloatDecimal
|
|
max_value: FloatDecimal
|