""" 데이트레이딩 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.services.loss_tracker import loss_tracker from app.core.database import get_db, AsyncSessionLocal from app.models.models import ActiveTrade from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select # MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려) from app.api.endpoints.mcp import request_mcp_risk_assessment router = APIRouter() logger = logging.getLogger(__name__) KST = pytz.timezone("Asia/Seoul") # API 호출 간격 (settings.API_MIN_REQUEST_INTERVAL 사용, 기본 0.5초) _API_CALL_DELAY: float = settings.API_MIN_REQUEST_INTERVAL # ── 인메모리 상태 ────────────────────────────────────────────────────────────── 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() def _is_new_entry_allowed() -> bool: """신규 진입 마감 시간(CLAUDE.md 7.3: 14:30, settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN) 이전인지 확인. 청산 경로에는 영향 없음 — 신규 진입 체크에서만 사용.""" return _kst_time() < ENTRY_CUTOFF # 감시 종목 (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 # 마지막으로 조회된 총자산 (is_daily_loss_exceeded 계산용 캐시) _last_total_assets: float = 0.0 # 당일 청산 종목 (재진입 방지 — 청산 후 당일 동일 종목 재매수 차단) _closed_today: set = set() # 마지막 초기화 날짜 _last_reset_date: Optional[date] = None # 매매 실행 lock — _positions/_daily_pnl/_closed_today 변경 시 반드시 획득 _trade_lock = asyncio.Lock() # 포트폴리오 요약 캐시 (get_balance() 호출 최소화) _portfolio_summary_cache: Dict[str, float] = {} _portfolio_summary_ts: Optional[datetime] = None _portfolio_cache_dirty: bool = True # 최초 또는 매매 발생 시 강제 갱신 _PORTFOLIO_CACHE_TTL: float = 300.0 # 매매 없을 때 최대 5분 캐시 유지 def _reset_if_new_day() -> None: """날짜가 바뀌면 일일 상태 초기화""" global _daily_pnl, _orb_cache, _last_reset_date, _last_total_assets, _closed_today global _portfolio_summary_cache, _portfolio_summary_ts, _portfolio_cache_dirty today = _today_kst() if _last_reset_date != today: _daily_pnl = 0.0 _orb_cache = {} _last_total_assets = 0.0 _closed_today = set() _last_reset_date = today _portfolio_summary_cache = {} _portfolio_summary_ts = None _portfolio_cache_dirty = True 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(force: bool = False) -> Dict[str, float]: """포트폴리오 요약 (총자산, 가용 현금). 매매가 발생했거나 캐시 TTL이 만료된 경우에만 KIS get_balance() 호출. 그 외에는 인메모리 캐시 반환 (API 호출 최소화). """ global _portfolio_summary_cache, _portfolio_summary_ts, _portfolio_cache_dirty now = _kst_now() cache_age = (now - _portfolio_summary_ts).total_seconds() if _portfolio_summary_ts else 9999.0 cache_valid = ( not _portfolio_cache_dirty and cache_age < _PORTFOLIO_CACHE_TTL and bool(_portfolio_summary_cache) ) if not force and cache_valid: logger.debug(f"⏭️ Portfolio summary cache hit (age={cache_age:.0f}s) — skipping get_balance()") return _portfolio_summary_cache try: balance = await korea_investment_api.get_balance() # output2가 빈 리스트([])로 오는 경우까지 방어 (get의 default는 키가 없을 때만 적용됨) summary = (balance.get("output2") or [{}])[0] total_assets = float(summary.get("tot_evlu_amt", 0) or 0) available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0) _portfolio_summary_cache = {"total_assets": total_assets, "available_cash": available_cash} _portfolio_summary_ts = now _portfolio_cache_dirty = False return _portfolio_summary_cache except Exception as e: logger.error(f"포트폴리오 조회 실패: {e}") return _portfolio_summary_cache or {"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 = 시장가 (None으로 변환) ) -> bool: """매도 주문 실행""" try: result = await korea_investment_api.place_order( stock_code=stock_code, order_type="SELL", quantity=quantity, price=price if price > 0 else None, # 0이면 None → 시장가(01) ) 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, order_price: Optional[float] = None, # 주문가 (슬리피지 분석용) ) -> None: """TradeHistory DB 기록 (db_worker 큐 경유 -- CLAUDE.md 3.2 준수). db 파라미터는 시그니처 호환을 위해 유지하되 사용하지 않음 (읽기 전용). """ try: from app.services.db_worker import enqueue_trade_history total = price * quantity slippage = (price - order_price) if order_price is not None else None await enqueue_trade_history({ "stock_code": stock_code, "stock_name": stock_name, "order_type": order_type, "order_price": order_price, "order_quantity": quantity, "executed_price": price, "executed_quantity": quantity, "total_amount": total, "net_profit_loss": profit_loss, "profit_loss_rate": (profit_loss / total * 100) if total > 0 else 0, "slippage": slippage, "status": "COMPLETED", "entry_reason": reason if order_type == "BUY" else None, "exit_reason": reason if order_type == "SELL" else None, "executed_at": _kst_now(), }) 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. 매매 가능 구간 ── in_entry_window = is_day_trade_entry_window() # 09:30~15:00 # 포지션이 있으면 입장 창 밖(15:00~15:20)에서도 청산 체크 수행 if not in_entry_window and not _positions: result["phase"] = "outside_window" return result result["phase"] = "active" # 사이클 단위 분봉 캐시 — 청산 체크와 신규 진입에서 동일 종목 중복 fetch 방지 _cycle_candle_cache: Dict[str, List[MinuteCandle]] = {} # ── 4. 활성 포지션 청산 체크 ── exits = await _check_exits(candle_cache=_cycle_candle_cache) result["exits"] = exits # ── 5. 신규 진입 신호 체크 (진입 창 안에서만) ── if not in_entry_window: result["phase"] = "exit_only" return result # MDD -10% 시 단기 전략(데이트레이딩 포함) 중단 체크 (CLAUDE.md 섹션 5.3) from app.services.mdd_manager import mdd_manager if not await mdd_manager.is_short_term_strategy_enabled(): logger.info("⏸️ [DayTrade] MDD -10% 이상 — 단기 전략 중단 (신규 진입 불가)") result["phase"] = "active" return result if not _is_new_entry_allowed(): # 14:30(DAY_TRADE_ENTRY_CUTOFF) 이후 — 신규 진입 마감 (CLAUDE.md 7.3), 청산은 위에서 이미 처리됨 result["phase"] = "entry_cutoff" return result 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"] global _last_total_assets if total_assets > 0: _last_total_assets = total_assets else: # 총자산 조회 실패 시 신규 진입 차단 — 손실 한도 체크 우회 방지 logger.warning("⚠️ 총자산 조회 실패(0원) → 신규 진입 차단") return result 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, candle_cache=_cycle_candle_cache, ) 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(_API_CALL_DELAY) async def _check_exits( candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None, ) -> List[Dict[str, Any]]: """활성 포지션 청산 조건 체크 및 실행 Args: candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch. """ global _daily_pnl, _portfolio_cache_dirty 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 if candle_cache is not None and code in candle_cache: candles = candle_cache[code] else: candles = await _fetch_candles(code) if candle_cache is not None: candle_cache[code] = candles 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 _portfolio_cache_dirty = True # 매도 체결 → 잔고 변경됨 async with _trade_lock: _daily_pnl += pnl _positions.pop(code, None) _closed_today.add(code) # 당일 재진입 차단 # db_worker 큐 경유 — CLAUDE.md 3.2 준수 from app.services.db_worker import enqueue_close_active_trade await _record_trade( None, code, pos["stock_name"], "SELL", current_price, quantity, exit_signal.reason, pnl, ) await enqueue_close_active_trade(code, strategy_name="day_trade") # loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수 _profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0 await loss_tracker.record_trade_result_safe(code, _profit_rate < 0) await loss_tracker.save_to_db(db=None, stock_code=code) 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(_API_CALL_DELAY) return exits async def _scan_signals( total_assets: float, available_cash: float, current_day_trade_amount: float, candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None, ) -> List[Dict[str, Any]]: """감시 종목에서 진입 신호 스캔 Args: candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch. """ results = [] for code, name in list(_watchlist.items()): if code in _positions: continue if code in _closed_today: # 당일 청산 종목 재진입 차단 continue try: if candle_cache is not None and code in candle_cache: candles_1m = candle_cache[code] else: candles_1m = await _fetch_candles(code) if candle_cache is not None: candle_cache[code] = candles_1m 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(_API_CALL_DELAY) # 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]: """진입 주문 실행 및 포지션 등록. 주문 직전 MCP RISK_ASSESSMENT를 호출해 approve 시에만 매수한다 (이슈 4). MCP 장애/타임아웃은 fail-open(approve=True)으로 처리해 매매를 차단하지 않는다. """ async with _trade_lock: if len(_positions) >= MAX_POSITIONS: return None if signal.stock_code in _positions: return None if signal.stock_code in _closed_today: # 당일 청산 종목 재진입 최종 방어 logger.info(f"⛔ 당일 청산 종목 재진입 차단: {signal.stock_code}") return None # ── MCP RISK_ASSESSMENT (공통 함수, 이슈 4 — day_trading 측) ──────────── _mcp_trade_plan = { "action": "BUY", "strategy": signal.strategy, "stock_code": signal.stock_code, "stock_name": signal.stock_name, "entry_price": signal.entry_price, "stop_loss": signal.stop_loss, "target_price": signal.target_price, "quantity": quantity, "reason": signal.reason, } _mcp_signals = { "strategy": signal.strategy, "confidence": signal.confidence, "reason": signal.reason, } _approve, _mcp_reason = await request_mcp_risk_assessment( _mcp_trade_plan, {}, _mcp_signals ) if not _approve: logger.warning( f"🛑 MCP RISK_ASSESSMENT DENY [{signal.stock_code}] " f"→ 주문 스킵. 사유: {_mcp_reason}" ) return None if _mcp_reason: logger.info( f"✅ MCP RISK_ASSESSMENT APPROVE [{signal.stock_code}]. 참고: {_mcp_reason}" ) # ───────────────────────────────────────────────────────────────────── order_no = await _place_buy_order( signal.stock_code, quantity, int(signal.entry_price) ) if not order_no: return None global _portfolio_cache_dirty _portfolio_cache_dirty = True # 매수 체결 → 잔고 변경됨 _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, } # db_worker 큐 경유 — CLAUDE.md 3.2 준수 from app.services.db_worker import enqueue_active_trade_upsert await _record_trade( None, signal.stock_code, signal.stock_name, "BUY", signal.entry_price, quantity, signal.reason, order_price=float(int(signal.entry_price)), # 주문가 = 지정가 ) # ActiveTrade 저장 — 재시작 시 포지션 복구를 위해 await enqueue_active_trade_upsert({ "stock_code": signal.stock_code, "stock_name": signal.stock_name, "quantity": quantity, "entry_price": signal.entry_price, "stop_loss": signal.stop_loss, "take_profit": signal.target_price, "highest_since_entry": signal.entry_price, "strategy_name": "day_trade", "entry_reason": signal.strategy, "status": "active", }) 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, _portfolio_cache_dirty 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"] _portfolio_cache_dirty = True # EOD 청산 → 잔고 변경됨 async with _trade_lock: _daily_pnl += pnl _positions.pop(code, None) _closed_today.add(code) # 당일 재진입 차단 logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원") # db_worker 큐 경유 — CLAUDE.md 3.2 준수 from app.services.db_worker import enqueue_close_active_trade await _record_trade( None, code, pos["stock_name"], "SELL", current_price, pos["quantity"], "EOD_flat", pnl, ) await enqueue_close_active_trade(code, strategy_name="day_trade") # loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수 _profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0 await loss_tracker.record_trade_result_safe(code, _profit_rate < 0) await loss_tracker.save_to_db(db=None, stock_code=code) await websocket_manager.broadcast(_dumps({ "type": "day_trade_exit", "stock_code": code, "reason": "EOD_flat", "pnl": pnl, })) except Exception as e: logger.error(f"EOD 청산 오류 [{code}]: {e}") await asyncio.sleep(_API_CALL_DELAY) async def restore_positions_from_db() -> None: """앱 재시작 후 오늘 데이트레이딩 포지션 DB 복구. strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로 인메모리 _positions를 재구성한다. created_at은 UTC naive datetime으로 저장되므로 날짜 비교는 UTC 기준으로 수행. 한국 장중(09:00~15:30 KST = 00:00~06:30 UTC)은 UTC/KST 날짜가 항상 일치하므로 UTC 기준으로 비교해도 동일한 결과를 얻는다. """ if _positions: return # 이미 포지션 있으면 스킵 from datetime import timezone today_utc = datetime.now(timezone.utc).date() try: async with AsyncSessionLocal() as db: result = await db.execute( select(ActiveTrade).where( ActiveTrade.strategy_name == "day_trade", ActiveTrade.status == "active", ) ) rows = result.scalars().all() for row in rows: if row.created_at and row.created_at.date() != today_utc: continue entry_price = float(row.entry_price) if row.created_at: created_kst = row.created_at.replace(tzinfo=pytz.utc).astimezone(KST) entry_time_str = created_kst.strftime("%H:%M:%S") else: entry_time_str = "??:??:??" _positions[row.stock_code] = { "stock_name": row.stock_name or row.stock_code, "strategy": row.entry_reason or "unknown", "entry_price": entry_price, "stop_loss": float(row.stop_loss) if row.stop_loss else entry_price * 0.97, "target_price": float(row.take_profit) if row.take_profit else entry_price * 1.03, "quantity": row.quantity, "highest_price": float(row.highest_since_entry or row.entry_price), "entry_time": entry_time_str, "reason": row.entry_reason or "", } if _positions: logger.info(f"📋 데이트레이딩 포지션 복구: {list(_positions.keys())}") except Exception as e: logger.error(f"데이트레이딩 포지션 복구 실패: {e}") # ── API 엔드포인트 ──────────────────────────────────────────────────────────── # ── 실제 매매 엔진(services/daytrading) 상태 연결 ───────────────────────────── # main.py 는 services/daytrading/scheduler.daytrading_scheduler() 를 실행한다. # 이 모듈의 _watchlist/_positions/_orb_cache 는 그 엔진과 공유되지 않는 별개 dict라 # 조회 API가 항상 0을 돌려주는 문제가 있었음(2026-09-22). 조회/설정 엔드포인트는 # 엔진 상태(entry_service._watchlist, day_trading_state)를 기준으로 응답한다. def _engine(): from app.services.daytrading import entry_service as _es from app.services.daytrading.position_manager import day_trading_state as _st return _es, _st @router.get("/watchlist") async def get_watchlist(): """감시 종목 목록 조회 (실제 엔진 기준)""" _reset_if_new_day() _es, _st = _engine() _st.reset_if_new_day() wl = _es.get_watchlist() return { "watchlist": [ {"code": k, "name": v, "has_orb": _st.has_orb(k)} for k, v in wl.items() ], "count": len(wl), } @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} _es, _ = _engine() _es.set_watchlist(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).") if not _is_new_entry_allowed(): raise HTTPException(400, f"신규 진입 마감 시간({ENTRY_CUTOFF.strftime('%H:%M')}) 이후에는 진입할 수 없습니다.") portfolio = await _get_portfolio_summary() total_assets = portfolio["total_assets"] available_cash = portfolio["available_cash"] # 총자산 조회 실패 시 진입 차단 — 손실 한도 체크 우회 방지 if total_assets <= 0: raise HTTPException(503, "총자산 조회에 실패했습니다. 잠시 후 다시 시도해주세요.") if is_daily_loss_exceeded(total_assets, _daily_pnl): raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.") candles_1m = await _fetch_candles(code) # 분봉 데이터 없으면 신호 분석 불가 → early return if not candles_1m: return {"status": "no_signal", "message": "분봉 데이터를 가져올 수 없습니다."} candles_5m = aggregate_to_5min(candles_1m) current_price = candles_1m[-1].close 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() _, _st = _engine() _st.reset_if_new_day() result = [] for code, pos in _st.positions.items(): entry_price = float(pos.get("entry_price", 0) or 0) quantity = int(pos.get("quantity", 0) or 0) try: current_price = await _get_current_price(code) except Exception: current_price = entry_price pnl = (current_price - entry_price) * quantity pnl_pct = ((current_price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 result.append({ "stock_code": code, "stock_name": pos.get("stock_name", code), "strategy": pos.get("strategy", ""), "entry_price": entry_price, "current_price": current_price, "stop_loss": pos.get("stop_loss"), "target_price": pos.get("target_price"), "quantity": quantity, "pnl": round(pnl, 0), "pnl_pct": round(pnl_pct, 2), "entry_time": str(pos.get("entry_time", "")), }) return { "positions": result, "count": len(result), "daily_pnl": round(_st.daily_pnl, 0), "is_daily_loss_exceeded": is_daily_loss_exceeded( _st.last_total_assets, _st.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() _es, _st = _engine() _st.reset_if_new_day() return { "watchlist_count": len(_es.get_watchlist()), "active_positions": _st.position_count(), "orb_collected": len(_st.orb_cache), "daily_pnl": round(_st.daily_pnl, 0), "closed_today": len(_st.closed_today), "cooldown_active": bool(_st.is_cooldown_active()), "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(), }