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,
|
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
|
||||||
RebalanceCalculateRequest, RebalanceCalculateResponse,
|
RebalanceCalculateRequest, RebalanceCalculateResponse,
|
||||||
RebalanceApplyRequest, RebalanceApplyResponse,
|
RebalanceApplyRequest, RebalanceApplyResponse,
|
||||||
|
PositionSizeResponse,
|
||||||
)
|
)
|
||||||
from app.services.rebalance import RebalanceService
|
from app.services.rebalance import RebalanceService
|
||||||
|
|
||||||
@ -249,6 +250,7 @@ async def get_transactions(
|
|||||||
price=tx.price,
|
price=tx.price,
|
||||||
executed_at=tx.executed_at,
|
executed_at=tx.executed_at,
|
||||||
memo=tx.memo,
|
memo=tx.memo,
|
||||||
|
realized_pnl=tx.realized_pnl,
|
||||||
)
|
)
|
||||||
for tx in transactions
|
for tx in transactions
|
||||||
]
|
]
|
||||||
@ -306,6 +308,8 @@ async def add_transaction(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Insufficient quantity for {data.ticker}"
|
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
|
holding.quantity -= data.quantity
|
||||||
if holding.quantity == 0:
|
if holding.quantity == 0:
|
||||||
db.delete(holding)
|
db.delete(holding)
|
||||||
@ -415,6 +419,7 @@ async def apply_rebalance(
|
|||||||
elif tx_type == TransactionType.SELL:
|
elif tx_type == TransactionType.SELL:
|
||||||
if not holding or holding.quantity < item.quantity:
|
if not holding or holding.quantity < item.quantity:
|
||||||
raise HTTPException(status_code=400, detail=f"Insufficient quantity for {item.ticker}")
|
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
|
holding.quantity -= item.quantity
|
||||||
if holding.quantity == 0:
|
if holding.quantity == 0:
|
||||||
db.delete(holding)
|
db.delete(holding)
|
||||||
@ -439,6 +444,7 @@ async def apply_rebalance(
|
|||||||
price=tx.price,
|
price=tx.price,
|
||||||
executed_at=tx.executed_at,
|
executed_at=tx.executed_at,
|
||||||
memo=tx.memo,
|
memo=tx.memo,
|
||||||
|
realized_pnl=tx.realized_pnl,
|
||||||
)
|
)
|
||||||
for tx in transactions
|
for tx in transactions
|
||||||
]
|
]
|
||||||
@ -496,6 +502,19 @@ async def get_portfolio_detail(
|
|||||||
if total_value > 0:
|
if total_value > 0:
|
||||||
h.current_ratio = (h.value / total_value * 100).quantize(Decimal("0.01"))
|
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
|
# Calculate risk asset ratio for pension portfolios
|
||||||
risk_asset_ratio = None
|
risk_asset_ratio = None
|
||||||
if portfolio.portfolio_type == PortfolioType.PENSION and total_value > 0:
|
if portfolio.portfolio_type == PortfolioType.PENSION and total_value > 0:
|
||||||
@ -525,5 +544,75 @@ async def get_portfolio_detail(
|
|||||||
total_value=total_value,
|
total_value=total_value,
|
||||||
total_invested=total_invested,
|
total_invested=total_invested,
|
||||||
total_profit_loss=total_value - 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,
|
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,
|
status_code=400,
|
||||||
detail=f"Insufficient quantity for {signal.ticker}"
|
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
|
holding.quantity -= data.quantity
|
||||||
if holding.quantity == 0:
|
if holding.quantity == 0:
|
||||||
db.delete(holding)
|
db.delete(holding)
|
||||||
|
|||||||
@ -70,6 +70,7 @@ class Transaction(Base):
|
|||||||
price = Column(Numeric(12, 2), nullable=False)
|
price = Column(Numeric(12, 2), nullable=False)
|
||||||
executed_at = Column(DateTime, nullable=False)
|
executed_at = Column(DateTime, nullable=False)
|
||||||
memo = Column(Text, nullable=True)
|
memo = Column(Text, nullable=True)
|
||||||
|
realized_pnl = Column(Numeric(15, 2), nullable=True)
|
||||||
|
|
||||||
portfolio = relationship("Portfolio", back_populates="transactions")
|
portfolio = relationship("Portfolio", back_populates="transactions")
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,7 @@ class TransactionCreate(TransactionBase):
|
|||||||
class TransactionResponse(TransactionBase):
|
class TransactionResponse(TransactionBase):
|
||||||
id: int
|
id: int
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
realized_pnl: FloatDecimal | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -109,6 +110,8 @@ class PortfolioDetail(PortfolioResponse):
|
|||||||
total_value: FloatDecimal | None = None
|
total_value: FloatDecimal | None = None
|
||||||
total_invested: FloatDecimal | None = None
|
total_invested: FloatDecimal | None = None
|
||||||
total_profit_loss: 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
|
risk_asset_ratio: FloatDecimal | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -247,3 +250,17 @@ class RebalanceApplyRequest(BaseModel):
|
|||||||
class RebalanceApplyResponse(BaseModel):
|
class RebalanceApplyResponse(BaseModel):
|
||||||
transactions: List[TransactionResponse]
|
transactions: List[TransactionResponse]
|
||||||
holdings_updated: int
|
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;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
executed_at: string;
|
executed_at: string;
|
||||||
|
realized_pnl: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortfolioDetail {
|
interface PortfolioDetail {
|
||||||
@ -51,6 +52,8 @@ interface PortfolioDetail {
|
|||||||
total_value: number | null;
|
total_value: number | null;
|
||||||
total_invested: number | null;
|
total_invested: number | null;
|
||||||
total_profit_loss: number | null;
|
total_profit_loss: number | null;
|
||||||
|
total_realized_pnl: number | null;
|
||||||
|
total_unrealized_pnl: number | null;
|
||||||
risk_asset_ratio: number | null;
|
risk_asset_ratio: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,7 +240,7 @@ export default function PortfolioDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* 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>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-sm text-muted-foreground mb-1">총 평가금액</p>
|
<p className="text-sm text-muted-foreground mb-1">총 평가금액</p>
|
||||||
@ -254,6 +257,20 @@ export default function PortfolioDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-sm text-muted-foreground mb-1">총 손익</p>
|
<p className="text-sm text-muted-foreground mb-1">총 손익</p>
|
||||||
@ -268,14 +285,28 @@ export default function PortfolioDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<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
|
<p
|
||||||
className={`text-2xl font-bold ${
|
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>
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -461,6 +492,12 @@ export default function PortfolioDetailPage() {
|
|||||||
>
|
>
|
||||||
거래금액
|
거래금액
|
||||||
</th>
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
실현손익
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@ -490,11 +527,22 @@ export default function PortfolioDetailPage() {
|
|||||||
<td className="px-4 py-3 text-sm text-right">
|
<td className="px-4 py-3 text-sm text-right">
|
||||||
{formatCurrency(tx.quantity * tx.price)}
|
{formatCurrency(tx.quantity * tx.price)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{transactions.length === 0 && (
|
{transactions.length === 0 && (
|
||||||
<tr>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -55,6 +55,20 @@ interface Holding {
|
|||||||
avg_price: number;
|
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 }> = {
|
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
|
||||||
buy: {
|
buy: {
|
||||||
label: '매수',
|
label: '매수',
|
||||||
@ -116,6 +130,7 @@ export default function SignalsPage() {
|
|||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [executeError, setExecuteError] = useState('');
|
const [executeError, setExecuteError] = useState('');
|
||||||
const [currentHoldings, setCurrentHoldings] = useState<Holding[]>([]);
|
const [currentHoldings, setCurrentHoldings] = useState<Holding[]>([]);
|
||||||
|
const [positionSize, setPositionSize] = useState<PositionSize | null>(null);
|
||||||
|
|
||||||
// Cancel modal state
|
// Cancel modal state
|
||||||
const [cancelModalOpen, setCancelModalOpen] = useState(false);
|
const [cancelModalOpen, setCancelModalOpen] = useState(false);
|
||||||
@ -203,12 +218,14 @@ export default function SignalsPage() {
|
|||||||
setSelectedPortfolioId('');
|
setSelectedPortfolioId('');
|
||||||
setExecuteError('');
|
setExecuteError('');
|
||||||
setCurrentHoldings([]);
|
setCurrentHoldings([]);
|
||||||
|
setPositionSize(null);
|
||||||
await fetchPortfolios();
|
await fetchPortfolios();
|
||||||
setExecuteModalOpen(true);
|
setExecuteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePortfolioChange = async (portfolioId: string) => {
|
const handlePortfolioChange = async (portfolioId: string) => {
|
||||||
setSelectedPortfolioId(portfolioId);
|
setSelectedPortfolioId(portfolioId);
|
||||||
|
setPositionSize(null);
|
||||||
if (portfolioId) {
|
if (portfolioId) {
|
||||||
try {
|
try {
|
||||||
const holdings = await api.get<Holding[]>(`/api/portfolios/${portfolioId}/holdings`);
|
const holdings = await api.get<Holding[]>(`/api/portfolios/${portfolioId}/holdings`);
|
||||||
@ -222,6 +239,21 @@ export default function SignalsPage() {
|
|||||||
: Math.floor(holding.quantity / 2);
|
: Math.floor(holding.quantity / 2);
|
||||||
setExecuteQuantity(String(defaultQty));
|
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 {
|
} catch {
|
||||||
setCurrentHoldings([]);
|
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 */}
|
{/* Quantity */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="exec-quantity">수량 (주)</Label>
|
<Label htmlFor="exec-quantity">수량 (주)</Label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user