292 lines
9.7 KiB
Python
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,
|
|
}
|