feat(execution): AccountOrderWorker로 매수·매도 주문 직렬화
전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다. 틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
198
kis_trader/execution/order_worker.py
Normal file
198
kis_trader/execution/order_worker.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user