feat: add realized/unrealized PnL tracking and position sizing guide

- 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>
This commit is contained in:
머니페니 2026-03-18 19:04:36 +09:00
parent 65618cd957
commit 9249821a25
8 changed files with 512 additions and 5 deletions

View File

@ -0,0 +1,30 @@
"""add realized_pnl to transactions
Revision ID: 606a5011f84f
Revises: a1b2c3d4e5f6
Create Date: 2026-03-18 19:00:02.245720
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '606a5011f84f'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('transactions', sa.Column('realized_pnl', sa.Numeric(precision=15, scale=2), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('transactions', 'realized_pnl')
# ### end Alembic commands ###

View File

@ -19,6 +19,7 @@ from app.schemas.portfolio import (
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
RebalanceCalculateRequest, RebalanceCalculateResponse,
RebalanceApplyRequest, RebalanceApplyResponse,
PositionSizeResponse,
)
from app.services.rebalance import RebalanceService
@ -249,6 +250,7 @@ async def get_transactions(
price=tx.price,
executed_at=tx.executed_at,
memo=tx.memo,
realized_pnl=tx.realized_pnl,
)
for tx in transactions
]
@ -306,6 +308,8 @@ async def add_transaction(
status_code=400,
detail=f"Insufficient quantity for {data.ticker}"
)
# Calculate realized PnL: (sell_price - avg_price) * quantity
transaction.realized_pnl = (data.price - holding.avg_price) * data.quantity
holding.quantity -= data.quantity
if holding.quantity == 0:
db.delete(holding)
@ -415,6 +419,7 @@ async def apply_rebalance(
elif tx_type == TransactionType.SELL:
if not holding or holding.quantity < item.quantity:
raise HTTPException(status_code=400, detail=f"Insufficient quantity for {item.ticker}")
transaction.realized_pnl = (item.price - holding.avg_price) * item.quantity
holding.quantity -= item.quantity
if holding.quantity == 0:
db.delete(holding)
@ -439,6 +444,7 @@ async def apply_rebalance(
price=tx.price,
executed_at=tx.executed_at,
memo=tx.memo,
realized_pnl=tx.realized_pnl,
)
for tx in transactions
]
@ -496,6 +502,19 @@ async def get_portfolio_detail(
if total_value > 0:
h.current_ratio = (h.value / total_value * 100).quantize(Decimal("0.01"))
# Calculate realized PnL (sum of all sell transactions with realized_pnl)
from sqlalchemy import func
total_realized_pnl_result = (
db.query(func.coalesce(func.sum(Transaction.realized_pnl), 0))
.filter(
Transaction.portfolio_id == portfolio_id,
Transaction.realized_pnl.isnot(None),
)
.scalar()
)
total_realized_pnl = Decimal(str(total_realized_pnl_result))
total_unrealized_pnl = (total_value - total_invested)
# Calculate risk asset ratio for pension portfolios
risk_asset_ratio = None
if portfolio.portfolio_type == PortfolioType.PENSION and total_value > 0:
@ -525,5 +544,75 @@ async def get_portfolio_detail(
total_value=total_value,
total_invested=total_invested,
total_profit_loss=total_value - total_invested,
total_realized_pnl=total_realized_pnl,
total_unrealized_pnl=total_unrealized_pnl,
risk_asset_ratio=risk_asset_ratio,
)
@router.get("/{portfolio_id}/position-size", response_model=PositionSizeResponse)
async def get_position_size(
portfolio_id: int,
ticker: str,
price: Decimal,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""포지션 사이징 가이드: 추천 수량과 최대 수량을 계산한다."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
service = RebalanceService(db)
# Calculate total portfolio value
holding_tickers = [h.ticker for h in portfolio.holdings]
prices = service.get_current_prices(holding_tickers)
total_value = Decimal("0")
for holding in portfolio.holdings:
cp = prices.get(holding.ticker, Decimal("0"))
total_value += cp * holding.quantity
# Current holding for this ticker
current_holding = db.query(Holding).filter(
Holding.portfolio_id == portfolio_id,
Holding.ticker == ticker,
).first()
current_qty = current_holding.quantity if current_holding else 0
current_value = price * current_qty
# Current ratio
current_ratio = (current_value / total_value * 100) if total_value > 0 else Decimal("0")
# Target ratio from portfolio targets
target = db.query(Target).filter(
Target.portfolio_id == portfolio_id,
Target.ticker == ticker,
).first()
target_ratio = Decimal(str(target.target_ratio)) if target else None
# Max position: 20% of portfolio (or target ratio if set)
max_ratio = target_ratio if target_ratio else Decimal("20")
max_value = total_value * max_ratio / 100
max_additional_value = max(max_value - current_value, Decimal("0"))
max_quantity = int(max_additional_value / price) if price > 0 else 0
# Recommended: equal-weight across targets, or 10% if no targets
num_targets = len(portfolio.targets) or 1
equal_ratio = Decimal("100") / num_targets
rec_ratio = target_ratio if target_ratio else min(equal_ratio, Decimal("10"))
rec_value = total_value * rec_ratio / 100
rec_additional_value = max(rec_value - current_value, Decimal("0"))
recommended_quantity = int(rec_additional_value / price) if price > 0 else 0
return PositionSizeResponse(
ticker=ticker,
price=price,
total_portfolio_value=total_value,
current_holding_quantity=current_qty,
current_holding_value=current_value,
current_ratio=current_ratio.quantize(Decimal("0.01")) if isinstance(current_ratio, Decimal) else current_ratio,
target_ratio=target_ratio,
recommended_quantity=recommended_quantity,
max_quantity=max_quantity,
recommended_value=rec_additional_value,
max_value=max_additional_value,
)

View File

@ -122,6 +122,8 @@ async def execute_signal(
status_code=400,
detail=f"Insufficient quantity for {signal.ticker}"
)
# Calculate realized PnL: (sell_price - avg_price) * quantity
transaction.realized_pnl = (data.price - holding.avg_price) * data.quantity
holding.quantity -= data.quantity
if holding.quantity == 0:
db.delete(holding)

View File

@ -70,6 +70,7 @@ class Transaction(Base):
price = Column(Numeric(12, 2), nullable=False)
executed_at = Column(DateTime, nullable=False)
memo = Column(Text, nullable=True)
realized_pnl = Column(Numeric(15, 2), nullable=True)
portfolio = relationship("Portfolio", back_populates="transactions")

View File

@ -72,6 +72,7 @@ class TransactionCreate(TransactionBase):
class TransactionResponse(TransactionBase):
id: int
name: str | None = None
realized_pnl: FloatDecimal | None = None
class Config:
from_attributes = True
@ -109,6 +110,8 @@ class PortfolioDetail(PortfolioResponse):
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
@ -247,3 +250,17 @@ class RebalanceApplyRequest(BaseModel):
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

View File

@ -0,0 +1,265 @@
"""
E2E tests for realized/unrealized PnL tracking and position sizing.
"""
import pytest
from fastapi.testclient import TestClient
def _create_portfolio(client: TestClient, auth_headers: dict, name: str = "PnL Test") -> int:
"""Helper to create a portfolio and return its ID."""
resp = client.post(
"/api/portfolios",
json={"name": name, "portfolio_type": "general"},
headers=auth_headers,
)
assert resp.status_code == 201
return resp.json()["id"]
def test_sell_transaction_records_realized_pnl(client: TestClient, auth_headers):
"""매도 거래 시 realized_pnl이 계산되어 저장된다."""
pid = _create_portfolio(client, auth_headers)
# Buy 10 shares at 70,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
# Sell 5 shares at 80,000 → realized_pnl = (80000 - 70000) * 5 = 50,000
resp = client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 80000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
assert resp.status_code == 201
tx = resp.json()
assert tx["realized_pnl"] == 50000.0
def test_sell_transaction_loss_realized_pnl(client: TestClient, auth_headers):
"""매도 손실 시 음수 realized_pnl이 기록된다."""
pid = _create_portfolio(client, auth_headers)
# Buy 10 shares at 70,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
# Sell 5 shares at 60,000 → realized_pnl = (60000 - 70000) * 5 = -50,000
resp = client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 60000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
assert resp.status_code == 201
tx = resp.json()
assert tx["realized_pnl"] == -50000.0
def test_buy_transaction_no_realized_pnl(client: TestClient, auth_headers):
"""매수 거래에는 realized_pnl이 없다."""
pid = _create_portfolio(client, auth_headers)
resp = client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
assert resp.status_code == 201
tx = resp.json()
assert tx["realized_pnl"] is None
def test_transaction_list_includes_realized_pnl(client: TestClient, auth_headers):
"""거래 목록 조회 시 realized_pnl이 포함된다."""
pid = _create_portfolio(client, auth_headers)
# Buy then sell
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 75000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
resp = client.get(f"/api/portfolios/{pid}/transactions", headers=auth_headers)
assert resp.status_code == 200
txs = resp.json()
assert len(txs) == 2
# Most recent first (sell)
sell_tx = next(t for t in txs if t["tx_type"] == "sell")
buy_tx = next(t for t in txs if t["tx_type"] == "buy")
assert sell_tx["realized_pnl"] == 25000.0
assert buy_tx["realized_pnl"] is None
def test_portfolio_detail_includes_realized_unrealized_pnl(client: TestClient, auth_headers):
"""포트폴리오 상세에 실현/미실현 수익이 포함된다."""
pid = _create_portfolio(client, auth_headers)
# Buy 10 shares at 70,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
# Sell 5 shares at 80,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 80000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
resp = client.get(f"/api/portfolios/{pid}/detail", headers=auth_headers)
assert resp.status_code == 200
detail = resp.json()
assert detail["total_realized_pnl"] == 50000.0
# unrealized_pnl depends on current prices but should be present
assert "total_unrealized_pnl" in detail
def test_rebalance_apply_records_realized_pnl(client: TestClient, auth_headers):
"""리밸런싱 적용 시 매도 거래에 realized_pnl이 기록된다."""
pid = _create_portfolio(client, auth_headers)
# Setup initial holdings
client.put(
f"/api/portfolios/{pid}/holdings",
json=[{"ticker": "005930", "quantity": 10, "avg_price": 70000}],
headers=auth_headers,
)
# Apply rebalance with a sell
resp = client.post(
f"/api/portfolios/{pid}/rebalance/apply",
json={
"items": [
{"ticker": "005930", "action": "sell", "quantity": 3, "price": 75000},
]
},
headers=auth_headers,
)
assert resp.status_code == 201
data = resp.json()
sell_tx = data["transactions"][0]
assert sell_tx["realized_pnl"] == 15000.0 # (75000 - 70000) * 3
def test_position_size_endpoint(client: TestClient, auth_headers):
"""포지션 사이징 가이드 API가 올바르게 동작한다."""
pid = _create_portfolio(client, auth_headers)
# Set holdings and targets
client.put(
f"/api/portfolios/{pid}/holdings",
json=[
{"ticker": "005930", "quantity": 10, "avg_price": 70000},
],
headers=auth_headers,
)
client.put(
f"/api/portfolios/{pid}/targets",
json=[
{"ticker": "005930", "target_ratio": 50},
{"ticker": "000660", "target_ratio": 50},
],
headers=auth_headers,
)
# Get position size for a new ticker
resp = client.get(
f"/api/portfolios/{pid}/position-size?ticker=000660&price=150000",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "000660"
assert data["current_holding_quantity"] == 0
assert data["target_ratio"] == 50.0
assert data["recommended_quantity"] >= 0
assert data["max_quantity"] >= 0
def test_position_size_no_targets(client: TestClient, auth_headers):
"""목표 비중 없을 때 포지션 사이징이 기본값으로 동작한다."""
pid = _create_portfolio(client, auth_headers)
client.put(
f"/api/portfolios/{pid}/holdings",
json=[{"ticker": "005930", "quantity": 10, "avg_price": 70000}],
headers=auth_headers,
)
resp = client.get(
f"/api/portfolios/{pid}/position-size?ticker=000660&price=150000",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "000660"
assert data["target_ratio"] is None
# Without targets, max should use 20% default
assert data["max_quantity"] >= 0

View File

@ -38,6 +38,7 @@ interface Transaction {
quantity: number;
price: number;
executed_at: string;
realized_pnl: number | null;
}
interface PortfolioDetail {
@ -51,6 +52,8 @@ interface PortfolioDetail {
total_value: number | null;
total_invested: number | null;
total_profit_loss: number | null;
total_realized_pnl: number | null;
total_unrealized_pnl: number | null;
risk_asset_ratio: number | null;
}
@ -237,7 +240,7 @@ export default function PortfolioDetailPage() {
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"> </p>
@ -254,6 +257,20 @@ export default function PortfolioDetailPage() {
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"></p>
<p
className={`text-2xl font-bold ${
(returnPercent ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatPercent(returnPercent)}
</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"> </p>
@ -268,14 +285,28 @@ export default function PortfolioDetailPage() {
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p
className={`text-2xl font-bold ${
(returnPercent ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
(portfolio.total_realized_pnl ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatPercent(returnPercent)}
{formatCurrency(portfolio.total_realized_pnl)}
</p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"> </p>
<p
className={`text-2xl font-bold ${
(portfolio.total_unrealized_pnl ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatCurrency(portfolio.total_unrealized_pnl)}
</p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</CardContent>
</Card>
</div>
@ -461,6 +492,12 @@ export default function PortfolioDetailPage() {
>
</th>
<th
scope="col"
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@ -490,11 +527,22 @@ export default function PortfolioDetailPage() {
<td className="px-4 py-3 text-sm text-right">
{formatCurrency(tx.quantity * tx.price)}
</td>
<td
className={`px-4 py-3 text-sm text-right ${
tx.realized_pnl !== null
? tx.realized_pnl >= 0
? 'text-green-600'
: 'text-red-600'
: ''
}`}
>
{tx.realized_pnl !== null ? formatCurrency(tx.realized_pnl) : '-'}
</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>

View File

@ -55,6 +55,20 @@ interface Holding {
avg_price: number;
}
interface PositionSize {
ticker: string;
price: number;
total_portfolio_value: number;
current_holding_quantity: number;
current_holding_value: number;
current_ratio: number;
target_ratio: number | null;
recommended_quantity: number;
max_quantity: number;
recommended_value: number;
max_value: number;
}
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
buy: {
label: '매수',
@ -116,6 +130,7 @@ export default function SignalsPage() {
const [executing, setExecuting] = useState(false);
const [executeError, setExecuteError] = useState('');
const [currentHoldings, setCurrentHoldings] = useState<Holding[]>([]);
const [positionSize, setPositionSize] = useState<PositionSize | null>(null);
// Cancel modal state
const [cancelModalOpen, setCancelModalOpen] = useState(false);
@ -203,12 +218,14 @@ export default function SignalsPage() {
setSelectedPortfolioId('');
setExecuteError('');
setCurrentHoldings([]);
setPositionSize(null);
await fetchPortfolios();
setExecuteModalOpen(true);
};
const handlePortfolioChange = async (portfolioId: string) => {
setSelectedPortfolioId(portfolioId);
setPositionSize(null);
if (portfolioId) {
try {
const holdings = await api.get<Holding[]>(`/api/portfolios/${portfolioId}/holdings`);
@ -222,6 +239,21 @@ export default function SignalsPage() {
: Math.floor(holding.quantity / 2);
setExecuteQuantity(String(defaultQty));
}
// 매수 신호일 때 포지션 사이징 가이드 조회
if (executeSignal.signal_type === 'buy' && executeSignal.entry_price) {
try {
const ps = await api.get<PositionSize>(
`/api/portfolios/${portfolioId}/position-size?ticker=${executeSignal.ticker}&price=${executeSignal.entry_price}`
);
setPositionSize(ps);
if (ps.recommended_quantity > 0) {
setExecuteQuantity(String(ps.recommended_quantity));
}
} catch {
// Position sizing is optional
}
}
}
} catch {
setCurrentHoldings([]);
@ -625,6 +657,29 @@ export default function SignalsPage() {
);
})()}
{/* Position sizing guide */}
{positionSize && executeSignal?.signal_type === 'buy' && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950 p-3 space-y-2 text-sm">
<p className="font-medium text-blue-800 dark:text-blue-200"> </p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-blue-700 dark:text-blue-300">
<span> </span>
<span className="text-right font-mono">{formatPrice(positionSize.total_portfolio_value)}</span>
<span> </span>
<span className="text-right font-mono">{positionSize.current_ratio.toFixed(1)}%</span>
{positionSize.target_ratio !== null && (
<>
<span> </span>
<span className="text-right font-mono">{positionSize.target_ratio.toFixed(1)}%</span>
</>
)}
<span> </span>
<span className="text-right font-mono font-medium">{positionSize.recommended_quantity.toLocaleString()}</span>
<span> </span>
<span className="text-right font-mono">{positionSize.max_quantity.toLocaleString()}</span>
</div>
</div>
)}
{/* Quantity */}
<div className="space-y-2">
<Label htmlFor="exec-quantity"> ()</Label>