ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -76,6 +76,7 @@ class BaseStrategy(ABC, threading.Thread):
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}")
@@ -87,19 +88,23 @@ class BaseStrategy(ABC, threading.Thread):
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 이면 가드 없음)
self.logger = get_logger(f"kis_trader.strategy.{self.strategy_id}")
# MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회)
self._panic_log_ts: float = 0.0
# 유니버스 소스: "ranking" | "condition"(KIS) | "kiwoom_condition"(키움 WS)
# 유니버스 소스: 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"):
if self.universe_source not in (
"ranking", "condition", "kiwoom_condition", "ls_condition",
):
self.logger.warning(
"알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용",
self.universe_source, default,
@@ -113,12 +118,19 @@ class BaseStrategy(ABC, threading.Thread):
self._runtime: Dict[str, dict] = {}
# 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프)
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
self._sync_holdings_from_db(log_restore=True)
# ------------------------------------------------------------------
@@ -127,6 +139,87 @@ class BaseStrategy(ABC, threading.Thread):
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):
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()
# ------------------------------------------------------------------
# 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
@@ -173,11 +266,13 @@ class BaseStrategy(ABC, threading.Thread):
"""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)
# ------------------------------------------------------------------
@@ -221,19 +316,31 @@ class BaseStrategy(ABC, threading.Thread):
# 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
self._sync_holdings_from_db()
# 일일익절 B안 — 매수루프 비어도 hit 후 보유 리스크 정리
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)
# ── [1] 매도 먼저 ────────────────────────────────
sell_signals = self.check_sell_signals()
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
self.order_mgr.prefetch_broker_holdings()
for sig in sell_signals:
self._submit_sell(sig)
with self._sell_lock:
sell_signals = self.check_sell_signals()
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
self.order_mgr.prefetch_broker_holdings()
for sig in sell_signals:
self._submit_sell(sig)
# ── [2] 후보 구독 동기화 (공유 WS) ────────────────
candidates = self._load_candidates()
# 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록
self._note_candidate_enters(candidates)
cand_codes = [c.get("code") for c in candidates if c.get("code")]
hold_codes = list(self.holdings.keys())
# KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.sync_targets_split)
self.ws.sync_targets_split(self.strategy_id, cand_codes, hold_codes)
# US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용
self._sync_ws_for_loop(cand_codes, hold_codes)
# ── [2b] 미체결 지정가 만료 취소 ───────────────────
self.manage_pending_orders()
@@ -243,6 +350,17 @@ class BaseStrategy(ABC, threading.Thread):
active_cnt = len(self.holdings)
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]),
)
# 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
self._capture_runtime_overlay()
@@ -256,6 +374,18 @@ class BaseStrategy(ABC, threading.Thread):
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
@@ -387,10 +517,22 @@ class BaseStrategy(ABC, threading.Thread):
except Exception as ex:
self.logger.debug("일일익절 가드 예외(매수 계속): %s", ex)
self.logger.info(
"🔍 [매수체크] 후보 %d (보유 %d/%d)",
len(candidates), active_cnt, max_stocks,
)
_codes = [
str(c.get("code") or c.get("stk_cd") or "").strip()
for c in (candidates or [])
]
_codes = [c for c in _codes if c]
if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"):
self.logger.info(
"🔍 [매수체크] 후보 %d (보유 %d/%d) codes=%s",
len(candidates), active_cnt, max_stocks,
",".join(_codes[:12]),
)
else:
self.logger.info(
"🔍 [매수체크] 후보 %d (보유 %d/%d)",
len(candidates), active_cnt, max_stocks,
)
for c in candidates:
if not self._running:
return
@@ -430,6 +572,45 @@ class BaseStrategy(ABC, threading.Thread):
return # 1루프당 1매수 (포지션 과집중 방지)
time.sleep(self._scan_sleep("buy_fail"))
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 호출 래퍼
# ------------------------------------------------------------------
@@ -553,7 +734,13 @@ class BaseStrategy(ABC, threading.Thread):
return start <= hhmm_now <= end
def check_buy_allowed(self) -> bool:
"""신규 매수 허용 시간 — ``{SID}_TIME_END`` / ``TIME_END`` 기준 (매수 종료)."""
"""신규 매수 허용 시간 — ``{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():
@@ -561,7 +748,25 @@ class BaseStrategy(ABC, threading.Thread):
now = dt.now()
hhmm_now = now.hour * 100 + now.minute
start, buy_end = self._buy_time_bounds()
return start <= hhmm_now <= buy_end
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:
"""전략별 동시 보유 한도.
@@ -747,17 +952,21 @@ class BaseStrategy(ABC, threading.Thread):
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"):
if current_src not in (
"ranking", "condition", "kiwoom_condition", "ls_condition",
):
current_src = default
if current_src != self.universe_source:
self.logger.info(
@@ -767,15 +976,20 @@ class BaseStrategy(ABC, threading.Thread):
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,
}
order = [self.universe_source] + [
s for s in ("condition", "kiwoom_condition", "ranking")
if s != self.universe_source
]
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] = []
for tag in order:
@@ -796,7 +1010,10 @@ class BaseStrategy(ABC, threading.Thread):
except Exception as e:
self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)
# 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
# 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
# (ls_condition 은 DB 폴백도 하지 않음 — 빈 후보)
if self.universe_source == "ls_condition":
return self._post_filter_candidates([])
try:
candidates = self.db.get_target_candidates() or []
except Exception as e:
@@ -840,15 +1057,25 @@ class BaseStrategy(ABC, threading.Thread):
)
candidates = candidates[:cap]
# 3) 백테 유니버스 슬롯 정합 — target_candidates_history 스냅샷 ∩ 실시간 후보
# 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,
)
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,
)
candidates, dropped = filter_candidates_by_history_universe(
candidates, self.db, sid,
candidates,
self.db,
sid,
universe_source=univ_src,
history_source=hist_src,
)
if dropped > 0:
now_ts = time.time()
@@ -856,8 +1083,9 @@ class BaseStrategy(ABC, threading.Thread):
if now_ts - last >= 120:
self._universe_slot_log_ts = now_ts
self.logger.info(
"🔍 [유니버스슬롯] %s 후보 %d%d (history 교집합, 제외 %d)",
sid, before, len(candidates), dropped,
"🔍 [유니버스슬롯] %s 후보 %d%d "
"(history=%s 교집합, 제외 %d)",
sid, before, len(candidates), hist_src, dropped,
)
return candidates