머니페니 9249821a25 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>
2026-03-18 19:04:36 +09:00

237 lines
7.7 KiB
Python

"""
KJB Signal API endpoints.
"""
from datetime import date, datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.signal import Signal, SignalStatus, SignalType
from app.models.portfolio import Holding, Transaction, TransactionType
from app.schemas.signal import SignalExecuteRequest, SignalResponse
from app.schemas.portfolio import TransactionResponse
router = APIRouter(prefix="/api/signal", tags=["signal"])
@router.get("/kjb/today", response_model=List[SignalResponse])
async def get_today_signals(
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get today's KJB trading signals."""
today = date.today()
signals = (
db.query(Signal)
.filter(Signal.date == today)
.order_by(Signal.signal_type, Signal.ticker)
.all()
)
return signals
@router.get("/kjb/history", response_model=List[SignalResponse])
async def get_signal_history(
current_user: CurrentUser,
db: Session = Depends(get_db),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
ticker: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
):
"""Get historical KJB signals."""
query = db.query(Signal)
if start_date:
query = query.filter(Signal.date >= start_date)
if end_date:
query = query.filter(Signal.date <= end_date)
if ticker:
query = query.filter(Signal.ticker == ticker)
signals = (
query.order_by(Signal.date.desc(), Signal.ticker)
.limit(limit)
.all()
)
return signals
@router.post("/{signal_id}/execute", response_model=dict)
async def execute_signal(
signal_id: int,
data: SignalExecuteRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Execute a signal by creating a portfolio transaction and updating signal status."""
from app.api.portfolio import _get_portfolio
# 1. Look up the signal and verify it's active
signal = db.query(Signal).filter(Signal.id == signal_id).first()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
if signal.status != SignalStatus.ACTIVE:
raise HTTPException(status_code=400, detail="Signal is not active")
# 2. Verify portfolio ownership
portfolio = _get_portfolio(db, data.portfolio_id, current_user.id)
# 3. Map signal type to transaction type
if signal.signal_type == SignalType.BUY:
tx_type = TransactionType.BUY
else:
tx_type = TransactionType.SELL
# 4. Create transaction (reuse portfolio transaction logic)
transaction = Transaction(
portfolio_id=data.portfolio_id,
ticker=signal.ticker,
tx_type=tx_type,
quantity=data.quantity,
price=data.price,
executed_at=datetime.now(timezone.utc),
memo=f"KJB signal #{signal.id}: {signal.signal_type.value}",
)
db.add(transaction)
# 5. Update holding
holding = db.query(Holding).filter(
Holding.portfolio_id == data.portfolio_id,
Holding.ticker == signal.ticker,
).first()
if tx_type == TransactionType.BUY:
if holding:
total_value = (holding.quantity * holding.avg_price) + (data.quantity * data.price)
new_quantity = holding.quantity + data.quantity
holding.quantity = new_quantity
holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0
else:
holding = Holding(
portfolio_id=data.portfolio_id,
ticker=signal.ticker,
quantity=data.quantity,
avg_price=data.price,
)
db.add(holding)
elif tx_type == TransactionType.SELL:
if not holding or holding.quantity < data.quantity:
raise HTTPException(
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)
# 6. Update signal status to executed with execution details
signal.status = SignalStatus.EXECUTED
signal.executed_price = data.price
signal.executed_quantity = data.quantity
signal.executed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(transaction)
db.refresh(signal)
return {
"transaction": {
"id": transaction.id,
"ticker": transaction.ticker,
"tx_type": transaction.tx_type.value,
"quantity": transaction.quantity,
"price": float(transaction.price),
"executed_at": transaction.executed_at.isoformat(),
},
"signal": {
"id": signal.id,
"status": signal.status.value,
},
}
@router.delete("/{signal_id}/cancel", response_model=dict)
async def cancel_signal(
signal_id: int,
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""실행된 신호를 취소한다. 연결된 거래를 삭제하고 보유량을 복원하며 신호를 ACTIVE로 되돌린다."""
from app.api.portfolio import _get_portfolio
from decimal import Decimal
# 1. 신호 조회 및 상태 확인
signal = db.query(Signal).filter(Signal.id == signal_id).first()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
if signal.status != SignalStatus.EXECUTED:
raise HTTPException(status_code=400, detail="Signal is not in EXECUTED status")
# 2. 포트폴리오 소유권 확인
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
# 3. 연결된 거래 조회 (신호 메모 기준)
memo_prefix = f"KJB signal #{signal_id}:"
transaction = (
db.query(Transaction)
.filter(
Transaction.portfolio_id == portfolio_id,
Transaction.ticker == signal.ticker,
Transaction.memo.like(f"{memo_prefix}%"),
)
.order_by(Transaction.executed_at.desc())
.first()
)
if not transaction:
raise HTTPException(status_code=404, detail="Related transaction not found")
# 4. 보유량 복원 (거래 역방향)
holding = db.query(Holding).filter(
Holding.portfolio_id == portfolio_id,
Holding.ticker == signal.ticker,
).first()
if transaction.tx_type == TransactionType.BUY:
# 매수 취소 → 보유량 감소
if holding:
holding.quantity -= transaction.quantity
if holding.quantity <= 0:
db.delete(holding)
elif transaction.tx_type == TransactionType.SELL:
# 매도 취소 → 보유량 복원
if holding:
# 평균단가 재계산 (역산 불가이므로 수량만 복원)
holding.quantity += transaction.quantity
else:
holding = Holding(
portfolio_id=portfolio_id,
ticker=signal.ticker,
quantity=transaction.quantity,
avg_price=transaction.price,
)
db.add(holding)
# 5. 거래 삭제
db.delete(transaction)
# 6. 신호 상태 복원
signal.status = SignalStatus.ACTIVE
signal.executed_price = None
signal.executed_quantity = None
signal.executed_at = None
db.commit()
return {
"signal_id": signal_id,
"signal_status": signal.status.value,
"transaction_deleted": True,
"ticker": signal.ticker,
}