diff --git a/kis_trader/execution/order_intent.py b/kis_trader/execution/order_intent.py new file mode 100644 index 0000000..93ced91 --- /dev/null +++ b/kis_trader/execution/order_intent.py @@ -0,0 +1,40 @@ +"""주문 실행 큐 intent — Signal(판단)과 Execution(place) 분리용.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + +# 우선순위: 숫자 작을수록 먼저 (동순위는 seq FIFO) +PRIO_URGENT_SELL = 0 +PRIO_TICK_SELL = 1 +PRIO_SCAN_SELL = 2 +PRIO_BUY = 3 + + +def resolve_order_priority(side: str, signal: Dict[str, Any], source: str) -> int: + """side/source/reason 으로 큐 우선순위 결정.""" + side_u = str(side or "").upper() + src = str(source or "").strip().lower() + if side_u == "BUY": + return PRIO_BUY + if src in ("halt", "eod", "pending", "risk", "force"): + return PRIO_URGENT_SELL + reason = str((signal or {}).get("reason") or "").lower() + urgent_keys = ("stop", "손절", "eod", "halt", "risk", "긴급", "force", "pending") + if any(k in reason for k in urgent_keys): + return PRIO_URGENT_SELL + if src == "tick": + return PRIO_TICK_SELL + return PRIO_SCAN_SELL + + +@dataclass(order=True) +class OrderIntent: + """AccountOrderWorker 큐 1건 — strategy 참조는 strategy_id 로 Worker 가 조회.""" + + priority: int + seq: int + strategy_id: str + side: str + signal: Dict[str, Any] + source: str = "scan" diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py index 9d4b4a4..285a3c7 100644 --- a/kis_trader/execution/order_manager.py +++ b/kis_trader/execution/order_manager.py @@ -1058,12 +1058,14 @@ class OrderManager: return od = datetime.datetime.now().strftime("%Y-%m-%d") try: + mock_flag = self.db._resolve_is_mock(None) with self.db.conn: self.db.conn.execute( """ UPDATE orders SET qty=%s, filled_qty=%s, status='PARTIAL' WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s + AND is_mock=%s """, ( int(filled_qty), @@ -1072,6 +1074,7 @@ class OrderManager: req.strategy_id, req.code, od, + mock_flag, ), ) except Exception as e: @@ -1323,6 +1326,28 @@ class OrderManager: 매도가능 0 · 잔고맵 없음만으로 ghost_purge(0원) 하지 않는다. """ # ── 잔고 칸 / 시장 미체결 매도가 있으면 유령 아님 ── + try: + from database import TradeDB + cur_mock = int(TradeDB.resolve_kis_is_mock(None)) + at = self.db.conn.execute( + "SELECT is_mock FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1", + (req.code, req.strategy_id), + ).fetchone() + if at is not None: + row_m = dict(at).get("is_mock") + if row_m is None or int(row_m) != cur_mock: + logger.warning( + "%s⏸ [유령보류] [%s] %s %s — is_mock=%s ≠ 현재=%s → ghost_purge 스킵%s", + LOG_YELLOW, req.strategy_id, req.name, req.code, + row_m, cur_mock, LOG_RESET, + ) + return OrderResult( + False, + reason="ghost_blocked:other_account", + request=req, + ) + except Exception as e: + logger.debug("ghost is_mock 가드 스킵: %s", e) try: real_row = (self.get_broker_holdings(force=False) or {}).get(req.code) except Exception: diff --git a/kis_trader/execution/order_worker.py b/kis_trader/execution/order_worker.py new file mode 100644 index 0000000..03a575c --- /dev/null +++ b/kis_trader/execution/order_worker.py @@ -0,0 +1,198 @@ +"""계좌 단일 주문 실행 Worker — 4전략 BUY/SELL intent 를 우선순위 FIFO 로 직렬 place.""" +from __future__ import annotations + +import itertools +import queue +import threading +import time +from typing import Any, Dict, Optional, TYPE_CHECKING + +from ..utils.env import get_env_bool, get_env_int +from ..utils.logger import get_logger +from .order_intent import OrderIntent, resolve_order_priority + +if TYPE_CHECKING: + from ..strategies.base import BaseStrategy + from .order_manager import OrderManager + +logger = get_logger("kis_trader.order_worker") + +_SENTINEL = object() + + +class AccountOrderWorker: + """증권 계좌 1개에 맞춘 주문 대기줄 — 한 번에 place() 1건만.""" + + def __init__(self, order_mgr: "OrderManager") -> None: + self.order_mgr = order_mgr + self._pq: "queue.PriorityQueue[tuple]" = queue.PriorityQueue() + self._seq = itertools.count() + self._strategies: Dict[str, Any] = {} + self._running = False + self._thread: Optional[threading.Thread] = None + self._start_lock = threading.Lock() + self.enqueued_total = 0 + self.processed_total = 0 + self.dropped_total = 0 + + def register_strategy(self, strategy: "BaseStrategy") -> None: + sid = str(getattr(strategy, "strategy_id", "") or "").strip() + if sid: + self._strategies[sid] = strategy + + def start(self) -> None: + with self._start_lock: + if self._thread is not None and self._thread.is_alive(): + return + self._running = True + th = threading.Thread( + target=self._worker_loop, + name="AccountOrderWorker", + daemon=True, + ) + self._thread = th + th.start() + logger.info("▶ [OrderWorker] 계좌 단일 주문 큐 기동") + + def stop(self) -> None: + self._running = False + try: + self._pq.put_nowait((9999, next(self._seq), _SENTINEL)) + except Exception: + pass + th = self._thread + if th is not None: + th.join(timeout=5.0) + + def queue_depth(self) -> int: + try: + return int(self._pq.qsize()) + except Exception: + return 0 + + def enqueue( + self, + strategy: "BaseStrategy", + side: str, + signal: Dict[str, Any], + *, + source: str = "scan", + priority: Optional[int] = None, + ) -> bool: + """intent 큐 적재. False=큐 만료 drop (inflight 는 호출자가 해제).""" + if not self._running: + return False + max_q = max(1, int(get_env_int("ORDER_WORKER_MAX_QUEUE", 500) or 500)) + if self.queue_depth() >= max_q: + self.dropped_total += 1 + code = str((signal or {}).get("code") or "") + logger.warning( + "⚠️ [OrderWorker] 큐 만료 drop [%s] %s %s depth>=%d", + getattr(strategy, "strategy_id", "?"), + side, + code, + max_q, + ) + return False + prio = ( + int(priority) + if priority is not None + else resolve_order_priority(side, signal or {}, source) + ) + intent = OrderIntent( + priority=prio, + seq=next(self._seq), + strategy_id=str(getattr(strategy, "strategy_id", "") or ""), + side=str(side or "").upper(), + signal=dict(signal or {}), + source=str(source or "scan"), + ) + self._pq.put((intent.priority, intent.seq, intent)) + self.enqueued_total += 1 + return True + + def _worker_loop(self) -> None: + while True: + try: + _prio, _seq, item = self._pq.get(timeout=0.3) + except queue.Empty: + if not self._running: + break + continue + if item is _SENTINEL: + break + if not isinstance(item, OrderIntent): + continue + if not self._running: + self._release_inflight_for_intent(item) + continue + self._process_intent(item) + + def _process_intent(self, intent: OrderIntent) -> None: + strat = self._strategies.get(intent.strategy_id) + code = str((intent.signal or {}).get("code") or "").strip() + side = str(intent.side or "").upper() + t0 = time.perf_counter() + try: + if strat is None: + logger.warning( + "⚠️ [OrderWorker] 미등록 전략 %s — skip %s %s", + intent.strategy_id, + side, + code, + ) + return + if side == "SELL" and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): + try: + self.order_mgr.prefetch_broker_holdings() + except Exception: + pass + if side == "BUY": + strat._submit_buy(intent.signal) + elif side == "SELL": + strat._submit_sell(intent.signal) + else: + logger.warning( + "⚠️ [OrderWorker] invalid side=%s [%s] %s", + side, + intent.strategy_id, + code, + ) + except Exception as ex: + logger.warning( + "⚠️ [OrderWorker] place 예외 [%s] %s %s: %s", + intent.strategy_id, + side, + code or "-", + ex, + ) + finally: + self._release_inflight_for_intent(intent) + self.processed_total += 1 + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + if elapsed_ms >= 3000.0: + logger.warning( + "⚠️ [OrderWorker] 처리 지연 [%s] %s %s src=%s wait+place=%.0fms", + intent.strategy_id, + side, + code or "-", + intent.source, + elapsed_ms, + ) + + def _release_inflight_for_intent(self, intent: OrderIntent) -> None: + strat = self._strategies.get(intent.strategy_id) + if strat is None: + return + code = str((intent.signal or {}).get("code") or "").strip() + if not code: + return + side = str(intent.side or "").upper() + if side == "SELL": + release = getattr(strat, "_release_sell_inflight", None) + if callable(release): + release(code) + elif side == "BUY": + release = getattr(strat, "_release_buy_inflight", None) + if callable(release): + release(code) diff --git a/kis_trader/main.py b/kis_trader/main.py index 343c4d8..a5a661c 100644 --- a/kis_trader/main.py +++ b/kis_trader/main.py @@ -70,6 +70,7 @@ from .database.db_manager import get_db from .execution.kis_client import KISClient from .execution.account_cash import AccountCashLedger from .execution.order_manager import OrderManager +from .execution.order_worker import AccountOrderWorker from .network.condition_manager import ConditionSearchManager from .network.kiwoom_condition_manager import KiwoomConditionSearchManager from .network.ls_condition_manager import ( @@ -189,6 +190,7 @@ class TradingOrchestrator: self.order_mgr = OrderManager( client=self.client, db=self.db, cash_ledger=self.cash_ledger, ) + self.order_worker = AccountOrderWorker(self.order_mgr) self.ws = WSManager(db=self.db, kis_client=self.market_client) # 해외(US) 실시간(지연)체결가 수신기 — 국내 WS와 분리된 별도 인스턴스(야간 가동). # permanent_subscriptions 테이블의 US 종목을 HDFSCNT0 로 구독. start()에서 기동. @@ -380,7 +382,7 @@ class TradingOrchestrator: if buy_price > 0 and px > 0: profit_pct = (px - buy_price) / buy_price * 100.0 try: - strat._submit_sell({ + strat._enqueue_sell({ "code": code, "name": (row or {}).get("name") or h.get("name") or code, "qty": qty, @@ -389,7 +391,7 @@ class TradingOrchestrator: "buy_price": buy_price, "profit_pct": profit_pct, "reason": reason, - }) + }, source="halt") except Exception as ex: logger.warning( "⚠️ [리스크버짓] %s %s 청산 실패: %s", sid_u, code, ex, @@ -723,6 +725,10 @@ class TradingOrchestrator: self._register_strategies() # 해외 WS 는 등록 전에 기동됨 → 여기서 US_MOMENTUM 에 핸들 주입 self._attach_overseas_ws_to_strategies() + for strat in self.strategies: + self.order_worker.register_strategy(strat) + strat.order_worker = self.order_worker + self.order_worker.start() for strat in self.strategies: strat.start() # threading.Thread.start() logger.info("▶ 쓰레드 기동: %s", strat.name) @@ -2666,6 +2672,8 @@ class TradingOrchestrator: ) if getattr(ns, "strategy_id", "") == "US_MOMENTUM" and self.overseas_ws: ns.overseas_ws = self.overseas_ws + ns.order_worker = self.order_worker + self.order_worker.register_strategy(ns) ns.start() new_list.append(ns) logger.info("🔁 전략 재기동: %s", ns.name) @@ -2734,6 +2742,10 @@ class TradingOrchestrator: def stop(self) -> None: for s in self.strategies: s.stop_loop() + try: + self.order_worker.stop() + except Exception: + pass for s in self.strategies: try: s.join(timeout=5) @@ -2802,6 +2814,13 @@ class TradingOrchestrator: def main() -> None: + import faulthandler + import signal + faulthandler.enable() + try: + faulthandler.register(signal.SIGUSR1, all_threads=True, chain=False) + except: + pass orch = TradingOrchestrator() try: orch.start() diff --git a/kis_trader/strategies/base.py b/kis_trader/strategies/base.py index 7f6e080..034bc9e 100644 --- a/kis_trader/strategies/base.py +++ b/kis_trader/strategies/base.py @@ -13,7 +13,6 @@ kis_trader/strategies/base.py — 전략 공통 기반 클래스 """ from __future__ import annotations -import queue import random import threading import time @@ -25,7 +24,7 @@ from ..database.db_manager import TradeDBExt from ..execution.kis_client import KISClient from ..execution.order_manager import OrderManager from ..network.ws_manager import WSManager -from ..utils.env import get_env_bool, get_env_from_db, get_env_int +from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger import logging @@ -200,17 +199,17 @@ class BaseStrategy(ABC, threading.Thread): self.daily_profit_halt: Any = None # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 OFF · env 로 ON) - self._sell_lock = threading.Lock() + # 주문 실행은 Orchestrator 의 AccountOrderWorker(계좌 단일 큐) 가 place 직렬화. + self.order_worker: Any = None # main 에서 주입 + self._inflight_lock = threading.Lock() self._tick_sell_last_ts: Dict[str, float] = {} self._tick_sell_listener_on = False - # 종목당 매도 1장 (틱 큐·루프 공유). REST/place 는 워커·루프만. + # 종목당 매도/매수 intent 1장 — enqueue 중복 방지 (Worker 완료 시 해제) self._sell_inflight: set = set() - self._tick_sell_q: Optional[queue.Queue] = None - self._tick_sell_worker: Optional[threading.Thread] = None - self._tick_sell_worker_lock = threading.Lock() + self._buy_inflight: set = set() + self._order_enqueue_skip = 0 # 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED) self._loop_prof_i = 0 - self._tick_sell_lock_miss = 0 self._loop_prof_scan: Optional[Dict[str, Any]] = None # 당일 trade_history — 루프당 1회 DB, 종목 check_buy 는 RAM 필터만 self._today_trades_cache_day: str = "" @@ -232,7 +231,6 @@ class BaseStrategy(ABC, threading.Thread): def stop_loop(self) -> None: """쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출).""" self._running = False - self._stop_tick_sell_worker() self._unregister_tick_sell_listener() def _tick_sell_enabled(self) -> bool: @@ -244,82 +242,80 @@ class BaseStrategy(ABC, threading.Thread): return bool(get_env_bool(sid_key, False)) return bool(get_env_bool("TICK_SELL_ENABLED", False)) - def _tick_sell_async_place(self) -> bool: - """틱 콜백에서 place()/잔고 REST 금지. 기본 ON.""" - return bool(get_env_bool("TICK_SELL_ASYNC_PLACE", True)) - - def _ensure_tick_sell_worker(self) -> None: - with self._tick_sell_worker_lock: - if self._tick_sell_worker is not None and self._tick_sell_worker.is_alive(): - return - if self._tick_sell_q is None: - self._tick_sell_q = queue.Queue() - th = threading.Thread( - target=self._tick_sell_worker_loop, - name="tick-sell-%s" % (self.strategy_id or "BASE"), - daemon=True, - ) - self._tick_sell_worker = th - th.start() - - def _stop_tick_sell_worker(self) -> None: - q = self._tick_sell_q - if q is not None: - try: - q.put_nowait(None) - except Exception: - pass - - def _tick_sell_worker_loop(self) -> None: - q = self._tick_sell_q - if q is None: - return - while True: - try: - item = q.get(timeout=0.3) - except queue.Empty: - if not self._running: - break - continue - if item is None: - break - sig = item if isinstance(item, dict) else {} - code = str(sig.get("code") or "").strip() - try: - self._sell_lock.acquire(blocking=True) - try: - if self._running: - if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): - try: - self.order_mgr.prefetch_broker_holdings() - except Exception: - pass - self._submit_sell(sig) - finally: - if code: - self._sell_inflight.discard(code) - self._sell_lock.release() - except Exception as ex: - self.logger.warning("틱매도 워커 예외 %s: %s", code or "-", ex) - if code: - try: - self._sell_lock.acquire(blocking=True) - try: - self._sell_inflight.discard(code) - finally: - self._sell_lock.release() - except Exception: - self._sell_inflight.discard(code) - def _mark_sell_inflight(self, code: str) -> bool: - """호출자 _sell_lock 보유. True=이 종목 매도 슬롯을 가져감.""" + """True=이 종목 매도 intent 슬롯 확보 (Worker 완료 시 해제).""" code = (code or "").strip() if not code: return False - if code in self._sell_inflight: + with self._inflight_lock: + if code in self._sell_inflight: + return False + self._sell_inflight.add(code) + return True + + def _release_sell_inflight(self, code: str) -> None: + code = (code or "").strip() + if not code: + return + with self._inflight_lock: + self._sell_inflight.discard(code) + + def _mark_buy_inflight(self, code: str) -> bool: + code = (code or "").strip() + if not code: return False - self._sell_inflight.add(code) - return True + with self._inflight_lock: + if code in self._buy_inflight: + return False + self._buy_inflight.add(code) + return True + + def _release_buy_inflight(self, code: str) -> None: + code = (code or "").strip() + if not code: + return + with self._inflight_lock: + self._buy_inflight.discard(code) + + def _enqueue_sell(self, sig: Dict, *, source: str = "scan") -> bool: + """매도 signal → AccountOrderWorker. False=inflight/큐 drop.""" + code = str((sig or {}).get("code") or "").strip() + if not code: + return False + if not self._mark_sell_inflight(code): + return False + ow = getattr(self, "order_worker", None) + if ow is None: + try: + self._submit_sell(sig) + return True + finally: + self._release_sell_inflight(code) + if ow.enqueue(self, "SELL", sig, source=source): + return True + self._release_sell_inflight(code) + self._order_enqueue_skip = int(getattr(self, "_order_enqueue_skip", 0) or 0) + 1 + return False + + def _enqueue_buy(self, sig: Dict, *, source: str = "scan") -> bool: + """매수 signal → AccountOrderWorker.""" + code = str((sig or {}).get("code") or "").strip() + if not code: + return False + if not self._mark_buy_inflight(code): + return False + ow = getattr(self, "order_worker", None) + if ow is None: + try: + self._submit_buy(sig) + return True + finally: + self._release_buy_inflight(code) + if ow.enqueue(self, "BUY", sig, source=source): + return True + self._release_buy_inflight(code) + self._order_enqueue_skip = int(getattr(self, "_order_enqueue_skip", 0) or 0) + 1 + return False def _register_tick_sell_listener(self) -> None: if self._tick_sell_listener_on: @@ -331,11 +327,9 @@ class BaseStrategy(ABC, threading.Thread): ws.register_price_listener(self._on_ws_price_tick) self._tick_sell_listener_on = True if self._tick_sell_enabled(): - self._ensure_tick_sell_worker() self.logger.info( - "📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL) async_place=%s", + "📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL) → AccountOrderWorker", self.strategy_id, - self._tick_sell_async_place(), ) else: self.logger.debug( @@ -356,11 +350,10 @@ class BaseStrategy(ABC, threading.Thread): self._tick_sell_listener_on = False def _on_ws_price_tick(self, code: str, price: float, raw: Any = None) -> None: - """WS 현재가 갱신 → 보유 중이면 기존 check_sell_signals 경로로 매도 검사. + """WS 현재가 갱신 → 보유 중이면 check_sell_signals → OrderWorker enqueue. 매도 규칙은 루프 매도와 동일 함수. 바뀌는 것은 호출 시점(틱)뿐. - 이 함수는 한투·키움·LS **수신 스레드**에서 돈다. - ``TICK_SELL_ASYNC_PLACE``(기본 true) 이면 신호만 큐에 넣고 REST/place 는 워커. + 이 함수는 한투·키움·LS **수신 스레드**에서 돈다 — place/REST 없음. """ if not self._running or not self._tick_sell_enabled(): return @@ -376,40 +369,17 @@ class BaseStrategy(ABC, threading.Thread): if min_ms > 0 and (now - last) * 1000.0 < float(min_ms): return self._tick_sell_last_ts[code] = now - if not self._sell_lock.acquire(blocking=False): - self._tick_sell_lock_miss = int(getattr(self, "_tick_sell_lock_miss", 0) or 0) + 1 - return try: if code not in self.holdings: return - sell_signals = self.check_sell_signals() or [] + sell_signals = self.check_sell_signals(only_code=code) or [] for sig in sell_signals: if (sig.get("code") or "") != code: continue - if not self._mark_sell_inflight(code): + if self._enqueue_sell(sig, source="tick"): break - if self._tick_sell_async_place(): - self._ensure_tick_sell_worker() - try: - self._tick_sell_q.put_nowait(sig) - except Exception as qex: - self._sell_inflight.discard(code) - self.logger.warning("틱매도 큐 실패 %s: %s", code, qex) - break - try: - if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): - try: - self.order_mgr.prefetch_broker_holdings() - except Exception: - pass - self._submit_sell(sig) - finally: - self._sell_inflight.discard(code) - break except Exception as ex: self.logger.debug("틱매도 예외 %s: %s", code, ex) - finally: - self._sell_lock.release() def _loop_profile_on(self) -> bool: """LOOP_PROFILE_ENABLED — 한 바퀴 숙제별 ms 계측 ON/OFF.""" @@ -435,11 +405,8 @@ class BaseStrategy(ABC, threading.Thread): f"sync_merge={row.get('sync_merge_ms', 0):.1f}", f"sync_skip={row.get('sync_skip', 0)}", f"halt={row.get('halt_ms', 0):.1f}", - f"lock_wait={row.get('lock_wait_ms', 0):.1f}", - f"lock_hold={row.get('lock_hold_ms', 0):.1f}", f"sell_chk={row.get('sell_chk_ms', 0):.1f}", - f"prefetch={row.get('prefetch_ms', 0):.1f}", - f"submit_sell={row.get('submit_sell_ms', 0):.1f}", + f"enqueue_sell={row.get('enqueue_sell_ms', 0):.1f}", f"cand={row.get('cand_ms', 0):.1f}", f"cand_load={row.get('cand_load_ms', 0):.1f}", f"cand_mgr={row.get('cand_mgr_ms', 0):.1f}", @@ -482,7 +449,7 @@ class BaseStrategy(ABC, threading.Thread): f"g_hit={row.get('guard_trades_hit', 0)}", f"overlay={row.get('overlay_ms', 0):.1f}", f"sleep={row.get('sleep_ms', 0):.1f}", - f"tick_lock_miss={row.get('tick_lock_miss', 0)}", + f"order_enqueue_skip={row.get('order_enqueue_skip', 0)}", f"sum_parts={row.get('sum_parts_ms', 0):.1f}", ] if row.get("buy_max_code"): @@ -586,45 +553,26 @@ class BaseStrategy(ABC, threading.Thread): except Exception: pass - def _run_sell_section_locked(self) -> Dict[str, float]: - """매도 구간 — _sell_lock blocking. wait/hold/세부 ms 반환.""" + def _run_sell_section(self) -> Dict[str, float]: + """매도 구간 — check_sell_signals 만 (place 는 OrderWorker).""" out = { - "lock_wait_ms": 0.0, - "lock_hold_ms": 0.0, "sell_chk_ms": 0.0, - "prefetch_ms": 0.0, - "submit_sell_ms": 0.0, + "enqueue_sell_ms": 0.0, } - t_wait0 = time.perf_counter() - self._sell_lock.acquire(blocking=True) - out["lock_wait_ms"] = (time.perf_counter() - t_wait0) * 1000.0 - t_hold0 = time.perf_counter() + t0 = time.perf_counter() try: - t0 = time.perf_counter() sell_signals = self.check_sell_signals() or [] - out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0 - if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): - t1 = time.perf_counter() - try: - self.order_mgr.prefetch_broker_holdings() - except Exception: - pass - out["prefetch_ms"] = (time.perf_counter() - t1) * 1000.0 - t2 = time.perf_counter() - for sig in sell_signals: - sc = str(sig.get("code") or "").strip() - if not sc: - continue - if not self._mark_sell_inflight(sc): - continue - try: - self._submit_sell(sig) - finally: - self._sell_inflight.discard(sc) - out["submit_sell_ms"] = (time.perf_counter() - t2) * 1000.0 - finally: - out["lock_hold_ms"] = (time.perf_counter() - t_hold0) * 1000.0 - self._sell_lock.release() + except Exception as ex: + self.logger.debug("매도체크 예외: %s", ex) + sell_signals = [] + out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0 + t1 = time.perf_counter() + for sig in sell_signals: + sc = str(sig.get("code") or "").strip() + if not sc: + continue + self._enqueue_sell(sig, source="scan") + out["enqueue_sell_ms"] = (time.perf_counter() - t1) * 1000.0 return out def _ws_last_quote(self, code: str) -> Optional[dict]: @@ -724,7 +672,6 @@ class BaseStrategy(ABC, threading.Thread): except Exception as e: self.logger.exception("전략 루프 예외: %s", e) finally: - self._stop_tick_sell_worker() self._unregister_tick_sell_listener() self.logger.info("⏹ 전략 쓰레드 종료 [%s]", self.strategy_id) @@ -744,7 +691,9 @@ class BaseStrategy(ABC, threading.Thread): ) row: Dict[str, Any] = {} t_loop0 = time.perf_counter() if do_prof else 0.0 - miss0 = int(getattr(self, "_tick_sell_lock_miss", 0) or 0) + miss0 = int(getattr(self, "_order_enqueue_skip", 0) or 0) + self._loop_iter_start = time.time() + self._set_loop_phase("start") now = dt.now() today = now.strftime("%Y-%m-%d") @@ -797,12 +746,14 @@ class BaseStrategy(ABC, threading.Thread): self._prof_slot_cache_miss = 0 self._prof_guard_acc = {} self.reload_config() + self._set_loop_phase("reload") if do_prof: row["reload_ms"] = (time.perf_counter() - t0) * 1000.0 # 보유 = 이벤트 RAM + 안전망 DB sync(기본 60초). 매수/매도 체결은 RAM 즉시 갱신. t0 = time.perf_counter() if do_prof else 0.0 self._sync_holdings_from_db() + self._set_loop_phase("sync_hold") if do_prof: row["sync_hold_ms"] = (time.perf_counter() - t0) * 1000.0 row["sync_db_ms"] = float(self._prof_sync_db_ms) @@ -812,7 +763,8 @@ class BaseStrategy(ABC, threading.Thread): # 전략 ON/OFF 핫게이트 — WS 구독 해제 없음. 보유 청산만 유지. if not self._strategy_switch_enabled(): if self.holdings: - self._run_sell_section_locked() + self._run_sell_section() + self._set_loop_phase("switch_off_sleep") time.sleep(self._scan_sleep("loop")) continue @@ -824,11 +776,13 @@ class BaseStrategy(ABC, threading.Thread): guard.maybe_trim_open_risk(self.strategy_id) except Exception as ex: self.logger.debug("일일익절 리스크버짓 예외: %s", ex) + self._set_loop_phase("halt") if do_prof: row["halt_ms"] = (time.perf_counter() - t0) * 1000.0 # ── [1] 매도 먼저 ──────────────────────────────── - sell_timings = self._run_sell_section_locked() + sell_timings = self._run_sell_section() + self._set_loop_phase("sell_done") if do_prof: row.update(sell_timings) @@ -837,6 +791,7 @@ class BaseStrategy(ABC, threading.Thread): t_c0 = time.perf_counter() candidates = self._load_candidates() self._prof_cand_load_ms = (time.perf_counter() - t_c0) * 1000.0 + self._set_loop_phase("candidates") # 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록 t_n0 = time.perf_counter() self._note_candidate_enters(candidates) @@ -856,12 +811,14 @@ class BaseStrategy(ABC, threading.Thread): # US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용 t0 = time.perf_counter() if do_prof else 0.0 self._sync_ws_for_loop(cand_codes, hold_codes) + self._set_loop_phase("ws_sync") if do_prof: row["ws_sync_ms"] = (time.perf_counter() - t0) * 1000.0 # ── [2b] 미체결 지정가 만료 취소 ─────────────────── t0 = time.perf_counter() if do_prof else 0.0 self.manage_pending_orders() + self._set_loop_phase("scan") if do_prof: row["pending_ms"] = (time.perf_counter() - t0) * 1000.0 @@ -872,17 +829,8 @@ class BaseStrategy(ABC, threading.Thread): t0 = time.perf_counter() if do_prof else 0.0 if candidates and active_cnt < max_stocks and self.check_buy_allowed(): self._scan_and_buy(candidates, max_stocks, active_cnt) - elif candidates and active_cnt >= max_stocks: - # 보유만석이면 _scan_and_buy 미진입 → 매수체크 로그가 안 나와 "멈춘 것"처럼 보임 - now_m = time.time() - last_m = float(getattr(self, "_last_full_skip_log_ts", 0) or 0) - if now_m - last_m >= 60.0: - self._last_full_skip_log_ts = now_m - self.logger.info( - "🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s", - active_cnt, max_stocks, - ",".join(list(self.holdings.keys())[:12]), - ) + else: + self._log_buy_section_gate(candidates, active_cnt, max_stocks) if do_prof: row["scan_ms"] = (time.perf_counter() - t0) * 1000.0 sc = getattr(self, "_loop_prof_scan", None) or {} @@ -932,17 +880,16 @@ class BaseStrategy(ABC, threading.Thread): if do_prof: row["sleep_ms"] = (time.perf_counter() - t0) * 1000.0 row["total_ms"] = (time.perf_counter() - t_loop0) * 1000.0 - row["tick_lock_miss"] = int( - getattr(self, "_tick_sell_lock_miss", 0) or 0 + row["order_enqueue_skip"] = int( + getattr(self, "_order_enqueue_skip", 0) or 0 ) - miss0 # 숙제 합( sleep 제외 ) — total 과 비교해 미계측 구간 파악 part_keys = ( "reload_ms", "sync_hold_ms", "halt_ms", - "lock_wait_ms", "lock_hold_ms", + "sell_chk_ms", "enqueue_sell_ms", "cand_ms", "ws_sync_ms", "pending_ms", "scan_ms", "overlay_ms", ) - # lock_hold 안에 sell_chk/prefetch/submit 포함 → 합산 시 hold만 row["sum_parts_ms"] = sum(float(row.get(k, 0) or 0) for k in part_keys) self._loop_profile_emit(row) @@ -954,12 +901,7 @@ class BaseStrategy(ABC, threading.Thread): time.sleep(5) def _sync_ws_for_loop(self, cand_codes: List[str], hold_codes: List[str]) -> None: - """후보·보유 WS 구독 동기화 — 해외 전략은 오버라이드. - - ``ls_condition``: - - LS US3 = 틱·현재가 (히스토리와 한 묶음) - - 키움 = 갭보정·분봉 (기존 잘 되는 경로) - """ + """후보·보유 WS 구독 동기화 — reconcile 은 WSManager 백그라운드 워커.""" ls_feed = str(getattr(self, "universe_source", "") or "") == "ls_condition" self.ws.sync_targets_split( self.strategy_id, cand_codes, hold_codes, ls_feed=ls_feed, @@ -1058,6 +1000,93 @@ class BaseStrategy(ABC, threading.Thread): invest_cap=invest_cap, ) + def _loop_diag_enabled(self) -> bool: + try: + return bool(get_env_bool("STRATEGY_LOOP_DIAG_ENABLED", True)) + except Exception: + return True + + def _loop_stall_sec(self) -> float: + try: + return max(0.0, float(get_env_float("STRATEGY_LOOP_STALL_SEC", 30.0) or 0.0)) + except Exception: + return 30.0 + + def _set_loop_phase(self, phase: str) -> None: + self._loop_phase = str(phase or "") + if self._loop_diag_enabled(): + self._maybe_log_loop_stall() + + def _maybe_log_loop_stall(self) -> None: + start = float(getattr(self, "_loop_iter_start", 0) or 0) + if start <= 0: + return + elapsed = time.time() - start + stall = self._loop_stall_sec() + if stall <= 0 or elapsed < stall: + return + now_m = time.time() + last = float(getattr(self, "_loop_stall_log_ts", 0) or 0) + if now_m - last < 60.0: + return + self._loop_stall_log_ts = now_m + self.logger.warning( + "⚠️ [루프 지연] %s phase=%s elapsed=%.0fs holdings=%d order_enqueue_skip=%d", + self.strategy_id, + getattr(self, "_loop_phase", "?"), + elapsed, + len(self.holdings), + int(getattr(self, "_order_enqueue_skip", 0) or 0), + ) + + def _log_buy_section_gate( + self, + candidates: List[Dict], + active_cnt: int, + max_stocks: int, + ) -> None: + """매수체크 미진입 사유 — 60초 rate limit (후보0·만석·매수허용OFF).""" + if not self._loop_diag_enabled(): + return + now_m = time.time() + last = float(getattr(self, "_last_buy_gate_log_ts", 0) or 0) + first_iter = int(getattr(self, "_loop_prof_i", 0) or 0) <= 1 + if not first_iter and now_m - last < 60.0: + return + self._last_buy_gate_log_ts = now_m + + if active_cnt >= max_stocks: + self.logger.info( + "🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s", + active_cnt, + max_stocks, + ",".join(list(self.holdings.keys())[:12]), + ) + return + + if not candidates: + detail = f"src={self.universe_source}" + if self.universe_source == "kiwoom_condition": + mgr = self.kiwoom_condition_mgr + if mgr is None: + detail += " kiwoom_condition_mgr=None(핸들갱신 필요)" + elif not self._is_strategy_registered(mgr): + detail += " 키움매니저미등록" + else: + try: + univ_n = len(mgr.get_universe_for(self.strategy_id) or []) + detail += f" universe={univ_n}" + except Exception: + pass + self.logger.info("🔍 [매수체크 스킵] 후보0 %s", detail) + return + + if not self.check_buy_allowed(): + self.logger.info( + "🔍 [매수체크 스킵] 매수허용=False " + "(장외·TIME_END·EOD·LS복구게이트·PANIC)" + ) + def _scan_and_buy(self, candidates: List[Dict], max_stocks: int, active_cnt: int) -> None: if self._live_portfolio_budget_full(max_stocks): now_ts = time.time() @@ -1108,18 +1137,15 @@ class BaseStrategy(ABC, threading.Thread): for c in (candidates or []) ] _codes = [c for c in _codes if c] - _tick_p, _ob_p = _live_feed_providers() if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"): self.logger.info( - "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d) codes=%s", - _tick_p, _ob_p, + "🔍 [매수체크] 후보 %d (보유 %d/%d) codes=%s", len(candidates), active_cnt, max_stocks, ",".join(_codes[:12]), ) else: self.logger.info( - "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d)", - _tick_p, _ob_p, + "🔍 [매수체크] 후보 %d (보유 %d/%d)", len(candidates), active_cnt, max_stocks, ) prof_scan = self._loop_profile_on() @@ -1262,15 +1288,14 @@ class BaseStrategy(ABC, threading.Thread): sleep_rej_ms += (time.perf_counter() - t_sl) * 1000.0 continue - result = self._submit_buy(signal) - if result and result.success: + if self._enqueue_buy(signal, source="scan"): if prof_scan: t_sl = time.perf_counter() time.sleep(self._scan_sleep("buy_ok")) if prof_scan: sleep_ok_ms += (time.perf_counter() - t_sl) * 1000.0 self._loop_prof_scan = _snap_scan() - return # 1루프당 1매수 (포지션 과집중 방지) + return # 1루프당 1매수 intent (포지션 과집중 방지) if prof_scan: t_sl = time.perf_counter() time.sleep(self._scan_sleep("buy_fail")) @@ -1924,8 +1949,8 @@ class BaseStrategy(ABC, threading.Thread): raise NotImplementedError @abstractmethod - def check_sell_signals(self) -> List[Dict]: - """보유 종목 순회 → 매도 시그널 리스트.""" + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: + """보유 종목 순회 → 매도 시그널 리스트. only_code=틱매도 1종목만.""" raise NotImplementedError def _candidate_filter(self, candidate: Dict) -> bool: diff --git a/kis_trader/strategies/breakout.py b/kis_trader/strategies/breakout.py index f36f11f..a17a13e 100644 --- a/kis_trader/strategies/breakout.py +++ b/kis_trader/strategies/breakout.py @@ -1942,7 +1942,7 @@ class BreakoutStrategy(BaseStrategy): # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings: return [] @@ -1958,6 +1958,8 @@ class BreakoutStrategy(BaseStrategy): signals: List[Dict] = [] for code, holding in list(self.holdings.items()): + if only_code and code != only_code: + continue try: name = holding.get("name", code) buy_price = float(holding.get("buy_price", 0)) diff --git a/kis_trader/strategies/dart_strategy.py b/kis_trader/strategies/dart_strategy.py index 4280840..0cda840 100644 --- a/kis_trader/strategies/dart_strategy.py +++ b/kis_trader/strategies/dart_strategy.py @@ -197,7 +197,7 @@ class DartStrategy(BaseStrategy): self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e) return None - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings: return [] signals: List[Dict] = [] diff --git a/kis_trader/strategies/dbband_strategy.py b/kis_trader/strategies/dbband_strategy.py index e9b149e..7646d37 100644 --- a/kis_trader/strategies/dbband_strategy.py +++ b/kis_trader/strategies/dbband_strategy.py @@ -163,7 +163,7 @@ class DbBandStrategy(BaseStrategy): self.logger.error("DBBAND check_buy 오류 %s: %s", code, e) return None - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings or bbe is None: return [] diff --git a/kis_trader/strategies/momentum.py b/kis_trader/strategies/momentum.py index a0cea90..6b3855d 100644 --- a/kis_trader/strategies/momentum.py +++ b/kis_trader/strategies/momentum.py @@ -272,7 +272,7 @@ class MomentumStrategy(BaseStrategy): "entry_features": {}, } - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings: return [] @@ -284,9 +284,15 @@ class MomentumStrategy(BaseStrategy): now, default_hm="15:20", ) - params_base = dict(self._engine_params or me.get_momentum_defaults_from_db()) + cached = getattr(self, "_engine_params", None) + if cached: + params_base = dict(cached) + else: + params_base = dict(me.get_momentum_defaults_from_db()) for code, holding in list(self.holdings.items()): + if only_code and code != only_code: + continue try: name = holding.get("name", code) buy_price = float(holding.get("buy_price", 0)) diff --git a/kis_trader/strategies/range_break.py b/kis_trader/strategies/range_break.py index 47ac518..21e9a41 100644 --- a/kis_trader/strategies/range_break.py +++ b/kis_trader/strategies/range_break.py @@ -210,7 +210,7 @@ class RangeBreakStrategy(BaseStrategy): "entry_features": {}, } - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings: return [] diff --git a/kis_trader/strategies/scalping.py b/kis_trader/strategies/scalping.py index 2c6a4aa..7380f3c 100644 --- a/kis_trader/strategies/scalping.py +++ b/kis_trader/strategies/scalping.py @@ -331,7 +331,7 @@ class ScalpingStrategy(BaseStrategy): # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: """엔진 check_sell_signal_live 사용 (백테스트 동일).""" if not self.holdings: return [] @@ -348,7 +348,11 @@ class ScalpingStrategy(BaseStrategy): ) try: - params = se.get_scalping_defaults_from_db() + cached = getattr(self, "_scan_engine_params", None) or {} + if cached: + params = dict(cached) + else: + params = se.get_scalping_defaults_from_db() except Exception: params = {} params.update({ @@ -370,6 +374,8 @@ class ScalpingStrategy(BaseStrategy): }) for code, holding in list(self.holdings.items()): + if only_code and code != only_code: + continue try: name = holding.get("name", code) buy_price = float(holding.get("buy_price", 0)) diff --git a/kis_trader/strategies/tail_catch.py b/kis_trader/strategies/tail_catch.py index 3b6fe1a..7e6fd55 100644 --- a/kis_trader/strategies/tail_catch.py +++ b/kis_trader/strategies/tail_catch.py @@ -459,7 +459,7 @@ class TailCatchStrategy(BaseStrategy): # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings or te is None: return [] @@ -477,6 +477,8 @@ class TailCatchStrategy(BaseStrategy): signals: List[Dict] = [] for code, holding in list(self.holdings.items()): + if only_code and code != only_code: + continue try: name = holding.get("name", code) buy_price = float(holding.get("buy_price", 0)) diff --git a/kis_trader/strategies/updow_strategy.py b/kis_trader/strategies/updow_strategy.py index 4adf3ed..b332774 100644 --- a/kis_trader/strategies/updow_strategy.py +++ b/kis_trader/strategies/updow_strategy.py @@ -667,7 +667,7 @@ class UpdowStrategy(BaseStrategy): }, } - def check_sell_signals(self) -> List[Dict]: + def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings: return [] diff --git a/kis_trader/strategies/us_momentum.py b/kis_trader/strategies/us_momentum.py index d8e35f6..c11e030 100644 --- a/kis_trader/strategies/us_momentum.py +++ b/kis_trader/strategies/us_momentum.py @@ -833,7 +833,7 @@ class UsMomentumStrategy(MomentumStrategy): self.logger.error("paper holdings sync 실패: %s", e) - def check_sell_signals(self): + def check_sell_signals(self, only_code: Optional[str] = None): """국내 매도 엔진 + 종목별 stock_cfg 오버레이 + 해외 WS 가격.""" if not self.holdings: return [] diff --git a/scripts/test_live_execution_validation.py b/scripts/test_live_execution_validation.py index 06b1945..e4a0cbe 100755 --- a/scripts/test_live_execution_validation.py +++ b/scripts/test_live_execution_validation.py @@ -746,7 +746,7 @@ def run_validation() -> bool: else: _성공("LS 3차 폴백 구독 _sync_feed_fallback_to_ls") - rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions) + rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions_locked) if "_finalize_ls_subscriptions" not in rec_src: 실패목록.append("split reconcile LS finalize 없음") _실패("_reconcile_split_subscriptions 가 _finalize_ls_subscriptions 를 안 부름") @@ -890,7 +890,7 @@ def run_validation() -> bool: _성공("후보 LS 갭 REST 는 LS_GAP_FILL_CANDIDATES 가드") # 갭 집합에 perm 합집합 금지 (후보|보유만) - recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions) + recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions_locked) if "_gap_refill_codes = set(kis_want) | set(kw_want) | perm" in recon_src: 실패목록.append("갭보정에 영구구독 포함") _실패("_gap_refill_codes 가 영구구독(perm)을 포함함 — ls_ws_candles 전용이어야 함") @@ -1219,27 +1219,38 @@ def run_validation() -> bool: _파일필수( 실패목록, - "kis_trader/strategies/base.py", - "TICK_SELL_ASYNC_PLACE", - "틱매도 place 비동기(수신스레드 REST 금지)", + "kis_trader/execution/order_worker.py", + "AccountOrderWorker", + "계좌 단일 주문 큐 Worker", ) _파일필수( 실패목록, "kis_trader/strategies/base.py", - "_tick_sell_worker_loop", - "틱매도 워커", + "_enqueue_sell", + "매도 signal → OrderWorker", + ) + _파일필수( + 실패목록, + "kis_trader/strategies/base.py", + "_enqueue_buy", + "매수 signal → OrderWorker", ) _파일필수( 실패목록, "kis_trader/strategies/base.py", "_sell_inflight", - "틱·루프 종목당 매도 1장", + "틱·루프 종목당 매도 intent 1장", ) + if "ORDER_WORKER_MAX_QUEUE" not in ENV_CONFIG_KEYS: + 실패목록.append("ENV 누락 ORDER_WORKER_MAX_QUEUE") + _실패("ENV_CONFIG_KEYS 에 ORDER_WORKER_MAX_QUEUE 없음") + else: + _성공("ORDER_WORKER_MAX_QUEUE env 등록") if "TICK_SELL_ASYNC_PLACE" not in ENV_CONFIG_KEYS: 실패목록.append("ENV 누락 TICK_SELL_ASYNC_PLACE") _실패("ENV_CONFIG_KEYS 에 TICK_SELL_ASYNC_PLACE 없음") else: - _성공("TICK_SELL_ASYNC_PLACE env 등록") + _성공("TICK_SELL_ASYNC_PLACE env 등록 (레거시·틱 place는 OrderWorker 경유)") _소스필수( 실패목록, KISWebSocketPriceCache._get_approval_key,