zephyrdark eb06dfc48b
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
feat: implement scenario gap analysis - core loop completion
Phase 1 (Critical):
- Add bulk rebalance apply API + UI with confirmation modal
- Add strategy results to portfolio targets flow (shared component)

Phase 2 (Important):
- Show current holdings in signal execute modal with auto-fill
- Add DC pension risk asset ratio warning (70% limit)
- Add KOSPI benchmark comparison to portfolio returns
- Track signal execution details (price, quantity, timestamp)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:18:15 +09:00

153 lines
4.9 KiB
Python

"""
KJB Signal API endpoints.
"""
from datetime import date, datetime
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.utcnow(),
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.utcnow()
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,
},
}