From 9249821a25196c49d3aabbfb541569d4a6b3f00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Wed, 18 Mar 2026 19:04:36 +0900 Subject: [PATCH] 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 --- ...11f84f_add_realized_pnl_to_transactions.py | 30 ++ backend/app/api/portfolio.py | 89 ++++++ backend/app/api/signal.py | 2 + backend/app/models/portfolio.py | 1 + backend/app/schemas/portfolio.py | 17 ++ backend/tests/e2e/test_realized_pnl.py | 265 ++++++++++++++++++ frontend/src/app/portfolio/[id]/page.tsx | 58 +++- frontend/src/app/signals/page.tsx | 55 ++++ 8 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/606a5011f84f_add_realized_pnl_to_transactions.py create mode 100644 backend/tests/e2e/test_realized_pnl.py diff --git a/backend/alembic/versions/606a5011f84f_add_realized_pnl_to_transactions.py b/backend/alembic/versions/606a5011f84f_add_realized_pnl_to_transactions.py new file mode 100644 index 0000000..b97e2ee --- /dev/null +++ b/backend/alembic/versions/606a5011f84f_add_realized_pnl_to_transactions.py @@ -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 ### diff --git a/backend/app/api/portfolio.py b/backend/app/api/portfolio.py index d4fa1d5..f9f7fed 100644 --- a/backend/app/api/portfolio.py +++ b/backend/app/api/portfolio.py @@ -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, + ) diff --git a/backend/app/api/signal.py b/backend/app/api/signal.py index 69f4155..a0a6465 100644 --- a/backend/app/api/signal.py +++ b/backend/app/api/signal.py @@ -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) diff --git a/backend/app/models/portfolio.py b/backend/app/models/portfolio.py index f111fa8..85f9a2a 100644 --- a/backend/app/models/portfolio.py +++ b/backend/app/models/portfolio.py @@ -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") diff --git a/backend/app/schemas/portfolio.py b/backend/app/schemas/portfolio.py index 0ecd835..8760f81 100644 --- a/backend/app/schemas/portfolio.py +++ b/backend/app/schemas/portfolio.py @@ -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 diff --git a/backend/tests/e2e/test_realized_pnl.py b/backend/tests/e2e/test_realized_pnl.py new file mode 100644 index 0000000..3bcbdee --- /dev/null +++ b/backend/tests/e2e/test_realized_pnl.py @@ -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 diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index b67e1ef..f3e57ca 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -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 */} -
+

총 평가금액

@@ -254,6 +257,20 @@ export default function PortfolioDetailPage() {

+ + +

수익률

+

= 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatPercent(returnPercent)} +

+
+
+
+

총 손익

@@ -268,14 +285,28 @@ export default function PortfolioDetailPage() {
-

수익률

+

실현 수익

= 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)}

+

매도 확정 손익

+
+
+ + +

미실현 수익

+

= 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatCurrency(portfolio.total_unrealized_pnl)} +

+

보유 중 평가 손익

@@ -461,6 +492,12 @@ export default function PortfolioDetailPage() { > 거래금액 + + 실현손익 + @@ -490,11 +527,22 @@ export default function PortfolioDetailPage() { {formatCurrency(tx.quantity * tx.price)} + = 0 + ? 'text-green-600' + : 'text-red-600' + : '' + }`} + > + {tx.realized_pnl !== null ? formatCurrency(tx.realized_pnl) : '-'} + ))} {transactions.length === 0 && ( - + 거래 내역이 없습니다. diff --git a/frontend/src/app/signals/page.tsx b/frontend/src/app/signals/page.tsx index 2b9669d..acf2652 100644 --- a/frontend/src/app/signals/page.tsx +++ b/frontend/src/app/signals/page.tsx @@ -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 = { buy: { label: '매수', @@ -116,6 +130,7 @@ export default function SignalsPage() { const [executing, setExecuting] = useState(false); const [executeError, setExecuteError] = useState(''); const [currentHoldings, setCurrentHoldings] = useState([]); + const [positionSize, setPositionSize] = useState(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(`/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( + `/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' && ( +
+

포지션 사이징 가이드

+
+ 총 자산 + {formatPrice(positionSize.total_portfolio_value)}원 + 현재 비중 + {positionSize.current_ratio.toFixed(1)}% + {positionSize.target_ratio !== null && ( + <> + 목표 비중 + {positionSize.target_ratio.toFixed(1)}% + + )} + 추천 수량 + {positionSize.recommended_quantity.toLocaleString()}주 + 최대 수량 + {positionSize.max_quantity.toLocaleString()}주 +
+
+ )} + {/* Quantity */}