All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
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>
153 lines
4.9 KiB
Python
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,
|
|
},
|
|
}
|