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:
40
kis_trader/execution/order_intent.py
Normal file
40
kis_trader/execution/order_intent.py
Normal file
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
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