""" kis_trader/execution/order_manager.py — Master Executor ======================================================== 두 전략(스캘핑/꼬리잡기)이 공유하는 "단 하나의 주문 실행자". 설계 목적: * 전략은 **시그널만 생성**한다. 주문 실행은 전부 여기서 직렬화 처리. * 매수: ``active_trades (code, strategy)`` 복합키로 전략별 qty 관리 — 타 전략/실계좌 보유와 무관하게 추가 매수 가능. * 매도 전 실계좌 잔고 재확인 → 요청 qty 만큼만 매도 (``min(요청, 실보유)``). (DB·메모리 정리까지 한 번에). * 주문번호(ODNO) 기준 UNIQUE 제약으로 **서버단 중복 차단**. 정책 (env_config 로 제어): * ``STRATEGY_SAME_CODE_POLICY`` - ``allow`` (기본): 전략별 active_trades·ODNO 분리 — 다른 전략 보유와 무관하게 매수 허용. - ``block`` : 한 종목은 한 전략만 (다른 전략 active_trades 보유 시 차단). * ``REAL_BALANCE_VERIFY_BEFORE_SELL`` (기본 True): 매도 전 실잔고 조회. * ``BROKER_HOLDINGS_CACHE_TTL_SEC`` (기본 5): 잔고 캐시 TTL — 매도 루프 내 N종목 공유. * ``GHOST_POSITION_COOLDOWN_SEC`` (기본 300): 유령잔고 정리 후 동일 (전략,종목) 재시도 쿨다운. * ``REAL_BALANCE_VERIFY_BEFORE_BUY`` (기본 False): 매수 전 검증 (기본 OFF — 전략별 복합키가 주 관리). * ``REAL_BALANCE_VERIFY_BEFORE_BUY_MODE`` — ``strategy``(기본): 동일 전략 DB 보유 시만 차단 / ``global``(레거시): 실계좌에 1주라도 있으면 차단. * ``STRICT_FILL_VERIFY`` (기본 False): 모의에서 true 시 실전과 동일하게 fill 미확인 시 active_trades 미반영·heartbeat 재조회. **실전은 항상 엄격**. * ``PENDING_FILL_POLL_INTERVAL_SEC`` / ``PENDING_BUY_MAX_AGE_SEC`` / ``PENDING_SELL_MAX_AGE_SEC`` / ``PENDING_SELL_STOP_MAX_AGE_SEC`` — 미체결 재조회·만료. * ``PENDING_POLL_BATCH_FETCH`` (기본 True): heartbeat ``poll_pending_fills`` 에서 당일 체결을 1 REST 로 일괄 조회 (실매 전용 · 백테 무관). * ``DUPLICATE_ORDER_FILL_RECOVERY_ENABLED`` (기본 True): insert 실패(주문DB중복) 시 ``inquire-daily-ccld`` 1회로 체결 복구 → active_trades 반영. * ``DUPLICATE_ORDER_RECOVERY_WAIT_SEC`` (기본 ORDER_FILL_WAIT_SEC): 중복복구 체결 대기. * ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문. * ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만 ``ORDER_CASH_PCT`` 로 수량 축소 (매수체크 루프에서는 REST 미호출). """ from __future__ import annotations import datetime import json 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_float, get_env_from_db, get_env_int from ..utils.stock_name import resolve_stock_display_name from ..utils.logger import ( LOG_CYAN, LOG_GREEN, LOG_RED, LOG_RESET, LOG_YELLOW, get_logger, msg_mm_strategy, ) from .account_cash import AccountCashLedger from .kis_client import KISClient from .orderbook_sell import ( evaluate_take_profit_limit_sell, is_profit_take_sell_reason, is_urgent_market_sell_reason, resolve_orderbook_raw, ) 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 use_limit_buy: bool = False # 매도 시 계산 결과 전달 (로그용) 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 호출 횟수 절감. """ def __init__( self, *, client: KISClient, db: TradeDBExt, cash_ledger: Optional[AccountCashLedger] = None, ): self.client = client self.db = db self.cash_ledger: Optional[AccountCashLedger] = cash_ledger # 종목별 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._holdings_last_fetch_ok: bool = True # 유령잔고 정리 쿨다운 — (strategy_id, code) → epoch self._ghost_purged_at: Dict[tuple, float] = {} # 매도 실패 백오프 (영업일 아님 등) 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 # TradingOrchestrator.start() 가 self._strategy_daily_pnl_for_notify 를 세팅. # signature: (strategy_id: str) -> (당일 실현손익, 청산건수) | None self.strategy_pnl_provider: Optional[Callable[..., tuple]] = None # 호가 조회 — WSManager.get_orderbook 등 (키움 0D 캐시 우선) self.orderbook_provider: Optional[Callable[..., Optional[dict]]] = None # ------------------------------------------------------------------ # Lock 헬퍼 # ------------------------------------------------------------------ def _lock_for(self, code: str) -> threading.Lock: with self._global_lock: return self._code_locks[code] # ------------------------------------------------------------------ # 실잔고 조회 (캐시) # ------------------------------------------------------------------ def _holdings_cache_ttl(self) -> float: return float(get_env_int("BROKER_HOLDINGS_CACHE_TTL_SEC", 5)) def get_broker_holdings(self, force: bool = False) -> Dict[str, Dict]: """ 실계좌 잔고 맵 {code: {qty, avg_price, ...}}. TTL 캐시로 API 폭주 방지. API 실패 시 stale 캐시가 있으면 그걸 반환. ``_holdings_last_fetch_ok`` 로 최신 조회 성공 여부 확인. """ now = time.time() ttl = self._holdings_cache_ttl() with self._holdings_lock: if ( not force and self._holdings_cache is not None and (now - self._holdings_cache_ts) < ttl ): self._holdings_last_fetch_ok = True return dict(self._holdings_cache) m = self.client.get_broker_holdings_map() if m is None: self._holdings_last_fetch_ok = False if self._holdings_cache is not None: logger.debug( "⏸ [잔고캐시] API 실패 → stale 캐시 사용 (age=%.1fs)", now - self._holdings_cache_ts, ) return dict(self._holdings_cache) return {} self._holdings_last_fetch_ok = True self._holdings_cache = m self._holdings_cache_ts = now return dict(m) def prefetch_broker_holdings(self) -> bool: """ 전략 매도 루프 시작 전 1회 호출. 동일 루프에서 N종목 매도 시 inquire-balance 중복 호출을 줄인다. """ self.get_broker_holdings(force=True) return self._holdings_last_fetch_ok def invalidate_holdings_cache(self) -> None: with self._holdings_lock: self._holdings_cache = None self._holdings_cache_ts = 0.0 def _resolve_order_display_name(self, req: OrderRequest) -> str: """MM·DB·로그용 종목명 — code=이름이면 DB/잔고에서 보완.""" fb = str(req.name or req.code or "").strip() if fb and fb != req.code: return fb try: holdings = self.get_broker_holdings(force=False) except Exception: holdings = None resolved = resolve_stock_display_name( self.db, req.code, fb, holdings_map=holdings, ) if resolved and resolved != req.code: req.name = resolved return req.name or req.code # ------------------------------------------------------------------ # 체결 검증 (실전 항상 엄격 / 모의는 STRICT_FILL_VERIFY) # ------------------------------------------------------------------ def _strict_fill_required(self) -> bool: """실전은 fill 미확인 시 가정 체결 금지. 모의는 env 로 훈련 모드 전환.""" if not self.client.mock: return True return get_env_bool("STRICT_FILL_VERIFY", False) def _buy_pending_meta(self, req: OrderRequest) -> str: """재시작·heartbeat 재조회용 매수 메타 (orders.raw_json).""" return json.dumps({ "strategy_id": req.strategy_id, "code": req.code, "name": req.name, "qty": req.qty, "price_ref": req.price_ref, "stop_price": req.stop_price, "target_price": req.target_price, "atr_entry": req.atr_entry, "size_class": req.size_class, "entry_features": req.entry_features or {}, "use_limit_buy": req.use_limit_buy, }, ensure_ascii=False) def _sell_pending_meta(self, req: OrderRequest) -> str: return json.dumps({ "strategy_id": req.strategy_id, "code": req.code, "name": req.name, "qty": req.qty, "price_ref": req.price_ref, "reason": req.reason, "buy_price": req.buy_price, "profit_pct": req.profit_pct, }, ensure_ascii=False) def _req_from_order_row(self, row: Dict) -> Optional[OrderRequest]: """orders 행 → OrderRequest (raw_json 우선).""" side = str(row.get("side") or "").upper() raw = row.get("raw_json") if raw: try: d = json.loads(raw) if side == "SELL": return OrderRequest( strategy_id=d.get("strategy_id") or row.get("strategy_id", ""), code=d.get("code") or row.get("code", ""), name=d.get("name") or row.get("name", ""), side="SELL", qty=int(d.get("qty") or row.get("qty") or 0), price_ref=float(d.get("price_ref") or row.get("price") or 0), reason=str(d.get("reason") or ""), buy_price=float(d.get("buy_price") or 0), profit_pct=float(d.get("profit_pct") or 0), ) return OrderRequest( strategy_id=d.get("strategy_id") or row.get("strategy_id", ""), code=d.get("code") or row.get("code", ""), name=d.get("name") or row.get("name", ""), side="BUY", qty=int(d.get("qty") or row.get("qty") or 0), price_ref=float(d.get("price_ref") or row.get("price") or 0), stop_price=float(d.get("stop_price") or 0), target_price=float(d.get("target_price") or 0), atr_entry=float(d.get("atr_entry") or 0), size_class=d.get("size_class"), entry_features=d.get("entry_features") or {}, use_limit_buy=bool(d.get("use_limit_buy")), ) except Exception as e: logger.debug("raw_json 파싱 실패 ord_no=%s: %s", row.get("ord_no"), e) qty = int(row.get("qty") or 0) if qty <= 0: return None if side == "SELL": return OrderRequest( strategy_id=str(row.get("strategy_id") or ""), code=str(row.get("code") or ""), name=str(row.get("name") or ""), side="SELL", qty=qty, price_ref=float(row.get("price") or 0), ) return OrderRequest( strategy_id=str(row.get("strategy_id") or ""), code=str(row.get("code") or ""), name=str(row.get("name") or ""), side="BUY", qty=qty, price_ref=float(row.get("price") or 0), ) def _order_age_sec(self, row: Dict) -> float: """submitted_at 기준 경과 초.""" submitted = str(row.get("submitted_at") or "").strip() if not submitted: return 0.0 try: ts = dt.strptime(submitted, "%Y-%m-%d %H:%M:%S") return max(0.0, time.time() - ts.timestamp()) except Exception: return 0.0 def _pending_buy_max_age_sec(self) -> float: legacy = get_env_int("PENDING_FILL_MAX_AGE_SEC", 120) return float(get_env_int("PENDING_BUY_MAX_AGE_SEC", legacy)) def _pending_sell_max_age_sec(self, reason: str) -> float: if is_urgent_market_sell_reason(reason): return float(get_env_int("PENDING_SELL_STOP_MAX_AGE_SEC", 15)) return float(get_env_int("PENDING_SELL_MAX_AGE_SEC", 60)) def _submit_market_sell_and_confirm( self, req: OrderRequest, sell_qty: int ) -> OrderResult: """시장가 매도 접수 + 체결 확인 (손절·만료 재주문용).""" 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 "" return OrderResult( False, reason=f"sell_reject:{cd or m1}", request=req, ) 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", raw_json=self._sell_pending_meta(req), ) 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 and int(fill.get("filled_qty", 0) or 0) > 0: return self._finalize_sell_fill( req, ord_no, int(fill["filled_qty"]), float(fill["avg_price"]), sell_qty, ) if self._strict_fill_required(): self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, status="PENDING_FILL", ) return OrderResult( False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) sell_price = float(req.price_ref or req.buy_price or 0) if sell_price <= 0: return OrderResult( False, ord_no=ord_no, reason="zero_sell_fill", request=req, ) return self._finalize_sell_fill( req, ord_no, sell_qty, sell_price, sell_qty, ) def _escalate_urgent_sell_market( self, req: OrderRequest, sell_qty: int, *, tag: str, ) -> None: """손절·장마감 등 — 만료 취소 직후 시장가 재매도.""" if sell_qty <= 0: return if not get_env_bool("SELL_PENDING_REORDER_ON_EXPIRE", True): return if not is_urgent_market_sell_reason(req.reason or ""): return logger.warning( "%s🔁 [%s] [%s] %s %s — %d주 시장가 재매도%s", LOG_YELLOW, tag, req.strategy_id, req.name, req.code, sell_qty, LOG_RESET, ) with self._lock_for(req.code): if self.db.get_pending_sell_order(req.strategy_id, req.code): logger.debug( "escalate skip — pending sell exists %s/%s", req.strategy_id, req.code, ) return self._submit_market_sell_and_confirm(req, sell_qty) def _try_cancel_buy_remainder( self, ord_no: str, code: str, remain_qty: int, *, use_limit: bool, ) -> None: """부분체결 잔량 취소 (IOC 시장가는 브로커가 자동 취소).""" if remain_qty <= 0: return if not get_env_bool("AUTO_CANCEL_PARTIAL_BUY_REMAINDER", True): return if not use_limit and get_env_bool("USE_MARKET_IOC", True): return try: ok = self.client.cancel_order(ord_no, qty=remain_qty) if ok: logger.info( "🛑 [매수잔량취소] %s ODNO=%s 잔량 %d주", code, ord_no, remain_qty, ) except Exception as e: logger.warning("매수 잔량 취소 실패 %s ord_no=%s: %s", code, ord_no, e) def _try_recover_duplicate_buy_fill( self, req: OrderRequest, ord_no: str, buy_qty: int, ) -> Optional[OrderResult]: """ insert_order 실패(주문DB중복) 시 브로커 체결 1회 조회 → active_trades 복구. DUPLICATE_ORDER_FILL_RECOVERY_ENABLED=false 이면 None (호출부에서 실패 반환). """ if not get_env_bool("DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", True): return None from ..utils.strategy_ids import canonical_strategy_id sid = canonical_strategy_id(req.strategy_id) existing = self.db.get_order_by_odno( ord_no, strategy_id=req.strategy_id, code=req.code, ) if existing: ex_filled = int(existing.get("filled_qty") or 0) ex_status = str(existing.get("status") or "").upper() if ex_filled > 0 and ex_status in ("FILLED", "PARTIAL", "SUBMITTED"): at_qty = 0 try: row = self.db.conn.execute( "SELECT current_qty FROM active_trades " "WHERE code=%s AND strategy=%s", (req.code, sid), ).fetchone() if row: at_qty = int(float( row.get("current_qty") if isinstance(row, dict) else row[0] ) or 0) except Exception as e: logger.debug("체결복구 active_trades 조회 실패 %s: %s", req.code, e) if at_qty > 0: logger.info( "🔄 [체결복구스킵] %s %s — orders/active_trades 이미 반영 (qty=%d)", req.code, ord_no, at_qty, ) return OrderResult( True, ord_no=ord_no, filled_qty=ex_filled, filled_avg_price=float( existing.get("filled_avg_price") or req.price_ref ), reason="duplicate_already_finalized", request=req, ) if req.use_limit_buy: wait_sec = float(get_env_int("LIMIT_ORDER_FILL_WAIT_SEC", 1)) else: wait_sec = float(get_env_int( "DUPLICATE_ORDER_RECOVERY_WAIT_SEC", 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 not fill or int(fill.get("filled_qty", 0) or 0) <= 0: logger.warning( "%s⚠️ [체결복구실패] insert 실패 + 브로커 미체결 %s %s ODNO=%s%s", LOG_YELLOW, req.name, req.code, ord_no, LOG_RESET, ) return None filled_qty = int(fill["filled_qty"]) filled_price = float(fill["avg_price"]) if 0 < filled_qty < buy_qty: miss = buy_qty - filled_qty logger.warning( "%s⚠️ [체결복구·부분체결] [%s] %s %s: %d/%d주%s", LOG_YELLOW, req.strategy_id, req.name, req.code, filled_qty, buy_qty, LOG_RESET, ) self._try_cancel_buy_remainder( ord_no, req.code, miss, use_limit=req.use_limit_buy, ) logger.warning( "%s🔄 [체결복구] insert 실패했으나 브로커 체결 확인 — " "[%s] %s %s × %d주%s", LOG_CYAN, req.strategy_id, req.name, req.code, filled_qty, LOG_RESET, ) return self._finalize_buy_fill( req, ord_no, filled_qty, filled_price, buy_qty, log_tag="체결복구", ) def _finalize_buy_fill( self, req: OrderRequest, ord_no: str, filled_qty: int, filled_price: float, order_qty: int, *, log_tag: str = "매수체결", ) -> OrderResult: """체결 확정 후 active_trades·알림 반영.""" if filled_qty <= 0 or filled_price <= 0: return OrderResult(False, ord_no=ord_no, reason="zero_fill", request=req) self._resolve_order_display_name(req) status = "FILLED" if filled_qty >= order_qty else "PARTIAL" self.db.update_order_fill( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, filled_qty=filled_qty, filled_avg_price=filled_price, status=status, ) now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S") from ..utils.strategy_ids import canonical_strategy_id self.db.upsert_trade({ "code": req.code, "name": req.name, "strategy": canonical_strategy_id(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() if self.cash_ledger is not None: fee_buf = max(1.0, get_env_float("ORDER_CASH_FEE_BUFFER", 1.01)) self.cash_ledger.apply_trade_delta( -filled_qty * filled_price * fee_buf, source="trade_delta", ) logger.info( "%s✅ [%s] [%s] %s %s @ %d원 × %d주 (ODNO=%s)%s", LOG_GREEN, log_tag, req.strategy_id, req.name, req.code, int(filled_price), filled_qty, ord_no, LOG_RESET, ) try: disp = _strategy_display(req.strategy_id) header = ( f"🔷 **[{log_tag}:{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}\n" f"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) msg = header + ("\n\n" + tail if tail else "") msg_mm_strategy( msg, _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 _finalize_sell_fill( self, req: OrderRequest, ord_no: str, filled_qty: int, sell_price: float, order_qty: int, ) -> OrderResult: """매도 체결 확정 후 close_trade·알림.""" if filled_qty <= 0 or sell_price <= 0: return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req) self._resolve_order_display_name(req) status = "FILLED" if filled_qty >= order_qty else "PARTIAL" self.db.update_order_fill( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, filled_qty=filled_qty, filled_avg_price=sell_price, status=status, ) 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 if req.name and req.name != req.code: try: from ..utils.strategy_ids import canonical_strategy_id sid = canonical_strategy_id(req.strategy_id) with self.db.conn: self.db.conn.execute( "UPDATE active_trades SET name=%s WHERE code=%s AND strategy=%s", (req.name, req.code, sid), ) except Exception as exc: logger.debug("active_trades name 보정 실패(%s): %s", req.code, exc) # close_trade 전에 매수시각 확보 (보유구간 봉 백필용) buy_date_for_bf = None try: from ..utils.strategy_ids import canonical_strategy_id _sid_bf = canonical_strategy_id(req.strategy_id) _ar = self.db.conn.execute( "SELECT buy_date FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1", (req.code, _sid_bf), ).fetchone() if _ar: buy_date_for_bf = dict(_ar).get("buy_date") except Exception: buy_date_for_bf = None 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, ) # 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회 if buy_date_for_bf: try: from kis_trader.engine.post_sell_candle_backfill import ( schedule_post_sell_backfill, ) schedule_post_sell_backfill( code=req.code, buy_date=buy_date_for_bf, sell_date=None, strategy=str(req.strategy_id or ""), ) except Exception as _bf_e: logger.debug("post-sell candle backfill schedule 스킵: %s", _bf_e) self.invalidate_holdings_cache() if self.cash_ledger is not None: gross = filled_qty * sell_price net = gross * (1.0 - fee_rate - tax_rate) self.cash_ledger.apply_trade_delta(net, source="trade_delta") 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}주\n" f"{req.reason} · 수익률 {req.profit_pct*100:+.2f}%\n" f"실현 {pnl_str} · ODNO={ord_no}" ) if self.strategy_pnl_provider is not None: try: _sp = self.strategy_pnl_provider(req.strategy_id) if _sp is not None: _spnl, _scnt = _sp header += ( f"\n📊 {disp} 당일 {_spnl:+,.0f}원 · 청산 {_scnt}건" ) except Exception as _e: logger.debug("strategy_pnl_provider(SELL) 실패: %s", _e) 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) msg = header + ("\n\n" + tail if tail else "") msg_mm_strategy( msg, _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 _lookup_fill( self, ord_no: str, code: str, *, fill_map: Optional[Dict[str, Dict]] = None, wait_sec: float = 0.0, ) -> Optional[Dict]: """체결 맵 우선, 없으면 건별 inquire-daily-ccld.""" key = str(ord_no or "").strip() if fill_map is not None: hit = fill_map.get(key) if hit: row_code = str(hit.get("code") or "").strip() if code and row_code and row_code != str(code).strip(): return None return { "filled_qty": int(hit["filled_qty"]), "avg_price": float(hit["avg_price"]), } return None return self.client.get_execution_by_odno( key, code=code, wait_sec=wait_sec, ) def poll_pending_fills(self) -> int: """ heartbeat — 미확인·부분체결 주문 재조회. 처리(체결 반영 또는 만료 취소) 건수 반환. """ rows = self.db.get_pending_fill_orders() if not rows: return 0 wait_sec = float(get_env_int("PENDING_FILL_POLL_SEC", 1)) handled = 0 fill_map: Optional[Dict[str, Dict]] = None batch_ok = False if get_env_bool("PENDING_POLL_BATCH_FETCH", True): fill_map = self.client.get_today_execution_map(wait_sec=wait_sec) batch_ok = fill_map is not None if batch_ok: logger.debug( "poll_pending_fills 일괄체결조회 %d건 pending / map %d ODNO", len(rows), len(fill_map or {}), ) else: logger.debug( "poll_pending_fills 일괄체결조회 실패 → 건별 폴백 (%d건)", len(rows), ) fill_map = None for row in rows: ord_no = str(row.get("ord_no") or "").strip() if not ord_no: continue req = self._req_from_order_row(row) if req is None: continue order_qty = int(row.get("qty") or req.qty or 0) prev_filled = int(row.get("filled_qty") or 0) side = str(row.get("side") or req.side or "").upper() age = self._order_age_sec(row) if side == "BUY": max_age = self._pending_buy_max_age_sec() else: max_age = self._pending_sell_max_age_sec(req.reason or "") row_wait = 0.0 if batch_ok else wait_sec fill = self._lookup_fill( ord_no, req.code, fill_map=fill_map if batch_ok else None, wait_sec=row_wait, ) if fill and int(fill.get("filled_qty", 0) or 0) > 0: filled_qty = int(fill["filled_qty"]) avg_price = float(fill["avg_price"]) if side == "BUY": if 0 < filled_qty < order_qty: miss = order_qty - filled_qty logger.warning( "%s⚠️ [매수 부분체결·폴링] [%s] %s %s: " "주문 %d주 → 체결 %d주 (미체결 %d주)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, order_qty, filled_qty, miss, LOG_RESET, ) self._try_cancel_buy_remainder( ord_no, req.code, miss, use_limit=bool(getattr(req, "use_limit_buy", False)), ) self._finalize_buy_fill( req, ord_no, filled_qty, avg_price, order_qty, log_tag="매수체결(재조회)", ) else: self._finalize_sell_fill( req, ord_no, filled_qty, avg_price, order_qty, ) handled += 1 continue if age < max_age: continue # 만료 — 미체결 또는 부분체결 잔량 정리 remain = max(0, order_qty - prev_filled) if remain > 0: try: self.client.cancel_order(ord_no, qty=remain) except Exception as e: logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e) if prev_filled <= 0: self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, status="CANCELLED", ) logger.warning( "%s⏱ [체결만료] %s %s ODNO=%s — 미체결 취소 (%.0fs)%s", LOG_YELLOW, req.name, req.code, ord_no, age, LOG_RESET, ) handled += 1 if side == "SELL": self._escalate_urgent_sell_market( req, order_qty, tag="만료재손절", ) elif side == "BUY" and prev_filled > 0: self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, status="PARTIAL", ) logger.warning( "%s⏱ [부분체결만료] %s %s ODNO=%s — 잔량 %d주 취소%s", LOG_YELLOW, req.name, req.code, ord_no, remain, LOG_RESET, ) handled += 1 return handled def _purge_ghost_position(self, req: OrderRequest, log_tag: str) -> OrderResult: """ 브로커 0주인데 로컬만 남은 포지션 정리. DB 삭제 + 전략 holdings.pop 트리거(extra.purge_holdings). 동일 (전략, 종목) 은 GHOST_POSITION_COOLDOWN_SEC 동안 재로그·재API 방지. """ key = (req.strategy_id, req.code) cooldown_sec = get_env_int("GHOST_POSITION_COOLDOWN_SEC", 300) now = time.time() last = self._ghost_purged_at.get(key, 0.0) if last > 0 and (now - last) < cooldown_sec: remain = int(cooldown_sec - (now - last)) logger.debug( "⏸ [유령쿨다운] [%s] %s %s — %d초 내 재처리 스킵 (%s)", req.strategy_id, req.name, req.code, remain, log_tag, ) return OrderResult( False, reason="ghost_cooldown", request=req, ) self._ghost_purged_at[key] = now if log_tag == "broker_response": logger.warning( "%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 active_trades 삭제%s", LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET, ) else: 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) self.invalidate_holdings_cache() return OrderResult( False, reason="broker_no_position", request=req, extra={"purge_holdings": True}, ) # ------------------------------------------------------------------ # 공개 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", "allow")).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] 매수 전 검증 (선택) ───────────────────────────────── # ※ 계좌 잔고는 종목당 통짜이지만, 봇은 (code, strategy) + ODNO 로 전략별 qty 를 관리. # 타 전략·수동(MANUAL) 보유가 있어도 추가 매수 가능. 매도는 요청 qty 만 청산. if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_BUY", False): mode = str( get_env_from_db("REAL_BALANCE_VERIFY_BEFORE_BUY_MODE", "strategy") ).lower() if mode == "global": real_map = self.get_broker_holdings() if req.code in real_map and real_map[req.code]["qty"] > 0: logger.warning( "%s🚫 [이미실보유-global] %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, ) elif mode == "strategy": try: row = self.db.conn.execute( "SELECT current_qty FROM active_trades " "WHERE code=%s AND strategy=%s", (req.code, req.strategy_id), ).fetchone() db_qty = 0 if row: db_qty = int(float( row.get("current_qty") if isinstance(row, dict) else row[0] ) or 0) if db_qty > 0: logger.warning( "%s🚫 [이미전략보유] [%s] %s %s DB %d주 → 매수 스킵%s", LOG_YELLOW, req.strategy_id, req.name, req.code, db_qty, LOG_RESET, ) return OrderResult( False, reason="already_held_strategy", request=req, ) except Exception as e: logger.debug("매수 전 전략 보유 조회 실패(%s): %s", req.code, e) # ── [2a] 동일 종목 미체결 매수 있으면 중복 주문 방지 ─────── # 체결확인 API(inquire-daily-ccld) 장애로 PENDING_FILL 이 쌓이면, # 전략 루프가 같은 종목을 매 턴(10초) 재주문하는 폭주가 발생한다. # → 미체결 매수 1건이라도 있으면 스킵. 만료 시 poll 이 CANCELLED 처리해 자동 해제. if get_env_bool("BUY_DEDUP_PENDING", True): pending_buy = self.db.get_pending_buy_order(req.strategy_id, req.code) if pending_buy: pend_no = str(pending_buy.get("ord_no") or "") logger.info( "%s⏸ [매수대기중] [%s] %s %s — 기존 ODNO=%s 체결확인 대기 (재주문 스킵)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, pend_no, LOG_RESET, ) return OrderResult( False, ord_no=pend_no or None, reason="buy_pending_dup", request=req, ) # ── [2b] 예수금 부족 시에만 qty 축소 (기존 전략 산식 우선) ───── buy_qty = req.qty if self.cash_ledger is not None: buy_qty, cash_reason = self.cash_ledger.clamp_buy_qty_if_insufficient( req.qty, req.price_ref, req.code, req.name, req.strategy_id, ) if buy_qty <= 0: return OrderResult( False, reason=cash_reason or "insufficient_cash", request=req, ) # ── [3] 주문 전송 (지정가 / 시장가) ───────────────────── if req.use_limit_buy and req.price_ref > 0: limit_px = int(req.price_ref) ord_no = self.client.buy_limit_order(req.code, buy_qty, limit_px) if ord_no: logger.info( "📤 [지정가매수] %s %s × %d주 @ %s원", req.name, req.code, buy_qty, f"{limit_px:,}", ) else: ord_no = self.client.buy_market_order(req.code, buy_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=buy_qty, price=req.price_ref, status="SUBMITTED", raw_json=self._buy_pending_meta(req), ) if not inserted: # ord_no PK 중복(동일 ODNO 재전송) — 브로커 주문은 이미 나갔을 수 있음 logger.error( "%s❌ [주문DB중복] strategy=%s %s ord_no=%s — " "브로커 체결 여부 확인 필요 (active_trades 미반영 가능)%s", LOG_RED, req.strategy_id, req.code, ord_no, LOG_RESET, ) recovered = self._try_recover_duplicate_buy_fill( req, ord_no, buy_qty, ) if recovered is not None: return recovered return OrderResult( False, ord_no=ord_no, reason="duplicate_order_record", request=req, ) # ── [5] 체결 확인 ──────────────────────────────────────── if req.use_limit_buy: wait_sec = float(get_env_int("LIMIT_ORDER_FILL_WAIT_SEC", 1)) else: 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) filled_qty = 0 filled_price = 0.0 if fill and int(fill.get("filled_qty", 0) or 0) > 0: filled_qty = int(fill["filled_qty"]) filled_price = float(fill["avg_price"]) if 0 < filled_qty < buy_qty: miss = buy_qty - filled_qty logger.warning( "%s⚠️ [매수 부분체결] [%s] %s %s: 주문 %d주 → 체결 %d주 (미체결 %d주, ODNO=%s)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, buy_qty, filled_qty, miss, ord_no, LOG_RESET, ) self._try_cancel_buy_remainder( ord_no, req.code, miss, use_limit=req.use_limit_buy, ) elif req.use_limit_buy: return OrderResult( success=True, ord_no=ord_no, filled_qty=0, filled_avg_price=0.0, reason="limit_pending", request=req, ) elif self._strict_fill_required(): self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, status="PENDING_FILL", ) logger.warning( "%s⏳ [매수체결대기] [%s] %s %s ODNO=%s — fill 미확인, heartbeat 재조회%s", LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET, ) return OrderResult( False, ord_no=ord_no, reason="fill_pending", request=req, ) else: # 레거시 모의: 체결 미확인 시 참고가로 가정 체결 filled_qty = buy_qty filled_price = req.price_ref self.db.update_order_fill( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, filled_qty=filled_qty, filled_avg_price=filled_price, status="SUBMITTED", ) if filled_qty <= 0: return OrderResult( False, ord_no=ord_no, reason="zero_fill", request=req, ) # ── [6] active_trades upsert (전략별 독립 row) ──────────── return self._finalize_buy_fill( req, ord_no, filled_qty, filled_price, buy_qty, ) def try_finalize_limit_buy(self, req: OrderRequest, ord_no: str) -> OrderResult: """미체결 지정가 — 체결됐으면 active_trades 반영.""" odno = str(ord_no or "").strip() if not odno or req.qty <= 0: return OrderResult(False, reason="invalid_limit_finalize", request=req) wait_sec = float(get_env_int("LIMIT_ORDER_FILL_POLL_SEC", 1)) fill = self.client.get_execution_by_odno(odno, code=req.code, wait_sec=wait_sec) if not fill or int(fill.get("filled_qty", 0) or 0) <= 0: return OrderResult(False, reason="not_filled_yet", request=req) filled_qty = int(fill["filled_qty"]) filled_price = float(fill["avg_price"]) if 0 < filled_qty < req.qty: miss = req.qty - filled_qty logger.warning( "%s⚠️ [지정가 부분체결] [%s] %s %s: %d/%d주%s", LOG_YELLOW, req.strategy_id, req.name, req.code, filled_qty, req.qty, LOG_RESET, ) self._try_cancel_buy_remainder( odno, req.code, miss, use_limit=True, ) return self._finalize_buy_fill( req, odno, filled_qty, filled_price, req.qty, log_tag="지정가체결", ) # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ 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] 실계좌 잔고 재검증 ───────────────────────────────── # force=True 는 전략 루프 시작 시 prefetch_broker_holdings() 1회만. # 여기서는 TTL 캐시 공유 → N종목 매도 시 inquire-balance 폭주 방지. real_qty = None if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): real_map = self.get_broker_holdings(force=False) if not self._holdings_last_fetch_ok: logger.warning( "%s⏸ [매도보류] [%s] %s %s — 잔고 API 불가 (유령정리 스킵)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET, ) return OrderResult(False, reason="balance_unavailable", request=req) real_row = real_map.get(req.code) real_qty = int((real_row or {}).get("qty", 0)) if real_qty <= 0: return self._purge_ghost_position(req, "broker_zero") # 실제 보유수량 > 요청수량이면 요청수량만 매도 (다른 전략 몫 보호) 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 # ── [2b] 동일 종목 미체결 매도 있으면 중복 주문 방지 ─────── pending_sell = self.db.get_pending_sell_order(req.strategy_id, req.code) if pending_sell: pend_no = str(pending_sell.get("ord_no") or "") logger.info( "%s⏸ [매도대기중] [%s] %s %s — 기존 ODNO=%s (poll 재조회)%s", LOG_YELLOW, req.strategy_id, req.name, req.code, pend_no, LOG_RESET, ) return OrderResult( False, ord_no=pend_no or None, reason="sell_order_pending", request=req, ) # ── [3] 주문 전송 (익절·수익청산만 호가 지정가, 손절·긴급은 시장가) ── limit_price = 0 use_limit = False if ( get_env_bool("SELL_USE_ORDERBOOK_ON_PROFIT", True) and is_profit_take_sell_reason(req.reason or "") ): ob_raw = None ob_src = "none" if self.orderbook_provider: try: ob_raw, ob_src = resolve_orderbook_raw( req.code, ws_get=self.orderbook_provider, ) except Exception: ob_raw = None if not ob_raw: ob_raw, ob_src = resolve_orderbook_raw( req.code, rest_client=self.client, ) ok_ob, limit_price, ob_msg = evaluate_take_profit_limit_sell( sell_qty, ob_raw, ) if ok_ob and limit_price > 0: use_limit = True elif ob_msg == "orderbook_thin": logger.info( "%s⏸ [익절대기] %s %s — 매수호가 잔량 부족(%s), 다음 틱 재시도%s", LOG_YELLOW, req.name, req.code, ob_src, LOG_RESET, ) return OrderResult(False, reason="orderbook_thin", request=req) if use_limit: ord_no = self.client.sell_limit_order(req.code, sell_qty, limit_price) logger.info( "📤 [익절지정가] %s %s × %d주 @ %s원 (매수1호가)", req.name, req.code, sell_qty, f"{limit_price:,}", ) else: 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: return self._purge_ghost_position(req, "broker_response") 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", raw_json=self._sell_pending_meta(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) sell_price = 0.0 filled_qty = 0 if fill and int(fill.get("filled_qty", 0) or 0) > 0: filled_qty = int(fill["filled_qty"]) sell_price = float(fill["avg_price"]) elif self._strict_fill_required(): self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, status="PENDING_FILL", ) logger.warning( "%s⏳ [매도체결대기] [%s] %s %s ODNO=%s — fill 미확인%s", LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET, ) return OrderResult( False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) else: # 레거시 모의: 참고가로 가정 체결 sell_price = req.price_ref or req.buy_price filled_qty = sell_qty self.db.update_order_fill( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, filled_qty=filled_qty, filled_avg_price=sell_price, status="SUBMITTED", ) # 익절 지정가 미체결 잔량 → 시장가로 잔여 청산 (손절은 처음부터 시장가) if use_limit and fill and int(fill.get("filled_qty", 0) or 0) < sell_qty: remain = sell_qty - int(fill["filled_qty"]) logger.warning( "%s⚠️ [익절지정가 부분체결] %s 잔여 %d주 시장가 보완%s", LOG_YELLOW, req.code, remain, LOG_RESET, ) mkt_no = self.client.sell_market_order(req.code, remain) if mkt_no: mfill = self.client.get_execution_by_odno( mkt_no, code=req.code, wait_sec=wait_sec, ) if mfill: add_q = int(mfill.get("filled_qty", 0) or 0) add_p = float(mfill.get("avg_price", 0) or 0) if add_q > 0 and add_p > 0: prev_q = int(fill["filled_qty"]) prev_p = float(fill["avg_price"]) filled_qty = prev_q + add_q sell_price = ( (prev_p * prev_q + add_p * add_q) / filled_qty if filled_qty > 0 else add_p ) fill = {"filled_qty": filled_qty, "avg_price": sell_price} self.db.update_order_fill( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, filled_qty=filled_qty, filled_avg_price=sell_price, status="FILLED" if filled_qty >= sell_qty else "PARTIAL", ) elif use_limit and not fill: logger.warning( "%s⚠️ [익절지정가 미확인] %s 시장가 재시도%s", LOG_YELLOW, req.code, LOG_RESET, ) ord_no2 = self.client.sell_market_order(req.code, sell_qty) if ord_no2: fill = self.client.get_execution_by_odno( ord_no2, code=req.code, wait_sec=wait_sec, ) if fill: sell_price = float(fill["avg_price"]) filled_qty = int(fill["filled_qty"]) if filled_qty <= 0 or sell_price <= 0: if self._strict_fill_required(): self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, status="PENDING_FILL", ) return OrderResult( False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req) # ── [6] 손익 계산 + active_trades→trade_history 이동 ─────── return self._finalize_sell_fill( req, ord_no, filled_qty, sell_price, sell_qty, ) # ------------------------------------------------------------------ # 내부 유틸 # ------------------------------------------------------------------ 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 (config_*). 없으면 MATTERMOST_CHANNEL(통합) 폴백.""" sid = (strategy_id or "").upper() if sid.startswith("SCALP"): return str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping") or "scalping") if sid.startswith("SHORT"): return str(get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock") if sid.startswith("MOMENTUM"): return str(get_env_from_db("KIS_MOMENTUM_MM_CHANNEL", "stock") or "stock") if sid.startswith("BREAKOUT"): return str(get_env_from_db("KIS_BREAKOUT_MM_CHANNEL", "stock") or "stock") if sid.startswith("UPDOW"): return str(get_env_from_db("KIS_UPDOW_MM_CHANNEL", "kis_updown") or "kis_updown") return str(get_env_from_db("MATTERMOST_CHANNEL", "stock") or "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