235 lines
7.6 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}"
)
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,
}