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:
parent
65618cd957
commit
9249821a25
@ -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 ###
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
265
backend/tests/e2e/test_realized_pnl.py
Normal file
265
backend/tests/e2e/test_realized_pnl.py
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user