feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가

변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -6,9 +6,9 @@ kis_trader/strategies/base.py — 전략 공통 기반 클래스
stop() → self._running = False + join
루프 안에서 하는 일:
1. 장 시간 체크 (check_market_status)
1. 장 세션 체크 (check_market_status — 매도·EOD 포함, 정규장 마감까지)
2. 보유 종목(active_trades where strategy=self.strategy_id) 로드 → 매도 시그널 체크
3. target_candidates 중 본인 정책에 맞는 후보 순회 → 매수 시그널 체크
3. check_buy_allowed() 통과 시 후보 순회 → 매수 시그널 체크 ({SID}_TIME_END = 매수 종료만)
4. 시그널 발생 시 OrderManager.place() 로 집중 (실제 주문은 OrderManager 내부에서 종목Lock+ODNO+실잔고검증)
"""
from __future__ import annotations
@@ -18,7 +18,7 @@ import threading
import time
from abc import ABC, abstractmethod
from datetime import datetime as dt
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Any, Tuple
from ..database.db_manager import TradeDBExt
from ..execution.kis_client import KISClient
@@ -76,13 +76,13 @@ class BaseStrategy(ABC, threading.Thread):
loop_min_sleep: float = 1.0
loop_max_sleep: float = 2.0
# 전략별 유니버스 소스 기본값 (env 미설정 시)
# SCALP, SHORT → 거래량 랭킹 REST (VolumeRankManager)
# BREAKOUT → HTS 조건검색 (ConditionSearchManager)
# 전략별 유니버스 소스 기본값 (env 미설정 시) — HTS 조건검색 단일 정책
DEFAULT_UNIVERSE_SOURCES: Dict[str, str] = {
"SCALP": "ranking",
"SHORT": "ranking",
"BREAKOUT": "condition",
"SCALP": "condition",
"SHORT": "kiwoom_condition", # 키움 tail(A 시가대비+F 저가회복) WS 실시간
"BREAKOUT": "kiwoom_condition",
"MOMENTUM": "kiwoom_condition", # 키움 WS 실시간 조건 (KIS REST 폴링 대비 유니버스 품질↑)
"UPDOW": "condition",
}
def __init__(
@@ -94,6 +94,7 @@ class BaseStrategy(ABC, threading.Thread):
order_mgr: OrderManager,
condition_mgr=None,
ranking_mgr=None,
kiwoom_condition_mgr=None,
market_guard=None,
):
super().__init__(daemon=True, name=f"Strat-{self.strategy_id}")
@@ -101,21 +102,23 @@ class BaseStrategy(ABC, threading.Thread):
self.client = client
self.ws = ws
self.order_mgr = order_mgr
self.condition_mgr = condition_mgr # ConditionSearchManager (선택)
self.condition_mgr = condition_mgr # ConditionSearchManager (KIS REST, 선택)
self.ranking_mgr = ranking_mgr # VolumeRankManager (선택)
# KiwoomConditionSearchManager (키움 WS 실시간 조건검색, 선택). KIS 와 별개 소스.
self.kiwoom_condition_mgr = kiwoom_condition_mgr
self.market_guard = market_guard # MarketGuard (선택, None 이면 가드 없음)
self.logger = get_logger(f"kis_trader.strategy.{self.strategy_id}")
# MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회)
self._panic_log_ts: float = 0.0
# 유니버스 소스: "ranking" | "condition"
# 유니버스 소스: "ranking" | "condition"(KIS) | "kiwoom_condition"(키움 WS)
# env: {STRATEGY_ID}_UNIVERSE_SOURCE — 런타임에 스위치 가능.
key = f"{self.strategy_id}_UNIVERSE_SOURCE"
default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
self.universe_source = (
(get_env_from_db(key, default) or default).strip().lower()
)
if self.universe_source not in ("ranking", "condition"):
if self.universe_source not in ("ranking", "condition", "kiwoom_condition"):
self.logger.warning(
"알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용",
self.universe_source, default,
@@ -130,6 +133,9 @@ class BaseStrategy(ABC, threading.Thread):
# 당일 매매불가 종목 (다음 후보로 넘어감)
self.untradable_skip: set = set()
# 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF)
self.daily_profit_halt: Any = None
self._load_holdings_from_db()
# ------------------------------------------------------------------
@@ -139,6 +145,47 @@ class BaseStrategy(ABC, threading.Thread):
"""쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출)."""
self._running = False
# ------------------------------------------------------------------
# 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
# ------------------------------------------------------------------
# REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
# 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.
def _scan_sleep(self, kind: str) -> float:
"""kind: 'loop'(루프끝) | 'reject'(탈락) | 'buy_ok'(매수성공) | 'buy_fail'(주문실패)."""
from ..utils.env import get_env_float
if kind == "loop":
lo = get_env_float("STRATEGY_LOOP_SLEEP_MIN", self.loop_min_sleep)
hi = get_env_float("STRATEGY_LOOP_SLEEP_MAX", self.loop_max_sleep)
elif kind == "reject":
lo = get_env_float("SCAN_REJECT_SLEEP_MIN", 0.2)
hi = get_env_float("SCAN_REJECT_SLEEP_MAX", 0.5)
elif kind == "buy_ok":
lo = get_env_float("SCAN_BUY_OK_SLEEP_MIN", 1.0)
hi = get_env_float("SCAN_BUY_OK_SLEEP_MAX", 2.0)
else: # buy_fail
lo = get_env_float("SCAN_BUY_FAIL_SLEEP_MIN", 0.3)
hi = get_env_float("SCAN_BUY_FAIL_SLEEP_MAX", 0.8)
lo = max(0.0, lo)
hi = max(lo, hi)
return random.uniform(lo, hi)
# ------------------------------------------------------------------
# 하락매수(dip) 종목 제외 — 대형주에 떨어지는 칼날 잡기 방지
# ------------------------------------------------------------------
def is_dip_buy_excluded(self, code: str) -> bool:
"""
하락매수 계열(UPDOW·SHORT) 이 매수하면 안 되는 종목인지 판정.
- ``DIP_BUY_EXCLUDE_CODES`` : 콤마구분 종목코드 화이트리스트 제외 (기본 빈 값 → 무효)
예) 삼성전자·하이닉스 등 대형 주도주는 하락매수가 아니라 추세추종 대상.
기본값이 비어 있으면 기존 동작과 100% 동일 (필터 OFF).
"""
raw = str(get_env_from_db("DIP_BUY_EXCLUDE_CODES", "") or "").strip()
if not raw:
return False
code = str(code or "").strip()
excluded = {c.strip() for c in raw.split(",") if c.strip()}
return code in excluded
def run(self) -> None:
"""threading.Thread.run() 오버라이드 — 전략 메인 루프."""
self._running = True
@@ -190,23 +237,28 @@ class BaseStrategy(ABC, threading.Thread):
# ── [1] 매도 먼저 ────────────────────────────────
sell_signals = self.check_sell_signals()
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
self.order_mgr.prefetch_broker_holdings()
for sig in sell_signals:
self._submit_sell(sig)
# ── [2] 후보 구독 동기화 (공유 WS) ────────────────
candidates = self._load_candidates()
codes = [c.get("code") for c in candidates if c.get("code")]
# 보유 종목도 구독 유지
codes += list(self.holdings.keys())
self.ws.sync_targets(self.strategy_id, codes)
cand_codes = [c.get("code") for c in candidates if c.get("code")]
hold_codes = list(self.holdings.keys())
# KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.sync_targets_split)
self.ws.sync_targets_split(self.strategy_id, cand_codes, hold_codes)
# ── [3] 매수 체크 ────────────────────────────────
# ── [2b] 미체결 지정가 만료 취소 ───────────────────
self.manage_pending_orders()
# ── [3] 매수 체크 (매수 종료 TIME_END 와 매도 세션 분리) ──
max_stocks = self._max_stocks()
active_cnt = len(self.holdings)
if candidates and active_cnt < max_stocks:
if candidates and active_cnt < max_stocks and self.check_buy_allowed():
self._scan_and_buy(candidates, max_stocks, active_cnt)
time.sleep(random.uniform(self.loop_min_sleep, self.loop_max_sleep))
time.sleep(self._scan_sleep("loop"))
except KeyboardInterrupt:
self._running = False
@@ -215,7 +267,107 @@ class BaseStrategy(ABC, threading.Thread):
self.logger.error("루프 예외: %s", e)
time.sleep(5)
def manage_pending_orders(self) -> None:
"""미체결 ATR 지정가 만료 시 취소 — 서브클래스에서 구현."""
return None
def on_limit_buy_submitted(self, signal: Dict, result) -> None:
"""지정가 접수 성공 — 체결 전까지 holdings 미반영 (서브클래스)."""
return None
def _resolve_buy_qty_live(
self,
curr_price: float,
*,
invest_cap: Optional[float] = None,
hard_cap: int = 0,
max_stocks: Optional[int] = None,
) -> Tuple[int, Optional[str]]:
"""포트폴리오 정합 ON → ``resolve_live_buy_qty``, OFF → ``invest_qty_for_price``."""
from ..utils.position_sizing import invest_qty_for_price
cap = float(
invest_cap if invest_cap is not None
else getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
)
if hard_cap > 0 and cap > hard_cap:
cap = float(hard_cap)
if self._live_portfolio_budget_enabled():
qty, _, rej = self._resolve_live_buy_qty(
curr_price, invest_cap=cap, max_stocks=max_stocks,
)
return qty, rej
qty = invest_qty_for_price(curr_price, cap)
if qty < 1:
return 0, "수량0"
return qty, None
def _live_portfolio_budget_enabled(self) -> bool:
from ..utils.live_portfolio_common import live_portfolio_budget_align_enabled
return live_portfolio_budget_align_enabled(self.strategy_id)
def _portfolio_exposure_krw(self) -> float:
from ..backtest.backtest_portfolio_common import portfolio_exposure_krw
return portfolio_exposure_krw(self.holdings)
def _live_total_budget_krw(self, max_stocks: Optional[int] = None) -> float:
from ..utils.live_portfolio_common import resolve_live_total_budget_krw
ms = max_stocks if max_stocks is not None else self._max_stocks()
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
return resolve_live_total_budget_krw(self.strategy_id, ms, slot)
def _live_portfolio_budget_full(self, max_stocks: Optional[int] = None) -> bool:
from ..utils.live_portfolio_common import live_portfolio_budget_full
if not self._live_portfolio_budget_enabled():
return False
ms = max_stocks if max_stocks is not None else self._max_stocks()
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
return live_portfolio_budget_full(self.holdings, self.strategy_id, slot, ms)
def _live_portfolio_entry_guard(self, code: str, max_stocks: Optional[int] = None) -> Optional[str]:
from ..utils.live_portfolio_common import live_portfolio_entry_reject
if not self._live_portfolio_budget_enabled():
return None
ms = max_stocks if max_stocks is not None else self._max_stocks()
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
return live_portfolio_entry_reject(
self.db, self.holdings, self.strategy_id, code, slot, ms,
)
def _resolve_live_buy_qty(
self,
curr_price: float,
*,
invest_cap: Optional[float] = None,
max_stocks: Optional[int] = None,
) -> Tuple[int, float, Optional[str]]:
from ..utils.live_portfolio_common import resolve_live_buy_qty
ms = max_stocks if max_stocks is not None else self._max_stocks()
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
return resolve_live_buy_qty(
curr_price,
self.holdings,
self.strategy_id,
slot,
max_stocks=ms,
invest_cap=invest_cap,
)
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()
last = getattr(self, "_live_budget_full_log_ts", 0.0)
if now_ts - last >= 300:
self._live_budget_full_log_ts = now_ts
self.logger.info(
"🔍 [탈락-총한도] %s exposure=%.0f >= budget=%.0f (보유 %d/%d)",
self.strategy_id,
self._portfolio_exposure_krw(),
self._live_total_budget_krw(max_stocks),
active_cnt, max_stocks,
)
return
# ── 시장 급락 서킷브레이커 가드 ────────────────────────────────
# MarketGuard 가 PANIC 모드면 신규 매수 전면 차단.
# 매도는 평소처럼 동작 (포지션 정리·손실 확대 방지).
@@ -229,6 +381,23 @@ class BaseStrategy(ABC, threading.Thread):
)
return
guard = getattr(self, "daily_profit_halt", None)
if guard is not None:
try:
allowed, halt_msg = guard.buy_allowed(self.strategy_id)
if not allowed:
now_ts = time.time()
log_key = f"profit_halt_{self.strategy_id}"
if now_ts - getattr(self, "_profit_halt_log_ts", 0.0) >= 60.0:
self._profit_halt_log_ts = now_ts
self.logger.info(
"⛔ [매수차단] %s (보유 %d, 후보 %d)",
halt_msg or "일일익절", active_cnt, len(candidates),
)
return
except Exception as ex:
self.logger.debug("일일익절 가드 예외(매수 계속): %s", ex)
self.logger.info(
"🔍 [매수체크] 후보 %d (보유 %d/%d)",
len(candidates), active_cnt, max_stocks,
@@ -244,6 +413,10 @@ class BaseStrategy(ABC, threading.Thread):
continue
if not self._candidate_filter(c):
continue
guard = self._live_portfolio_entry_guard(code, max_stocks)
if guard:
self.logger.info("🔍 [%s] %s(%s)", guard, name, code)
continue
# 재진입 쿨다운
cooldown_sec = self._reentry_cooldown_sec()
elapsed = time.time() - self.recently_sold.get(code, 0)
@@ -252,14 +425,14 @@ class BaseStrategy(ABC, threading.Thread):
signal = self.check_buy(code, name)
if not signal:
time.sleep(random.uniform(0.2, 0.5))
time.sleep(self._scan_sleep("reject"))
continue
result = self._submit_buy(signal)
if result and result.success:
time.sleep(random.uniform(1.0, 2.0))
time.sleep(self._scan_sleep("buy_ok"))
return # 1루프당 1매수 (포지션 과집중 방지)
time.sleep(random.uniform(0.3, 0.8))
time.sleep(self._scan_sleep("buy_fail"))
# ------------------------------------------------------------------
# OrderManager 호출 래퍼
@@ -279,21 +452,26 @@ class BaseStrategy(ABC, threading.Thread):
atr_entry=float(signal.get("atr_entry", 0)),
size_class=signal.get("size_class"),
entry_features=signal.get("entry_features"),
use_limit_buy=bool(signal.get("use_limit_buy")),
)
result = self.order_mgr.place(req)
if result.success:
if result.success and not signal.get("use_limit_buy"):
# 로컬 holdings 갱신 (DB 는 OrderManager 가 이미 upsert 함)
fp = float(result.filled_avg_price)
self.holdings[req.code] = {
"buy_price": result.filled_avg_price,
"buy_price": fp,
"qty": result.filled_qty,
"stop_price": req.stop_price,
"target_price": req.target_price,
"max_price": result.filled_avg_price,
"max_price": float(signal.get("max_price", fp) or fp),
"session_low": float(signal.get("session_low", fp) or fp),
"atr_entry": req.atr_entry,
"buy_time": dt.now().strftime("%Y-%m-%d %H:%M:%S"),
"name": req.name,
"size_class": req.size_class or "",
}
elif result.success and signal.get("use_limit_buy"):
self.on_limit_buy_submitted(signal, result)
else:
# 매매불가 종목은 당일 제외
if result.reason and "order_reject" in result.reason:
@@ -319,20 +497,55 @@ class BaseStrategy(ABC, threading.Thread):
if result.success:
self.recently_sold[req.code] = time.time()
self.holdings.pop(req.code, None)
elif result.reason in ("broker_no_position", "ghost_cooldown") or (
result.extra and result.extra.get("purge_holdings")
):
if req.code in self.holdings:
self.logger.info(
"🧹 [유령정리] %s %s — 로컬 holdings 제거 (%s)",
req.name, req.code, result.reason,
)
self.holdings.pop(req.code, None)
return result
# ------------------------------------------------------------------
# 서브클래스 공통 헬퍼
# ------------------------------------------------------------------
def _session_time_bounds(self) -> Tuple[int, int]:
"""실매 **매도·EOD** 세션 (HHMM). ``{SID}_TIME_END``(매수 종료)와 별도.
종료 우선순위:
- SHORT: ``TAIL_TIME_START`` / ``TAIL_TIME_END``
- 그 외: ``{SID}_SELL_TIME_END`` → ``MARKET_SESSION_END_HM`` → 기본 **1530**
"""
sid = self.strategy_id.upper()
if sid == "SHORT":
from ..engine.tail_env_keys import tail_market_time_hm
return tail_market_time_hm()
start = get_env_int(f"{sid}_TIME_START", 0) or get_env_int("TIME_START", 0) or 900
sell_end = (
get_env_int(f"{sid}_SELL_TIME_END", 0)
or get_env_int("MARKET_SESSION_END_HM", 0)
or 1530
)
return start, sell_end
def _buy_time_bounds(self) -> Tuple[int, int]:
"""신규 **매수** 허용 구간 (HHMM). ``{SID}_TIME_END`` / ``TIME_END`` = 매수 종료만."""
sid = self.strategy_id.upper()
if sid == "SHORT":
return self._session_time_bounds()
start = get_env_int(f"{sid}_TIME_START", 0) or get_env_int("TIME_START", 0) or 900
buy_end = get_env_int(f"{sid}_TIME_END", 0) or get_env_int("TIME_END", 0)
if buy_end <= 0:
_, sell_end = self._session_time_bounds()
buy_end = sell_end
return start, buy_end
def check_market_status(self) -> bool:
"""전략별 매매 허용 시간대 체크.
DB env 우선순위 (HHMM 정수 — 930=09:30, 1500=15:00):
1) ``{STRATEGY_ID}_TIME_START`` / ``{STRATEGY_ID}_TIME_END``
예: SCALP_TIME_START=900, SCALP_TIME_END=1530
2) 미설정 시 글로벌 ``TIME_START`` / ``TIME_END``
3) 글로벌도 미설정 시 기본 09:00~15:30
"""매도·EOD 포함 실매 세션이 열려 있는지 (정규장 마감까지).
``{STRATEGY_ID}_TIME_END`` 는 **매수 종료** 전용 — 여기서는 사용하지 않는다.
``FORCE_MARKET_OPEN=true`` 면 모든 시간 통과 (백테스트/디버그용).
"""
if get_env_bool("FORCE_MARKET_OPEN", False):
@@ -341,13 +554,20 @@ class BaseStrategy(ABC, threading.Thread):
if now.weekday() >= 5: # 토/일
return False
hhmm_now = now.hour * 100 + now.minute
sid = self.strategy_id.upper()
start = get_env_int(f"{sid}_TIME_START", 0) or get_env_int("TIME_START", 0) or 900
end = get_env_int(f"{sid}_TIME_END", 0) or get_env_int("TIME_END", 0) or 1530
start, end = self._session_time_bounds()
return start <= hhmm_now <= end
def check_buy_allowed(self) -> bool:
"""신규 매수 허용 시간 — ``{SID}_TIME_END`` / ``TIME_END`` 기준 (매수 종료)."""
if get_env_bool("FORCE_MARKET_OPEN", False):
return True
if not self.check_market_status():
return False
now = dt.now()
hhmm_now = now.hour * 100 + now.minute
start, buy_end = self._buy_time_bounds()
return start <= hhmm_now <= buy_end
def _max_stocks(self) -> int:
"""전략별 동시 보유 한도.
@@ -357,12 +577,26 @@ class BaseStrategy(ABC, threading.Thread):
3. 3 (최후 fallback)
"""
sid = self.strategy_id.upper()
per_strategy = get_env_int(f"{sid}_MAX_STOCKS", 0)
if sid == "SHORT":
per_strategy = (
get_env_int("TAIL_MAX_STOCKS", 0)
or get_env_int("SHORT_MAX_STOCKS", 0)
)
else:
per_strategy = get_env_int(f"{sid}_MAX_STOCKS", 0)
if per_strategy > 0:
return per_strategy
return get_env_int("MAX_STOCKS", 3)
def _reentry_cooldown_sec(self) -> int:
sid = self.strategy_id.upper()
cd = get_env_int(f"{sid}_COOLDOWN_SEC", 0)
if cd > 0:
return cd
if sid == "SHORT":
tail_cd = get_env_int("TAIL_COOLDOWN_SEC", 0)
if tail_cd > 0:
return tail_cd
return get_env_int("REENTRY_COOLDOWN_SEC", 300)
def _load_holdings_from_db(self) -> None:
@@ -385,12 +619,14 @@ class BaseStrategy(ABC, threading.Thread):
if _is_non_stock(name, code):
skipped_non_stock.append(f"{code}({name})")
continue
avg_bp = float(t.get("avg_buy_price", 0) or 0)
self.holdings[code] = {
"buy_price": t.get("avg_buy_price", 0),
"buy_price": avg_bp,
"qty": t.get("current_qty", 0),
"stop_price": t.get("stop_price", 0),
"target_price": t.get("target_price", 0),
"max_price": t.get("max_price", 0),
"max_price": float(t.get("max_price") or avg_bp or 0),
"session_low": float(t.get("session_low") or avg_bp or 0),
"atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
"buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
"name": t.get("name", code),
@@ -410,21 +646,20 @@ class BaseStrategy(ABC, threading.Thread):
def _load_candidates(self) -> List[Dict]:
"""
후보 로드 우선순위:
1) universe_source == "ranking" → VolumeRankManager
2) universe_source == "condition" → ConditionSearchManager
3) 선택된 매니저에 설정 없으면 → 반대편 매니저로 폴백
4) 둘 다 없으면 → DB target_candidates (레거시 경로)
1) universe_source == "ranking" → VolumeRankManager
2) universe_source == "condition" → ConditionSearchManager (KIS REST)
3) universe_source == "kiwoom_condition" → KiwoomConditionSearchManager (키움 WS)
런타임 스위치 지원:
매 호출마다 DB env_config 의 {SID}_UNIVERSE_SOURCE 를 재조회하여
재시작 없이 ranking ↔ condition 전환이 가능하다.
(단, 전환 대상 매니저에 본 전략이 '시작 시' 등록돼 있어야 함 —
조건식 NAME/SEQ 자체 변경은 여전히 재시작 필요)
운영 스위치는 **{SID}_UNIVERSE_SOURCE 하나** 만 바꾸면 됨.
- condition + CONDITION_{SID}_NAME/SEQ → KIS 조건 (REST 폴링)
- kiwoom_condition + CONDITION_{SID}_NAME → 키움 조건 (WS 실시간, seq 자동)
- ranking → 거래량/거래대금 순위
(키움 seq 를 고정하려면 CONDITION_{SID}_KIWOOM_SEQ 선택 설정)
"""
key = f"{self.strategy_id}_UNIVERSE_SOURCE"
default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
current_src = (get_env_from_db(key, default) or default).strip().lower()
if current_src not in ("ranking", "condition"):
if current_src not in ("ranking", "condition", "kiwoom_condition"):
current_src = default
if current_src != self.universe_source:
self.logger.info(
@@ -433,12 +668,20 @@ class BaseStrategy(ABC, threading.Thread):
)
self.universe_source = current_src
primary = self.ranking_mgr if self.universe_source == "ranking" else self.condition_mgr
secondary = self.condition_mgr if self.universe_source == "ranking" else self.ranking_mgr
# 소스명 → 매니저 매핑. primary(선택 소스) 먼저, 나머지는 폴백 순.
mgr_by_src = {
"ranking": self.ranking_mgr,
"condition": self.condition_mgr,
"kiwoom_condition": self.kiwoom_condition_mgr,
}
order = [self.universe_source] + [
s for s in ("condition", "kiwoom_condition", "ranking")
if s != self.universe_source
]
candidates: List[Dict] = []
for mgr, tag in ((primary, self.universe_source),
(secondary, "condition" if self.universe_source == "ranking" else "ranking")):
for tag in order:
mgr = mgr_by_src.get(tag)
if mgr is None:
continue
if not self._is_strategy_registered(mgr):
@@ -499,6 +742,26 @@ class BaseStrategy(ABC, threading.Thread):
)
candidates = candidates[:cap]
# 3) 백테 유니버스 슬롯 정합 — target_candidates_history 스냅샷 ∩ 실시간 후보
from ..utils.live_portfolio_common import (
filter_candidates_by_history_universe,
live_universe_slot_align_enabled,
)
if live_universe_slot_align_enabled(sid):
before = len(candidates)
candidates, dropped = filter_candidates_by_history_universe(
candidates, self.db, sid,
)
if dropped > 0:
now_ts = time.time()
last = getattr(self, "_universe_slot_log_ts", 0.0)
if now_ts - last >= 120:
self._universe_slot_log_ts = now_ts
self.logger.info(
"🔍 [유니버스슬롯] %s 후보 %d%d (history 교집합, 제외 %d)",
sid, before, len(candidates), dropped,
)
return candidates
def _is_strategy_registered(self, mgr) -> bool: