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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user