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:
Your Name
2026-08-28 16:45:26 +09:00
parent ffe84a9375
commit bc2b1b642c
15 changed files with 541 additions and 207 deletions

View 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"

View File

@@ -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:

View 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)