galaxis-po/backend/app/api/screening.py
머니페니 0b66eae847
All checks were successful
Deploy to Production / deploy (push) Successful in 3m5s
feat: improve KJB screening workflow
2026-05-14 07:52:28 +09:00

292 lines
9.7 KiB
Python

from datetime import date
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.screening import ScreeningSignal, AutoOrder
from app.schemas.screening import (
ScreeningSignalResponse, AutoOrderResponse, WatchlistItem, ScreeningSummary,
)
router = APIRouter(tags=["screening"])
@router.get("/api/screening/today", response_model=List[ScreeningSignalResponse])
async def get_today_screening(
current_user: CurrentUser,
db: Session = Depends(get_db),
):
today = date.today()
signals = (
db.query(ScreeningSignal)
.filter(ScreeningSignal.screen_date == today)
.order_by(ScreeningSignal.ticker)
.all()
)
return signals
@router.get("/api/screening/history", response_model=List[ScreeningSignalResponse])
async def get_screening_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),
status: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
):
query = db.query(ScreeningSignal)
if start_date:
query = query.filter(ScreeningSignal.screen_date >= start_date)
if end_date:
query = query.filter(ScreeningSignal.screen_date <= end_date)
if ticker:
query = query.filter(ScreeningSignal.ticker == ticker)
if status:
query = query.filter(ScreeningSignal.status == status)
signals = (
query.order_by(ScreeningSignal.screen_date.desc(), ScreeningSignal.ticker)
.limit(limit)
.all()
)
return signals
@router.get("/api/screening/watchlist", response_model=List[WatchlistItem])
async def get_watchlist(
current_user: CurrentUser,
db: Session = Depends(get_db),
):
signals = (
db.query(ScreeningSignal)
.filter(ScreeningSignal.status.in_(["pending", "watching"]))
.order_by(ScreeningSignal.screen_date.desc(), ScreeningSignal.ticker)
.all()
)
return signals
@router.post("/api/screening/execute", response_model=dict)
async def execute_screening(
current_user: CurrentUser,
db: Session = Depends(get_db),
execute_orders: bool = Query(False, description="true일 때만 KIS 주문 전송"),
):
from app.core.config import get_settings
from app.services.trading.kis_executor import KISTradeExecutor
from app.models.stock import Price
from app.services.strategy.kospi_screener import KJBScreeningSignalGenerator
from jobs.screening_job import _latest_price_date, _price_rows_to_frame
from datetime import datetime, timedelta
settings = get_settings()
if not settings.kis_app_key or not settings.kis_app_secret:
raise HTTPException(status_code=400, detail="KIS API credentials not configured")
watching = (
db.query(ScreeningSignal)
.filter(ScreeningSignal.status.in_(["pending", "watching"]))
.all()
)
if not watching:
return {"message": "No watching signals to execute", "orders": [], "execute_orders": execute_orders}
executor = KISTradeExecutor(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
account_no=settings.kis_account_no,
paper_trade=settings.kis_paper_trade,
)
balance = executor.get_account_balance()
capital = balance.total_amount or balance.available_amount
if capital <= 0:
raise HTTPException(status_code=400, detail="KIS account balance unavailable")
signal_gen = KJBScreeningSignalGenerator()
latest_date = _latest_price_date(db, market="KOSPI") or date.today()
held_tickers = {position.ticker for position in executor.get_positions()}
results = []
for signal in watching:
if signal.ticker in held_tickers:
results.append({
"ticker": signal.ticker,
"success": False,
"status": "skipped",
"message": "Already held",
})
continue
rows = (
db.query(Price)
.filter(
Price.ticker == signal.ticker,
Price.date >= (signal.screen_date - timedelta(days=20)),
Price.date <= latest_date,
)
.order_by(Price.date)
.all()
)
price_df = _price_rows_to_frame(rows)
entry = signal_gen.check_entry(signal.ticker, price_df, signal.screen_date)
if not entry:
results.append({
"ticker": signal.ticker,
"success": False,
"status": "waiting",
"message": "Entry condition not met",
})
continue
entry_price = float(entry["entry_price"])
stop_price = float(entry["stop_price"])
qty = signal_gen.calculate_position_size(
entry_price=entry_price,
stop_price=stop_price,
capital=capital,
risk_pct=settings.screening_risk_pct,
)
max_qty_by_order = int(settings.screening_max_order_amount / entry_price)
qty = min(qty, max_qty_by_order)
if qty <= 0:
results.append({
"ticker": signal.ticker,
"success": False,
"status": "skipped",
"entry_price": entry_price,
"stop_price": stop_price,
"qty": 0,
"message": "Calculated quantity is zero",
})
continue
signal.entry_date = entry["entry_date"].date() if hasattr(entry["entry_date"], "date") else entry["entry_date"]
signal.entry_price = entry_price
if not execute_orders:
signal.status = "watching"
results.append({
"ticker": signal.ticker,
"success": True,
"status": "planned",
"entry_price": entry_price,
"stop_price": stop_price,
"qty": qty,
"risk_amount": (entry_price - stop_price) * qty,
"message": "Dry-run only. Add execute_orders=true to place KIS order.",
})
continue
order = executor.place_buy_order(ticker=signal.ticker, qty=qty, price=int(entry_price))
auto_order = AutoOrder(
order_date=datetime.now(),
ticker=signal.ticker,
order_type="buy",
qty=qty,
price=entry_price,
order_no=order.order_no,
status="filled" if order.success else "rejected",
screening_signal_id=signal.id,
)
db.add(auto_order)
if order.success:
signal.status = "entered"
held_tickers.add(signal.ticker)
results.append({
"ticker": signal.ticker,
"success": order.success,
"status": "ordered" if order.success else "rejected",
"order_no": order.order_no,
"entry_price": entry_price,
"stop_price": stop_price,
"qty": qty,
"message": order.message,
})
db.commit()
ordered_count = sum(1 for item in results if item.get("status") == "ordered")
planned_count = sum(1 for item in results if item.get("status") == "planned")
return {
"message": f"ordered={ordered_count}, planned={planned_count}, total={len(results)}",
"execute_orders": execute_orders,
"orders": results,
}
@router.get("/api/trading/orders", response_model=List[AutoOrderResponse])
async def get_orders(
current_user: CurrentUser,
db: Session = Depends(get_db),
limit: int = Query(100, ge=1, le=1000),
):
orders = (
db.query(AutoOrder)
.order_by(AutoOrder.order_date.desc())
.limit(limit)
.all()
)
return orders
@router.get("/api/trading/positions", response_model=dict)
async def get_trading_positions(
current_user: CurrentUser,
):
from app.core.config import get_settings
from app.services.trading.kis_executor import KISTradeExecutor
settings = get_settings()
if not settings.kis_app_key or not settings.kis_app_secret:
raise HTTPException(status_code=400, detail="KIS API credentials not configured")
executor = KISTradeExecutor(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
account_no=settings.kis_account_no,
paper_trade=settings.kis_paper_trade,
)
positions = executor.get_positions()
return {"positions": [
{
"ticker": p.ticker,
"name": p.name,
"qty": p.qty,
"avg_price": p.avg_price,
"current_price": p.current_price,
"pnl_amount": p.pnl_amount,
"pnl_rate": p.pnl_rate,
}
for p in positions
]}
@router.get("/api/trading/balance", response_model=dict)
async def get_trading_balance(
current_user: CurrentUser,
):
from app.core.config import get_settings
from app.services.trading.kis_executor import KISTradeExecutor
settings = get_settings()
if not settings.kis_app_key or not settings.kis_app_secret:
raise HTTPException(status_code=400, detail="KIS API credentials not configured")
executor = KISTradeExecutor(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
account_no=settings.kis_account_no,
paper_trade=settings.kis_paper_trade,
)
balance = executor.get_account_balance()
return {
"total_amount": balance.total_amount,
"available_amount": balance.available_amount,
"stock_amount": balance.stock_amount,
"pnl_amount": balance.pnl_amount,
}