""" kis_trader/execution/order_manager.py — Master Executor ======================================================== 두 전략(스캘핑/꼬리잡기)이 공유하는 "단 하나의 주문 실행자". 설계 목적: * 전략은 **시그널만 생성**한다. 주문 실행은 전부 여기서 직렬화 처리. * 매수 전 실계좌 잔고 재확인 → 다른 전략이 이미 보유 중이면 차단. * 매도 전 실계좌 잔고 재확인 → 실제 보유 0 이면 매도 시도 자체를 차단 (DB·메모리 정리까지 한 번에). * 주문번호(ODNO) 기준 UNIQUE 제약으로 **서버단 중복 차단**. 정책 (env_config 로 제어): * ``STRATEGY_SAME_CODE_POLICY`` - ``block`` (기본): 한 종목은 한 전략만. 다른 전략이 이미 들고 있으면 신규 매수 차단. - ``allow`` : 두 전략 독립 보유 허용 (기존 복합PK 구조 유지). * ``REAL_BALANCE_VERIFY_BEFORE_SELL`` (기본 True): 매도 전 실잔고 조회. * ``REAL_BALANCE_VERIFY_BEFORE_BUY`` (기본 True): 매수 전 실잔고 조회. """ from __future__ import annotations import datetime import threading import time from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime as dt from typing import Callable, Dict, Optional from ..database.db_manager import TradeDBExt from ..utils.env import get_env_bool, get_env_from_db, get_env_int from ..utils.logger import LOG_CYAN, LOG_GREEN, LOG_RED, LOG_RESET, LOG_YELLOW, get_logger, msg_mm from .kis_client import KISClient logger = get_logger("kis_trader.order_mgr") @dataclass class OrderRequest: """전략이 OrderManager 에 넘기는 주문 요청.""" strategy_id: str code: str name: str side: str # 'BUY' | 'SELL' qty: int price_ref: float = 0.0 # 참고가(로그/DB용, 시장가 주문이라도 체결 추정치) reason: str = "" # 매도 사유 # 매수 시 포지션 관리용 부가 정보 stop_price: float = 0.0 target_price: float = 0.0 atr_entry: float = 0.0 size_class: Optional[str] = None entry_features: Optional[Dict] = None # 매도 시 계산 결과 전달 (로그용) buy_price: float = 0.0 profit_pct: float = 0.0 @dataclass class OrderResult: success: bool ord_no: Optional[str] = None filled_qty: int = 0 filled_avg_price: float = 0.0 reason: str = "" # 실패 사유 (차단/거부/오류) request: Optional[OrderRequest] = None extra: Dict = field(default_factory=dict) class OrderManager: """ 단일 주문 실행자 (싱글톤처럼 운용). - 종목별 Lock → 같은 종목에 대한 요청 직렬화. - 실잔고 캐시 (짧은 TTL) → API 호출 횟수 절감. """ # 실잔고 캐시 TTL (초). 너무 짧으면 API 폭주, 너무 길면 sync out. _HOLDINGS_CACHE_TTL = 2.0 def __init__(self, *, client: KISClient, db: TradeDBExt): self.client = client self.db = db # 종목별 Lock: 한 종목에 대한 주문 요청은 순차 처리 self._code_locks: Dict[str, threading.Lock] = defaultdict(threading.Lock) # 전역 Lock: _code_locks 인스턴스 생성 시 경합 방지 self._global_lock = threading.Lock() # 실잔고 맵 캐시 self._holdings_cache: Optional[Dict[str, Dict]] = None self._holdings_cache_ts: float = 0.0 self._holdings_lock = threading.Lock() # 매도 실패 백오프 (영업일 아님 등) self._sell_backoff: Dict[str, float] = {} # 체결 알림에 덧붙일 자산 요약 라인 생성기(주입식). # TradingOrchestrator.start() 가 self._asset_line_for_notify 를 세팅. # signature: (side: str, extra: Optional[Dict]) -> str (여러 줄 가능) self.asset_line_provider: Optional[Callable[..., str]] = None # ------------------------------------------------------------------ # Lock 헬퍼 # ------------------------------------------------------------------ def _lock_for(self, code: str) -> threading.Lock: with self._global_lock: return self._code_locks[code] # ------------------------------------------------------------------ # 실잔고 조회 (캐시) # ------------------------------------------------------------------ def get_broker_holdings(self, force: bool = False) -> Dict[str, Dict]: """ 실계좌 잔고 맵 {code: {qty, avg_price, ...}}. TTL 캐시로 API 폭주 방지. """ now = time.time() with self._holdings_lock: if ( not force and self._holdings_cache is not None and (now - self._holdings_cache_ts) < self._HOLDINGS_CACHE_TTL ): return dict(self._holdings_cache) m = self.client.get_broker_holdings_map() # API 호출 실패 시 빈 dict 반환 → 정책상 "알 수 없음"으로 간주. if m is not None: self._holdings_cache = m self._holdings_cache_ts = now return dict(m) return {} def invalidate_holdings_cache(self) -> None: with self._holdings_lock: self._holdings_cache = None self._holdings_cache_ts = 0.0 # ------------------------------------------------------------------ # 공개 API # ------------------------------------------------------------------ def place(self, req: OrderRequest) -> OrderResult: """전략이 호출하는 유일한 진입점. BUY / SELL 모두 처리.""" side = (req.side or "").upper() if side == "BUY": return self._place_buy(req) if side == "SELL": return self._place_sell(req) return OrderResult(success=False, reason=f"invalid side={req.side}", request=req) # ------------------------------------------------------------------ # 매수 # ------------------------------------------------------------------ def _place_buy(self, req: OrderRequest) -> OrderResult: if req.qty <= 0: return OrderResult(False, reason="qty<=0", request=req) with self._lock_for(req.code): # ── [1] 같은 종목 다른 전략 보유 여부 체크 ───────────────── policy = str(get_env_from_db("STRATEGY_SAME_CODE_POLICY", "block")).lower() if policy == "block": # 다른 전략이 이미 active_trades 에 들고 있으면 차단 blocked = self._is_code_held_by_other_strategy( req.code, req.strategy_id ) if blocked: logger.warning( "%s🚫 [중복종목차단] %s %s → 다른 전략(%s) 보유 중 → 매수 스킵%s", LOG_YELLOW, req.name, req.code, blocked, LOG_RESET, ) return OrderResult(False, reason=f"held_by_{blocked}", request=req) # ── [2] 실계좌 잔고 재검증 (선택적) ──────────────────────── if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_BUY", True): real_map = self.get_broker_holdings() if req.code in real_map and real_map[req.code]["qty"] > 0: logger.warning( "%s🚫 [이미실보유] %s %s × %d주 → 매수 스킵%s", LOG_YELLOW, req.name, req.code, real_map[req.code]["qty"], LOG_RESET, ) return OrderResult(False, reason="already_held_broker", request=req) # ── [3] 주문 전송 ──────────────────────────────────────── ord_no = self.client.buy_market_order(req.code, req.qty) if not ord_no: cd = self.client._last_order_msg_cd or "" m1 = self.client._last_order_msg1 or "" reason = f"order_reject:{cd or m1}" logger.error( "%s❌ [매수주문거부] %s %s: %s%s", LOG_RED, req.name, req.code, m1 or cd, LOG_RESET, ) return OrderResult(False, reason=reason, request=req) # ── [4] DB에 주문 기록 (ODNO PK, 전략단위 UNIQUE) ────────── inserted = self.db.insert_order( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, name=req.name, side="BUY", qty=req.qty, price=req.price_ref, status="SUBMITTED", ) if not inserted: # ORDERS UNIQUE 위반 = "이미 같은 전략·같은 종목·같은 날·같은 방향" # 아주 드문 케이스(동일 시각 중복 호출). 상태 REJECT 로 두고 스킵. logger.warning( "⚠️ [주문 UNIQUE 충돌] strategy=%s code=%s ord_no=%s — 이미 DB 기록 존재", req.strategy_id, req.code, ord_no, ) return OrderResult( False, ord_no=ord_no, reason="duplicate_order_record", request=req, ) # ── [5] 체결 확인 ──────────────────────────────────────── wait_sec = float(get_env_int("ORDER_FILL_WAIT_SEC", 2)) fill = self.client.get_execution_by_odno(ord_no, code=req.code, wait_sec=wait_sec) if fill: filled_qty = fill["filled_qty"] filled_price = fill["avg_price"] self.db.update_order_fill( ord_no=ord_no, filled_qty=filled_qty, filled_avg_price=filled_price, status=("FILLED" if filled_qty == req.qty else "PARTIAL"), ) # 모의서버에서 빈번한 부분체결 감지 → 사용자 인지용 경고 로그 if 0 < filled_qty < req.qty: miss = req.qty - filled_qty logger.warning( "%s⚠️ [매수 부분체결] [%s] %s %s: 주문 %d주 → 체결 %d주 (미체결 %d주, ODNO=%s)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, req.qty, filled_qty, miss, ord_no, LOG_RESET, ) else: # 체결 확인 실패 → 주문 수량/시그널가 기준으로 가정 저장 filled_qty = req.qty filled_price = req.price_ref self.db.update_order_fill( ord_no=ord_no, filled_qty=filled_qty, filled_avg_price=filled_price, status="SUBMITTED", ) # ── [6] active_trades upsert (전략별 독립 row) ──────────── now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S") self.db.upsert_trade({ "code": req.code, "name": req.name, "strategy": req.strategy_id, "avg_buy_price": filled_price, "current_price": filled_price, "stop_price": req.stop_price, "target_price": req.target_price, "max_price": filled_price, "atr_entry": req.atr_entry, "target_qty": filled_qty, "current_qty": filled_qty, "total_invested": filled_price * filled_qty, "status": "HOLDING", "buy_date": now_str, "size_class": req.size_class or "", "entry_features": req.entry_features or {}, }) # 실잔고 캐시 무효화 self.invalidate_holdings_cache() logger.info( "%s✅ [매수체결] [%s] %s %s @ %d원 × %d주 (ODNO=%s)%s", LOG_GREEN, req.strategy_id, req.name, req.code, int(filled_price), filled_qty, ord_no, LOG_RESET, ) # 체결 알림 (매매 루프 내부에서 호출되므로 jitter=False) try: disp = _strategy_display(req.strategy_id) header = ( f"🔷 **[매수체결:{disp}]** {req.name}({req.code})\n" f"{filled_price:,.0f}원 × {filled_qty}주 = {filled_price*filled_qty:,.0f}원\n" f"손절 {req.stop_price:,.0f} / 목표 {req.target_price:,.0f} (ODNO={ord_no})" ) tail = "" if self.asset_line_provider is not None: try: tail = self.asset_line_provider("BUY", None) or "" except Exception as _e: logger.debug("asset_line_provider(BUY) 실패: %s", _e) tail = "" msg = header + ("\n" + tail if tail else "") msg_mm( msg, channel_alias=_strategy_mm_channel(req.strategy_id), jitter=False, ) except Exception: pass return OrderResult( True, ord_no=ord_no, filled_qty=filled_qty, filled_avg_price=filled_price, request=req, ) # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ def _place_sell(self, req: OrderRequest) -> OrderResult: if req.qty <= 0: return OrderResult(False, reason="qty<=0", request=req) with self._lock_for(req.code): # ── [1] 매도 실패 백오프 ─────────────────────────────────── backoff_until = self._sell_backoff.get(req.code, 0.0) if time.time() < backoff_until: remain = int(backoff_until - time.time()) logger.debug( "⏸ [매도백오프] %s(%s) — %d초 남음", req.name, req.code, remain, ) return OrderResult(False, reason="sell_backoff", request=req) # ── [2] 실계좌 잔고 재검증 ───────────────────────────────── real_qty = None if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): real_map = self.get_broker_holdings(force=True) real_row = real_map.get(req.code) real_qty = int((real_row or {}).get("qty", 0)) if real_qty <= 0: # 브로커에 없는데 DB/메모리에만 남은 유령 포지션 → 강제 정리 logger.warning( "%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 active_trades 삭제%s", LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET, ) self.db.delete_active_trade(code=req.code, strategy=req.strategy_id) return OrderResult(False, reason="broker_no_position", request=req) # 실제 보유수량 > 요청수량이면 요청수량만 매도 (다른 전략 몫 보호) sell_qty = min(req.qty, real_qty) if sell_qty < req.qty: # 원인: (1) 모의서버 매수 부분체결 (2) 잔고 반영 지연 # → 실 브로커 수량 기준으로만 매도 (안전) logger.warning( "%s⚠️ [매도수량조정] [%s] %s %s: 요청 %d주 → 실보유 %d주 " "(미체결 매수 %d주 추정 — 모의서버 부분체결 가능)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, req.qty, sell_qty, req.qty - sell_qty, LOG_RESET, ) else: sell_qty = req.qty # ── [3] 주문 전송 ───────────────────────────────────────── ord_no = self.client.sell_market_order(req.code, sell_qty) if not ord_no: cd = self.client._last_sell_msg_cd or "" m1 = self.client._last_sell_msg1 or "" non_biz = {"40100000", "40200000", "APBK0013", "APBK0962", "40910000"} if cd in non_biz or any(k in m1 for k in ("영업일", "장외", "시장")): backoff_sec = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800) self._sell_backoff[req.code] = time.time() + backoff_sec logger.warning( "⏸ [%s(%s)] 매도 실패(%s) → %d초 백오프", req.name, req.code, m1 or cd, backoff_sec, ) elif "잔고" in m1 or "보유" in m1 or "APBK3020" in cd: # 브로커 잔고 없음 → DB 정리 logger.warning( "⚠️ [유령잔고응답] %s %s: 로컬 active_trades 삭제", req.name, req.code, ) self.db.delete_active_trade(code=req.code, strategy=req.strategy_id) return OrderResult(False, reason=f"sell_reject:{cd or m1}", request=req) # ── [4] 주문 기록 ──────────────────────────────────────── self.db.insert_order( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, name=req.name, side="SELL", qty=sell_qty, price=req.price_ref, status="SUBMITTED", ) # ── [5] 체결 확인 ───────────────────────────────────────── wait_sec = float(get_env_int("ORDER_FILL_WAIT_SEC", 2)) fill = self.client.get_execution_by_odno(ord_no, code=req.code, wait_sec=wait_sec) if fill: self.db.update_order_fill( ord_no=ord_no, filled_qty=fill["filled_qty"], filled_avg_price=fill["avg_price"], status="FILLED" if fill["filled_qty"] == sell_qty else "PARTIAL", ) sell_price = fill["avg_price"] filled_qty = fill["filled_qty"] else: # 체결 확인 실패 → 시그널 참고가로 가정 sell_price = req.price_ref or req.buy_price filled_qty = sell_qty self.db.update_order_fill( ord_no=ord_no, filled_qty=filled_qty, filled_avg_price=sell_price, status="SUBMITTED", ) # ── [6] 손익 계산 + active_trades→trade_history 이동 ─────── # 수수료/거래세 반영한 순손익 계산 (전략 공통 규칙 + .cursorrules) fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0 tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0 buy_price = req.buy_price or 0 if buy_price > 0: fees = ( buy_price * filled_qty * fee_rate + sell_price * filled_qty * (fee_rate + tax_rate) ) realized_pnl = (sell_price - buy_price) * filled_qty - fees else: realized_pnl = None # close_trade 내부에서 gross 로 계산 self.db.close_trade( code=req.code, sell_price=sell_price, sell_reason=req.reason or "", strategy=req.strategy_id, realized_pnl_override=realized_pnl, ) self.invalidate_holdings_cache() color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED logger.info( "%s💸 [매도체결] [%s] %s %s × %d주 @ %d원 | 사유=%s (ODNO=%s)%s", color, req.strategy_id, req.name, req.code, filled_qty, int(sell_price), req.reason, ord_no, LOG_RESET, ) try: emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴" pnl_str = f"{realized_pnl:+,.0f}원" if realized_pnl is not None else "-" disp = _strategy_display(req.strategy_id) header = ( f"{emoji} **[매도체결:{disp}]** {req.name}({req.code})\n" f"{sell_price:,.0f}원 × {filled_qty}주 | {req.reason} | " f"수익률 {req.profit_pct*100:+.2f}% (실현 {pnl_str}) (ODNO={ord_no})" ) tail = "" if self.asset_line_provider is not None: try: tail = self.asset_line_provider( "SELL", {"realized_pnl": realized_pnl}, ) or "" except Exception as _e: logger.debug("asset_line_provider(SELL) 실패: %s", _e) tail = "" msg = header + ("\n" + tail if tail else "") msg_mm( msg, channel_alias=_strategy_mm_channel(req.strategy_id), jitter=False, ) except Exception: pass return OrderResult( True, ord_no=ord_no, filled_qty=filled_qty, filled_avg_price=sell_price, request=req, ) # ------------------------------------------------------------------ # 내부 유틸 # ------------------------------------------------------------------ def _is_code_held_by_other_strategy( self, code: str, strategy_id: str ) -> Optional[str]: """ active_trades 에서 code 를 strategy_id 가 아닌 다른 전략이 들고 있는지 확인. 반환: 다른 전략 ID 문자열 (없으면 None). """ try: cursor = self.db.conn.execute( "SELECT strategy FROM active_trades WHERE code=%s", (code,), ) rows = cursor.fetchall() or [] for r in rows: strat = r.get("strategy") if isinstance(r, dict) else r[0] if strat and strat != strategy_id: return strat return None except Exception as e: logger.debug("active_trades 교차 조회 실패(%s): %s", code, e) return None def _strategy_mm_channel(strategy_id: str) -> str: """전략별 MM 채널 alias. 환경변수에 없으면 기본 채널로 폴백.""" if strategy_id.startswith("SCALP"): return str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping")) if strategy_id.startswith("SHORT"): return str(get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock")) return str(get_env_from_db("MATTERMOST_CHANNEL", "stock")) def _strategy_display(strategy_id: str) -> str: """ 알림 문구에 노출되는 전략 라벨. 내부 ID('SHORT' = 꼬리잡기)는 사용자 직관 맞춰 'TAIL' 로 표기. - SCALP → 'SCALP' - SHORT → 'TAIL' - SHORT_ANT_* → 'TAIL_ANT_*' (prefix 교체) - 그 외 → 그대로 반환 """ sid = strategy_id or "" if sid == "SHORT": return "TAIL" if sid.startswith("SHORT"): return "TAIL" + sid[len("SHORT"):] return sid