- 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>
237 lines
7.7 KiB
Python
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,
|
|
}
|