Changes: - Updated import paths for `compute_atr_series` and `is_strategy_eod_bar` to reflect new module structure. - Removed the unused `compute_atr_series` function from `tail_engine.py`, streamlining the codebase. Impact: - These changes enhance code organization and maintainability by ensuring that only necessary components are imported and utilized, while also eliminating redundant code.
917 lines
41 KiB
Python
917 lines
41 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
|
|
|
|
|
|
# ETN/ETF/레버리지/인버스/스팩/우선주 자동 필터 키워드
|
|
# (KISClient._is_valid_stock_for_rank 와 동기화 — 한곳만 수정해도 양쪽 적용되도록 모듈 상수화)
|
|
_NON_STOCK_KEYWORDS = (
|
|
"ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물",
|
|
"KODEX", "TIGER", "KBSTAR", "ARIRANG", "HANARO", "SOL ",
|
|
"KOSEF", "ACE ", "KINDEX", "RISE ", "PLUS ", "TIMEFOLIO",
|
|
"파워", "히어로", "SMART", "TREX", "WON", "KOACT",
|
|
)
|
|
|
|
|
|
def _is_non_stock(name: str, code: str) -> bool:
|
|
"""ETN/ETF/스팩/우선주 등 비본주 여부.
|
|
|
|
조건검색·랭킹 매니저 양쪽에서 들어오는 후보를 동일 규칙으로 거르기 위해
|
|
BaseStrategy 단계에서 한번 더 차단한다. False 면 매매 가능 종목.
|
|
"""
|
|
if not name or not code:
|
|
# 이름이 비어있으면 보수적으로 통과 (이후 KIS 응답으로 자연 거름)
|
|
return False
|
|
nm = name.upper()
|
|
if any(k in nm for k in _NON_STOCK_KEYWORDS):
|
|
return True
|
|
if "스팩" in name or "SPAC" in nm:
|
|
return True
|
|
if name.endswith("우") or name.endswith("우B") or name.endswith("(전환)"):
|
|
return True
|
|
# KIS 코드 체계: 7로 시작하는 6자리는 ETN (예: 760006)
|
|
code = code.strip()
|
|
if len(code) == 6 and code[0] == "7":
|
|
return True
|
|
return False
|
|
|
|
|
|
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 = 1.0
|
|
loop_max_sleep: float = 2.0
|
|
|
|
# 전략별 유니버스 소스 기본값 (env 미설정 시) — HTS 조건검색 단일 정책
|
|
DEFAULT_UNIVERSE_SOURCES: Dict[str, str] = {
|
|
"SCALP": "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,
|
|
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
|
|
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)
|
|
# 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"):
|
|
self.logger.warning(
|
|
"알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용",
|
|
self.universe_source, default,
|
|
)
|
|
self.universe_source = default
|
|
|
|
self._running = False
|
|
# 보유 종목 — 매 루프 DB active_trades 와 동기화 (진실의 원천 = DB)
|
|
self.holdings: Dict[str, dict] = {}
|
|
# 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지)
|
|
self._runtime: Dict[str, dict] = {}
|
|
# 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프)
|
|
self.recently_sold: Dict[str, float] = {}
|
|
# 당일 매매불가 종목 (다음 후보로 넘어감)
|
|
self.untradable_skip: set = set()
|
|
|
|
# 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF)
|
|
self.daily_profit_halt: Any = None
|
|
|
|
self._sync_holdings_from_db(log_restore=True)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 외부 인터페이스
|
|
# ------------------------------------------------------------------
|
|
def stop_loop(self) -> None:
|
|
"""쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출)."""
|
|
self._running = False
|
|
|
|
# ------------------------------------------------------------------
|
|
# 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
|
|
# ------------------------------------------------------------------
|
|
# REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
|
|
# 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.
|
|
def _scan_sleep(self, kind: str) -> float:
|
|
"""kind: 'loop'(루프끝) | 'reject'(탈락) | 'buy_ok'(매수성공) | 'buy_fail'(주문실패)."""
|
|
from ..utils.env import get_env_float
|
|
if kind == "loop":
|
|
lo = get_env_float("STRATEGY_LOOP_SLEEP_MIN", self.loop_min_sleep)
|
|
hi = get_env_float("STRATEGY_LOOP_SLEEP_MAX", self.loop_max_sleep)
|
|
elif kind == "reject":
|
|
lo = get_env_float("SCAN_REJECT_SLEEP_MIN", 0.2)
|
|
hi = get_env_float("SCAN_REJECT_SLEEP_MAX", 0.5)
|
|
elif kind == "buy_ok":
|
|
lo = get_env_float("SCAN_BUY_OK_SLEEP_MIN", 1.0)
|
|
hi = get_env_float("SCAN_BUY_OK_SLEEP_MAX", 2.0)
|
|
else: # buy_fail
|
|
lo = get_env_float("SCAN_BUY_FAIL_SLEEP_MIN", 0.3)
|
|
hi = get_env_float("SCAN_BUY_FAIL_SLEEP_MAX", 0.8)
|
|
lo = max(0.0, lo)
|
|
hi = max(lo, hi)
|
|
return random.uniform(lo, hi)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 하락매수(dip) 종목 제외 — 대형주에 떨어지는 칼날 잡기 방지
|
|
# ------------------------------------------------------------------
|
|
def is_dip_buy_excluded(self, code: str) -> bool:
|
|
"""
|
|
하락매수 계열(UPDOW·SHORT) 이 매수하면 안 되는 종목인지 판정.
|
|
- ``DIP_BUY_EXCLUDE_CODES`` : 콤마구분 종목코드 화이트리스트 제외 (기본 빈 값 → 무효)
|
|
예) 삼성전자·하이닉스 등 대형 주도주는 하락매수가 아니라 추세추종 대상.
|
|
기본값이 비어 있으면 기존 동작과 100% 동일 (필터 OFF).
|
|
"""
|
|
raw = str(get_env_from_db("DIP_BUY_EXCLUDE_CODES", "") or "").strip()
|
|
if not raw:
|
|
return False
|
|
code = str(code or "").strip()
|
|
excluded = {c.strip() for c in raw.split(",") if c.strip()}
|
|
return code in excluded
|
|
|
|
def run(self) -> None:
|
|
"""threading.Thread.run() 오버라이드 — 전략 메인 루프."""
|
|
self._running = True
|
|
self.logger.info("🚀 전략 쓰레드 시작 [%s]", self.strategy_id)
|
|
try:
|
|
self._run_loop()
|
|
except Exception as e:
|
|
self.logger.exception("전략 루프 예외: %s", e)
|
|
finally:
|
|
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:
|
|
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 실시간 반영)
|
|
self.reload_config()
|
|
|
|
# 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
|
|
self._sync_holdings_from_db()
|
|
|
|
# ── [1] 매도 먼저 ────────────────────────────────
|
|
sell_signals = self.check_sell_signals()
|
|
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
|
|
self.order_mgr.prefetch_broker_holdings()
|
|
for sig in sell_signals:
|
|
self._submit_sell(sig)
|
|
|
|
# ── [2] 후보 구독 동기화 (공유 WS) ────────────────
|
|
candidates = self._load_candidates()
|
|
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)
|
|
|
|
# ── [2b] 미체결 지정가 만료 취소 ───────────────────
|
|
self.manage_pending_orders()
|
|
|
|
# ── [3] 매수 체크 (매수 종료 TIME_END 와 매도 세션 분리) ──
|
|
max_stocks = self._max_stocks()
|
|
active_cnt = len(self.holdings)
|
|
if candidates and active_cnt < max_stocks and self.check_buy_allowed():
|
|
self._scan_and_buy(candidates, max_stocks, active_cnt)
|
|
|
|
# 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
|
|
self._capture_runtime_overlay()
|
|
|
|
time.sleep(self._scan_sleep("loop"))
|
|
|
|
except KeyboardInterrupt:
|
|
self._running = False
|
|
break
|
|
except Exception as e:
|
|
self.logger.error("루프 예외: %s", e)
|
|
time.sleep(5)
|
|
|
|
def manage_pending_orders(self) -> None:
|
|
"""미체결 ATR 지정가 만료 시 취소 — 서브클래스에서 구현."""
|
|
return None
|
|
|
|
def on_limit_buy_submitted(self, signal: Dict, result) -> None:
|
|
"""지정가 접수 성공 — 체결 전까지 holdings 미반영 (서브클래스)."""
|
|
return None
|
|
|
|
def _resolve_buy_qty_live(
|
|
self,
|
|
curr_price: float,
|
|
*,
|
|
invest_cap: Optional[float] = None,
|
|
hard_cap: int = 0,
|
|
max_stocks: Optional[int] = None,
|
|
) -> Tuple[int, Optional[str]]:
|
|
"""포트폴리오 정합 ON → ``resolve_live_buy_qty``, OFF → ``invest_qty_for_price``."""
|
|
from ..utils.position_sizing import invest_qty_for_price
|
|
|
|
cap = float(
|
|
invest_cap if invest_cap is not None
|
|
else getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
|
|
)
|
|
if hard_cap > 0 and cap > hard_cap:
|
|
cap = float(hard_cap)
|
|
if self._live_portfolio_budget_enabled():
|
|
qty, _, rej = self._resolve_live_buy_qty(
|
|
curr_price, invest_cap=cap, max_stocks=max_stocks,
|
|
)
|
|
return qty, rej
|
|
qty = invest_qty_for_price(curr_price, cap)
|
|
if qty < 1:
|
|
return 0, "수량0"
|
|
return qty, None
|
|
|
|
def _live_portfolio_budget_enabled(self) -> bool:
|
|
from ..utils.live_portfolio_common import live_portfolio_budget_align_enabled
|
|
return live_portfolio_budget_align_enabled(self.strategy_id)
|
|
|
|
def _portfolio_exposure_krw(self) -> float:
|
|
from ..backtest.backtest_portfolio_common import portfolio_exposure_krw
|
|
return portfolio_exposure_krw(self.holdings)
|
|
|
|
def _live_total_budget_krw(self, max_stocks: Optional[int] = None) -> float:
|
|
from ..utils.live_portfolio_common import resolve_live_total_budget_krw
|
|
ms = max_stocks if max_stocks is not None else self._max_stocks()
|
|
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
|
|
return resolve_live_total_budget_krw(self.strategy_id, ms, slot)
|
|
|
|
def _live_portfolio_budget_full(self, max_stocks: Optional[int] = None) -> bool:
|
|
from ..utils.live_portfolio_common import live_portfolio_budget_full
|
|
if not self._live_portfolio_budget_enabled():
|
|
return False
|
|
ms = max_stocks if max_stocks is not None else self._max_stocks()
|
|
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
|
|
return live_portfolio_budget_full(self.holdings, self.strategy_id, slot, ms)
|
|
|
|
def _live_portfolio_entry_guard(self, code: str, max_stocks: Optional[int] = None) -> Optional[str]:
|
|
from ..utils.live_portfolio_common import live_portfolio_entry_reject
|
|
if not self._live_portfolio_budget_enabled():
|
|
return None
|
|
ms = max_stocks if max_stocks is not None else self._max_stocks()
|
|
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
|
|
return live_portfolio_entry_reject(
|
|
self.db, self.holdings, self.strategy_id, code, slot, ms,
|
|
)
|
|
|
|
def _resolve_live_buy_qty(
|
|
self,
|
|
curr_price: float,
|
|
*,
|
|
invest_cap: Optional[float] = None,
|
|
max_stocks: Optional[int] = None,
|
|
) -> Tuple[int, float, Optional[str]]:
|
|
from ..utils.live_portfolio_common import resolve_live_buy_qty
|
|
ms = max_stocks if max_stocks is not None else self._max_stocks()
|
|
slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
|
|
return resolve_live_buy_qty(
|
|
curr_price,
|
|
self.holdings,
|
|
self.strategy_id,
|
|
slot,
|
|
max_stocks=ms,
|
|
invest_cap=invest_cap,
|
|
)
|
|
|
|
def _scan_and_buy(self, candidates: List[Dict], max_stocks: int, active_cnt: int) -> None:
|
|
if self._live_portfolio_budget_full(max_stocks):
|
|
now_ts = time.time()
|
|
last = getattr(self, "_live_budget_full_log_ts", 0.0)
|
|
if now_ts - last >= 300:
|
|
self._live_budget_full_log_ts = now_ts
|
|
self.logger.info(
|
|
"🔍 [탈락-총한도] %s exposure=%.0f >= budget=%.0f (보유 %d/%d)",
|
|
self.strategy_id,
|
|
self._portfolio_exposure_krw(),
|
|
self._live_total_budget_krw(max_stocks),
|
|
active_cnt, max_stocks,
|
|
)
|
|
return
|
|
|
|
# ── 시장 급락 서킷브레이커 가드 ────────────────────────────────
|
|
# MarketGuard 가 PANIC 모드면 신규 매수 전면 차단.
|
|
# 매도는 평소처럼 동작 (포지션 정리·손실 확대 방지).
|
|
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)
|
|
|
|
self.logger.info(
|
|
"🔍 [매수체크] 후보 %d (보유 %d/%d)",
|
|
len(candidates), active_cnt, max_stocks,
|
|
)
|
|
for c in candidates:
|
|
if not self._running:
|
|
return
|
|
code = c.get("code") or c.get("stk_cd", "")
|
|
name = c.get("name") or c.get("stk_nm", code)
|
|
if not code or code in self.holdings:
|
|
continue
|
|
if code in self.untradable_skip:
|
|
continue
|
|
if not self._candidate_filter(c):
|
|
continue
|
|
guard = self._live_portfolio_entry_guard(code, max_stocks)
|
|
if guard:
|
|
self.logger.info("🔍 [%s] %s(%s)", guard, name, code)
|
|
continue
|
|
# 재진입 쿨다운
|
|
cooldown_sec = self._reentry_cooldown_sec()
|
|
elapsed = time.time() - self.recently_sold.get(code, 0)
|
|
if elapsed < cooldown_sec:
|
|
continue
|
|
|
|
signal = self.check_buy(code, name)
|
|
if not signal:
|
|
time.sleep(self._scan_sleep("reject"))
|
|
continue
|
|
|
|
result = self._submit_buy(signal)
|
|
if result and result.success:
|
|
time.sleep(self._scan_sleep("buy_ok"))
|
|
return # 1루프당 1매수 (포지션 과집중 방지)
|
|
time.sleep(self._scan_sleep("buy_fail"))
|
|
|
|
# ------------------------------------------------------------------
|
|
# OrderManager 호출 래퍼
|
|
# ------------------------------------------------------------------
|
|
def _submit_buy(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="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()
|
|
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`` 기준 (매수 종료)."""
|
|
if get_env_bool("FORCE_MARKET_OPEN", False):
|
|
return True
|
|
if not self.check_market_status():
|
|
return False
|
|
now = dt.now()
|
|
hhmm_now = now.hour * 100 + now.minute
|
|
start, buy_end = self._buy_time_bounds()
|
|
return start <= hhmm_now <= buy_end
|
|
|
|
def _max_stocks(self) -> int:
|
|
"""전략별 동시 보유 한도.
|
|
|
|
우선순위:
|
|
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)
|
|
|
|
def _drop_local_position(self, code: str) -> None:
|
|
"""매도·유령정리 후 메모리 보유·런타임 오버레이 제거."""
|
|
self.holdings.pop(code, None)
|
|
self._runtime.pop(code, None)
|
|
|
|
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) -> None:
|
|
"""DB active_trades → holdings (진실의 원천). 장중 고점은 _runtime 과 merge.
|
|
|
|
- DB에 없는 종목은 holdings·_runtime 에서 제거 (양방향 정합)
|
|
- poll_pending 매수 체결·재시작 후에도 다음 루프에 자동 반영
|
|
- ETN/ETF/스팩 등 비본주는 가격 API 불가 → 자동 제외
|
|
"""
|
|
try:
|
|
prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
|
|
rows = self.db.get_active_trades(strategy_prefix=prefix)
|
|
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()
|
|
if _is_non_stock(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()
|
|
|
|
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(
|
|
"⚠️ ETN/ETF 보유 자동 제외(매수/매도 모두 봇이 안 건드림 — 한투 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)
|
|
|
|
⚡ 운영 스위치는 **{SID}_UNIVERSE_SOURCE 하나** 만 바꾸면 됨.
|
|
- condition + CONDITION_{SID}_NAME/SEQ → KIS 조건 (REST 폴링)
|
|
- kiwoom_condition + CONDITION_{SID}_NAME → 키움 조건 (WS 실시간, seq 자동)
|
|
- ranking → 거래량/거래대금 순위
|
|
(키움 seq 를 고정하려면 CONDITION_{SID}_KIWOOM_SEQ 선택 설정)
|
|
"""
|
|
key = f"{self.strategy_id}_UNIVERSE_SOURCE"
|
|
default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
|
|
current_src = (get_env_from_db(key, default) or default).strip().lower()
|
|
if current_src not in ("ranking", "condition", "kiwoom_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(선택 소스) 먼저, 나머지는 폴백 순.
|
|
mgr_by_src = {
|
|
"ranking": self.ranking_mgr,
|
|
"condition": self.condition_mgr,
|
|
"kiwoom_condition": self.kiwoom_condition_mgr,
|
|
}
|
|
order = [self.universe_source] + [
|
|
s for s in ("condition", "kiwoom_condition", "ranking")
|
|
if s != self.universe_source
|
|
]
|
|
|
|
candidates: List[Dict] = []
|
|
for tag in order:
|
|
mgr = mgr_by_src.get(tag)
|
|
if mgr is None:
|
|
continue
|
|
if not self._is_strategy_registered(mgr):
|
|
continue
|
|
try:
|
|
universe = mgr.get_universe_for(self.strategy_id)
|
|
# 매니저에 등록은 돼 있으나 아직 비어있음 → [] 이 정답 (폴백 금지).
|
|
# (설정된 유니버스가 비어있을 수 있는 상태: 장 마감 후 등)
|
|
if universe:
|
|
candidates = mgr.get_candidates_for(self.strategy_id) or []
|
|
else:
|
|
candidates = []
|
|
return self._post_filter_candidates(candidates)
|
|
except Exception as e:
|
|
self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)
|
|
|
|
# 두 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
|
|
try:
|
|
candidates = self.db.get_target_candidates() or []
|
|
except Exception as e:
|
|
self.logger.debug("target_candidates 조회 실패: %s", e)
|
|
candidates = []
|
|
return self._post_filter_candidates(candidates)
|
|
|
|
def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]:
|
|
"""매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡.
|
|
|
|
- ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외.
|
|
KISClient.filter_rank_rows 와 동일 규칙 (`_is_non_stock`).
|
|
- ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용.
|
|
WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장.
|
|
"""
|
|
if not candidates:
|
|
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) 백테 유니버스 슬롯 정합 — target_candidates_history 스냅샷 ∩ 실시간 후보
|
|
from ..utils.live_portfolio_common import (
|
|
filter_candidates_by_history_universe,
|
|
live_universe_slot_align_enabled,
|
|
)
|
|
if live_universe_slot_align_enabled(sid):
|
|
before = len(candidates)
|
|
candidates, dropped = filter_candidates_by_history_universe(
|
|
candidates, self.db, sid,
|
|
)
|
|
if dropped > 0:
|
|
now_ts = time.time()
|
|
last = getattr(self, "_universe_slot_log_ts", 0.0)
|
|
if now_ts - last >= 120:
|
|
self._universe_slot_log_ts = now_ts
|
|
self.logger.info(
|
|
"🔍 [유니버스슬롯] %s 후보 %d→%d (history 교집합, 제외 %d)",
|
|
sid, before, len(candidates), dropped,
|
|
)
|
|
|
|
return candidates
|
|
|
|
def _is_strategy_registered(self, mgr) -> bool:
|
|
"""매니저의 _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 reload_config(self) -> None:
|
|
"""설정 리로드 훅 (서브클래스 오버라이드)."""
|
|
pass
|
|
|
|
# ------------------------------------------------------------------
|
|
# 서브클래스 구현 필수 메서드
|
|
# ------------------------------------------------------------------
|
|
@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
|