""" kis_trader/strategies/base.py — 전략 공통 기반 클래스 ====================================================== 각 전략은 **독립 쓰레드**로 돌아간다. 구조: start() → 백그라운드 thread 기동 → self._run_loop() (while self._running) stop() → self._running = False + join 루프 안에서 하는 일: 1. 장 세션 체크 (check_market_status — 매도·EOD 포함, 정규장 마감까지) 2. 보유 종목(active_trades where strategy=self.strategy_id) 로드 → 매도 시그널 체크 3. check_buy_allowed() 통과 시 후보 순회 → 매수 시그널 체크 ({SID}_TIME_END = 매수 종료만) 4. 시그널 발생 시 OrderManager.place() 로 집중 (실제 주문은 OrderManager 내부에서 종목Lock+ODNO+실잔고검증) """ from __future__ import annotations import random import threading import time from abc import ABC, abstractmethod from datetime import datetime as dt from typing import Dict, List, Optional, Any, Tuple from ..database.db_manager import TradeDBExt from ..execution.kis_client import KISClient from ..execution.order_manager import OrderManager from ..network.ws_manager import WSManager from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger import logging def _live_feed_providers() -> Tuple[str, str]: """시세(T)·호가(O) provider — RAM TTL 캐시(get_env_from_db). 로그 접두용. TradeDB.get_merged_env_snapshot() 직접 호출 금지(핫패스에서 config 전체 재조회). """ tick_p = str(get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower() ob_p = str(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() return tick_p, ob_p class FeedPrefixLoggerAdapter(logging.LoggerAdapter): """탈락/스캔 로그(🔍 [) 앞에 T:설정|O:설정|R:실제읽기 접두어를 붙인다. 예: 🔍 [탈락-RSI] → 🔍 [T:kis|O:kiwoom|R:kis(1차)|탈락-RSI] T/O = LIVE_*_PROVIDER(DB). R = get_tick_feed_label(code) — 직전 get_price 읽기. extra={'scan_code': code} 로 R: 활성화 (_scan_log 헬퍼 사용). TRIGGER_FEED_DETAIL_LOG(기본 true) 이면 틱가·틱타임·호가·1/2/3차 체인 꼬리 추가. """ def log(self, level, msg, *args, **kwargs): if self.isEnabledFor(level): if args: try: msg = str(msg) % args args = () except Exception: pass msg, kwargs = self.process(msg, kwargs) self.logger.log(level, msg, *args, **kwargs) def process(self, msg, kwargs): if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra: try: # 이미 T:|O: 접두가 있으면 중복 삽입 금지 (매수체크 로그 등) if "🔍 [T:" not in msg[:32]: tick_p, ob_p = _live_feed_providers() read_lab = "" extra = kwargs.get("extra") or {} scan_code = str(extra.get("scan_code") or "").strip() ws = self.extra.get("ws") if scan_code and ws is not None and hasattr(ws, "get_tick_feed_label"): try: read_lab = str(ws.get_tick_feed_label(scan_code) or "").strip() except Exception: read_lab = "" if read_lab: msg = msg.replace( "🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|R:{read_lab}|", 1, ) elif tick_p or ob_p: msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1) if scan_code and ws is not None and hasattr(ws, "get_trigger_feed_trace"): try: detail = str(ws.get_trigger_feed_trace(scan_code) or "").strip() except Exception: detail = "" if detail and "틱1차설정=" not in msg: msg = f"{msg} · {detail}" except Exception: pass return msg, kwargs # 비본주 판별 — kis_trader.utils.non_stock 공용 (KISClient 랭킹 필터와 동일) from ..utils.non_stock import is_non_stock as _is_non_stock from ..utils.non_stock import is_unmanageable_holding as _is_unmanageable_holding from ..engine.strategy_eod import ( is_backtest_eod_bar, is_live_eod_now, is_strategy_eod_bar, parse_eod_hm, resolve_strategy_eod_params, ) class BaseStrategy(ABC, threading.Thread): """ 모든 전략의 공통 부모 클래스. threading.Thread 상속 → start() 시 독립 쓰레드. 서브클래스 구현 필수: - strategy_id (class attribute 또는 property, 고유 문자열) - check_buy(code, name) -> Optional[dict] (매수 시그널 dict) - check_sell_signals() -> List[dict] (매도 시그널 리스트) - _candidate_filter(c) -> bool (본인이 관심 있는 후보인지) """ strategy_id: str = "BASE" loop_min_sleep: float = 0.1 loop_max_sleep: float = 0.1 # 전략별 유니버스 소스 기본값 (env 미설정 시) — HTS 조건검색 단일 정책 DEFAULT_UNIVERSE_SOURCES: Dict[str, str] = { "SCALP": "kiwoom_condition", "SHORT": "kiwoom_condition", # 키움 tail(A 시가대비+F 저가회복) WS 실시간 "BREAKOUT": "kiwoom_condition", "MOMENTUM": "kiwoom_condition", # 키움 WS 실시간 조건 (KIS REST 폴링 대비 유니버스 품질↑) "UPDOW": "condition", } def __init__( self, *, db: TradeDBExt, client: KISClient, ws: WSManager, order_mgr: OrderManager, condition_mgr=None, ranking_mgr=None, kiwoom_condition_mgr=None, ls_condition_mgr=None, market_guard=None, ): super().__init__(daemon=True, name=f"Strat-{self.strategy_id}") self.db = db self.client = client self.ws = ws self.order_mgr = order_mgr self.condition_mgr = condition_mgr # ConditionSearchManager (KIS REST, 선택) self.ranking_mgr = ranking_mgr # VolumeRankManager (선택) # KiwoomConditionSearchManager (키움 WS 실시간 조건검색, 선택). KIS 와 별개 소스. self.kiwoom_condition_mgr = kiwoom_condition_mgr # LsConditionSearchManager (LS AFR 조건검색, 선택). 시세는 키움/한투 유지. self.ls_condition_mgr = ls_condition_mgr self.market_guard = market_guard # MarketGuard (선택, None 이면 가드 없음) base_logger = get_logger(f"kis_trader.strategy.{self.strategy_id}") self.logger = FeedPrefixLoggerAdapter( base_logger, {"db": self.db, "ws": self.ws, "LIVE_TICK_PROVIDER": True}, ) # MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회) self._panic_log_ts: float = 0.0 # 유니버스 소스: ranking | condition(KIS) | kiwoom_condition | ls_condition # 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", "kiwoom_condition", "ls_condition", ): self.logger.warning( "알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용", self.universe_source, default, ) self.universe_source = default self._running = False # 보유 종목 — 재시작·이벤트·안전망(기본 60초) DB sync. 루프마다 SELECT 금지. self.holdings: Dict[str, dict] = {} # 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지) self._runtime: Dict[str, dict] = {} self._holdings_db_sync_ts: float = 0.0 self._prof_sync_skipped: int = 0 # 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프) self.recently_sold: Dict[str, float] = {} # 후보 ENTER 시각 (datetime) — 중분 편입 시 해당 봉 시가 매수 보류용 self._cand_enter_dt: Dict[str, Any] = {} # 당일 매매불가 종목 (다음 후보로 넘어감) self.untradable_skip: set = set() # 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF) self.daily_profit_halt: Any = None # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 OFF · env 로 ON) # 주문 실행은 Orchestrator 의 AccountOrderWorker(계좌 단일 큐) 가 place 직렬화. self.order_worker: Any = None # main 에서 주입 self._inflight_lock = threading.Lock() self._tick_sell_last_ts: Dict[str, float] = {} self._tick_sell_listener_on = False # 종목당 매도/매수 intent 1장 — enqueue 중복 방지 (Worker 완료 시 해제) self._sell_inflight: set = set() self._buy_inflight: set = set() # sell_locked/sellable_zero/pending 시 tick enqueue 쿨다운 (SELL_LOCKED_ENQUEUE_COOLDOWN_SEC) self._sell_locked_until: Dict[str, float] = {} self._order_enqueue_skip = 0 # 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED) self._loop_prof_i = 0 self._loop_prof_scan: Optional[Dict[str, Any]] = None # 당일 trade_history — 루프당 1회 DB, 종목 check_buy 는 RAM 필터만 self._today_trades_cache_day: str = "" self._today_trades_cache: List[Dict] = [] # 세밀 계측 카운터 (루프마다 리셋) self._prof_trd_hit = 0 self._prof_trd_miss = 0 self._prof_trd_db_ms = 0.0 self._prof_sync_db_ms = 0.0 self._prof_sync_merge_ms = 0.0 self._prof_cand_load_ms = 0.0 self._prof_cand_note_ms = 0.0 self._sync_holdings_from_db(log_restore=True) # ------------------------------------------------------------------ # 외부 인터페이스 # ------------------------------------------------------------------ def stop_loop(self) -> None: """쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출).""" self._running = False self._unregister_tick_sell_listener() def _tick_sell_enabled(self) -> bool: """공통 TICK_SELL_ENABLED 또는 {SID}_TICK_SELL_ENABLED (전략키 비어있지 않으면 우선).""" sid = (self.strategy_id or "BASE").upper() sid_key = f"{sid}_TICK_SELL_ENABLED" raw_sid = str(get_env_from_db(sid_key, "") or "").strip() if raw_sid: return bool(get_env_bool(sid_key, False)) return bool(get_env_bool("TICK_SELL_ENABLED", False)) def _mark_sell_inflight(self, code: str) -> bool: """True=이 종목 매도 intent 슬롯 확보 (Worker 완료 시 해제).""" code = (code or "").strip() if not code: return False with self._inflight_lock: if code in self._sell_inflight: return False self._sell_inflight.add(code) return True def _release_sell_inflight(self, code: str) -> None: code = (code or "").strip() if not code: return with self._inflight_lock: self._sell_inflight.discard(code) def _mark_buy_inflight(self, code: str) -> bool: code = (code or "").strip() if not code: return False with self._inflight_lock: if code in self._buy_inflight: return False self._buy_inflight.add(code) return True def _release_buy_inflight(self, code: str) -> None: code = (code or "").strip() if not code: return with self._inflight_lock: self._buy_inflight.discard(code) def _enqueue_sell(self, sig: Dict, *, source: str = "scan") -> bool: """매도 signal → AccountOrderWorker. False=inflight/큐 drop.""" code = str((sig or {}).get("code") or "").strip() if not code: return False cooldown_sec = max( 0, int(get_env_int("SELL_LOCKED_ENQUEUE_COOLDOWN_SEC", 20) or 0), ) if cooldown_sec > 0: until = self._sell_locked_until.get(code, 0.0) if time.time() < until: remain = int(until - time.time()) self.logger.debug( "⏸ [매도enqueue쿨다운] %s — %d초 남음 (source=%s)", code, remain, source, ) return False if not self._mark_sell_inflight(code): return False ow = getattr(self, "order_worker", None) if ow is None: try: self._submit_sell(sig) return True finally: self._release_sell_inflight(code) if ow.enqueue(self, "SELL", sig, source=source): return True self._release_sell_inflight(code) self._order_enqueue_skip = int(getattr(self, "_order_enqueue_skip", 0) or 0) + 1 return False def _enqueue_buy(self, sig: Dict, *, source: str = "scan") -> bool: """매수 signal → AccountOrderWorker.""" code = str((sig or {}).get("code") or "").strip() if not code: return False if not self._mark_buy_inflight(code): return False ow = getattr(self, "order_worker", None) if ow is None: try: self._submit_buy(sig) return True finally: self._release_buy_inflight(code) if ow.enqueue(self, "BUY", sig, source=source): return True self._release_buy_inflight(code) self._order_enqueue_skip = int(getattr(self, "_order_enqueue_skip", 0) or 0) + 1 return False def _register_tick_sell_listener(self) -> None: if self._tick_sell_listener_on: return ws = getattr(self, "ws", None) if ws is None or not hasattr(ws, "register_price_listener"): return try: ws.register_price_listener(self._on_ws_price_tick) self._tick_sell_listener_on = True if self._tick_sell_enabled(): self.logger.info( "📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL) → AccountOrderWorker", self.strategy_id, ) else: self.logger.debug( "틱매도 리스너 등록(대기) — env OFF 시 콜백 no-op", ) except Exception as ex: self.logger.warning("틱매도 리스너 등록 실패: %s", ex) def _unregister_tick_sell_listener(self) -> None: if not self._tick_sell_listener_on: return ws = getattr(self, "ws", None) if ws is not None and hasattr(ws, "unregister_price_listener"): try: ws.unregister_price_listener(self._on_ws_price_tick) except Exception: pass self._tick_sell_listener_on = False def _on_ws_price_tick(self, code: str, price: float, raw: Any = None) -> None: """WS 현재가 갱신 → 보유 중이면 check_sell_signals → OrderWorker enqueue. 매도 규칙은 루프 매도와 동일 함수. 바뀌는 것은 호출 시점(틱)뿐. 이 함수는 한투·키움·LS **수신 스레드**에서 돈다 — place/REST 없음. """ if not self._running or not self._tick_sell_enabled(): return code = (code or "").strip() if not code or code not in self.holdings: return try: min_ms = max(0, int(get_env_int("TICK_SELL_MIN_INTERVAL_MS", 50) or 0)) except Exception: min_ms = 50 now = time.time() last = float(self._tick_sell_last_ts.get(code, 0.0) or 0.0) if min_ms > 0 and (now - last) * 1000.0 < float(min_ms): return self._tick_sell_last_ts[code] = now try: if code not in self.holdings: return sell_signals = self.check_sell_signals(only_code=code) or [] for sig in sell_signals: if (sig.get("code") or "") != code: continue if self._enqueue_sell(sig, source="tick"): break except Exception as ex: self.logger.debug("틱매도 예외 %s: %s", code, ex) def _loop_profile_on(self) -> bool: """LOOP_PROFILE_ENABLED — 한 바퀴 숙제별 ms 계측 ON/OFF.""" try: return bool(get_env_bool("LOOP_PROFILE_ENABLED", False)) except Exception: return False def _loop_profile_every_n(self) -> int: try: return max(1, int(get_env_int("LOOP_PROFILE_EVERY_N", 1) or 1)) except Exception: return 1 def _loop_profile_emit(self, row: Dict[str, Any]) -> None: """계측 1줄 — 전략 logger + 선택 파일.""" parts = [ f"[LOOP_PROF] {self.strategy_id}", f"total={row.get('total_ms', 0):.1f}", f"reload={row.get('reload_ms', 0):.1f}", f"sync_hold={row.get('sync_hold_ms', 0):.1f}", f"sync_db={row.get('sync_db_ms', 0):.1f}", f"sync_merge={row.get('sync_merge_ms', 0):.1f}", f"sync_skip={row.get('sync_skip', 0)}", f"halt={row.get('halt_ms', 0):.1f}", f"sell_chk={row.get('sell_chk_ms', 0):.1f}", f"enqueue_sell={row.get('enqueue_sell_ms', 0):.1f}", f"cand={row.get('cand_ms', 0):.1f}", f"cand_load={row.get('cand_load_ms', 0):.1f}", f"cand_mgr={row.get('cand_mgr_ms', 0):.1f}", f"cand_post={row.get('cand_post_ms', 0):.1f}", f"cand_slot={row.get('cand_slot_ms', 0):.1f}", f"cand_note={row.get('cand_note_ms', 0):.1f}", f"cand_src={row.get('cand_src', '-')}", f"cand_n={row.get('cand_n', 0)}", f"ws_sync={row.get('ws_sync_ms', 0):.1f}", f"pending={row.get('pending_ms', 0):.1f}", f"scan={row.get('scan_ms', 0):.1f}", f"scan_other={row.get('scan_other_ms', 0):.1f}", f"scan_name={row.get('scan_name_ms', 0):.1f}", f"scan_pre={row.get('scan_pre_ms', 0):.1f}", f"pre_filt={row.get('pre_filt_ms', 0):.1f}", f"pre_guard={row.get('pre_guard_ms', 0):.1f}", f"g_env={row.get('guard_env_ms', 0):.1f}", f"g_daily={row.get('guard_daily_ms', 0):.1f}", f"g_trdb={row.get('guard_trades_db_ms', 0):.1f}", f"g_tr_n={row.get('guard_trades_n', 0)}", f"g_budg={row.get('guard_budget_ms', 0):.1f}", f"pre_cd={row.get('pre_cd_ms', 0):.1f}", f"pre_gap={row.get('pre_gap_ms', 0):.1f}", f"sleep_rej={row.get('sleep_rej_ms', 0):.1f}", f"sleep_ok={row.get('sleep_ok_ms', 0):.1f}", f"sleep_fail={row.get('sleep_fail_ms', 0):.1f}", f"buy_n={row.get('buy_n', 0)}", f"buy_sum={row.get('buy_sum_ms', 0):.1f}", f"buy_max={row.get('buy_max_ms', 0):.1f}", f"trd_hit={row.get('trd_hit', 0)}", f"trd_miss={row.get('trd_miss', 0)}", f"trd_db={row.get('trd_db_ms', 0):.1f}", f"gap_skip={row.get('gap_skip', 0)}", f"slot_et={row.get('slot_et_ms', 0):.1f}", f"slot_get={row.get('slot_get_ms', 0):.1f}", f"slot_ok={row.get('slot_ok', 0)}", f"slot_miss={row.get('slot_miss', 0)}", f"slot_hit={row.get('slot_cache_hit', 0)}", f"slot_missc={row.get('slot_cache_miss', 0)}", f"g_hit={row.get('guard_trades_hit', 0)}", f"overlay={row.get('overlay_ms', 0):.1f}", f"sleep={row.get('sleep_ms', 0):.1f}", f"order_enqueue_skip={row.get('order_enqueue_skip', 0)}", f"sum_parts={row.get('sum_parts_ms', 0):.1f}", ] if row.get("buy_max_code"): parts.append(f"buy_max_code={row.get('buy_max_code')}") line = " ".join(parts) try: self.logger.info("%s", line) except Exception: pass path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip() if not path: return try: import os if not os.path.isabs(path): root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) path = os.path.join(root, path) os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "a", encoding="utf-8") as f: f.write(dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n") except Exception as ex: try: self.logger.debug("LOOP_PROF 파일 기록 실패: %s", ex) except Exception: pass def _cb_prof_start(self, code: str) -> Optional[Dict[str, Any]]: """check_buy 내부 단계 계측 시작 — LOOP_PROFILE_ENABLED 일 때만.""" if not self._loop_profile_on(): return None now = time.perf_counter() return { "code": str(code or ""), "t0": now, "_last": now, "stages": {}, } def _cb_prof_mark(self, ctx: Optional[Dict[str, Any]], stage: str) -> None: if not ctx: return now = time.perf_counter() last = float(ctx.get("_last") or ctx.get("t0") or now) st = ctx.setdefault("stages", {}) st[stage] = float(st.get(stage, 0) or 0) + (now - last) * 1000.0 ctx["_last"] = now def _cb_prof_finish(self, ctx: Optional[Dict[str, Any]], *, note: str = "") -> None: """check_buy 끝 — 느린 건 CHECK_BUY_PROF 로그 + 최근 결과 보관.""" if not ctx: return now = time.perf_counter() total = (now - float(ctx.get("t0") or now)) * 1000.0 stages = ctx.setdefault("stages", {}) # 마지막 mark ~ finish (탈락 logger / 어댑터 등) try: last = float(ctx.get("_last") or ctx.get("t0") or now) after_ms = (now - last) * 1000.0 if after_ms >= 0.05: stages["after"] = float(stages.get("after", 0) or 0) + after_ms except Exception: pass ctx["total_ms"] = total self._last_cb_prof = ctx try: min_ms = float(get_env_int("CHECK_BUY_PROF_MIN_MS", 500) or 500) except Exception: min_ms = 500.0 if total < min_ms: return # 큰 단계 순 ordered = sorted( ((k, float(v or 0)) for k, v in stages.items()), key=lambda x: -x[1], ) parts = [ f"[CHECK_BUY_PROF] {self.strategy_id}", f"code={ctx.get('code')}", f"total={total:.1f}", ] if note: parts.append(f"note={note}") for k, v in ordered[:12]: parts.append(f"{k}={v:.1f}") line = " ".join(parts) try: self.logger.info("%s", line) except Exception: pass path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip() if not path: return try: import os if not os.path.isabs(path): root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) path = os.path.join(root, path) os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "a", encoding="utf-8") as f: f.write(dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n") except Exception: pass def _run_sell_section(self) -> Dict[str, float]: """매도 구간 — check_sell_signals 만 (place 는 OrderWorker).""" out = { "sell_chk_ms": 0.0, "enqueue_sell_ms": 0.0, } t0 = time.perf_counter() try: sell_signals = self.check_sell_signals() or [] except Exception as ex: self.logger.debug("매도체크 예외: %s", ex) sell_signals = [] out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0 t1 = time.perf_counter() for sig in sell_signals: sc = str(sig.get("code") or "").strip() if not sc: continue self._enqueue_sell(sig, source="scan") out["enqueue_sell_ms"] = (time.perf_counter() - t1) * 1000.0 return out def _ws_last_quote(self, code: str) -> Optional[dict]: """실매 매수 현재가. 생략=2초 체인(메인 실패→2차→3차). REST 없음.""" ws = self.ws getp = getattr(ws, "get_price", None) if not callable(getp): return None try: return getp(code) except TypeError: try: return getp(code, max_age_sec=0) except Exception: return None except Exception: return None def _scan_log( self, level: str, code: Optional[str], msg: str, *args: Any, **kwargs: Any, ) -> None: """🔍 스캔/탈락 로그 — FeedPrefixLoggerAdapter 가 T/O/R 접두 부착.""" extra = dict(kwargs.pop("extra", None) or {}) c = str(code or "").strip() if c: extra["scan_code"] = c getattr(self.logger, level)(msg, *args, extra=extra, **kwargs) def _resolve_sell_price(self, code: str, *, is_eod: bool, buy_price: float) -> float: """실매 매도 현재가 — 마지막 WS를 TTL로 버리지 않음. EOD는 매수가 폴백.""" from kis_trader.engine.live_sell_price import resolve_live_sell_price inquire = getattr(self.client, "inquire_price", None) px, _src = resolve_live_sell_price( self.ws, inquire, code, is_eod=bool(is_eod), fallback_price=float(buy_price or 0.0), logger=self.logger, ) return float(px or 0.0) # ------------------------------------------------------------------ # 스캔 루프 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 self.logger.info("🚀 전략 쓰레드 시작 [%s]", self.strategy_id) self._register_tick_sell_listener() try: self._run_loop() except Exception as e: self.logger.exception("전략 루프 예외: %s", e) finally: self._unregister_tick_sell_listener() self.logger.info("⏹ 전략 쓰레드 종료 [%s]", self.strategy_id) # ------------------------------------------------------------------ # 메인 루프 # ------------------------------------------------------------------ def _run_loop(self) -> None: last_date = dt.now().strftime("%Y-%m-%d") last_closed_log = 0.0 while self._running: try: prof = self._loop_profile_on() self._loop_prof_i = int(getattr(self, "_loop_prof_i", 0) or 0) + 1 do_prof = bool( prof and (self._loop_prof_i % self._loop_profile_every_n() == 0) ) row: Dict[str, Any] = {} t_loop0 = time.perf_counter() if do_prof else 0.0 miss0 = int(getattr(self, "_order_enqueue_skip", 0) or 0) self._loop_iter_start = time.time() self._set_loop_phase("start") now = dt.now() today = now.strftime("%Y-%m-%d") # 날짜 변경 처리 (당일 매매불가 리셋 등) if today != last_date: last_date = today self.untradable_skip.clear() self.on_new_day() # 장 시간 체크 (서브클래스 오버라이드 가능) if not self.check_market_status(): # 장외 heartbeat: 봇 기동 직후 1회 + 이후 1시간마다 1회. # (봇 살아있음·보유·유니버스 상태만 확인용 — 잦은 로그 노이즈 제거) interval = get_env_int("OFF_HOURS_LOG_INTERVAL_SEC", 3600) if time.time() - last_closed_log >= interval: try: universe = self._load_candidates() except Exception: universe = [] self.logger.info( "🌙 [장외] holdings=%d universe=%d recently_sold=%d", len(self.holdings), len(universe), len(self.recently_sold), ) last_closed_log = time.time() time.sleep(30) continue # 설정 리로드 (DB env_config 실시간 반영) t0 = time.perf_counter() if do_prof else 0.0 # 세밀 계측 카운터 리셋 (당일거래 공유캐시는 루프마다 비우지 않음) self._prof_trd_hit = 0 self._prof_trd_miss = 0 self._prof_trd_db_ms = 0.0 self._prof_sync_db_ms = 0.0 self._prof_sync_merge_ms = 0.0 self._prof_sync_skipped = 0 self._prof_cand_load_ms = 0.0 self._prof_cand_note_ms = 0.0 self._prof_cand_mgr_ms = 0.0 self._prof_cand_post_ms = 0.0 self._prof_cand_slot_ms = 0.0 self._prof_cand_src = "-" self._prof_cand_n = 0 self._prof_slot_et_ms = 0.0 self._prof_slot_get_ms = 0.0 self._prof_slot_ok = 0 self._prof_slot_miss = 0 self._prof_slot_cache_hit = 0 self._prof_slot_cache_miss = 0 self._prof_guard_acc = {} self.reload_config() self._set_loop_phase("reload") if do_prof: row["reload_ms"] = (time.perf_counter() - t0) * 1000.0 # 보유 = 이벤트 RAM + 안전망 DB sync(기본 60초). 매수/매도 체결은 RAM 즉시 갱신. t0 = time.perf_counter() if do_prof else 0.0 self._sync_holdings_from_db() self._set_loop_phase("sync_hold") if do_prof: row["sync_hold_ms"] = (time.perf_counter() - t0) * 1000.0 row["sync_db_ms"] = float(self._prof_sync_db_ms) row["sync_merge_ms"] = float(self._prof_sync_merge_ms) row["sync_skip"] = int(getattr(self, "_prof_sync_skipped", 0) or 0) # 전략 ON/OFF 핫게이트 — WS 구독 해제 없음. 보유 청산만 유지. if not self._strategy_switch_enabled(): if self.holdings: self._run_sell_section() self._set_loop_phase("switch_off_sleep") time.sleep(self._scan_sleep("loop")) continue # 일일익절 B안 — 매수루프 비어도 hit 후 보유 리스크 정리 t0 = time.perf_counter() if do_prof else 0.0 guard = getattr(self, "daily_profit_halt", None) if guard is not None: try: guard.maybe_trim_open_risk(self.strategy_id) except Exception as ex: self.logger.debug("일일익절 리스크버짓 예외: %s", ex) self._set_loop_phase("halt") if do_prof: row["halt_ms"] = (time.perf_counter() - t0) * 1000.0 # ── [1] 매도 먼저 ──────────────────────────────── sell_timings = self._run_sell_section() self._set_loop_phase("sell_done") if do_prof: row.update(sell_timings) # ── [2] 후보 구독 동기화 (공유 WS) ──────────────── t0 = time.perf_counter() if do_prof else 0.0 t_c0 = time.perf_counter() candidates = self._load_candidates() self._prof_cand_load_ms = (time.perf_counter() - t_c0) * 1000.0 self._set_loop_phase("candidates") # 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록 t_n0 = time.perf_counter() self._note_candidate_enters(candidates) self._prof_cand_note_ms = (time.perf_counter() - t_n0) * 1000.0 if do_prof: row["cand_ms"] = (time.perf_counter() - t0) * 1000.0 row["cand_load_ms"] = float(self._prof_cand_load_ms) row["cand_mgr_ms"] = float(getattr(self, "_prof_cand_mgr_ms", 0) or 0) row["cand_post_ms"] = float(getattr(self, "_prof_cand_post_ms", 0) or 0) row["cand_slot_ms"] = float(getattr(self, "_prof_cand_slot_ms", 0) or 0) row["cand_note_ms"] = float(self._prof_cand_note_ms) row["cand_src"] = str(getattr(self, "_prof_cand_src", "-") or "-") row["cand_n"] = int(getattr(self, "_prof_cand_n", 0) or 0) cand_codes = [c.get("code") for c in candidates if c.get("code")] hold_codes = list(self.holdings.keys()) # WS_SUBSCRIBE_KIS_MINIMAL: 후보=키움, 한투=보유만, 영구KR=LS (sync_targets_split) # US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용 t0 = time.perf_counter() if do_prof else 0.0 self._sync_ws_for_loop(cand_codes, hold_codes) self._set_loop_phase("ws_sync") if do_prof: row["ws_sync_ms"] = (time.perf_counter() - t0) * 1000.0 # ── [2b] 미체결 지정가 만료 취소 ─────────────────── t0 = time.perf_counter() if do_prof else 0.0 self.manage_pending_orders() self._set_loop_phase("scan") if do_prof: row["pending_ms"] = (time.perf_counter() - t0) * 1000.0 # ── [3] 매수 체크 (매수 종료 TIME_END 와 매도 세션 분리) ── max_stocks = self._max_stocks() active_cnt = len(self.holdings) self._loop_prof_scan = None t0 = time.perf_counter() if do_prof else 0.0 if candidates and active_cnt < max_stocks and self.check_buy_allowed(): self._scan_and_buy(candidates, max_stocks, active_cnt) else: self._log_buy_section_gate(candidates, active_cnt, max_stocks) if do_prof: row["scan_ms"] = (time.perf_counter() - t0) * 1000.0 sc = getattr(self, "_loop_prof_scan", None) or {} row["buy_n"] = int(sc.get("buy_n", 0) or 0) row["buy_sum_ms"] = float(sc.get("buy_sum_ms", 0) or 0) row["buy_max_ms"] = float(sc.get("buy_max_ms", 0) or 0) row["buy_max_code"] = sc.get("buy_max_code") or "" row["gap_skip"] = int(sc.get("gap_skip", 0) or 0) # scan 중 check_buy 밖(이름해석·필터·sleep 등) row["scan_other_ms"] = max( 0.0, float(row["scan_ms"]) - float(row["buy_sum_ms"]), ) row["scan_name_ms"] = float(sc.get("scan_name_ms", 0) or 0) row["scan_pre_ms"] = float(sc.get("scan_pre_ms", 0) or 0) row["pre_filt_ms"] = float(sc.get("pre_filt_ms", 0) or 0) row["pre_guard_ms"] = float(sc.get("pre_guard_ms", 0) or 0) row["pre_cd_ms"] = float(sc.get("pre_cd_ms", 0) or 0) row["pre_gap_ms"] = float(sc.get("pre_gap_ms", 0) or 0) row["sleep_rej_ms"] = float(sc.get("sleep_rej_ms", 0) or 0) row["sleep_ok_ms"] = float(sc.get("sleep_ok_ms", 0) or 0) row["sleep_fail_ms"] = float(sc.get("sleep_fail_ms", 0) or 0) row["guard_env_ms"] = float(sc.get("guard_env_ms", 0) or 0) row["guard_daily_ms"] = float(sc.get("guard_daily_ms", 0) or 0) row["guard_trades_db_ms"] = float(sc.get("guard_trades_db_ms", 0) or 0) row["guard_trades_n"] = int(sc.get("guard_trades_n", 0) or 0) row["guard_budget_ms"] = float(sc.get("guard_budget_ms", 0) or 0) row["slot_et_ms"] = float(getattr(self, "_prof_slot_et_ms", 0) or 0) row["slot_get_ms"] = float(getattr(self, "_prof_slot_get_ms", 0) or 0) row["slot_ok"] = int(getattr(self, "_prof_slot_ok", 0) or 0) row["slot_miss"] = int(getattr(self, "_prof_slot_miss", 0) or 0) row["slot_cache_hit"] = int(getattr(self, "_prof_slot_cache_hit", 0) or 0) row["slot_cache_miss"] = int(getattr(self, "_prof_slot_cache_miss", 0) or 0) row["guard_trades_hit"] = int(sc.get("guard_trades_hit", 0) or 0) row["trd_hit"] = int(self._prof_trd_hit) row["trd_miss"] = int(self._prof_trd_miss) row["trd_db_ms"] = float(self._prof_trd_db_ms) # 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge) t0 = time.perf_counter() if do_prof else 0.0 self._capture_runtime_overlay() if do_prof: row["overlay_ms"] = (time.perf_counter() - t0) * 1000.0 t0 = time.perf_counter() if do_prof else 0.0 time.sleep(self._scan_sleep("loop")) if do_prof: row["sleep_ms"] = (time.perf_counter() - t0) * 1000.0 row["total_ms"] = (time.perf_counter() - t_loop0) * 1000.0 row["order_enqueue_skip"] = int( getattr(self, "_order_enqueue_skip", 0) or 0 ) - miss0 # 숙제 합( sleep 제외 ) — total 과 비교해 미계측 구간 파악 part_keys = ( "reload_ms", "sync_hold_ms", "halt_ms", "sell_chk_ms", "enqueue_sell_ms", "cand_ms", "ws_sync_ms", "pending_ms", "scan_ms", "overlay_ms", ) row["sum_parts_ms"] = sum(float(row.get(k, 0) or 0) for k in part_keys) self._loop_profile_emit(row) except KeyboardInterrupt: self._running = False break except Exception as e: self.logger.error("루프 예외: %s", e) time.sleep(5) def _sync_ws_for_loop(self, cand_codes: List[str], hold_codes: List[str]) -> None: """후보·보유 WS 구독 동기화 — reconcile 은 WSManager 백그라운드 워커.""" ls_feed = str(getattr(self, "universe_source", "") or "") == "ls_condition" self.ws.sync_targets_split( self.strategy_id, cand_codes, hold_codes, ls_feed=ls_feed, ) 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)) prof = None if self._loop_profile_on(): prof = getattr(self, "_prof_guard_acc", None) if prof is None: prof = {} self._prof_guard_acc = prof return live_portfolio_entry_reject( self.db, self.holdings, self.strategy_id, code, slot, ms, prof=prof, ) 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 _loop_diag_enabled(self) -> bool: try: return bool(get_env_bool("STRATEGY_LOOP_DIAG_ENABLED", True)) except Exception: return True def _loop_stall_sec(self) -> float: try: return max(0.0, float(get_env_float("STRATEGY_LOOP_STALL_SEC", 30.0) or 0.0)) except Exception: return 30.0 def _set_loop_phase(self, phase: str) -> None: self._loop_phase = str(phase or "") if self._loop_diag_enabled(): self._maybe_log_loop_stall() def _maybe_log_loop_stall(self) -> None: start = float(getattr(self, "_loop_iter_start", 0) or 0) if start <= 0: return elapsed = time.time() - start stall = self._loop_stall_sec() if stall <= 0 or elapsed < stall: return now_m = time.time() last = float(getattr(self, "_loop_stall_log_ts", 0) or 0) if now_m - last < 60.0: return self._loop_stall_log_ts = now_m self.logger.warning( "⚠️ [루프 지연] %s phase=%s elapsed=%.0fs holdings=%d order_enqueue_skip=%d", self.strategy_id, getattr(self, "_loop_phase", "?"), elapsed, len(self.holdings), int(getattr(self, "_order_enqueue_skip", 0) or 0), ) def _log_buy_section_gate( self, candidates: List[Dict], active_cnt: int, max_stocks: int, ) -> None: """매수체크 미진입 사유 — 60초 rate limit (후보0·만석·매수허용OFF).""" if not self._loop_diag_enabled(): return now_m = time.time() last = float(getattr(self, "_last_buy_gate_log_ts", 0) or 0) first_iter = int(getattr(self, "_loop_prof_i", 0) or 0) <= 1 if not first_iter and now_m - last < 60.0: return self._last_buy_gate_log_ts = now_m if active_cnt >= max_stocks: self.logger.info( "🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s", active_cnt, max_stocks, ",".join(list(self.holdings.keys())[:12]), ) return if not candidates: detail = f"src={self.universe_source}" if self.universe_source == "kiwoom_condition": mgr = self.kiwoom_condition_mgr if mgr is None: detail += " kiwoom_condition_mgr=None(핸들갱신 필요)" elif not self._is_strategy_registered(mgr): detail += " 키움매니저미등록" else: try: univ_n = len(mgr.get_universe_for(self.strategy_id) or []) detail += f" universe={univ_n}" except Exception: pass self.logger.info("🔍 [매수체크 스킵] 후보0 %s", detail) return if not self.check_buy_allowed(): self.logger.info( "🔍 [매수체크 스킵] 매수허용=False " "(장외·TIME_END·EOD·LS복구게이트·PANIC)" ) 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 모드면 신규 매수 전면 차단. # 매도는 평소처럼 동작 (포지션 정리·손실 확대 방지). if self.market_guard is not None and self.market_guard.is_panic(): now_ts = time.time() if now_ts - self._panic_log_ts >= 60: # 분당 1회만 로그 self._panic_log_ts = now_ts self.logger.warning( "⛔ [매수차단] MarketGuard PANIC: %s (보유 %d, 후보 %d)", self.market_guard.panic_reason(), active_cnt, len(candidates), ) 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) _codes = [ str(c.get("code") or c.get("stk_cd") or "").strip() for c in (candidates or []) ] _codes = [c for c in _codes if c] if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"): self.logger.info( "🔍 [매수체크] 후보 %d (보유 %d/%d) codes=%s", len(candidates), active_cnt, max_stocks, ",".join(_codes[:12]), ) else: self.logger.info( "🔍 [매수체크] 후보 %d (보유 %d/%d)", len(candidates), active_cnt, max_stocks, ) prof_scan = self._loop_profile_on() buy_n = 0 buy_sum = 0.0 buy_max = 0.0 buy_max_code = "" gap_skip = 0 scan_name_ms = 0.0 scan_pre_ms = 0.0 pre_filt_ms = 0.0 pre_guard_ms = 0.0 pre_cd_ms = 0.0 pre_gap_ms = 0.0 sleep_rej_ms = 0.0 sleep_ok_ms = 0.0 sleep_fail_ms = 0.0 if prof_scan: self._prof_guard_acc = {} def _snap_scan(): g = getattr(self, "_prof_guard_acc", None) or {} return { "buy_n": buy_n, "buy_sum_ms": buy_sum, "buy_max_ms": buy_max, "buy_max_code": buy_max_code, "gap_skip": gap_skip, "scan_name_ms": scan_name_ms, "scan_pre_ms": scan_pre_ms, "pre_filt_ms": pre_filt_ms, "pre_guard_ms": pre_guard_ms, "pre_cd_ms": pre_cd_ms, "pre_gap_ms": pre_gap_ms, "sleep_rej_ms": sleep_rej_ms, "sleep_ok_ms": sleep_ok_ms, "sleep_fail_ms": sleep_fail_ms, "guard_env_ms": float(g.get("guard_env_ms", 0) or 0), "guard_daily_ms": float(g.get("guard_daily_ms", 0) or 0), "guard_trades_db_ms": float(g.get("guard_trades_db_ms", 0) or 0), "guard_trades_n": int(g.get("guard_trades_n", 0) or 0), "guard_trades_hit": int(g.get("guard_trades_hit", 0) or 0), "guard_budget_ms": float(g.get("guard_budget_ms", 0) or 0), } for c in candidates: if not self._running: if prof_scan: self._loop_prof_scan = _snap_scan() return t_pre0 = time.perf_counter() if prof_scan else 0.0 code = c.get("code") or c.get("stk_cd", "") # 매수체크 핫패스: DB 이름조회 금지(느림·불필요). # 한글명은 주문/알림 시 OrderManager._resolve_order_display_name 만. name = c.get("name") or c.get("stk_nm") or code if not str(name or "").strip(): name = code if prof_scan: scan_name_ms += (time.perf_counter() - t_pre0) * 1000.0 t_pre0 = time.perf_counter() if not code or code in self.holdings: if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_filt_ms += dt continue if code in self.untradable_skip: if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_filt_ms += dt continue if not self._candidate_filter(c): if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_filt_ms += dt continue if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_filt_ms += dt t_pre0 = time.perf_counter() guard = self._live_portfolio_entry_guard(code, max_stocks) if guard: self._scan_log("info", code, "🔍 [%s] %s(%s)", guard, name, code) if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_guard_ms += dt continue if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_guard_ms += dt t_pre0 = time.perf_counter() # 재진입 쿨다운 cooldown_sec = self._reentry_cooldown_sec() elapsed = time.time() - self.recently_sold.get(code, 0) if elapsed < cooldown_sec: if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_cd_ms += dt continue if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_cd_ms += dt t_pre0 = time.perf_counter() # 갭보정 미완료 → 불완전한 봉으로 매수 판단 금지 # 갭보정 완료 후 자동으로 다음 루프에서 check_buy 진입 if hasattr(self.ws, "is_gap_ready") and not self.ws.is_gap_ready(code): gap_skip += 1 if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_gap_ms += dt continue if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt pre_gap_ms += dt t_buy0 = time.perf_counter() if prof_scan else 0.0 signal = self.check_buy(code, name) if prof_scan: dt_ms = (time.perf_counter() - t_buy0) * 1000.0 buy_n += 1 buy_sum += dt_ms if dt_ms >= buy_max: buy_max = dt_ms buy_max_code = str(code) if not signal: if prof_scan: t_sl = time.perf_counter() time.sleep(self._scan_sleep("reject")) if prof_scan: sleep_rej_ms += (time.perf_counter() - t_sl) * 1000.0 continue if self._enqueue_buy(signal, source="scan"): if prof_scan: t_sl = time.perf_counter() time.sleep(self._scan_sleep("buy_ok")) if prof_scan: sleep_ok_ms += (time.perf_counter() - t_sl) * 1000.0 self._loop_prof_scan = _snap_scan() return # 1루프당 1매수 intent (포지션 과집중 방지) if prof_scan: t_sl = time.perf_counter() time.sleep(self._scan_sleep("buy_fail")) if prof_scan: sleep_fail_ms += (time.perf_counter() - t_sl) * 1000.0 if prof_scan: self._loop_prof_scan = _snap_scan() def _note_candidate_enters(self, candidates: List[Dict]) -> None: """후보 집합 변화 → ENTER/EXIT 시각 갱신 (중분 시가 애매 가드).""" from datetime import datetime now = datetime.now() codes = set() for c in candidates or []: code = str(c.get("code") or c.get("stk_cd") or "").strip() if code: codes.add(code) if not hasattr(self, "_cand_enter_dt") or self._cand_enter_dt is None: self._cand_enter_dt = {} for gone in list(self._cand_enter_dt.keys()): if gone not in codes: self._cand_enter_dt.pop(gone, None) for code in codes: if code not in self._cand_enter_dt: self._cand_enter_dt[code] = now def _defer_mid_enroll_entry( self, code: str, entry_bar_key: Any, tf_min: int = 1, params: Optional[Dict] = None, ) -> Optional[str]: """중분 편입 + 같은 진입봉이면 사유 문자열, 아니면 None.""" from kis_trader.engine.mid_enroll_entry_gate import gate_reason_mid_enroll enroll = None if hasattr(self, "_cand_enter_dt"): enroll = (self._cand_enter_dt or {}).get(str(code).strip()) return gate_reason_mid_enroll( str(entry_bar_key or ""), enroll, tf_min=int(tf_min or 1), params=params, ) # ------------------------------------------------------------------ # OrderManager 호출 래퍼 # ------------------------------------------------------------------ def _submit_buy(self, signal: Dict): from ..execution.order_manager import OrderRequest import json code = signal["code"] feats = signal.get("entry_features") or {} if "_env_snapshot" not in feats: snap = {} try: ob = self.ws.get_orderbook(code) if ob: snap = ob.copy() except Exception: pass if snap: # 불필요한 큰 필드 제거 (메모리/DB 최적화) snap.pop("raw", None) feats["_env_snapshot"] = json.dumps(snap, ensure_ascii=False) else: feats["_env_snapshot"] = "{}" signal["entry_features"] = feats req = OrderRequest( strategy_id=self.strategy_id, code=signal["code"], name=signal.get("name", signal["code"]), side="BUY", qty=int(signal.get("qty", 0)), price_ref=float(signal.get("price", 0)), stop_price=float(signal.get("stop_price", 0)), target_price=float(signal.get("target_price", 0)), 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 and not signal.get("use_limit_buy"): # 로컬 holdings 갱신 (DB 는 OrderManager 가 이미 upsert 함) fp = float(result.filled_avg_price) self.holdings[req.code] = { "buy_price": fp, "qty": result.filled_qty, "stop_price": req.stop_price, "target_price": req.target_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 "", } self._capture_runtime_overlay() try: from ..utils.today_trades_cache import invalidate_today_trades_cache invalidate_today_trades_cache() except Exception: pass 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: if any(k in (result.reason or "") for k in ("매매불가", "40070000")): self.untradable_skip.add(req.code) return result def _submit_sell(self, signal: Dict): from ..execution.order_manager import OrderRequest req = OrderRequest( strategy_id=self.strategy_id, code=signal["code"], name=signal.get("name", signal["code"]), side="SELL", qty=int(signal.get("qty", 0)), price_ref=float(signal.get("current_price", signal.get("price", 0))), reason=signal.get("reason", ""), buy_price=float(signal.get("buy_price", 0)), profit_pct=float(signal.get("profit_pct", 0)), ) result = self.order_mgr.place(req) reason = str(result.reason or "") cooldown_sec = max( 0, int(get_env_int("SELL_LOCKED_ENQUEUE_COOLDOWN_SEC", 20) or 0), ) if cooldown_sec > 0 and ( reason.startswith("sell_locked:") or reason in ("sellable_zero", "sell_order_pending") ): self._sell_locked_until[req.code] = time.time() + float(cooldown_sec) if result.success: self.recently_sold[req.code] = time.time() self._drop_local_position(req.code) elif result.reason == "broker_no_position": if req.code in self.holdings: self.logger.info( "🧹 [유령정리] %s %s — 로컬 holdings 제거 (%s)", req.name, req.code, result.reason, ) self._drop_local_position(req.code) 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: """매도·EOD 포함 실매 세션이 열려 있는지 (정규장 마감까지). ``{STRATEGY_ID}_TIME_END`` 는 **매수 종료** 전용 — 여기서는 사용하지 않는다. ``FORCE_MARKET_OPEN=true`` 면 모든 시간 통과 (백테스트/디버그용). """ if get_env_bool("FORCE_MARKET_OPEN", False): return True now = dt.now() if now.weekday() >= 5: # 토/일 return False hhmm_now = now.hour * 100 + now.minute start, end = self._session_time_bounds() return start <= hhmm_now <= end def check_buy_allowed(self) -> bool: """신규 매수 허용 시간 — ``{SID}_TIME_END`` / ``TIME_END`` 기준 (매수 종료). EOD(``{SID}_EOD_HM``) 시각 이후에는 신규매수 금지. 벽시계 비교라 익일 장중(EOD 전)에는 자동으로 다시 허용된다. 전략에 ``eod_enabled`` 가 없거나 false 면 EOD 매수차단 없음 (예: 해외모멘텀 기본 ``US_MOMENTUM_EOD_ENABLED=false``). """ 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() if not (start <= hhmm_now <= buy_end): return False # EOD 이후 신규매수 차단 — SCALP 15:25 청산 직후 재매수(003470) 재발 방지 # 해외모멘텀은 check_buy_allowed 오버라이드 + eod 기본 OFF → 국장 EOD에 안 걸림 if is_live_eod_now( bool(getattr(self, "eod_enabled", False)), str(getattr(self, "eod_hm", "15:20") or "15:20"), now, default_hm="15:20", ): return False # LS 복구 중 신규매수 게이트 (기본 OFF — LS_WS_BLOCK_BUY_WHILE_RECOVERING) try: from kis_trader.engine.ls_feed_gate import ls_feed_blocks_new_buy if ls_feed_blocks_new_buy(): return False except Exception: pass return True def _max_stocks(self) -> int: """전략별 동시 보유 한도. 우선순위: 1. ``{STRATEGY_ID}_MAX_STOCKS`` (예: ``SCALP_MAX_STOCKS``) 2. ``MAX_STOCKS`` (글로벌, 미설정/구버전 호환) 3. 3 (최후 fallback) """ sid = self.strategy_id.upper() 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) # DB sync 시 holdings 에 합치지 않고 _runtime 만 유지하는 장중 오버레이 키 _RUNTIME_OVERLAY_KEYS: Tuple[str, ...] = ( "max_price", "session_low", "updow_entry_bar_key", "box_low", "box_high", ) def _load_holdings_from_db(self, *, log_restore: bool = False) -> None: """DB → holdings 강제 동기화 (지정가 체결·복원 등 이벤트 시).""" self._sync_holdings_from_db(log_restore=log_restore, force=True) def _drop_local_position(self, code: str) -> None: """매도·유령정리 후 메모리 보유·런타임 오버레이 제거.""" self.holdings.pop(code, None) self._runtime.pop(code, None) def _should_skip_holdings_db_sync(self, *, force: bool, log_restore: bool) -> bool: """하이브리드 안전망: 간격 미경과면 DB get_active_trades 생략. HOLDINGS_DB_SYNC_INTERVAL_SEC=0 → 매 루프 sync(레거시). force/log_restore → 항상 수행. """ if force or log_restore: return False try: interval = max(0, int(get_env_int("HOLDINGS_DB_SYNC_INTERVAL_SEC", 60) or 0)) except Exception: interval = 60 if interval <= 0: return False last = float(getattr(self, "_holdings_db_sync_ts", 0.0) or 0.0) if last <= 0.0: return False if (time.time() - last) < float(interval): self._prof_sync_db_ms = 0.0 self._prof_sync_merge_ms = 0.0 self._prof_sync_skipped = 1 return True return False def _merge_runtime_overlay( self, code: str, avg_bp: float, db_max: float, db_sess_low: float, ) -> Tuple[float, float]: """DB 행 + _runtime → max_price/session_low (퇴행 방지).""" rt = self._runtime.get(code) or {} max_p = max( avg_bp, float(db_max or 0), float(rt.get("max_price") or 0), ) sess_candidates = [ v for v in ( avg_bp, float(db_sess_low or 0), float(rt.get("session_low") or 0), ) if v > 0 ] sess_low = min(sess_candidates) if sess_candidates else avg_bp return max_p, sess_low def _apply_runtime_extra_fields(self, code: str, holding: Dict[str, Any]) -> None: """UPDOW 등 전략 부가 필드를 _runtime → holdings 로 복원.""" rt = self._runtime.get(code) or {} for k in self._RUNTIME_OVERLAY_KEYS: if k in ("max_price", "session_low"): continue if k in rt and rt[k] is not None: holding[k] = rt[k] def _capture_runtime_overlay(self) -> None: """매도 판단 루프가 갱신한 고점·저점을 _runtime 에 저장.""" for code, h in self.holdings.items(): rt = self._runtime.setdefault(code, {}) mp = float(h.get("max_price") or 0) if mp > float(rt.get("max_price") or 0): rt["max_price"] = mp sl = float(h.get("session_low") or 0) if sl > 0: prev = float(rt.get("session_low") or 0) rt["session_low"] = sl if prev <= 0 else min(prev, sl) for k in self._RUNTIME_OVERLAY_KEYS: if k in ("max_price", "session_low"): continue if k in h and h[k] is not None: rt[k] = h[k] def _after_holdings_sync(self) -> None: """서브클래스 훅 — DB sync 직후 (UPDOW entry_bar_key 등).""" return None def _sync_holdings_from_db(self, *, log_restore: bool = False, force: bool = False) -> None: """DB active_trades → holdings. 하이브리드: 이벤트 RAM + 주기 안전망. - 재시작(log_restore) / force / 지정가 체결(_load_holdings_from_db): 즉시 DB - 루프: HOLDINGS_DB_SYNC_INTERVAL_SEC(기본 60)마다만 DB (공유 conn 락 완화) - 시장가 매수·매도 성공 시 RAM은 이미 갱신 (_submit_buy / _drop_local_position) - 장중 고점은 _runtime 과 merge. DB에 없는 종목은 holdings·_runtime 제거 - ETF/ETN/스팩 등 시세 불가 종목만 보유 루프에서 제외. 우선주는 매수 차단 대상이지만, 이미 산 경우 매도 가능하도록 유지. """ if self._should_skip_holdings_db_sync(force=force, log_restore=log_restore): return self._prof_sync_skipped = 0 try: prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id t_db0 = time.perf_counter() rows = self.db.get_active_trades(strategy_prefix=prefix) self._prof_sync_db_ms = (time.perf_counter() - t_db0) * 1000.0 t_m0 = time.perf_counter() skipped_non_stock: list[str] = [] new_holdings: Dict[str, dict] = {} for code, t in rows.items(): if t.get("strategy") and t["strategy"] != self.strategy_id: continue if get_env_bool("EXCLUDE_NON_STOCK", True): name = (t.get("name") or "").strip() # 우선주(is_non_stock)는 매도 정리 위해 holdings 유지. # ETF/ETN/스팩만 자동 제외. if _is_unmanageable_holding(name, code): skipped_non_stock.append(f"{code}({name})") continue avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0) qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0) if qty <= 0 or avg_bp <= 0: continue db_max = float(t.get("max_price") or 0) db_sess = float(t.get("session_low") or 0) max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess) holding = { "buy_price": avg_bp, "qty": qty, "stop_price": t.get("stop_price", 0), "target_price": t.get("target_price", 0), "max_price": max_p, "session_low": sess_low, "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), "size_class": t.get("size_class", ""), } self._apply_runtime_extra_fields(code, holding) new_holdings[code] = holding db_codes = set(new_holdings.keys()) for code in list(self._runtime.keys()): if code not in db_codes: del self._runtime[code] prev_codes = set(self.holdings.keys()) self.holdings.clear() self.holdings.update(new_holdings) self._after_holdings_sync() self._prof_sync_merge_ms = (time.perf_counter() - t_m0) * 1000.0 self._holdings_db_sync_ts = time.time() if log_restore and self.holdings: self.logger.info( "📂 [DB 복원] 보유 %d종목 (%s)", len(self.holdings), self.strategy_id, ) elif not log_restore: added = db_codes - prev_codes if added: self.logger.info( "📂 [DB동기화] +%d종목 (%s) poll/체결 반영: %s", len(added), self.strategy_id, ",".join(sorted(added)[:5]), ) removed = prev_codes - db_codes if removed: self.logger.debug( "📂 [DB동기화] -%d종목 (%s) 청산 반영: %s", len(removed), self.strategy_id, ",".join(sorted(removed)[:5]), ) if skipped_non_stock: self.logger.warning( "⚠️ ETF/ETN/스팩 보유 자동 제외(시세불가 — 한투 HTS에서 직접 처분 권장): %s", ", ".join(skipped_non_stock), ) except Exception as e: self.logger.warning("DB holdings 동기화 실패: %s", e) def _load_candidates(self) -> List[Dict]: """ 후보 로드 우선순위: 1) universe_source == "ranking" → VolumeRankManager 2) universe_source == "condition" → ConditionSearchManager (KIS REST) 3) universe_source == "kiwoom_condition" → KiwoomConditionSearchManager (키움 WS) 4) universe_source == "ls_condition" → LsConditionSearchManager (LS AFR) ⚡ 운영 스위치는 **{SID}_UNIVERSE_SOURCE 하나** 만 바꾸면 됨. - condition + CONDITION_{SID}_NAME/SEQ → KIS 조건 (REST 폴링) - kiwoom_condition + CONDITION_{SID}_NAME → 키움 조건 (WS 실시간, seq 자동) - ls_condition + CONDITION_{SID}_NAME/LS_NAME → LS 동명 조건 (AFR, 시세는 키움/한투) - 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", "kiwoom_condition", "ls_condition", ): current_src = default if current_src != self.universe_source: self.logger.info( "🔄 [유니버스 소스 전환] %s: %s → %s (재시작 없이 즉시 반영)", self.strategy_id, self.universe_source, current_src, ) self.universe_source = current_src # 소스명 → 매니저 매핑. primary(선택 소스) 먼저, 나머지는 폴백 순. # ※ ls_condition 선택 시 키움/KIS 로 자동 폴백하지 않음 (의도 스위치 존중). mgr_by_src = { "ranking": self.ranking_mgr, "condition": self.condition_mgr, "kiwoom_condition": self.kiwoom_condition_mgr, "ls_condition": self.ls_condition_mgr, } if self.universe_source == "ls_condition": order = ["ls_condition"] else: order = [self.universe_source] + [ s for s in ("condition", "kiwoom_condition", "ranking") if s != self.universe_source ] candidates: List[Dict] = [] do_prof = self._loop_profile_on() self._prof_cand_src = current_src self._prof_cand_mgr_ms = 0.0 self._prof_cand_post_ms = 0.0 self._prof_cand_slot_ms = 0.0 for tag in order: mgr = mgr_by_src.get(tag) if mgr is None: continue if not self._is_strategy_registered(mgr): continue try: t_m0 = time.perf_counter() if do_prof else 0.0 universe = mgr.get_universe_for(self.strategy_id) # 매니저에 등록은 돼 있으나 아직 비어있음 → [] 이 정답 (폴백 금지). # (설정된 유니버스가 비어있을 수 있는 상태: 장 마감 후 등) if universe: candidates = mgr.get_candidates_for(self.strategy_id) or [] else: candidates = [] if do_prof: self._prof_cand_mgr_ms = (time.perf_counter() - t_m0) * 1000.0 self._prof_cand_src = tag out = self._post_filter_candidates(candidates) if do_prof: self._prof_cand_n = len(out) return out except Exception as e: self.logger.debug("%s 유니버스 로드 실패: %s", tag, e) # 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로 # (ls_condition 은 DB 폴백도 하지 않음 — 빈 후보) if self.universe_source == "ls_condition": out = self._post_filter_candidates([]) if do_prof: self._prof_cand_n = len(out) return out try: t_m0 = time.perf_counter() if do_prof else 0.0 candidates = self.db.get_target_candidates() or [] if do_prof: self._prof_cand_mgr_ms = (time.perf_counter() - t_m0) * 1000.0 self._prof_cand_src = "db_legacy" except Exception as e: self.logger.debug("target_candidates 조회 실패: %s", e) candidates = [] out = self._post_filter_candidates(candidates) if do_prof: self._prof_cand_n = len(out) return out def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]: """매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡. - ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외. ``kis_trader.utils.non_stock.is_non_stock`` (코드 끝 5/7/9·알파벳 + 이름). - ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용. WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장. (조건검색 매니저는 신규 ENTER·t1859 스냅을 앞으로 두어 캡에 신규가 안 잘리게 함) """ do_prof = self._loop_profile_on() t_post0 = time.perf_counter() if do_prof else 0.0 if not candidates: if do_prof: self._prof_cand_post_ms = (time.perf_counter() - t_post0) * 1000.0 self._prof_cand_slot_ms = 0.0 return [] # 1) 비본주 필터 if get_env_bool("EXCLUDE_NON_STOCK", True): filtered: List[Dict] = [] dropped = 0 for c in candidates: code = (c.get("code") or c.get("stk_cd") or "").strip() name = (c.get("name") or c.get("stk_nm") or "").strip() if _is_non_stock(name, code): dropped += 1 continue filtered.append(c) if dropped: self.logger.debug("🛡 비본주 자동 제외 %d종목 (ETN/ETF/스팩/우선주)", dropped) candidates = filtered # 2) 전략별 후보 하드캡 sid = self.strategy_id.upper() cap = get_env_int(f"{sid}_CAND_LIMIT", 0) if cap > 0 and len(candidates) > cap: self.logger.debug( "✂ 후보 하드캡 적용: %d → %d (%s_CAND_LIMIT=%d)", len(candidates), cap, sid, cap, ) candidates = candidates[:cap] # 실매 후보 = 키움/매니저 RAM. history 교집합 없음 (백테 재생용 복사본만). if do_prof: self._prof_cand_post_ms = (time.perf_counter() - t_post0) * 1000.0 self._prof_cand_slot_ms = 0.0 return candidates def _is_strategy_registered(self, mgr) -> bool: """매니저의 _configs 에 본 전략이 들어있는지.""" if mgr is None: return False try: cfgs = getattr(mgr, "_configs", []) return any(c.get("strategy_id") == self.strategy_id for c in cfgs) except Exception: return False def on_new_day(self) -> None: """날짜 변경 훅 (서브클래스 오버라이드 가능).""" pass def _get_today_trades(self, today: Optional[str] = None) -> List[Dict]: """당일 trade_history — 전략 공유 RAM + TTL (종목·전략마다 SELECT 금지). 일일 횟수/손익 게이트·pre_guard 가 동일 캐시 사용. TTL(기본 1초) 내·같은 날짜면 DB 안 감. 매수 체결 시 invalidate. """ from ..utils.today_trades_cache import get_today_trades_cached day = str(today or dt.now().strftime("%Y%m%d")) rows, from_cache, db_ms = get_today_trades_cached(self.db, day) if from_cache: self._prof_trd_hit += 1 else: self._prof_trd_miss += 1 self._prof_trd_db_ms += float(db_ms or 0) self._today_trades_cache_day = day self._today_trades_cache = rows return rows def reload_config(self) -> None: """설정 리로드 훅 (서브클래스 오버라이드).""" pass def _strategy_switch_enabled(self) -> bool: """``STRATEGY_{SID}_ENABLED`` — 운영설정 ON/OFF 핫게이트. False 여도 WS 구독은 유지(해제 안 함). 기동 시 OFF 전략은 쓰레드 자체가 없음. """ sid = str(getattr(self, "strategy_id", "") or "").strip().upper() if not sid: return True # main._register_strategies 기본값과 동기 defaults = { "SCALP": True, "SHORT": True, "MOMENTUM": False, "US_MOMENTUM": True, "BREAKOUT": False, "RANGE_BREAK": False, "UPDOW": False, "DBBAND": False, "DART": False, } return bool(get_env_bool(f"STRATEGY_{sid}_ENABLED", defaults.get(sid, True))) # ------------------------------------------------------------------ # 서브클래스 구현 필수 메서드 # ------------------------------------------------------------------ @abstractmethod def check_buy(self, code: str, name: str) -> Optional[Dict]: """매수 시그널. 성공 시 dict(code/name/price/qty/stop_price/...).""" raise NotImplementedError @abstractmethod def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: """보유 종목 순회 → 매도 시그널 리스트. only_code=틱매도 1종목만.""" raise NotImplementedError def _candidate_filter(self, candidate: Dict) -> bool: """후보 중 본 전략이 관심 있는 것만 True. 기본 True.""" return True