Files
kis_bot/kis_trader/strategies/base.py
Your Name 0780b2cdd0 feat: Enhance Optuna integration and logging for backtesting framework
Changes:
- Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies.
- Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests.
- Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes.
- Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework.

Impact:
- These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
2026-08-21 19:05:23 +09:00

1923 lines
85 KiB
Python

"""
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 queue
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_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 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)
self._sell_lock = threading.Lock()
self._tick_sell_last_ts: Dict[str, float] = {}
self._tick_sell_listener_on = False
# 종목당 매도 1장 (틱 큐·루프 공유). REST/place 는 워커·루프만.
self._sell_inflight: set = set()
self._tick_sell_q: Optional[queue.Queue] = None
self._tick_sell_worker: Optional[threading.Thread] = None
self._tick_sell_worker_lock = threading.Lock()
# 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED)
self._loop_prof_i = 0
self._tick_sell_lock_miss = 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._stop_tick_sell_worker()
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 _tick_sell_async_place(self) -> bool:
"""틱 콜백에서 place()/잔고 REST 금지. 기본 ON."""
return bool(get_env_bool("TICK_SELL_ASYNC_PLACE", True))
def _ensure_tick_sell_worker(self) -> None:
with self._tick_sell_worker_lock:
if self._tick_sell_worker is not None and self._tick_sell_worker.is_alive():
return
if self._tick_sell_q is None:
self._tick_sell_q = queue.Queue()
th = threading.Thread(
target=self._tick_sell_worker_loop,
name="tick-sell-%s" % (self.strategy_id or "BASE"),
daemon=True,
)
self._tick_sell_worker = th
th.start()
def _stop_tick_sell_worker(self) -> None:
q = self._tick_sell_q
if q is not None:
try:
q.put_nowait(None)
except Exception:
pass
def _tick_sell_worker_loop(self) -> None:
q = self._tick_sell_q
if q is None:
return
while True:
try:
item = q.get(timeout=0.3)
except queue.Empty:
if not self._running:
break
continue
if item is None:
break
sig = item if isinstance(item, dict) else {}
code = str(sig.get("code") or "").strip()
try:
self._sell_lock.acquire(blocking=True)
try:
if self._running:
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
try:
self.order_mgr.prefetch_broker_holdings()
except Exception:
pass
self._submit_sell(sig)
finally:
if code:
self._sell_inflight.discard(code)
self._sell_lock.release()
except Exception as ex:
self.logger.warning("틱매도 워커 예외 %s: %s", code or "-", ex)
if code:
try:
self._sell_lock.acquire(blocking=True)
try:
self._sell_inflight.discard(code)
finally:
self._sell_lock.release()
except Exception:
self._sell_inflight.discard(code)
def _mark_sell_inflight(self, code: str) -> bool:
"""호출자 _sell_lock 보유. True=이 종목 매도 슬롯을 가져감."""
code = (code or "").strip()
if not code:
return False
if code in self._sell_inflight:
return False
self._sell_inflight.add(code)
return True
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._ensure_tick_sell_worker()
self.logger.info(
"📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL) async_place=%s",
self.strategy_id,
self._tick_sell_async_place(),
)
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 경로로 매도 검사.
매도 규칙은 루프 매도와 동일 함수. 바뀌는 것은 호출 시점(틱)뿐.
이 함수는 한투·키움·LS **수신 스레드**에서 돈다.
``TICK_SELL_ASYNC_PLACE``(기본 true) 이면 신호만 큐에 넣고 REST/place 는 워커.
"""
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
if not self._sell_lock.acquire(blocking=False):
self._tick_sell_lock_miss = int(getattr(self, "_tick_sell_lock_miss", 0) or 0) + 1
return
try:
if code not in self.holdings:
return
sell_signals = self.check_sell_signals() or []
for sig in sell_signals:
if (sig.get("code") or "") != code:
continue
if not self._mark_sell_inflight(code):
break
if self._tick_sell_async_place():
self._ensure_tick_sell_worker()
try:
self._tick_sell_q.put_nowait(sig)
except Exception as qex:
self._sell_inflight.discard(code)
self.logger.warning("틱매도 큐 실패 %s: %s", code, qex)
break
try:
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
try:
self.order_mgr.prefetch_broker_holdings()
except Exception:
pass
self._submit_sell(sig)
finally:
self._sell_inflight.discard(code)
break
except Exception as ex:
self.logger.debug("틱매도 예외 %s: %s", code, ex)
finally:
self._sell_lock.release()
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"lock_wait={row.get('lock_wait_ms', 0):.1f}",
f"lock_hold={row.get('lock_hold_ms', 0):.1f}",
f"sell_chk={row.get('sell_chk_ms', 0):.1f}",
f"prefetch={row.get('prefetch_ms', 0):.1f}",
f"submit_sell={row.get('submit_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"tick_lock_miss={row.get('tick_lock_miss', 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_locked(self) -> Dict[str, float]:
"""매도 구간 — _sell_lock blocking. wait/hold/세부 ms 반환."""
out = {
"lock_wait_ms": 0.0,
"lock_hold_ms": 0.0,
"sell_chk_ms": 0.0,
"prefetch_ms": 0.0,
"submit_sell_ms": 0.0,
}
t_wait0 = time.perf_counter()
self._sell_lock.acquire(blocking=True)
out["lock_wait_ms"] = (time.perf_counter() - t_wait0) * 1000.0
t_hold0 = time.perf_counter()
try:
t0 = time.perf_counter()
sell_signals = self.check_sell_signals() or []
out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
t1 = time.perf_counter()
try:
self.order_mgr.prefetch_broker_holdings()
except Exception:
pass
out["prefetch_ms"] = (time.perf_counter() - t1) * 1000.0
t2 = time.perf_counter()
for sig in sell_signals:
sc = str(sig.get("code") or "").strip()
if not sc:
continue
if not self._mark_sell_inflight(sc):
continue
try:
self._submit_sell(sig)
finally:
self._sell_inflight.discard(sc)
out["submit_sell_ms"] = (time.perf_counter() - t2) * 1000.0
finally:
out["lock_hold_ms"] = (time.perf_counter() - t_hold0) * 1000.0
self._sell_lock.release()
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._stop_tick_sell_worker()
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, "_tick_sell_lock_miss", 0) or 0)
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()
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()
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_locked()
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)
if do_prof:
row["halt_ms"] = (time.perf_counter() - t0) * 1000.0
# ── [1] 매도 먼저 ────────────────────────────────
sell_timings = self._run_sell_section_locked()
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
# 중분 편입 시가 애매 가드용 — 후보 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)
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()
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)
elif candidates and active_cnt >= max_stocks:
# 보유만석이면 _scan_and_buy 미진입 → 매수체크 로그가 안 나와 "멈춘 것"처럼 보임
now_m = time.time()
last_m = float(getattr(self, "_last_full_skip_log_ts", 0) or 0)
if now_m - last_m >= 60.0:
self._last_full_skip_log_ts = now_m
self.logger.info(
"🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s",
active_cnt, max_stocks,
",".join(list(self.holdings.keys())[:12]),
)
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["tick_lock_miss"] = int(
getattr(self, "_tick_sell_lock_miss", 0) or 0
) - miss0
# 숙제 합( sleep 제외 ) — total 과 비교해 미계측 구간 파악
part_keys = (
"reload_ms", "sync_hold_ms", "halt_ms",
"lock_wait_ms", "lock_hold_ms",
"cand_ms", "ws_sync_ms", "pending_ms",
"scan_ms", "overlay_ms",
)
# lock_hold 안에 sell_chk/prefetch/submit 포함 → 합산 시 hold만
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 구독 동기화 — 해외 전략은 오버라이드.
``ls_condition``:
- LS US3 = 틱·현재가 (히스토리와 한 묶음)
- 키움 = 갭보정·분봉 (기존 잘 되는 경로)
"""
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 _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]
_tick_p, _ob_p = _live_feed_providers()
if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"):
self.logger.info(
"🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d) codes=%s",
_tick_p, _ob_p,
len(candidates), active_cnt, max_stocks,
",".join(_codes[:12]),
)
else:
self.logger.info(
"🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d)",
_tick_p, _ob_p,
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
result = self._submit_buy(signal)
if result and result.success:
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매수 (포지션 과집중 방지)
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)
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) -> List[Dict]:
"""보유 종목 순회 → 매도 시그널 리스트."""
raise NotImplementedError
def _candidate_filter(self, candidate: Dict) -> bool:
"""후보 중 본 전략이 관심 있는 것만 True. 기본 True."""
return True