feat: Implement backtest source management and enhance candle data handling Changes: - Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests. - Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment. - Refactored existing code to utilize the new source management functions, improving code readability and maintainability. - Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources. Impact: - These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
1793 lines
80 KiB
Python
1793 lines
80 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 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:호가 provider 접두어를 붙인다.
|
|
|
|
예: 🔍 [탈락-RSI] → 🔍 [T:kiwoom|O:kiwoom|탈락-RSI]
|
|
(피드 출처 디버깅용 · 매매 수치 아님)
|
|
"""
|
|
|
|
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[:24]:
|
|
tick_p, ob_p = _live_feed_providers()
|
|
if tick_p or ob_p:
|
|
msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1)
|
|
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, "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
|
|
# 루프 숙제별 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._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 _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)",
|
|
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 경로로 매도 검사.
|
|
|
|
매도 규칙은 루프 매도와 동일 함수. 바뀌는 것은 호출 시점(틱)뿐.
|
|
"""
|
|
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 []
|
|
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
|
|
try:
|
|
self.order_mgr.prefetch_broker_holdings()
|
|
except Exception:
|
|
pass
|
|
for sig in sell_signals:
|
|
if (sig.get("code") or "") == code:
|
|
self._submit_sell(sig)
|
|
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:
|
|
self._submit_sell(sig)
|
|
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
|
|
|
|
# ------------------------------------------------------------------
|
|
# 스캔 루프 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, "_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())
|
|
# KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.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.logger.info("🔍 [%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]
|
|
|
|
# 3) 백테 유니버스 슬롯 정합 — history 스냅샷 ∩ 실시간 후보
|
|
# ls_condition → ls_candidates_history / 그 외 → target_candidates_history
|
|
from ..utils.live_portfolio_common import (
|
|
filter_candidates_by_history_universe,
|
|
live_universe_slot_align_enabled,
|
|
resolve_live_universe_history_source,
|
|
)
|
|
slot_ms = 0.0
|
|
if live_universe_slot_align_enabled(sid):
|
|
before = len(candidates)
|
|
univ_src = str(getattr(self, "universe_source", "") or "")
|
|
hist_src = resolve_live_universe_history_source(
|
|
sid, universe_source=univ_src,
|
|
)
|
|
t_slot0 = time.perf_counter() if do_prof else 0.0
|
|
slot_prof: Dict[str, float] = {} if do_prof else None # type: ignore[assignment]
|
|
candidates, dropped = filter_candidates_by_history_universe(
|
|
candidates,
|
|
self.db,
|
|
sid,
|
|
universe_source=univ_src,
|
|
history_source=hist_src,
|
|
prof=slot_prof,
|
|
)
|
|
if do_prof:
|
|
slot_ms = (time.perf_counter() - t_slot0) * 1000.0
|
|
self._prof_slot_et_ms = float((slot_prof or {}).get("slot_et_ms", 0) or 0)
|
|
self._prof_slot_get_ms = float((slot_prof or {}).get("slot_get_ms", 0) or 0)
|
|
self._prof_slot_ok = int((slot_prof or {}).get("slot_ok", 0) or 0)
|
|
self._prof_slot_miss = int((slot_prof or {}).get("slot_miss", 0) or 0)
|
|
self._prof_slot_cache_hit = int((slot_prof or {}).get("slot_cache_hit", 0) or 0)
|
|
self._prof_slot_cache_miss = int((slot_prof or {}).get("slot_cache_miss", 0) or 0)
|
|
if dropped > 0:
|
|
now_ts = time.time()
|
|
last = getattr(self, "_universe_slot_log_ts", 0.0)
|
|
if now_ts - last >= 120:
|
|
self._universe_slot_log_ts = now_ts
|
|
self.logger.info(
|
|
"🔍 [유니버스슬롯] %s 후보 %d→%d "
|
|
"(history=%s 교집합, 제외 %d)",
|
|
sid, before, len(candidates), hist_src, dropped,
|
|
)
|
|
# 전량 탈락 = 운영 치명 (장중만 ops_alert 세션게이트)
|
|
if before > 0 and len(candidates) == 0:
|
|
try:
|
|
from ..utils.ops_alert import ops_alert
|
|
ops_alert(
|
|
"universe_wipe",
|
|
f"{sid} 유니버스 전량탈락 {before}→0",
|
|
detail=f"history={hist_src} 교집합 제외 {dropped}",
|
|
level="critical",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
if do_prof:
|
|
self._prof_cand_post_ms = (time.perf_counter() - t_post0) * 1000.0
|
|
self._prof_cand_slot_ms = slot_ms
|
|
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
|