""" 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, }