""" 데이트레이딩 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, ActiveTrade from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update as sa_update 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 # 마지막으로 조회된 총자산 (is_daily_loss_exceeded 계산용 캐시) _last_total_assets: float = 0.0 # 당일 청산 종목 (재진입 방지 — 청산 후 당일 동일 종목 재매수 차단) _closed_today: set = set() # 마지막 초기화 날짜 _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, _last_total_assets, _closed_today 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 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 = 시장가 (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 기록""" try: total = price * quantity slippage = (price - order_price) if order_price is not None else None trade = TradeHistory( 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(), ) 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. 매매 가능 구간 ── 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 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 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(0.6) 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 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 async with _trade_lock: _daily_pnl += pnl _positions.pop(code, None) _closed_today.add(code) # 당일 재진입 차단 async with AsyncSessionLocal() as db: await _record_trade( db, code, pos["stock_name"], "SELL", current_price, quantity, exit_signal.reason, pnl, ) await db.execute( sa_update(ActiveTrade) .where( ActiveTrade.stock_code == code, ActiveTrade.strategy_name == "day_trade", ActiveTrade.status == "active", ) .values(status="closed") ) await db.commit() 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, 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(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]: """진입 주문 실행 및 포지션 등록. 주문 직전 MCP RISK_ASSESSMENT를 호출해 approve 시에만 매수한다 (이슈 4). MCP 장애/타임아웃은 fail-open(approve=True)으로 처리해 매매를 차단하지 않는다. """ global _trade_lock 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 측) ──────────── from app.api.endpoints.mcp import request_mcp_risk_assessment _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 _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, order_price=float(int(signal.entry_price)), # 주문가 = 지정가 ) # ActiveTrade 저장 — 재시작 시 포지션 복구를 위해 active = ActiveTrade( 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", ) db.add(active) await db.commit() 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"] async with _trade_lock: _daily_pnl += pnl _positions.pop(code, None) _closed_today.add(code) # 당일 재진입 차단 logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원") async with AsyncSessionLocal() as db: await _record_trade( db, code, pos["stock_name"], "SELL", current_price, pos["quantity"], "EOD_flat", pnl, ) await db.execute( sa_update(ActiveTrade) .where( ActiveTrade.stock_code == code, ActiveTrade.strategy_name == "day_trade", ActiveTrade.status == "active", ) .values(status="closed") ) await db.commit() 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(0.6) 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 엔드포인트 ──────────────────────────────────────────────────────────── @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( _last_total_assets, _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(), }