""" 데이트레이딩 API 엔드포인트 라우터 prefix: /api/daytrading 엔드포인트: GET /watchlist — 감시 종목 목록 조회 POST /watchlist — 감시 종목 추가/교체 GET /signals — 현재 진입 신호 조회 POST /execute — 신호 기반 데이트레이딩 실행 GET /positions — 활성 데이트레이딩 포지션 조회 POST /close-all — 전체 포지션 강제 청산 (EOD 수동) POST /cycle — 1분 주기 사이클 수동 실행 (디버그용) 상태 (인메모리, 일일 초기화): _watchlist — 감시 종목 코드+이름 딕셔너리 _positions — 활성 포지션 {stock_code: {...}} _orb_cache — 종목별 ORB 데이터 _daily_pnl — 오늘 실현 손익 _last_date — 마지막 초기화 날짜 (날짜 바뀌면 자동 리셋) """ from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel from typing import List, Optional, Dict, Any from datetime import datetime, date, time as dtime import asyncio import logging import pytz from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( MinuteCandle, OpeningRange, DayTradeSignal, parse_minute_candles, aggregate_to_5min, calculate_vwap, calculate_opening_range, check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, check_day_trade_exit, calculate_day_trade_position_size, is_daily_loss_exceeded, ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS, ) from app.core.market_schedule import ( is_market_open, is_opening_range_period, is_day_trade_entry_window, is_eod_flat_time, ) from app.core.config import settings from app.core.json_helpers import _dumps from app.services.websocket_manager import websocket_manager from app.core.database import get_db, AsyncSessionLocal from app.models.models import TradeHistory from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter() logger = logging.getLogger(__name__) KST = pytz.timezone("Asia/Seoul") # ── 인메모리 상태 ────────────────────────────────────────────────────────────── def _kst_now() -> datetime: return datetime.now(KST).replace(tzinfo=None) def _kst_time() -> dtime: return datetime.now(KST).time() def _today_kst() -> date: return datetime.now(KST).date() # 감시 종목 (code → name) _watchlist: Dict[str, str] = {} # 활성 포지션 # {stock_code: { # "stock_name", "strategy", "entry_price", "stop_loss", "target_price", # "quantity", "highest_price", "entry_time", "reason" # }} _positions: Dict[str, Dict[str, Any]] = {} # ORB 캐시 (당일, stock_code → OpeningRange) _orb_cache: Dict[str, OpeningRange] = {} # 일일 실현 손익 (원) _daily_pnl: float = 0.0 # 마지막 초기화 날짜 _last_reset_date: Optional[date] = None # 매매 실행 lock _trade_lock = asyncio.Lock() def _reset_if_new_day() -> None: """날짜가 바뀌면 일일 상태 초기화""" global _daily_pnl, _orb_cache, _last_reset_date today = _today_kst() if _last_reset_date != today: _daily_pnl = 0.0 _orb_cache = {} _last_reset_date = today logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}") # ── Pydantic 모델 ───────────────────────────────────────────────────────────── class WatchlistRequest(BaseModel): stocks: List[Dict[str, str]] # [{"code": "005930", "name": "삼성전자"}, ...] class ExecuteRequest(BaseModel): stock_code: str dry_run: bool = False # True면 주문 없이 신호만 반환 # ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── async def _fetch_candles(stock_code: str) -> List[MinuteCandle]: """당일 전체 분봉 데이터 조회 및 파싱""" raw = await korea_investment_api.get_minute_chart_full_day(stock_code) return parse_minute_candles(raw) async def _get_current_price(stock_code: str) -> float: """현재가 조회""" try: result = await korea_investment_api.get_current_price(stock_code) return float(result.get("output", {}).get("stck_prpr", 0) or 0) except Exception: return 0.0 async def _get_portfolio_summary() -> Dict[str, float]: """포트폴리오 요약 (총자산, 가용 현금)""" try: balance = await korea_investment_api.get_balance() output2 = balance.get("output2", [{}]) summary = output2[0] if output2 else {} total_assets = float(summary.get("tot_evlu_amt", 0) or 0) available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0) return {"total_assets": total_assets, "available_cash": available_cash} except Exception as e: logger.error(f"포트폴리오 조회 실패: {e}") return {"total_assets": 0.0, "available_cash": 0.0} async def _place_buy_order( stock_code: str, quantity: int, price: int, ) -> Optional[str]: """매수 주문 실행, 주문번호 반환 (실패 시 None)""" try: result = await korea_investment_api.place_order( stock_code=stock_code, order_type="BUY", quantity=quantity, price=price, ) if result.get("rt_cd") == "0": return result.get("output", {}).get("ODNO", "") logger.warning(f"매수 주문 실패 [{stock_code}]: {result.get('msg1')}") return None except Exception as e: logger.error(f"매수 주문 오류 [{stock_code}]: {e}") return None async def _place_sell_order( stock_code: str, quantity: int, price: int = 0, # 0 = 시장가 ) -> bool: """매도 주문 실행""" try: result = await korea_investment_api.place_order( stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, ) return result.get("rt_cd") == "0" except Exception as e: logger.error(f"매도 주문 오류 [{stock_code}]: {e}") return False async def _record_trade( db: AsyncSession, stock_code: str, stock_name: str, order_type: str, price: float, quantity: int, reason: str, profit_loss: float = 0.0, ) -> None: """TradeHistory DB 기록""" try: trade = TradeHistory( stock_code=stock_code, stock_name=stock_name, order_type=order_type, executed_price=price, executed_quantity=quantity, total_amount=price * quantity, profit_loss=profit_loss, profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0, status="COMPLETED", entry_reason=reason if order_type == "BUY" else None, exit_reason=reason if order_type == "SELL" else None, executed_at=_kst_now(), ) db.add(trade) await db.commit() except Exception as e: logger.error(f"TradeHistory 기록 실패: {e}") # ── 핵심 사이클 로직 ────────────────────────────────────────────────────────── async def run_day_trading_cycle() -> Dict[str, Any]: """1분 주기 데이트레이딩 사이클 흐름: 1. 장 미개장 → 스킵 2. 일일 상태 초기화 체크 3. Opening Range 수집 (09:00~09:30) 4. 활성 포지션 청산 체크 5. 신규 진입 신호 체크 (09:30~15:00) 6. 결과 반환 """ _reset_if_new_day() now_time = _kst_time() result: Dict[str, Any] = { "time": _kst_now().strftime("%H:%M:%S"), "phase": "", "exits": [], "entries": [], "signals": [], } if not is_market_open(): result["phase"] = "market_closed" return result # ── 1. EOD 강제 청산 ── if is_eod_flat_time(): result["phase"] = "eod_flat" await _execute_eod_flat() return result # ── 2. ORB 수집 단계 (09:00~09:30) ── if is_opening_range_period(): result["phase"] = "opening_range" await _collect_opening_range() result["orb_count"] = len(_orb_cache) return result # ── 3. 매매 가능 구간 (09:30~15:00) ── if not is_day_trade_entry_window(): result["phase"] = "outside_window" return result result["phase"] = "active" # ── 4. 활성 포지션 청산 체크 ── exits = await _check_exits() result["exits"] = exits # ── 5. 신규 진입 신호 체크 ── if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED: portfolio = await _get_portfolio_summary() total_assets = portfolio["total_assets"] available_cash = portfolio["available_cash"] if not is_daily_loss_exceeded(total_assets, _daily_pnl): current_day_trade_amount = sum( p["entry_price"] * p["quantity"] for p in _positions.values() ) signals = await _scan_signals( total_assets=total_assets, available_cash=available_cash, current_day_trade_amount=current_day_trade_amount, ) result["signals"] = [ { "stock_code": s["signal"].stock_code, "strategy": s["signal"].strategy, "entry_price": s["signal"].entry_price, "stop_loss": s["signal"].stop_loss, "target_price": s["signal"].target_price, "reason": s["signal"].reason, "quantity": s["quantity"], } for s in signals ] for s in signals: if len(_positions) >= MAX_POSITIONS: break entry_result = await _execute_entry(s["signal"], s["quantity"]) if entry_result: result["entries"].append(entry_result) else: logger.warning( f"⚠️ 일일 손실 한도 도달 ({_daily_pnl:,.0f}원) → 신규 진입 중단" ) return result async def _collect_opening_range() -> None: """Opening Range 집계 (09:00~09:30)""" for code in list(_watchlist.keys()): if code in _orb_cache: continue try: candles = await _fetch_candles(code) orb = calculate_opening_range(candles) if orb: _orb_cache[code] = orb logger.info( f"📊 ORB 수집 [{code}]: " f"H={orb.high:.0f}, L={orb.low:.0f}, " f"분당거래량={orb.avg_vol_per_min:.0f}" ) except Exception as e: logger.warning(f"ORB 수집 실패 [{code}]: {e}") await asyncio.sleep(0.6) async def _check_exits() -> List[Dict[str, Any]]: """활성 포지션 청산 조건 체크 및 실행""" global _daily_pnl exits = [] now_time = _kst_time() for code, pos in list(_positions.items()): try: current_price = await _get_current_price(code) if current_price <= 0: continue # 최고가 갱신 if current_price > pos["highest_price"]: _positions[code]["highest_price"] = current_price candles = await _fetch_candles(code) candles_5m = aggregate_to_5min(candles) exit_signal = check_day_trade_exit( entry_price=pos["entry_price"], current_price=current_price, stop_loss=pos["stop_loss"], target_price=pos["target_price"], highest_since_entry=pos["highest_price"], candles_5min=candles_5m, current_time=now_time, ) if exit_signal: quantity = pos["quantity"] ok = await _place_sell_order(code, quantity, price=0) # 시장가 if ok: pnl = (current_price - pos["entry_price"]) * quantity _daily_pnl += pnl del _positions[code] async with AsyncSessionLocal() as db: await _record_trade( db, code, pos["stock_name"], "SELL", current_price, quantity, exit_signal.reason, pnl, ) exits.append({ "stock_code": code, "reason": exit_signal.reason, "pnl": round(pnl, 0), "price": current_price, }) logger.info( f"✅ 데이트레이딩 청산 [{code}]: " f"{exit_signal.reason}, PNL={pnl:+,.0f}원" ) await websocket_manager.broadcast(_dumps({ "type": "day_trade_exit", "stock_code": code, "reason": exit_signal.reason, "pnl": pnl, })) except Exception as e: logger.error(f"청산 체크 오류 [{code}]: {e}") await asyncio.sleep(0.6) return exits async def _scan_signals( total_assets: float, available_cash: float, current_day_trade_amount: float, ) -> List[Dict[str, Any]]: """감시 종목에서 진입 신호 스캔""" results = [] for code, name in list(_watchlist.items()): if code in _positions: continue try: candles_1m = await _fetch_candles(code) if not candles_1m: continue candles_5m = aggregate_to_5min(candles_1m) current_price = candles_1m[-1].close if candles_1m else 0 if current_price < settings.MIN_PRICE_KRW: continue vwap = calculate_vwap(candles_1m) orb = _orb_cache.get(code) signal: Optional[DayTradeSignal] = None # ORB 우선 if orb: signal = check_orb_signal(code, name, candles_1m, orb, current_price) # VWAP 반등 if signal is None and vwap: signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price) # RSI+BB if signal is None: signal = check_rsi_bb_signal(code, name, candles_5m, current_price) if signal: position_size = calculate_day_trade_position_size( total_assets, available_cash, current_day_trade_amount ) if position_size < signal.entry_price: continue # 최소 1주도 살 수 없으면 스킵 quantity = max(1, int(position_size // signal.entry_price)) results.append({"signal": signal, "quantity": quantity}) logger.info( f"🎯 데이트레이딩 신호 [{code}] {signal.strategy}: " f"{signal.reason}, 수량={quantity}" ) except Exception as e: logger.warning(f"신호 스캔 오류 [{code}]: {e}") await asyncio.sleep(0.6) # confidence 내림차순 정렬 results.sort(key=lambda x: x["signal"].confidence, reverse=True) return results[:MAX_POSITIONS - len(_positions)] async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict]: """진입 주문 실행 및 포지션 등록""" global _trade_lock async with _trade_lock: if len(_positions) >= MAX_POSITIONS: return None if signal.stock_code in _positions: return None order_no = await _place_buy_order( signal.stock_code, quantity, int(signal.entry_price) ) if not order_no: return None _positions[signal.stock_code] = { "stock_name": signal.stock_name, "strategy": signal.strategy, "entry_price": signal.entry_price, "stop_loss": signal.stop_loss, "target_price": signal.target_price, "quantity": quantity, "highest_price": signal.entry_price, "entry_time": _kst_now().strftime("%H:%M:%S"), "reason": signal.reason, } async with AsyncSessionLocal() as db: await _record_trade( db, signal.stock_code, signal.stock_name, "BUY", signal.entry_price, quantity, signal.reason, ) logger.info( f"🛒 데이트레이딩 진입 [{signal.stock_code}] " f"{signal.strategy}: {signal.entry_price:.0f}원 × {quantity}주" ) await websocket_manager.broadcast(_dumps({ "type": "day_trade_entry", "stock_code": signal.stock_code, "strategy": signal.strategy, "entry_price": signal.entry_price, "quantity": quantity, })) return { "stock_code": signal.stock_code, "strategy": signal.strategy, "entry_price": signal.entry_price, "quantity": quantity, "reason": signal.reason, } async def _execute_eod_flat() -> None: """EOD 전체 포지션 강제 청산""" global _daily_pnl for code, pos in list(_positions.items()): try: current_price = await _get_current_price(code) ok = await _place_sell_order(code, pos["quantity"], price=0) if ok: pnl = (current_price - pos["entry_price"]) * pos["quantity"] _daily_pnl += pnl del _positions[code] logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원") except Exception as e: logger.error(f"EOD 청산 오류 [{code}]: {e}") await asyncio.sleep(0.6) # ── API 엔드포인트 ──────────────────────────────────────────────────────────── @router.get("/watchlist") async def get_watchlist(): """감시 종목 목록 조회""" _reset_if_new_day() return { "watchlist": [ {"code": k, "name": v, "has_orb": k in _orb_cache} for k, v in _watchlist.items() ], "count": len(_watchlist), } @router.post("/watchlist") async def set_watchlist(req: WatchlistRequest): """감시 종목 설정 (최대 20종목) body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]} """ global _watchlist if len(req.stocks) > 20: raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.") _watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks} logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}") return {"status": "ok", "watchlist": list(_watchlist.keys())} @router.get("/signals") async def get_current_signals(): """현재 진입 가능 신호 조회 (주문 미실행)""" _reset_if_new_day() if not is_day_trade_entry_window(): return { "phase": "outside_window", "message": "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).", "signals": [], } portfolio = await _get_portfolio_summary() total_assets = portfolio["total_assets"] available_cash = portfolio["available_cash"] current_day_trade_amount = sum( p["entry_price"] * p["quantity"] for p in _positions.values() ) signals = await _scan_signals(total_assets, available_cash, current_day_trade_amount) return { "phase": "active", "signals": [ { "stock_code": s["signal"].stock_code, "stock_name": s["signal"].stock_name, "strategy": s["signal"].strategy, "entry_price": s["signal"].entry_price, "stop_loss": s["signal"].stop_loss, "target_price": s["signal"].target_price, "reason": s["signal"].reason, "confidence": s["signal"].confidence, "quantity": s["quantity"], } for s in signals ], "count": len(signals), } @router.post("/execute") async def execute_day_trade(req: ExecuteRequest): """특정 종목 데이트레이딩 신호 스캔 후 진입""" _reset_if_new_day() code = req.stock_code name = _watchlist.get(code, code) if code in _positions: raise HTTPException(400, f"{code}는 이미 포지션이 있습니다.") if len(_positions) >= MAX_POSITIONS: raise HTTPException(400, f"최대 포지션 수({MAX_POSITIONS}종목)에 도달했습니다.") if not is_day_trade_entry_window(): raise HTTPException(400, "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).") portfolio = await _get_portfolio_summary() total_assets = portfolio["total_assets"] available_cash = portfolio["available_cash"] if is_daily_loss_exceeded(total_assets, _daily_pnl): raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.") candles_1m = await _fetch_candles(code) candles_5m = aggregate_to_5min(candles_1m) current_price = candles_1m[-1].close if candles_1m else 0 vwap = calculate_vwap(candles_1m) orb = _orb_cache.get(code) signal = None if orb: signal = check_orb_signal(code, name, candles_1m, orb, current_price) if signal is None and vwap: signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price) if signal is None: signal = check_rsi_bb_signal(code, name, candles_5m, current_price) if not signal: return {"status": "no_signal", "message": "현재 진입 조건을 충족하는 신호가 없습니다."} current_day_trade_amount = sum( p["entry_price"] * p["quantity"] for p in _positions.values() ) position_size = calculate_day_trade_position_size( total_assets, available_cash, current_day_trade_amount ) quantity = max(1, int(position_size // signal.entry_price)) if req.dry_run: return { "status": "dry_run", "signal": { "strategy": signal.strategy, "entry_price": signal.entry_price, "stop_loss": signal.stop_loss, "target_price": signal.target_price, "reason": signal.reason, "quantity": quantity, }, } entry = await _execute_entry(signal, quantity) if not entry: raise HTTPException(500, "주문 실행에 실패했습니다.") return {"status": "ok", "entry": entry} @router.get("/positions") async def get_positions(): """활성 데이트레이딩 포지션 조회""" _reset_if_new_day() result = [] for code, pos in _positions.items(): try: current_price = await _get_current_price(code) except Exception: current_price = pos["entry_price"] pnl = (current_price - pos["entry_price"]) * pos["quantity"] pnl_pct = (current_price - pos["entry_price"]) / pos["entry_price"] * 100 result.append({ "stock_code": code, "stock_name": pos["stock_name"], "strategy": pos["strategy"], "entry_price": pos["entry_price"], "current_price": current_price, "stop_loss": pos["stop_loss"], "target_price": pos["target_price"], "quantity": pos["quantity"], "pnl": round(pnl, 0), "pnl_pct": round(pnl_pct, 2), "entry_time": pos["entry_time"], }) return { "positions": result, "count": len(result), "daily_pnl": round(_daily_pnl, 0), "is_daily_loss_exceeded": is_daily_loss_exceeded( sum(p["entry_price"] * p["quantity"] for p in _positions.values()), _daily_pnl, ), } @router.post("/close-all") async def close_all_positions(): """전체 포지션 수동 강제 청산 (EOD 대비 수동 실행)""" _reset_if_new_day() await _execute_eod_flat() return { "status": "ok", "message": "전체 데이트레이딩 포지션 청산 완료", "daily_pnl": round(_daily_pnl, 0), } @router.post("/cycle") async def manual_cycle(): """1분 사이클 수동 실행 (테스트/디버그용)""" _reset_if_new_day() result = await run_day_trading_cycle() return result @router.get("/status") async def get_status(): """데이트레이딩 시스템 상태 요약""" _reset_if_new_day() return { "watchlist_count": len(_watchlist), "active_positions": len(_positions), "orb_collected": len(_orb_cache), "daily_pnl": round(_daily_pnl, 0), "is_market_open": is_market_open(), "is_opening_range": is_opening_range_period(), "is_entry_window": is_day_trade_entry_window(), "is_eod_flat": is_eod_flat_time(), "today": _today_kst().isoformat(), }