Files
kis_bot/kis_trader/main.py
Your Name 253c95e2c2 feat: 틱/호가 데이터 출처 표출 UI 추가 및 데이터 수집 개선
- 백테스트 및 실거래 시 틱과 호가의 벤더 출처(ob_source, entry_source) 기록 및 추적 강화 (tail_engine.py)
- 웹 UI '체결디버그'에 [틱:kis / 호가:ls] 형태로 데이터 출처를 직관적으로 표출 (backtest.js, backtest.html)
- LS WebSocket 구독 100건 제한 하드코딩 해제 및 env_config_ext 연동 (ls_ws.py)
- 기타 백테스트 웹 및 DB 관련 최적화 적용
2026-09-02 20:53:16 +09:00

2880 lines
130 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/main.py — 통합 오케스트레이터
==========================================
구조:
[공유 인프라] KISClient, TradeDBExt, WSManager, OrderManager
[전략 쓰레드] ScalpingStrategy(독립 Thread), TailCatchStrategy(독립 Thread)
전략 스위치 (DB env_config 또는 OS env):
- ``STRATEGY_SCALP_ENABLED`` (기본 true) — SCALP=reversal(고정)
- ``STRATEGY_SHORT_ENABLED`` (기본 true)
- ``STRATEGY_BREAKOUT_ENABLED`` (기본 false)
- ``STRATEGY_MOMENTUM_ENABLED`` (기본 false) — 키움 'scalp' 조건검색 추종(추세 전략)
- ``STRATEGY_UPDOW_ENABLED`` (기본 false) — ``holding_stock_config`` 관심종목만 직전봉 몸통 하락·다음봉 시가 매수
- ``STRATEGY_SAME_CODE_POLICY`` (allow|block, 기본 allow — OrderManager 에서 사용)
유니버스 소스 스위치 (전략별, 런타임 교체 가능):
- ``SCALP_UNIVERSE_SOURCE`` (기본 "condition")
- ``SHORT_UNIVERSE_SOURCE`` (기본 "kiwoom_condition")
- ``BREAKOUT_UNIVERSE_SOURCE`` (기본 "condition")
- ``MOMENTUM_UNIVERSE_SOURCE`` (기본 "kiwoom_condition" — ranking | condition | kiwoom_condition)
- ``UPDOW_UNIVERSE_SOURCE`` (기본 "condition")
값: "ranking" | "condition"
── "ranking" (KIS 거래량/체결강도 상위 REST) ──
- ``RANKING_POLL_INTERVAL_SEC`` : 폴링 주기 (기본 10)
- 전략별 세부 설정:
SCALP : ``RANK_SCALP_SORT`` (기본 volume) / ``RANK_SCALP_LIMIT`` (기본 100)
SHORT : ``RANK_SHORT_SORT`` (기본 volume) / ``RANK_SHORT_LIMIT`` (기본 100)
BREAKOUT : ``RANK_BREAKOUT_SORT`` (기본 trading_value) / ``RANK_BREAKOUT_LIMIT`` (기본 100)
MOMENTUM : ``RANK_MOMENTUM_SORT`` (기본 volume) / ``RANK_MOMENTUM_LIMIT`` (기본 20)
UPDOW : ``RANK_UPDOW_SORT`` (기본 volume) / ``RANK_UPDOW_LIMIT`` (기본 100)
- sort 값: volume | trading_value | strength | fluct_up | fluct_down
── "condition" (HTS 조건식 REST) ──
- ``KIS_HTS_ID`` : 조건식이 저장된 HTS 로그인 ID (필수)
- ``CONDITION_POLL_INTERVAL_SEC``: 폴링 주기 초 (기본 10)
- 전략별:
SCALP : ``CONDITION_SCALP_NAME`` / ``CONDITION_SCALP_SEQ``
SHORT : ``CONDITION_SHORT_NAME`` / ``CONDITION_SHORT_SEQ``
BREAKOUT: ``CONDITION_BREAKOUT_NAME`` / ``CONDITION_BREAKOUT_SEQ``
MOMENTUM: ``CONDITION_MOMENTUM_NAME`` / ``CONDITION_MOMENTUM_SEQ``
UPDOW : ``CONDITION_UPDOW_NAME`` / ``CONDITION_UPDOW_SEQ``
공통:
- ``UNIVERSE_HISTORY_SAVE`` : 변동 감지 tick 마다 target_candidates_history 에
초단위 event_time 으로 **즉시** INSERT (기본 true). 버퍼/배치 flush 없음.
실행:
$ cd ~/kis_bot
$ python -m kis_trader.main
"""
from __future__ import annotations
import os
import signal
import sys
import time
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Tuple
# 패키지로 실행되지 않은 경우(스크립트 직접 실행) sys.path 보정
if __package__ in (None, ""):
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
__package__ = "kis_trader" # noqa: F841
from .engine.daily_profit_halt import DailyProfitHaltGuard
from .database.db_manager import get_db
from .execution.kis_client import KISClient
from .execution.account_cash import AccountCashLedger
from .execution.order_manager import OrderManager
from .execution.order_worker import AccountOrderWorker
from .network.condition_manager import ConditionSearchManager
from .network.kiwoom_condition_manager import KiwoomConditionSearchManager
from .network.ls_condition_manager import (
DEFAULT_SID_TO_LS_NAME,
LsConditionSearchManager,
)
from .network.market_guard import MarketGuard
from .network.ranking_manager import VolumeRankManager
from .network.ws_manager import WSManager
from .strategies import (
DbBandStrategy,
DartStrategy,
BreakoutStrategy,
MomentumStrategy,
UsMomentumStrategy,
RangeBreakStrategy,
ScalpingStrategy,
TailCatchStrategy,
UpdowStrategy,
)
from .strategies.base import BaseStrategy
from .utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from .utils.logger import get_logger, msg_mm, msg_tg
from .utils.strategy_ids import EXCLUDED_STRATEGY_IDS, canonical_strategy_id
logger = get_logger("kis_trader.main")
# kv_store — 당일 시작 총자산 (재시작해도 09:00 baseline 유지)
_KV_START_ASSET_DATE = "kis_trader.start_day_asset.date"
_KV_START_ASSET_VALUE = "kis_trader.start_day_asset.value"
_LEGACY_STRATEGY_SQL = ", ".join(["%s"] * len(EXCLUDED_STRATEGY_IDS))
# HTS 저장 조건식 목록과 매핑 (seq:name — 기동 시 DB 비어 있으면 자동 채움)
# 0:breakout 1:momentum 2:reversal 3:scalp 4:tail
_DEFAULT_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = {
"SCALP": ("scalp", "3"),
"SHORT": ("tail", "4"),
"MOMENTUM": ("momentum", "1"),
"BREAKOUT": ("breakout", "0"),
"RANGE_BREAK": ("momentum", "1"),
"UPDOW": ("reversal", "2"),
"DBBAND": ("dbband", "5"),
}
# 키움 HTS 전용 조건식 — KIS NAME 과 다를 때만 별도 지정 (비면 CONDITION_{SID}_NAME 폴백)
_DEFAULT_KIWOOM_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = {
"SCALP": ("scalp_re", ""),
"SHORT": ("tail", ""),
"MOMENTUM": ("momentum", ""),
"BREAKOUT": ("breakout", ""),
}
_CONDITION_ENV_KEYS: Dict[str, Tuple[str, str]] = {
"SCALP": ("CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ"),
"SHORT": ("CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ"),
"BREAKOUT": ("CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ"),
"RANGE_BREAK": ("CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ"),
"MOMENTUM": ("CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ"),
"UPDOW": ("CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ"),
"DBBAND": ("CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ"),
}
# 키움 전용 조건식 — ``CONDITION_{SID}_KIWOOM_NAME`` / ``CONDITION_{SID}_KIWOOM_SEQ``
# UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용 (seq 비면 CNSRLST 로 name→seq 자동 해결).
_STARTUP_NOTIFY_STRATEGY_ORDER: Tuple[str, ...] = (
"SCALP", "SHORT", "MOMENTUM", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND",
)
def _mask_account_no(raw: str) -> str:
s = str(raw or "").strip()
if not s:
return "-"
if len(s) <= 5:
return s[0] + "***" + s[-1]
return s[:3] + "***" + s[-2:]
class TradingOrchestrator:
"""전체 라이프사이클 관리 (공유 인프라 초기화 → 전략 쓰레드 기동 → 정리)."""
# 전략별 UNIVERSE_SOURCE 기본값 (BaseStrategy 와 동기화)
_DEFAULT_SOURCE = {
"SCALP": "kiwoom_condition",
"SHORT": "kiwoom_condition",
"BREAKOUT": "kiwoom_condition",
"RANGE_BREAK": "condition",
"MOMENTUM": "kiwoom_condition",
"UPDOW": "condition",
"DBBAND": "condition",
}
_DEFAULT_RANK_SORT = {
"SCALP": "volume",
"SHORT": "volume",
"BREAKOUT": "trading_value",
"RANGE_BREAK": "trading_value",
"MOMENTUM": "volume",
"UPDOW": "volume",
"DBBAND": "volume",
}
def __init__(self):
self.db = get_db()
# ── KIS 클라이언트 분리 정책 ────────────────────────────────────
# 한투 모의 서버는 시세/조회 API (분봉 inquire-time-itemchartprice,
# 조건검색 psearch-*, 거래량순위 volume-rank 등) 다수가 미지원/HTTP 500.
# 정책: **조회는 무조건 실키, 매매·계좌만 KIS_MOCK 따름.**
#
# self.client → 매매/계좌 (KIS_MOCK 따름) → OrderManager 주입
# 예: 주문 place, get_account_balance, 체결조회
# self.market_client → 시세/조회 (항상 실키) → 전략·WS·조건·랭킹 주입
# 예: inquire_price, get_minute_chart, condition,
# volume_rank 등 GET 류
# 실키(KIS_APP_KEY_REAL/_SECRET_REAL) 미설정 시 market_client = self.client
# 폴백 + 워닝. 그 경우 모의 도메인으로 시세 호출 가능 → 일부 500 발생.
self.client = KISClient() # 거래용 (mock or real)
self._market_data_client: KISClient | None = None # lazy 실키
self.market_client: KISClient = self._build_market_client()
self.cash_ledger = AccountCashLedger(self.db)
self.order_mgr = OrderManager(
client=self.client, db=self.db, cash_ledger=self.cash_ledger,
)
self.order_worker = AccountOrderWorker(self.order_mgr)
self.ws = WSManager(db=self.db, kis_client=self.market_client)
# 해외(US) 실시간(지연)체결가 수신기 — 국내 WS와 분리된 별도 인스턴스(야간 가동).
# permanent_subscriptions 테이블의 US 종목을 HDFSCNT0 로 구독. start()에서 기동.
self.overseas_ws = None
self.condition_mgr: ConditionSearchManager | None = None
self.kiwoom_condition_mgr: KiwoomConditionSearchManager | None = None
self.ls_condition_mgr: LsConditionSearchManager | None = None
self._pending_kiwoom_condition_configs: List[dict] = []
self._pending_ls_condition_configs: List[dict] = []
self.ranking_mgr: VolumeRankManager | None = None
# 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 전면 차단).
# 지수 조회는 모의 도메인 미지원 가능성 → 실키 우선, 폴백 self.client.
self.market_guard: MarketGuard | None = None
# 키움 시세 WS (기동은 _start_ws_validator: 키 있으면. WS_PROVIDER 로 끄지 않음)
self.kiwoom_ws = None
self.ws_validator = None
self.ls_ws = None
self.ls_ws_validator = None
self.strategies: List[BaseStrategy] = []
self._stop = False
# ── [자산/리포트 상태] ─────────────────────────────────────────
# - start_day_asset: 장 시작 시점 총자산(예수금+평가액).
# 매수/매도 알림의 '당일손익', 장마감 리포트 계산 기준.
# - total_deposit: 총 입금액 (env_config.TOTAL_DEPOSIT). 누적손익 기준.
# - _asset_cache: get_account_balance() 캐시 (API 호출 폭주 방지)
# - _closing_report_date / _final_report_date: 당일 리포트 중복 발송 가드
self.start_day_asset: float = 0.0
self.total_deposit: float = get_env_float("TOTAL_DEPOSIT", 0.0) or 0.0
self._asset_cache: Optional[Dict[str, Any]] = None
self._asset_cache_ts: float = 0.0
self._asset_cache_ttl: float = float(get_env_int("ASSET_CACHE_TTL_SEC", 2))
self._closing_report_date: str = ""
self._final_report_date: str = ""
# 09:00 장 시작 리포트 중복 발송 가드 (당일 1회)
self._market_open_report_date: str = ""
# 15:36~ 장마감 고아복구 중복 실행 가드 (당일 1회)
self._orphan_reconcile_date: str = ""
# Pre-EOD 고아복구 중복 실행 가드 (당일 1회)
self._orphan_pre_eod_reconcile_date: str = ""
# 08:35~09:15 개장 전/초기 계좌 평단가·수량 동기화 가드 (당일 1회 — 권리락·액면분할 방어)
self._morning_sync_date: str = ""
# start_day_asset 조회가 모의 서버 500 등으로 실패할 때 무한 재시도 방지.
# 다음 시도 가능 epoch (0 = 즉시 가능). 실패 시 N초 백오프.
# 한투 모의 inquire-balance 가 간헐 500 → 20초 hb 마다 폭주하던 이슈 방지.
self._start_asset_retry_at: float = 0.0
self._start_asset_backoff_sec: int = get_env_int(
"START_ASSET_RETRY_BACKOFF_SEC", 300
)
# OrderManager 에 체결 알림용 '자산 요약 라인 생성기' 주입.
# (None 이면 기존 간단 문구만 나감)
try:
self.order_mgr.asset_line_provider = self._asset_line_for_notify # type: ignore[attr-defined]
except Exception:
pass
# 체결 알림에 '전략별 당일 손익' 줄을 넣기 위한 생성기 주입.
try:
self.order_mgr.strategy_pnl_provider = self._strategy_daily_pnl_for_notify # type: ignore[attr-defined]
except Exception:
pass
try:
if hasattr(self.ws, "get_orderbook"):
self.order_mgr.orderbook_provider = self.ws.get_orderbook # type: ignore[attr-defined]
if hasattr(self.ws, "get_price"):
self.order_mgr.price_quote_provider = self.ws.get_price # type: ignore[attr-defined]
if hasattr(self.ws, "get_tick_feed_label"):
self.order_mgr.tick_feed_label_provider = self.ws.get_tick_feed_label # type: ignore[attr-defined]
if hasattr(self.ws, "get_ob_feed_label"):
self.order_mgr.ob_feed_label_provider = self.ws.get_ob_feed_label # type: ignore[attr-defined]
except Exception:
pass
# ------------------------------------------------------------------
def _register_strategies(self) -> None:
# 전략 BaseStrategy.client = 시세/조회 전용 (항상 실키 또는 폴백).
# 매매는 self.order_mgr 경유라서 trade client (self.client) 가 별도로 들어감.
shared = dict(
db=self.db, client=self.market_client, ws=self.ws, order_mgr=self.order_mgr,
condition_mgr=self.condition_mgr,
ranking_mgr=self.ranking_mgr,
kiwoom_condition_mgr=self.kiwoom_condition_mgr,
ls_condition_mgr=self.ls_condition_mgr,
market_guard=self.market_guard,
)
if get_env_bool("STRATEGY_SCALP_ENABLED", True):
self.strategies.append(ScalpingStrategy(**shared))
logger.info("✅ [전략 등록] SCALP (%s)", self._universe_tag("SCALP"))
else:
logger.info("⏸ [전략 비활성] SCALP")
if get_env_bool("STRATEGY_SHORT_ENABLED", True):
self.strategies.append(TailCatchStrategy(**shared))
logger.info("✅ [전략 등록] SHORT (Tail Catch) (%s)",
self._universe_tag("SHORT"))
else:
logger.info("⏸ [전략 비활성] SHORT")
if get_env_bool("STRATEGY_MOMENTUM_ENABLED", False):
self.strategies.append(MomentumStrategy(**shared))
logger.info("✅ [전략 등록] MOMENTUM (%s)",
self._universe_tag("MOMENTUM"))
else:
logger.info("⏸ [전략 비활성] MOMENTUM")
# 해외 모멘텀 — 영구구독 US 고정 유니버스 · 기본 ON(시그널 dry-run)
if get_env_bool("STRATEGY_US_MOMENTUM_ENABLED", True):
self.strategies.append(UsMomentumStrategy(**shared))
logger.info("✅ [전략 등록] US_MOMENTUM (permanent US · dry-run 기본)")
else:
logger.info("⏸ [전략 비활성] US_MOMENTUM")
if get_env_bool("STRATEGY_BREAKOUT_ENABLED", False):
self.strategies.append(BreakoutStrategy(**shared))
logger.info("✅ [전략 등록] BREAKOUT (%s)",
self._universe_tag("BREAKOUT"))
else:
logger.info("⏸ [전략 비활성] BREAKOUT")
if get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False):
self.strategies.append(RangeBreakStrategy(**shared))
logger.info("✅ [전략 등록] RANGE_BREAK (%s)",
self._universe_tag("RANGE_BREAK"))
else:
logger.info("⏸ [전략 비활성] RANGE_BREAK")
if get_env_bool("STRATEGY_UPDOW_ENABLED", False):
self.strategies.append(UpdowStrategy(**shared))
logger.info("✅ [전략 등록] UPDOW (%s)",
self._universe_tag("UPDOW"))
else:
logger.info("⏸ [전략 비활성] UPDOW")
if get_env_bool("STRATEGY_DBBAND_ENABLED", False):
self.strategies.append(DbBandStrategy(**shared))
logger.info("✅ [전략 등록] DBBAND (%s)",
self._universe_tag("DBBAND"))
else:
logger.info("⏸ [전략 비활성] DBBAND")
if get_env_bool("STRATEGY_DART_ENABLED", False):
self.strategies.append(DartStrategy(**shared))
logger.info("✅ [전략 등록] DART (%s)", self._universe_tag("DART"))
else:
logger.info("⏸ [전략 비활성] DART")
if not self.strategies:
logger.error("전략이 하나도 활성화되지 않음 → 종료")
sys.exit(1)
self._setup_daily_profit_halt()
def _setup_daily_profit_halt(self) -> None:
"""일일 익절 목표 가드 — 전략 쓰레드에 공통 주입."""
def _open_positions(sid: str) -> list:
sid_u = str(sid or "").strip().upper()
for strat in self.strategies:
if str(getattr(strat, "strategy_id", "")).upper() != sid_u:
continue
from .engine.daily_profit_halt import build_open_risk_rows
return build_open_risk_rows(sid_u, getattr(strat, "holdings", {}) or {})
return []
def _force_sell(sid: str, closes: list, reason: str) -> None:
sid_u = str(sid or "").strip().upper()
for strat in self.strategies:
if str(getattr(strat, "strategy_id", "")).upper() != sid_u:
continue
for row in closes or []:
code = str((row or {}).get("code") or "").strip()
if not code:
continue
h = (getattr(strat, "holdings", {}) or {}).get(code) or {}
qty = int((row or {}).get("qty") or h.get("qty") or 0)
if qty <= 0:
continue
buy_price = float(
(row or {}).get("buy_price") or h.get("buy_price") or 0
)
px = float(
(row or {}).get("current_price")
or h.get("max_price")
or buy_price
or 0
)
profit_pct = 0.0
if buy_price > 0 and px > 0:
profit_pct = (px - buy_price) / buy_price * 100.0
try:
strat._enqueue_sell({
"code": code,
"name": (row or {}).get("name") or h.get("name") or code,
"qty": qty,
"current_price": px,
"price": px,
"buy_price": buy_price,
"profit_pct": profit_pct,
"reason": reason,
}, source="halt")
except Exception as ex:
logger.warning(
"⚠️ [리스크버짓] %s %s 청산 실패: %s", sid_u, code, ex,
)
return
guard = DailyProfitHaltGuard(
global_pnl_fn=self._bot_daily_realized_pnl,
strategy_pnl_fn=self._bot_daily_realized_pnl_by_strategy,
active_strategies_fn=lambda: [s.strategy_id for s in self.strategies],
notify_fn=self._daily_profit_notify_mm,
open_positions_fn=_open_positions,
force_sell_fn=_force_sell,
)
for strat in self.strategies:
strat.daily_profit_halt = guard
from .engine.daily_profit_halt import (
describe_profit_guard_startup,
load_global_profit_target,
load_strategy_profit_target,
)
cfg = load_global_profit_target()
logger.info(describe_profit_guard_startup(cfg, scope="마스터"))
for strat in self.strategies:
sid = str(getattr(strat, "strategy_id", "")).upper()
scfg = load_strategy_profit_target(sid)
if scfg.get("risk_budget_enabled"):
logger.info(
"🧯 [리스크버짓·%s] ON — 일일익절 hit 시 cushion < 보유최악손절합 이면 전량정리 "
"(쿨다운 %ss)",
sid, scfg.get("risk_budget_cooldown_sec"),
)
def _daily_profit_notify_mm(
self, body: str, strategy_id: Optional[str] = None,
) -> None:
"""일일 익절 달성 Mattermost 알림."""
ch_map = {
"SCALP": ("KIS_SCALP_MM_CHANNEL", "scalping"),
"SHORT": ("KIS_SHORT_MM_CHANNEL", "stock"),
"MOMENTUM": ("KIS_MOMENTUM_MM_CHANNEL", "stock"),
"US_MOMENTUM": ("KIS_US_MOMENTUM_MM_CHANNEL", "stock"),
"BREAKOUT": ("KIS_BREAKOUT_MM_CHANNEL", "stock"),
"RANGE_BREAK": ("KIS_RANGE_BREAK_MM_CHANNEL", "stock"),
"UPDOW": ("KIS_UPDOW_MM_CHANNEL", "stock"),
"DBBAND": ("KIS_DBBAND_MM_CHANNEL", "stock"),
}
if strategy_id:
env_key, default = ch_map.get(
strategy_id.upper(),
("KIS_SHORT_MM_CHANNEL", "stock"),
)
ch = str(get_env_from_db(env_key, default) or default)
else:
ch = str(
get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock",
)
msg_mm(body, channel_alias=ch, jitter=False)
def _resolve_source(self, strategy_id: str) -> str:
"""{STRATEGY}_UNIVERSE_SOURCE env → ranking|condition|kiwoom_condition|ls_condition."""
from .utils.universe_source import resolve_universe_source
default = self._DEFAULT_SOURCE.get(strategy_id, "ranking")
return resolve_universe_source(strategy_id, default=default)
def _universe_tag(self, strategy_id: str) -> str:
"""로그용 — 전략이 어떤 유니버스를 쓰는지 한눈에."""
src = self._resolve_source(strategy_id)
if src == "ranking":
mgr = self.ranking_mgr
elif src == "kiwoom_condition":
mgr = self.kiwoom_condition_mgr
elif src == "ls_condition":
mgr = self.ls_condition_mgr
else:
mgr = self.condition_mgr
if mgr is None:
return f"{src}(매니저없음→DB폴백)"
try:
cfgs = getattr(mgr, "_configs", [])
for c in cfgs:
if c.get("strategy_id") == strategy_id:
if src == "ranking":
return (
f"ranking · {c.get('sort')} x{c.get('limit')}"
)
nm = c.get("name") or ""
sq = c.get("seq") or ""
if src == "kiwoom_condition":
label = "kiwoom_cond"
elif src == "ls_condition":
label = "ls_cond"
else:
label = "condition"
if nm and sq:
return f"{label} · {nm}(seq={sq})"
return f"{label} · {nm or sq or '?'}"
except Exception:
pass
if src == "condition":
nk, sk = _CONDITION_ENV_KEYS.get(strategy_id, ("", ""))
nm = (get_env_from_db(nk, "") or "").strip()
sq = (get_env_from_db(sk, "") or "").strip()
if nm or sq:
return f"condition · {nm or '?'}(seq={sq or '?'})"
return f"{src}(미매핑→DB)"
def _ensure_all_universe_condition(self) -> None:
"""전략별 UNIVERSE_SOURCE=condition + CONDITION_* 기본값을 config_* DB에 반영."""
try:
snap = self.db.get_merged_env_snapshot()
except Exception as e:
logger.warning("유니버스 condition 기본값 스킵(DB): %s", e)
return
patch: Dict[str, str] = {}
for sid, (dname, dseq) in _DEFAULT_CONDITION_BY_STRATEGY.items():
# ⚠️ 미설정/유효하지 않은 값일 때만 기본값 주입.
# 사용자가 명시적으로 고른 소스(ranking/condition/kiwoom_condition/ls_condition)는
# 재시작해도 덮어쓰지 않는다 (런타임 스위치 유지).
cur_src = (snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip().lower()
if cur_src not in ("ranking", "condition", "kiwoom_condition", "ls_condition"):
patch[f"{sid}_UNIVERSE_SOURCE"] = self._DEFAULT_SOURCE.get(sid, "condition")
nk, sk = _CONDITION_ENV_KEYS.get(sid, ("", ""))
if nk and not (snap.get(nk) or "").strip():
patch[nk] = dname
if sk and not (snap.get(sk) or "").strip():
patch[sk] = str(dseq)
kw_nk = f"CONDITION_{sid}_KIWOOM_NAME"
kw_sk = f"CONDITION_{sid}_KIWOOM_SEQ"
kd = _DEFAULT_KIWOOM_CONDITION_BY_STRATEGY.get(sid)
if kd:
kdname, kdseq = kd
if not (snap.get(kw_nk) or "").strip() and kdname:
patch[kw_nk] = kdname
if not (snap.get(kw_sk) or "").strip() and kdseq:
patch[kw_sk] = str(kdseq)
if not patch:
return
merged = dict(snap)
merged.update(patch)
try:
env_id = self.db.insert_env_snapshot(merged)
logger.info(
"📌 유니버스 condition 기본값 DB 반영 (env_id=%s): %s",
env_id, ", ".join(sorted(patch.keys())),
)
except Exception as e:
logger.error("유니버스 condition DB 저장 실패: %s", e)
def _build_startup_notify_body(
self,
startup_cash_line: str = "",
startup_cash_ok: bool = False,
) -> str:
"""기동 알림 — 전략별 유니버스(ranking/condition) 줄바꿈."""
lines: List[str] = [
"🚀 kis_trader 통합 봇 시작",
f"활성 전략: {', '.join(s.strategy_id for s in self.strategies)}",
"",
"【유니버스】",
]
enabled = {s.strategy_id for s in self.strategies}
for sid in _STARTUP_NOTIFY_STRATEGY_ORDER:
if sid not in enabled:
continue
src = self._resolve_source(sid)
detail = self._universe_tag(sid)
lines.append(f"{sid}: {src}")
lines.append(f"{detail}")
ws_info = []
if getattr(self, "kiwoom_ws", None) is not None:
km = "모의" if getattr(self.kiwoom_ws, "is_mock", False) else "실전"
ws_info.append(f"키움({km})")
kis_cache = None
if getattr(self, "ws", None) is not None:
kis_cache = getattr(self.ws, "ws_cache", None)
if kis_cache is not None:
km = "모의" if getattr(kis_cache, "is_mock", False) else "실전"
ws_info.append(f"KIS({km})")
if ws_info:
lines.append("")
lines.append(f"📡 [시세웹소켓] {', '.join(ws_info)}")
if startup_cash_line:
lines.append("")
prefix = "" if startup_cash_ok else "⚠️"
lines.append(f"{prefix} [기동 계좌] {startup_cash_line}")
return "\n".join(lines)
def _system_mm_channel(self) -> str:
"""장시작·장마감 리포트 단일 MM 채널 alias.
기본 'default'(mm_config.json → 키스명령봇 채널). DB env 로 변경 가능.
"""
return str(
get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default") or "default",
).strip() or "default"
def _lifecycle_mm_channels(self) -> List[str]:
"""시작/종료 알림 채널 목록 — 시스템 채널(키스명령봇) + stock 채널.
stock 채널에서 시작/종료를 짝으로 봐야 재시작/종료 구분이 되므로,
시스템 채널과 KIS_LIFECYCLE_MM_CHANNEL(기본 'stock') 양쪽에 발송한다.
(중복 alias 는 1회만)
"""
chans: List[str] = []
for ch in (
self._system_mm_channel(),
str(get_env_from_db("KIS_LIFECYCLE_MM_CHANNEL", "stock") or "").strip(),
):
if ch and ch not in chans:
chans.append(ch)
return chans
def _send_startup_notify(
self,
startup_cash_line: str = "",
startup_cash_ok: bool = False,
) -> None:
"""기동 알림 — 시스템 채널(키스명령봇) + stock 채널 + Telegram.
시작/종료는 stock 채널에도 보내 재시작/종료를 짝으로 확인 가능하게 한다.
"""
body = self._build_startup_notify_body(startup_cash_line, startup_cash_ok)
for ch in self._lifecycle_mm_channels():
try:
msg_mm(body, channel_alias=ch, jitter=False)
except Exception as e:
logger.debug("기동 MM(%s) 전송 실패: %s", ch, e)
try:
msg_tg(body, jitter=False)
except Exception as e:
logger.debug("기동 TG 전송 실패: %s", e)
# ------------------------------------------------------------------
def start(self) -> None:
logger.info("=" * 70)
logger.info("🚀 kis_trader 통합 봇 시작")
logger.info("=" * 70)
# KIS 접근토큰: 모의만 쓰면 실전이 방치되던 구멍 차단.
# 세션미커버/만료분만 발급 (1일1회·EGW00133 준수). approval 6h 와 무관.
try:
from kis_token_manager import ensure_both_tokens
ensure_both_tokens()
except Exception as e:
logger.warning("KIS 토큰(실전+모의) 기동 점검 실패: %s", e)
# WS 허브 먼저 기동 (전략이 구독 요청하기 전에)
ws_ok = self.ws.start()
logger.info("WSManager active=%s", ws_ok)
# 해외(US) 실시간 WS 기동 (permanent_subscriptions 테이블의 US 종목)
self._start_overseas_ws()
# 유니버스 매니저 (ranking/condition) 기동
self._start_universe_managers()
# 시장 급락 서킷브레이커 (ENABLED=true 일 때만 폴링·봉 저장·PANIC 판정)
self._start_market_guard()
if self.market_guard is not None:
# 전략 매수 루프 전 1회: ws_candles MG* + kv PANIC 복원 후 즉시 재판정
self.market_guard.bootstrap_sync()
# 시세 WS 마이그레이션 검증 / WS_SUBSCRIBE_KIS_MINIMAL 시 키움 WS 기동
self._start_ws_validator()
self._start_ls_ws_validation()
# 키움 조건검색은 시세 WS 와 **동일 세션** 공유 → 시세 WS 기동 후에만 등록
self._start_kiwoom_condition_manager()
self._start_ls_condition_manager()
self._wire_ws_split_feed_if_needed()
# 예수금 캐시: 전략 기동 전 API 선동기화 (재시작 직후 stale kv 사용 방지)
startup_cash_line = ""
startup_cash_ok = False
try:
snap = self._fetch_asset_snapshot(force=True)
cash = float(snap.get("cash", 0) or 0)
dnca = float(snap.get("dnca", 0) or 0)
d2 = float(snap.get("d2", 0) or 0)
ord_psbl = float(snap.get("ord_psbl", 0) or 0)
total_asset = float(snap.get("total_asset", 0) or 0)
hold_count = int(snap.get("hold_count", 0) or 0)
basis = str(snap.get("cash_basis", "dnca") or "dnca")
if cash > 0:
startup_cash_ok = True
logger.info("💰 [예수금캐시] 기동 API 선동기화 완료: %s", f"{cash:,.0f}")
startup_cash_line = (
f"계좌[{('모의' if self.client.mock else '실전')}] "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | "
f"보유 {hold_count}종목 / 총자산 {total_asset:,.0f}원 | "
f"cash_basis={basis} | "
f"예수금 {dnca:,.0f} / D+2 {d2:,.0f} / 주문가능 {ord_psbl:,.0f} / 사용기준 {cash:,.0f}"
)
else:
logger.warning(
"⚠️ [예수금캐시] 기동 API 응답 cash=0 → stale kv baseline 비활성"
)
self.cash_ledger.invalidate_baseline("startup_cash_zero")
startup_cash_line = (
f"계좌[{('모의' if self.client.mock else '실전')}] "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | "
"기동 예수금 동기화 실패(cash=0)"
)
except Exception as e:
logger.warning(
"⚠️ [예수금캐시] 기동 API 선동기화 실패: %s → stale kv baseline 비활성", e
)
self.cash_ledger.invalidate_baseline("startup_sync_failed")
startup_cash_line = (
f"계좌[{('모의' if self.client.mock else '실전')}] "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | "
f"기동 예수금 동기화 예외: {e}"
)
# 전략 쓰레드 기동 전: 실잔고 1회로 active_trades 맞춤 (매도 없음).
# 고아 upsert · 유령 삭제 · 수량/평단 sync. EOD 배치와 동일 함수.
# ORPHAN_RECONCILE_DATE 는 건드리지 않음 → 당일 Pre/Post EOD 는 그대로 1회 더 돈다.
if get_env_bool("ORPHAN_RECONCILE_ON_STARTUP", True):
try:
self._run_orphan_reconcile(dt.now().strftime("%Y-%m-%d"), phase="startup")
except Exception as e:
logger.warning("🧩 [기동 잔고=DB 맞춤] 실패(스킵, 매매 계속): %s", e)
# 전략 등록 후 쓰레드 시작 (각자 독립 쓰레드)
self._register_strategies()
# 해외 WS 는 등록 전에 기동됨 → 여기서 US_MOMENTUM 에 핸들 주입
self._attach_overseas_ws_to_strategies()
for strat in self.strategies:
self.order_worker.register_strategy(strat)
strat.order_worker = self.order_worker
self.order_worker.start()
for strat in self.strategies:
strat.start() # threading.Thread.start()
logger.info("▶ 쓰레드 기동: %s", strat.name)
self._wire_short_holding_peak_provider()
# 당일 시작 총자산 — kv 복원 또는 09:00 이후 최초 1회 기록
self._restore_start_day_asset()
try:
boot_snap = self._fetch_asset_snapshot()
if self.start_day_asset <= 0 and float(boot_snap.get("total_asset") or 0) > 0:
now_hm = dt.now()
if now_hm.hour > 9 or (now_hm.hour == 9 and now_hm.minute >= 0):
self._save_start_day_asset(float(boot_snap["total_asset"]))
except Exception as e:
logger.debug("start_day_asset 기동 설정 스킵: %s", e)
# 알림 (시작) — stock MM + TG, 전략별 ranking/condition 줄바꿈
try:
self._send_startup_notify(startup_cash_line, startup_cash_ok)
except Exception:
pass
# 시그널 핸들러 (SIGINT/SIGTERM)
signal.signal(signal.SIGINT, self._on_signal)
try:
signal.signal(signal.SIGTERM, self._on_signal)
except Exception:
pass
# 메인 쓰레드는 heartbeat 만 찍으며 대기
self._heartbeat_loop()
# ------------------------------------------------------------------
def _start_universe_managers(self) -> None:
"""
전략별 UNIVERSE_SOURCE 에 따라 VolumeRankManager / ConditionSearchManager 기동.
두 매니저 모두 전략에 주입되고, 전략은 본인 소스 선택 값으로 조회함.
"""
self._ensure_all_universe_condition()
strategy_switch = {
"SCALP": get_env_bool("STRATEGY_SCALP_ENABLED", True),
"SHORT": get_env_bool("STRATEGY_SHORT_ENABLED", True),
"BREAKOUT": get_env_bool("STRATEGY_BREAKOUT_ENABLED", False),
"RANGE_BREAK": get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False),
"MOMENTUM": get_env_bool("STRATEGY_MOMENTUM_ENABLED", False),
"US_MOMENTUM": get_env_bool("STRATEGY_US_MOMENTUM_ENABLED", True),
"UPDOW": get_env_bool("STRATEGY_UPDOW_ENABLED", False),
}
ranking_configs: List[dict] = []
condition_configs: List[dict] = []
# 키움 조건검색(WS 실시간) 소스 — KIS 와 별개. KIWOOM_CONDITION_{SID}_NAME/SEQ.
kiwoom_condition_configs: List[dict] = []
# LS 서버저장조건(AFR) — ls_condition 소스. 이력은 ls_candidates_history.
ls_condition_configs: List[dict] = []
cond_env_keys = {
"SCALP": ("CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ"),
"SHORT": ("CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ"),
"BREAKOUT": ("CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ"),
"RANGE_BREAK": ("CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ"),
"MOMENTUM": ("CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ"),
"UPDOW": ("CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ"),
}
rank_env_keys = {
"SCALP": ("RANK_SCALP_SORT", "RANK_SCALP_LIMIT"),
"SHORT": ("RANK_SHORT_SORT", "RANK_SHORT_LIMIT"),
"BREAKOUT": ("RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT"),
"RANGE_BREAK": ("RANK_RANGE_BREAK_SORT", "RANK_RANGE_BREAK_LIMIT"),
"MOMENTUM": ("RANK_MOMENTUM_SORT", "RANK_MOMENTUM_LIMIT"),
"UPDOW": ("RANK_UPDOW_SORT", "RANK_UPDOW_LIMIT"),
}
# ⚡ 런타임 스위치 지원:
# 기존에는 시작 시점의 {SID}_UNIVERSE_SOURCE 값 기준으로 한쪽 매니저에만 등록했지만,
# 운영 중 DB 값만 바꿔도 즉시 전환되도록 **설정이 존재하는 모든 소스에 미리 등록** 한다.
# 실제 어느 쪽을 쓸지는 BaseStrategy._load_candidates() 가 매 틱 DB 를 재조회하여 결정.
# (단, 조건식 이름/seq 자체 변경은 매니저 내부 _configs 정적 바인딩이라 여전히 재시작 필요)
for sid, enabled in strategy_switch.items():
if not enabled:
continue
# 해외 모멘텀: 영구구독 고정 — ranking/condition/키움 매니저 미등록
if sid == "US_MOMENTUM":
logger.info("📌 [US_MOMENTUM] 유니버스=permanent US (조건검색/랭킹 매니저 스킵)")
continue
src = self._resolve_source(sid) # 시작 시 '주 소스' (로그/기본값용)
# ── ranking 쪽 설정 수집 (UNIVERSE_SOURCE=ranking 일 때만 KIS REST 폴링) ─────
# kiwoom_condition / condition 소스 전략은 거래량순위 REST 폴링에 등록하지 않는다.
sort_key, limit_key = rank_env_keys[sid]
sort_default = self._DEFAULT_RANK_SORT.get(sid, "volume")
sort_val = (
get_env_from_db(sort_key, sort_default) or sort_default
).strip().lower()
limit_val = get_env_int(limit_key, 20)
register_ranking = src == "ranking"
if register_ranking:
ranking_configs.append({
"strategy_id": sid,
"sort": sort_val,
"limit": limit_val,
"market": "J",
})
# ── condition 쪽 설정 수집 (UNIVERSE_SOURCE=condition 일 때만 KIS REST 폴링) ────
# CONDITION_{SID}_NAME 은 키움 조건식과 HTS 동일명 공유용으로도 쓰임.
# kiwoom_condition / ranking 소스 전략은 KIS psearch REST 폴링에 등록하지 않는다.
name_key, seq_key = cond_env_keys[sid]
nm = (get_env_from_db(name_key, "") or "").strip()
sq = (get_env_from_db(seq_key, "") or "").strip()
has_cond = bool(nm or sq)
register_kis_cond = has_cond and src == "condition"
if register_kis_cond:
condition_configs.append({
"strategy_id": sid,
"name": nm or None,
"seq": sq or None,
})
elif src == "condition":
logger.warning(
"⚠️ [%s] UNIVERSE_SOURCE=condition 인데 "
"CONDITION_%s_NAME/SEQ 가 비어있음 → ranking 으로 자동 폴백 동작. "
"(조건식 세팅 후엔 봇 재시작 필요)",
sid, sid,
)
# ── 키움 조건: UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용 ─────
# 이름 → CONDITION_{SID}_NAME 공통 (KIS/키움 HTS 동일명이면 필드 1개로 충분)
# seq → CONDITION_{SID}_KIWOOM_SEQ (선택, 비면 CNSRLST 자동 해결)
kw_nm = (get_env_from_db(f"CONDITION_{sid}_KIWOOM_NAME", "") or "").strip() or nm
kw_sq = (get_env_from_db(f"CONDITION_{sid}_KIWOOM_SEQ", "") or "").strip()
has_kw_cond = bool(kw_nm or kw_sq)
if has_kw_cond:
kiwoom_condition_configs.append({
"strategy_id": sid,
"name": kw_nm or None,
"seq": kw_sq or None,
})
elif src == "kiwoom_condition":
logger.warning(
"⚠️ [%s] UNIVERSE_SOURCE=kiwoom_condition 인데 "
"CONDITION_%s_NAME(또는 KIWOOM_SEQ) 가 비어있음 → 다른 소스로 폴백.",
sid, sid,
)
# ── LS 조건: 이름 있으면 등록 (런타임 ls_condition 전환용) ─────
# CONDITION_{SID}_LS_NAME > KIWOOM_NAME > CONDITION_NAME > 기본동명
ls_nm = (
(get_env_from_db(f"CONDITION_{sid}_LS_NAME", "") or "").strip()
or kw_nm
or DEFAULT_SID_TO_LS_NAME.get(sid, "")
)
if ls_nm:
ls_condition_configs.append({
"strategy_id": sid,
"name": ls_nm,
"seq": None,
})
elif src == "ls_condition":
logger.warning(
"⚠️ [%s] UNIVERSE_SOURCE=ls_condition 인데 "
"조건명(CONDITION_%s_NAME/LS_NAME) 이 비어있음 → 후보 0.",
sid, sid,
)
logger.info(
"🔀 [%s] 시작소스=%s (매니저등록: ranking=%s condition=%s kiwoom=%s ls=%s) "
"— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환 "
"(KIS ranking/condition REST 는 시작소스 일치 시만 · "
"LS 매니저 최초 기동은 재시작 필요할 수 있음)",
sid, src, ("" if register_ranking else ""),
("" if register_kis_cond else ""),
("" if has_kw_cond else ""),
("" if ls_nm else ""), sid,
)
# ── ranking 매니저 기동 ────────────────────────────────
# ⚠️ KIS 거래량순위(volume-rank, FHPST01710000)도 **모의투자 미지원** (HTTP 500).
# 조건검색과 같은 정책: KIS_MOCK 와 무관하게 실전 도메인으로 호출.
if ranking_configs:
mkt_client = self._get_market_data_client()
if mkt_client is None:
logger.warning(
"⚠️ KIS_APP_KEY_REAL/KIS_APP_SECRET_REAL 미설정 → "
"거래량순위는 모의 미지원이라 실키 필수. 랭킹 매니저 비활성 "
"(%d개 전략 DB 폴백)", len(ranking_configs),
)
else:
try:
self.ranking_mgr = VolumeRankManager(
client=mkt_client,
configs=ranking_configs,
db=self.db,
)
if not self.ranking_mgr.start():
self.ranking_mgr = None
except Exception as e:
logger.error("랭킹 매니저 기동 실패: %s", e)
self.ranking_mgr = None
else:
logger.info(" ranking 소스 쓰는 전략 없음 → 랭킹 매니저 비활성")
# ── condition 매니저 기동 ──────────────────────────────
# ⚠️ KIS 종목조건검색(psearch-title/psearch-result)은 **모의투자 미지원**.
# 따라서 KIS_MOCK 값과 무관하게 **실전 키 전용 KISClient 를 별도 생성**해
# 조건검색 매니저에만 주입한다. (WSManager 가 시세를 실키로 받는 정책과 동일)
# 거래용 self.client 는 그대로 KIS_MOCK 스위치를 따른다 → 모의 매매 안전성 유지.
if condition_configs:
user_id = (get_env_from_db("KIS_HTS_ID", "") or "").strip()
mkt_client = self._get_market_data_client()
if not user_id:
logger.warning(
"⚠️ KIS_HTS_ID 미설정 → 조건검색 매니저 비활성 "
"(%d개 전략 DB 폴백)", len(condition_configs),
)
elif mkt_client is None:
logger.warning(
"⚠️ KIS_APP_KEY_REAL/KIS_APP_SECRET_REAL 미설정 → "
"조건검색은 모의 미지원이라 실키 필수. (%d개 전략 DB 폴백)",
len(condition_configs),
)
else:
try:
self.condition_mgr = ConditionSearchManager(
client=mkt_client,
user_id=user_id,
configs=condition_configs,
db=self.db,
)
if not self.condition_mgr.start():
self.condition_mgr = None
except Exception as e:
logger.error("조건검색 매니저 기동 실패: %s", e)
self.condition_mgr = None
else:
logger.info(" condition 소스 쓰는 전략 없음 → 조건검색 매니저 비활성")
# ── 키움 조건검색: 시세 WS 기동 후 _start_kiwoom_condition_manager() 에서 등록 ──
self._pending_kiwoom_condition_configs = kiwoom_condition_configs
if not kiwoom_condition_configs:
logger.info(" kiwoom_condition 소스 쓰는 전략 없음 → 키움 조건검색 매니저 비활성")
# ── LS 조건검색: start() 에서 _start_ls_condition_manager() ──
self._pending_ls_condition_configs = ls_condition_configs
if not ls_condition_configs:
logger.info(" LS 조건명 없음 → LS 조건검색 매니저 비활성")
# ------------------------------------------------------------------
def _start_market_guard(self) -> None:
"""
시장 급락 서킷브레이커 매니저 기동.
- ``MARKET_GUARD_ENABLED=false`` (기본값) 면 지수 API·봉 저장 없음(쓰레드는 대기만).
→ DB 에서 true 로 바꾸면 재시작 없이 감시 시작.
- ``bootstrap_sync()`` 는 ``start()`` 직후·전략 기동 전 1회 (재시작 PANIC 구멍 방지).
- 지수 조회 (inquire-index-price) 는 모의 도메인 미지원 가능성
→ market_client (실키 우선) 를 사용. 실키 없으면 self.client 폴백.
- PANIC 진입 시 BaseStrategy._scan_and_buy() 가 매수 차단.
"""
try:
self.market_guard = MarketGuard(client=self.market_client, db=self.db)
self.market_guard.start()
except Exception as e:
logger.error("MarketGuard 기동 실패 (전략은 가드 없이 동작): %s", e)
self.market_guard = None
# ------------------------------------------------------------------
def _start_ws_validator(self) -> None:
"""키움 WS 기동 + (선택) KIS↔키움 가격 검증기."""
from .utils.env import get_env_from_db
minimal = get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False)
need_validator = get_env_bool("LIVE_VALIDATOR_ENABLED", False)
# 키움 WS는 이제 틱/호가 프로바이더 및 적재 옵션에 따라 무조건 기동
need_kiwoom = True
# 키움 키 로드 — 검증/분리 시세 모두 실키·실전 권장
force_real_str = (get_env_from_db("KIWOOM_WS_FORCE_REAL", "true") or "true").strip().lower()
force_real = force_real_str in ("true", "1", "yes", "y")
app_key = app_secret = ""
is_mock = False
try:
r = self.db.get_auth_env()
if force_real:
app_key = (r.get("KIWOOM_APP_KEY_REAL") or "").strip()
app_secret = (r.get("KIWOOM_APP_SECRET_REAL") or "").strip()
if not app_key or not app_secret:
app_key = (r.get("KIWOOM_APP_KEY") or "").strip()
app_secret = (r.get("KIWOOM_APP_SECRET") or "").strip()
is_mock = False
logger.info("🔑 키움 WS 자격증명: 실키/실전 도메인 (KIWOOM_WS_FORCE_REAL=true)")
else:
from .ws.kis_ws import _get_kiwoom_creds
app_key, app_secret, is_mock = _get_kiwoom_creds(self.db)
logger.info(
"🔑 키움 WS 자격증명: KIS_MOCK 자동 (mock=%s) — 디버그 모드",
is_mock,
)
except Exception as e:
logger.warning("키움 자격증명 로드 실패 → 키움 WS 비활성: %s", e)
return
if not app_key or not app_secret:
logger.warning(
"키움 %s 키 미설정 → 키움 WS 비활성 (KIWOOM_APP_KEY%s 확인)",
"실키" if force_real else "",
"_REAL" if force_real else "",
)
return
if self.kiwoom_ws is None:
try:
from .ws.kiwoom_ws import KiwoomWebSocketPriceCache
self.kiwoom_ws = KiwoomWebSocketPriceCache(
app_key, app_secret, is_mock=is_mock,
)
if not self.kiwoom_ws.start():
logger.warning("키움 WS 시작 실패")
self.kiwoom_ws = None
return
else:
# 🚀 키움 틱 적재 설정 적용
tr = getattr(self.ws, "tick_recorder", None)
if tr is not None and get_env_bool("WS_TICK_SAVE_KIWOOM", True):
self.kiwoom_ws.attach_tick_recorder(tr)
logger.info("✅ 키움 WS에 TickRecorder 부착 완료 (키움 틱 → ws_ticks 적재)")
# 🚀 키움 호가 적재 설정 적용
tsr = getattr(self.ws, "trigger_snapshot_recorder", None)
if tsr is not None and get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True):
self.kiwoom_ws.attach_trigger_snapshot_recorder(tsr)
logger.info("✅ 키움 WS에 TriggerSnapshotRecorder 부착 완료 (키움 호가 → ws_orderbook 적재)")
except Exception as e:
logger.warning("키움 WS 인스턴스 생성 실패: %s", e)
self.kiwoom_ws = None
return
kis_ws_handle = getattr(self.ws, "ws_cache", None)
if need_validator and kis_ws_handle:
try:
from .network.ws_validator import WSPriceValidator
self.ws_validator = WSPriceValidator(
kis_ws=kis_ws_handle,
kiwoom_ws=self.kiwoom_ws,
db=self.db,
sync_kiwoom_to_kis=not minimal,
)
self.ws_validator.start()
self.ws_validator.start()
logger.info("🔬 [시세 검증] LIVE_VALIDATOR_ENABLED=True — ws_price_validation 기록 (KIS↔키움 교차 검증)")
except Exception as e:
logger.warning("Validator 기동 실패: %s", e)
self.ws_validator = None
elif need_validator and not kis_ws_handle:
logger.warning("KIS WS 핸들 미발견 → Validator 비활성")
def _start_ls_ws_validation(self) -> None:
"""LS 실키 WS — 검증·조건이력·AFR·ls_condition 틱.
매매 **주문** = KIS.
``ls_condition``: LS US3 틱 + 키움 갭/분봉 (``ls_feed`` = 틱만 LS).
"""
from .utils.env import get_env_from_db
validation_on = get_env_bool("LS_WS_VALIDATION_ENABLED", False)
history_on = get_env_bool("LS_CONDITION_HISTORY_ENABLED", False)
follow_on = get_env_bool("LS_WS_FOLLOW_CONDITION_UNIVERSE", True)
ls_ws_enabled = get_env_bool("LS_WS_ENABLED", False)
trade_ls = False
try:
for sid in ("SCALP", "BREAKOUT", "SHORT", "MOMENTUM"):
if self._resolve_source(sid) == "ls_condition":
trade_ls = True
break
except Exception:
trade_ls = False
if (
not validation_on
and not (history_on and follow_on)
and not trade_ls
and not ls_ws_enabled
):
logger.info(
" LS WS 미기동 (LS_WS_ENABLED=false, VALIDATION=false, "
"HISTORY/FOLLOW 미충족, ls_condition 전략 없음)"
)
return
if ls_ws_enabled and not trade_ls and not validation_on:
logger.info(
"📡 LS WS 기동 — LS_WS_ENABLED=true "
"(구독 spill 3차·영구구독 RAM)"
)
if trade_ls:
logger.info(
"📡 LS WS 기동 — ls_condition AFR+틱 "
"(갭보정·분봉은 키움)"
)
try:
r = self.db.get_auth_env()
except Exception as e:
logger.warning("LS 키 로드 실패 → LS WS 비활성: %s", e)
return
if not r:
logger.warning("env_auth_config 없음 → LS WS 비활성")
return
app_key = (r.get("LS_APP_KEY_REAL") or "").strip()
app_secret = (r.get("LS_APP_SECRET_REAL") or "").strip()
is_mock = False
if not app_key or not app_secret:
logger.warning(
"LS AppKey/Secret 미설정 → LS WS 비활성 "
"(LS_APP_KEY_REAL / LS_APP_SECRET_REAL)"
)
return
try:
from .ws.ls_ws import LSWebSocketPriceCache
also_hoga = get_env_bool("LS_WS_ALSO_HOGA", True)
self.ls_ws = LSWebSocketPriceCache(
app_key, app_secret, is_mock=is_mock, also_hoga=also_hoga,
)
if not self.ls_ws.start():
logger.warning("LS WS start 실패")
self.ls_ws = None
return
self._ls_perm_codes = set()
self._ls_perm_codes_ts = 0.0
self._ls_perm_ttl = float(get_env_int("PERM_SUB_UI_REFRESH_SEC", 15))
def _on_tick(code: str, payload: dict) -> None:
# 1) LS 전용 테이블 — 호가와 동일: 구독 중이면 적재 (영구만이 아님).
# 봉·VI 는 아래 _ls_should_persist(영구) 유지.
if get_env_bool("LS_WS_TICK_SAVE", False) and self._ls_is_subscribed(code):
tk_str = None
lag_sec = None
ts_dt = payload.get("ts")
che = str(payload.get("chetime") or "").strip()
if isinstance(ts_dt, datetime.datetime) and che and len(che) == 6:
tk_str = ts_dt.strftime("%Y%m%d") + che
try:
che_dt = datetime.datetime.strptime(tk_str, "%Y%m%d%H%M%S")
lag_sec = (ts_dt - che_dt).total_seconds()
except ValueError:
pass
self.db.insert_ls_ws_tick(
code=code,
ts=ts_dt,
price=float(payload.get("price") or 0),
volume=payload.get("volume"),
tot_volume=payload.get("tot_volume"),
chetime=che,
tr_cd=str(payload.get("tr_cd") or ""),
tk=tk_str,
lag_sec=lag_sec,
)
# 가끔 오래된 행 정리 (쓰로틀 — 매 틱마다 DELETE 금지)
try:
keep_lt = int(get_env_int("LS_WS_TICK_KEEP_DAYS", 7) or 7)
if keep_lt > 0 and hasattr(self.db, "cleanup_old_ls_ws_ticks"):
now_m = time.monotonic()
last = float(getattr(self, "_ls_tick_cleanup_mono", 0.0) or 0.0)
if now_m - last >= 3600.0:
self._ls_tick_cleanup_mono = now_m
self.db.cleanup_old_ls_ws_ticks(keep_days=keep_lt)
except Exception:
pass
# 1b) 호가 틱동기 — 체결 1건당 RAM 호가 1장 (스냅 없으면 생략=실매와 동일)
try:
self._maybe_save_ls_orderbook_on_tick(code, payload)
except Exception:
pass
# 2) 실매 get_recent_ticks 용 RAM 만 (기본).
# ws_ticks 이중 INSERT 는 용량 낭비 → LS_WS_TICK_MIRROR_WS_TICKS=true 때만.
tr = getattr(self.ws, "tick_recorder", None)
if tr is not None:
try:
px = float(payload.get("price") or 0)
vol = int(float(payload.get("volume") or 0))
che = str(payload.get("chetime") or "")
if px > 0:
mirror = get_env_bool("LS_WS_TICK_MIRROR_WS_TICKS", False)
tr.on_tick(
code, px, vol, che,
market="KR", source="ls",
persist_db=mirror,
)
except Exception:
pass
def _on_candle(code: str, candle: dict) -> None:
if self._ls_should_persist(code):
self.db.upsert_ls_ws_candle(code=code, candle=candle)
def _on_orderbook(code: str, snap: dict) -> None:
if self._ls_should_persist_orderbook(code):
tk_str = None
lag_sec = None
snap_time = str(snap.get("snap_time") or "").strip()
if len(snap_time) >= 14:
tk_str = snap_time[:14]
try:
tk_dt = datetime.datetime.strptime(tk_str, "%Y%m%d%H%M%S")
recv_ts_raw = snap.get("recv_ts")
if isinstance(recv_ts_raw, datetime.datetime):
recv_dt = recv_ts_raw
elif isinstance(recv_ts_raw, float):
recv_dt = datetime.datetime.fromtimestamp(recv_ts_raw)
else:
recv_dt = datetime.datetime.now()
lag_sec = (recv_dt - tk_dt).total_seconds()
except Exception:
pass
self.db.insert_ls_ws_orderbook(code=code, snap=snap, market="KR", tk=tk_str, lag_sec=lag_sec)
def _on_vi(code: str, payload: dict) -> None:
if not self._ls_should_persist(code):
return
self.db.insert_ls_ws_vi(
code=code,
ts=payload.get("ts"),
event_time=str(payload.get("event_time") or ""),
vi_gubun=str(payload.get("vi_gubun") or "0"),
krx_vi_gubun=payload.get("krx_vi_gubun"),
nxt_vi_gubun=payload.get("nxt_vi_gubun"),
svi_recprice=payload.get("svi_recprice"),
dvi_recprice=payload.get("dvi_recprice"),
vi_trgprice=payload.get("vi_trgprice"),
tr_cd=str(payload.get("tr_cd") or ""),
exchname=str(payload.get("exchname") or ""),
)
self.ls_ws.attach_tick_recorder(_on_tick)
self.ls_ws.attach_candle_flusher(_on_candle)
self.ls_ws.attach_orderbook_recorder(_on_orderbook)
self.ls_ws.attach_vi_recorder(_on_vi)
try:
self.ls_ws.set_candle_save_filter(self._ls_should_persist)
except Exception:
pass
if also_hoga:
_ob_mode = (
get_env_from_db("LS_WS_ORDERBOOK_SAVE_MODE", "tick") or "tick"
).strip().lower()
logger.info(
"LS WS 호가(UH1) 구독 ON → ls_ws_orderbook "
"(save=%s mode=%s gap_ms=%s)",
get_env_bool("LS_WS_ORDERBOOK_SAVE", True),
_ob_mode,
get_env_int("LS_WS_ORDERBOOK_SAVE_MS", 1000),
)
if get_env_bool("LS_WS_UVI_ENABLED", True):
logger.info(
"LS WS VI(UVI) 구독 ON → ls_ws_vi "
"(save=%s stale_sec=%s watchdog_skip=%s)",
get_env_bool("LS_WS_VI_SAVE", True),
get_env_int("LS_WS_VI_STALE_SEC", 180),
get_env_bool("LS_WS_WATCHDOG_SKIP_WHEN_VI", True),
)
# 영구구독 KR 선구독 (마스터 OFF면 빈 목록)
try:
import permanent_subs as _ps
rows = _ps.subscribe_codes(self.db, "KR")
for row2 in rows or []:
c = str(row2.get("code") or "").strip()
if c:
self.ls_ws.subscribe(c, owner="permanent")
logger.info("LS WS 영구구독 KR 선등록 %d종목 (owner=permanent)", len(rows or []))
except Exception as e:
logger.debug("LS 영구구독 선등록 실패: %s", e)
if validation_on:
from .network.ls_ws_validator import LSWSPriceValidator
kis_ws_handle = getattr(self.ws, "ws_cache", None)
self.ls_ws_validator = LSWSPriceValidator(
ls_ws=self.ls_ws,
db=self.db,
kis_ws=kis_ws_handle,
kiwoom_ws=self.kiwoom_ws,
)
self.ls_ws_validator.start()
logger.info(
"🔬 [LS 시세 검증] 그림자 ON — ls_ws_ticks / ls_ws_candles / "
"ls_ws_orderbook / ls_ws_vi / ws_price_validation_ls (매매 경로 미사용)"
)
else:
logger.info(
"📡 [LS WS] 기동 — ticks/candles/hoga "
"(ENABLED=%s VALIDATION=%s HISTORY=%s trade_ls=%s)",
ls_ws_enabled, validation_on, history_on, trade_ls,
)
try:
if getattr(self, "ws", None) is not None:
self.ws._reattach_all_price_listeners()
except Exception:
pass
except Exception as e:
logger.warning("LS WS 기동 실패: %s", e)
self.ls_ws = None
self.ls_ws_validator = None
def _ls_should_persist(self, code: str) -> bool:
"""LS 봉·VI DB: 영구구독 enabled 코드만 (마스터와 무관, 행 기준)."""
now = time.time()
ttl = float(getattr(self, "_ls_perm_ttl", 15.0) or 15.0)
if now - float(getattr(self, "_ls_perm_codes_ts", 0) or 0) >= ttl:
try:
import permanent_subs as ps
self._ls_perm_codes = ps.enabled_code_set(self.db, "KR")
except Exception:
self._ls_perm_codes = set()
self._ls_perm_codes_ts = now
from permanent_subs import should_persist_ls
return should_persist_ls(code, getattr(self, "_ls_perm_codes", set()))
def _ls_is_subscribed(self, code: str) -> bool:
"""LS WS 구독 중인가 (영구∪폴백 유니버스). 틱/호가 DB 공통 게이트."""
c = str(code or "").strip()
if not c:
return False
ls = getattr(self, "ls_ws", None)
if ls is None:
return False
try:
with ls._sub_lock:
return c in ls._subscribed
except Exception:
return False
def _ls_should_persist_orderbook(self, code: str) -> bool:
"""LS 호가 DB: SAVE ON 이고 구독 중. 봉/VI(영구)와 분리."""
if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True):
return False
return self._ls_is_subscribed(code)
def _maybe_save_ls_orderbook_on_tick(self, code: str, payload: dict) -> None:
"""LS 체결 1건당 호가 RAM 스냅 1장 → ls_ws_orderbook (틱 동기).
스냅 없거나 만료면 저장 안 함 (OHLC·키움으로 메우지 않음 = 실매 필터와 동일).
"""
if not self._ls_should_persist_orderbook(code):
return
if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True):
return
mode = (
get_env_from_db("LS_WS_ORDERBOOK_SAVE_MODE", "tick") or "tick"
).strip().lower()
if mode not in ("tick", "on_tick", "tick_sync", "sync"):
return
ls = getattr(self, "ls_ws", None)
if ls is None:
return
max_age = float(get_env_float("LS_WS_ORDERBOOK_TICK_MAX_AGE_SEC", 3.0) or 3.0)
getter = getattr(ls, "get_orderbook_snapshot", None)
if not callable(getter):
return
snap = getter(code, max_age_sec=max_age)
if snap is None:
return
if hasattr(snap, "to_storage_dict"):
body = snap.to_storage_dict()
elif isinstance(snap, dict):
body = dict(snap)
else:
return
# 틱 시각(chetime) = 키움 FID20 / 한투 chetime 과 같은 축.
# payload ts 는 datetime.now() 라서 쓰면 recv 시계가 되어 옵투나 pick 이 어긋남.
snap_time = ""
che = str(payload.get("chetime") or "").strip()
che_d = "".join(ch for ch in che if ch.isdigit())
if len(che_d) >= 6:
from datetime import datetime as _dt
day = _dt.now().strftime("%Y%m%d")
snap_time = day + che_d[-6:][:6]
if len(snap_time) < 14:
existing = str(body.get("snap_time") or "").strip()
if len(existing) >= 14 and existing[:14].isdigit():
snap_time = existing[:14]
if len(snap_time) >= 14:
body["snap_time"] = snap_time[:14]
body["source"] = str(body.get("source") or "ls_uh1")[:16]
self.db.insert_ls_ws_orderbook(code=code, snap=body, market="KR")
# 가끔 오래된 행 정리 (틱 폭주 대비)
try:
keep = int(get_env_int("LS_WS_ORDERBOOK_KEEP_DAYS", 7) or 7)
if keep > 0 and hasattr(self.db, "cleanup_old_ls_ws_orderbook"):
# 매 틱마다 DELETE 금지 — 몬otonic 스로틀
now_m = time.monotonic()
last = float(getattr(self, "_ls_ob_cleanup_mono", 0.0) or 0.0)
if now_m - last >= 3600.0:
self._ls_ob_cleanup_mono = now_m
self.db.cleanup_old_ls_ws_orderbook(keep_days=keep)
except Exception:
pass
def _start_kiwoom_condition_manager(self) -> None:
"""키움 조건검색 — 시세 WS(KiwoomWebSocketPriceCache) 와 단일 세션 공유.
키움은 동일 OAuth 토큰으로 WS 2접속 시 먼저 붙은 쪽에 Bye(1000)를 보내 끊는다.
조건검색 전용 소켓을 따로 열면 시세 WS 가 5초마다 끊기는 증상이 발생한다.
"""
configs = self._pending_kiwoom_condition_configs or []
if not configs:
return
kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
kw_key = kw_key or (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
kw_secret = kw_secret or (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
if not (kw_key and kw_secret):
logger.warning(
"⚠️ KIWOOM_APP_KEY_REAL/SECRET_REAL 미설정 → 키움 조건검색 매니저 비활성 "
"(%d개 전략 다른 소스 폴백)", len(configs),
)
return
shared = self.kiwoom_ws
if shared is None:
logger.warning(
"⚠️ 키움 시세 WS 미기동 → 조건검색 단독 접속 시도 "
"(키움 시세 WS 미기동 — LIVE_TICK/MINIMAL 과 별개, 키·기동 로그 확인)"
)
try:
self.kiwoom_condition_mgr = KiwoomConditionSearchManager(
app_key=kw_key,
app_secret=kw_secret,
is_mock=False,
configs=configs,
db=self.db,
shared_ws=shared,
)
if not self.kiwoom_condition_mgr.start():
self.kiwoom_condition_mgr = None
try:
from .utils.ops_alert import ops_alert
ops_alert(
"kwcond_start_fail",
"키움 조건검색 매니저 기동 실패",
detail="kiwoom_condition 전략은 DB폴백·유니버스 위험",
level="critical",
session_only=False,
)
except Exception:
pass
except Exception as e:
logger.error("키움 조건검색 매니저 기동 실패: %s", e)
self.kiwoom_condition_mgr = None
try:
from .utils.ops_alert import ops_alert
ops_alert(
"kwcond_start_fail",
"키움 조건검색 매니저 기동 예외",
detail=str(e),
level="critical",
session_only=False,
)
except Exception:
pass
def _attach_kiwoom_condition_mgr_to_strategies(self) -> None:
"""기동 실패→복구 시 전략이 들고 있던 None 핸들을 갱신."""
mgr = self.kiwoom_condition_mgr
for s in self.strategies:
try:
s.kiwoom_condition_mgr = mgr
except Exception:
pass
def _maybe_recover_kiwoom_condition_manager(self) -> None:
"""시세 WS LOGIN 후 토큰이 살아나면 조건검색을 재기동.
주말/야간 재시작 때 키움 토큰 JSON 파싱 실패로 매니저가 죽은 채
시세만 나중에 붙으면 MOMENTUM/SHORT 가 DB sticky + 낡은 history
교집합으로 유니버스 0이 된다. heartbeat 에서 복구한다.
"""
if self._stop:
return
if self.kiwoom_condition_mgr is not None:
return
if not (self._pending_kiwoom_condition_configs or []):
return
shared = self.kiwoom_ws
if shared is None:
return
if not getattr(shared, "is_authenticated", lambda: False)():
return
now_m = time.time()
last = float(getattr(self, "_kwcond_recover_mono", 0.0) or 0.0)
interval = float(get_env_int("KIWOOM_COND_RECOVER_INTERVAL_SEC", 60))
if now_m - last < max(15.0, interval):
return
self._kwcond_recover_mono = now_m
logger.info(
"🔄 키움 조건검색 매니저 복구 시도 "
"(시세 WS LOGIN OK · 이전 기동 실패 후 재시도)"
)
try:
self._start_kiwoom_condition_manager()
except Exception as e:
logger.warning("키움 조건검색 복구 예외: %s", e)
return
if self.kiwoom_condition_mgr is None:
return
self._attach_kiwoom_condition_mgr_to_strategies()
logger.info(
"✅ 키움 조건검색 매니저 복구 완료 → 전략 핸들 갱신 (%s)",
self._mgr_heartbeat_desc(self.kiwoom_condition_mgr),
)
def _ops_health_tick(self) -> None:
"""장중 운영건강 — WS/유니버스/history/kwcond. 쿨다운은 ops_alert 내부."""
try:
from .utils.ops_alert import ops_alert, _in_kr_session
except Exception:
return
if not _in_kr_session():
return
# 1) 시세 WS
try:
if self.ws is not None and not getattr(self.ws, "is_active", False):
ops_alert(
"ws_kis_down",
"KIS 시세 WS 비활성(IDLE)",
detail="장중 is_active=False — REST fallback 가능하나 지연·누락 위험",
level="critical",
)
except Exception:
pass
try:
kw = self.kiwoom_ws
if kw is not None and not getattr(kw, "is_authenticated", lambda: False)():
ops_alert(
"ws_kiwoom_down",
"키움 시세 WS LOGIN 안 됨",
detail="장중 미인증 — 조건검색·시세 위험",
level="critical",
)
except Exception:
pass
try:
ls = getattr(self, "ls_ws", None)
if ls is not None:
opened = getattr(ls, "_opened", None)
down = (
opened is not None
and hasattr(opened, "is_set")
and not opened.is_set()
)
hold_need = max(15, get_env_int("OPS_ALERT_WS_DOWN_HOLD_SEC", 60))
now_ls = time.time()
if down:
t0 = float(getattr(self, "_ls_ws_down_since", 0.0) or 0.0)
if t0 <= 0:
self._ls_ws_down_since = now_ls
elif (now_ls - t0) >= hold_need:
ops_alert(
"ws_ls_down",
"LS 시세 WS 미OPEN",
detail=f"장중 LS 소켓 미연결 {int(now_ls - t0)}초+",
level="critical",
)
# 쿨다운과 별도로 타이머 리셋(연속 스팸 방지)
self._ls_ws_down_since = now_ls
else:
self._ls_ws_down_since = 0.0
except Exception:
pass
# 2) kiwoom_condition 필요한데 매니저 없음
need_kw = False
for s in self.strategies:
sid = str(getattr(s, "strategy_id", "") or "").upper()
if not sid or sid.startswith("US_"):
continue
if self._resolve_source(sid) == "kiwoom_condition":
need_kw = True
break
if need_kw and self.kiwoom_condition_mgr is None:
ops_alert(
"kwcond_off",
"키움 조건검색 OFF (필요 전략 있음)",
detail="MOMENTUM/SHORT 등 kiwoom_condition → DB폴백·매매0 위험",
level="critical",
)
# 3) 전략별 유니버스 0 지속
zero_need = max(60, get_env_int("OPS_ALERT_UNIVERSE_ZERO_SEC", 180))
now_m = time.time()
zero_since = getattr(self, "_univ_zero_since", None)
if not isinstance(zero_since, dict):
zero_since = {}
self._univ_zero_since = zero_since
for s in self.strategies:
sid = str(getattr(s, "strategy_id", "") or "").upper()
if not sid or sid.startswith("US_"):
continue
try:
n = 0
src = self._resolve_source(sid)
if src == "kiwoom_condition" and self.kiwoom_condition_mgr:
n = len(self.kiwoom_condition_mgr.get_universe_for(sid) or [])
elif src == "ls_condition" and self.ls_condition_mgr:
n = len(self.ls_condition_mgr.get_universe_for(sid) or [])
elif src == "condition" and self.condition_mgr:
n = len(self.condition_mgr.get_universe_for(sid) or [])
elif src == "ranking" and self.ranking_mgr:
n = len(self.ranking_mgr.get_universe_for(sid) or [])
else:
continue
if n <= 0:
t0 = float(zero_since.get(sid) or 0.0)
if t0 <= 0:
zero_since[sid] = now_m
elif (now_m - t0) >= zero_need:
# 실매 소스 0 인데 다른 브로커에는 있으면 원인 힌트
kw_n = -1
ls_n = -1
try:
if self.kiwoom_condition_mgr:
kw_n = len(
self.kiwoom_condition_mgr.get_universe_for(sid)
or []
)
except Exception:
pass
try:
if self.ls_condition_mgr:
ls_n = len(
self.ls_condition_mgr.get_universe_for(sid)
or []
)
except Exception:
pass
hint = ""
if src == "ls_condition" and kw_n > 0 and ls_n <= 0:
hint = (
f" | 키움={kw_n} LS=0 → t1859공백/AFR미적재 "
f"(LS_T1859_EMPTY_RETRY 확인)"
)
elif src == "kiwoom_condition" and ls_n > 0 and kw_n <= 0:
hint = f" | LS={ls_n} 키움=0"
ops_alert(
"universe_zero",
f"{sid} 유니버스 0 지속 {int(now_m - t0)}",
detail=f"source={src} kw={kw_n} ls={ls_n}{hint}",
level="critical",
)
zero_since[sid] = now_m # 쿨다운과 별도로 타이머 리셋
else:
zero_since.pop(sid, None)
except Exception:
continue
# 4) 키움 history 당일 공백 (kiwoom_condition 전략)
if need_kw and self.db is not None:
stale_need = max(120, get_env_int("OPS_ALERT_HISTORY_STALE_SEC", 600))
open_grace = max(0, get_env_int("OPS_ALERT_HISTORY_OPEN_GRACE_SEC", 180))
try:
# 개장 직후 N초는 history 첫 적재 레이스 — CRITICAL 스킵
skip_open_race = False
if open_grace > 0:
start_hm = int(get_env_int("OPS_ALERT_SESSION_START_HM", 900) or 900)
now_dt = dt.now()
open_dt = now_dt.replace(
hour=start_hm // 100,
minute=start_hm % 100,
second=0,
microsecond=0,
)
if 0 <= (now_dt - open_dt).total_seconds() < float(open_grace):
skip_open_race = True
if not skip_open_race:
today = dt.now().strftime("%Y-%m-%d")
row = self.db.conn.execute(
"""
SELECT MAX(event_time) AS et
FROM target_candidates_history
WHERE strategy_id IN ('MOMENTUM','SHORT')
AND event_time >= %s
""",
(today + " 00:00:00",),
).fetchone()
et = (row or {}).get("et") if row else None
if not et:
ops_alert(
"history_stale",
"키움 target_candidates_history 당일 0건",
detail="조건검색 history 미적재 — 슬롯정합/백테 위험",
level="critical",
)
else:
if hasattr(et, "timestamp"):
age = time.time() - float(et.timestamp())
else:
try:
age = time.time() - dt.strptime(
str(et)[:19], "%Y-%m-%d %H:%M:%S"
).timestamp()
except Exception:
age = 0.0
if age >= stale_need:
ops_alert(
"history_stale",
f"키움 history 공백 {int(age)}",
detail=f"last_event={et}",
level="critical",
)
except Exception as e:
logger.debug("ops history check: %s", e)
def _start_ls_condition_manager(self) -> None:
"""LS 서버저장조건(AFR) — 시세 get_price 와 무관.
기동 조건 (둘 중 하나):
- ``LS_CONDITION_HISTORY_ENABLED=true`` → 이력만 (실매 유니버스 소스와 분리)
- 또는 어떤 전략이 ``UNIVERSE_SOURCE=ls_condition``
"""
configs = self._pending_ls_condition_configs or []
if not configs:
return
history_on = get_env_bool("LS_CONDITION_HISTORY_ENABLED", False)
want_ls_trade = False
for c in configs:
sid = str(c.get("strategy_id") or "").upper()
if sid and self._resolve_source(sid) == "ls_condition":
want_ls_trade = True
break
if not history_on and not want_ls_trade:
logger.info(
" LS 조건 설정 %d건 있으나 HISTORY=false 이고 "
"UNIVERSE_SOURCE=ls_condition 전략 없음 → 매니저 미기동",
len(configs),
)
return
try:
self.ls_condition_mgr = LsConditionSearchManager(
configs=configs,
db=self.db,
user_id=(get_env_from_db("LS_USER_ID", "") or "").strip(),
use_mock=False,
)
# start() 중 t1859 스냅샷 emit 을 놓치지 않도록 listener 선등록
follow = get_env_bool("LS_WS_FOLLOW_CONDITION_UNIVERSE", True)
if follow and self.ls_ws is not None:
def _on_ls_univ(codes: set) -> None:
try:
self.ls_ws.sync_owner_codes("condition", codes)
except Exception as e:
logger.debug("LS WS condition sync: %s", e)
self.ls_condition_mgr.set_universe_codes_listener(_on_ls_univ)
logger.info(
"📡 LS 조건 유니버스 → LS WS follow ON (owner=condition, plain6=%s)",
get_env_bool("LS_WS_CONDITION_PLAIN6_ONLY", True),
)
elif follow and self.ls_ws is None:
logger.warning(
"⚠️ LS_WS_FOLLOW_CONDITION_UNIVERSE=true 이나 LS WS 미기동 "
"— VALIDATION 또는 HISTORY+FOLLOW 로 WS 를 켜세요"
)
if history_on:
logger.info(
"📼 LS_CONDITION_HISTORY_ENABLED — ls_candidates_history 적재 "
"(실매 UNIVERSE_SOURCE 와 분리). 단독 collect_ls_condition_history 중지 권고."
)
if not self.ls_condition_mgr.start():
self.ls_condition_mgr = None
return
if follow and self.ls_ws is not None:
self.ls_condition_mgr.emit_universe_union()
except Exception as e:
logger.error("LS 조건검색 매니저 기동 실패: %s", e)
self.ls_condition_mgr = None
def _wire_short_holding_peak_provider(self) -> None:
"""SHORT 보유 종목 max_price → ws_candles.holding_peak (백테·tail_engine 정합)."""
ca = getattr(self.ws, "candle_agg", None)
if ca is None or not hasattr(ca, "set_peak_provider"):
return
short = next(
(s for s in self.strategies if getattr(s, "strategy_id", "") == "SHORT"),
None,
)
if short is None:
return
ca.set_peak_provider(
lambda code: (short.holdings.get(code) or {}).get("max_price"),
)
logger.info("📌 SHORT holding_peak → CandleAggregator (ws_candles 정합)")
def _start_overseas_ws(self) -> None:
"""
해외(US) 실시간(지연)체결가 WS(HDFSCNT0) 기동.
- permanent_subscriptions 테이블에서 market_type='US' (enabled) 종목을 구독.
- 국내 WS(WSManager.ws_cache)와 완전히 분리된 별도 인스턴스 → 야간(미국장)만 가동.
- 실패해도 봇 본체엔 영향 없음(REST 폴백/조회로 동작).
※ OVERSEAS_WS_ENABLED=false (env/DB) 로 끌 수 있음.
※ HDFSCNT0 는 '해외 실시간시세' 미신청 시 15분 지연으로 수신됨.
"""
if not get_env_bool("OVERSEAS_WS_ENABLED", True):
logger.info(" OVERSEAS_WS_ENABLED=false → 해외 WS 비활성")
return
# 해외 실시간시세는 실전(real) 자격증명 전용
real_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
real_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
if not real_key or not real_secret:
logger.warning("⚠️ KIS_APP_KEY_REAL/_SECRET_REAL 미설정 → 해외 WS 비활성")
return
try:
import permanent_subs as ps
us_rows = ps.subscribe_codes(self.db, "US")
except Exception as e:
logger.warning("해외 영구구독 목록 로드 실패 → 해외 WS 비활성: %s", e)
return
if not us_rows:
logger.info(" 해외(US) 영구구독 종목 없음 → 해외 WS 미기동")
return
try:
from .ws.kis_ws_overseas import KISOverseasWebSocket
self.overseas_ws = KISOverseasWebSocket(real_key, real_secret)
for row in us_rows:
self.overseas_ws.add_symbol(
row.get("code", ""),
row.get("exchange", "NASD"),
row.get("symbol") or row.get("code", ""),
)
# 국내 WS 와 동일한 CandleAggregator 에 해외 틱도 흘려 봉 정합 유지
if getattr(self.ws, "candle_agg", None):
self.overseas_ws.attach_candle_aggregator(self.ws.candle_agg)
if getattr(self.ws, "tick_recorder", None):
self.overseas_ws.attach_tick_recorder(self.ws.tick_recorder)
ok = self.overseas_ws.start()
logger.info(
"🌎 해외 WS active=%s (US %d종목: %s)",
ok, len(us_rows), ", ".join(r.get("code", "") for r in us_rows),
)
if ok:
# NOTE: start() 순서상 전략 등록(_register_strategies)보다 먼저 호출됨.
# 이 시점엔 US_MOMENTUM 이 아직 없을 수 있음 → 등록 직후
# _attach_overseas_ws_to_strategies() 로 다시 주입.
self._attach_overseas_ws_to_strategies()
else:
self.overseas_ws = None
except Exception as e:
logger.error("해외 WS 기동 실패 (봇은 정상 동작): %s", e)
self.overseas_ws = None
def _attach_overseas_ws_to_strategies(self) -> None:
"""US_MOMENTUM 전략에 해외 WS 핸들 주입 (등록/재기동 후 필수)."""
ows = getattr(self, "overseas_ws", None)
if ows is None:
return
n = 0
for s in self.strategies:
if getattr(s, "strategy_id", "") == "US_MOMENTUM":
s.overseas_ws = ows
n += 1
if n:
logger.info("🌎 해외 WS → US_MOMENTUM 전략 %d개 주입", n)
def _wire_ws_split_feed_if_needed(self) -> None:
"""키움 WS 주입. ``WS_SUBSCRIBE_KIS_MINIMAL`` 이면 후보=키움·한투=보유만(영구KR=LS)."""
if not self.kiwoom_ws:
return
ca = getattr(self.ws, "candle_agg", None)
if not ca:
return
try:
self.kiwoom_ws.attach_candle_aggregator(ca)
tr = getattr(self.ws, "tick_recorder", None)
if tr is not None and get_env_bool("WS_TICK_SAVE_KIWOOM", True):
self.kiwoom_ws.attach_tick_recorder(tr)
tsr = getattr(self.ws, "trigger_snapshot_recorder", None)
if tsr is not None and get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True):
self.kiwoom_ws.attach_trigger_snapshot_recorder(tsr)
# 무조건 WSManager에 키움 WS 인스턴스를 주입 (폴백/중복구독용)
self.ws.set_kiwoom_ws(self.kiwoom_ws)
if get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False):
self.ws.activate_split_feed(True)
except Exception as e:
logger.warning("WS 분리 시세 연결 실패: %s", e)
def _build_market_client(self) -> KISClient:
"""
시세/조회 전용 실전 KISClient 생성 (init 단계).
정책 (`__init__` 주석 참고):
- KIS_MOCK 와 무관하게 **항상 실전 도메인** 으로 시세 조회.
- 거래용 self.client 가 이미 실전(KIS_MOCK=false)이면 토큰·세션 공유 위해
그대로 재사용 → 폴백.
- 실키(KIS_APP_KEY_REAL/_SECRET_REAL) 미설정이면 self.client 폴백 + 워닝
(이 경우 모의 도메인으로 시세 호출 → inquire-price/balance 일부 500 가능).
"""
# 거래 client 가 실전이면 그대로 재사용
if not self.client.mock:
self._market_data_client = self.client
return self.client
real_app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
real_app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
if not real_app_key or not real_app_secret:
logger.warning(
"⚠️ KIS_APP_KEY_REAL/_SECRET_REAL 미설정 → 시세 client 도 모의로 폴백. "
"조건검색·분봉 등 모의 미지원 API 가 HTTP 500 폭주할 수 있음."
)
self._market_data_client = self.client
return self.client
try:
mc = KISClient(
mock=False,
app_key=real_app_key,
app_secret=real_app_secret,
account_no=(get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or "").strip(),
account_code=(get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01") or "01").strip(),
)
logger.info(
"🔑 시세 전용 실전 KISClient 생성 (모의 미지원 API 대응 — 매매는 KIS_MOCK 따름)"
)
self._market_data_client = mc
return mc
except Exception as e:
logger.error(
"시세 전용 실전 KISClient 생성 실패 → 거래용 client 폴백: %s", e
)
self._market_data_client = self.client
return self.client
def _get_market_data_client(self) -> KISClient | None:
"""
condition/ranking 매니저 호환 헬퍼.
실키 모드 client 가 확보돼 있으면 반환, 아니면 None (= 매니저 비활성 신호).
"""
mc = self.market_client
if mc is not None and not mc.mock:
return mc
return None
def _heartbeat_loop(self) -> None:
last_hb = 0.0
last_daily_tick = 0.0
last_pending_fill = 0.0
last_kwcond_recover = 0.0
pending_interval = float(get_env_int("PENDING_FILL_POLL_INTERVAL_SEC", 10))
while not self._stop:
now = time.time()
# 키움 조건검색: 토큰/LOGIN 지연 시 60초마다 복구 시도
if now - last_kwcond_recover >= 15:
try:
self._maybe_recover_kiwoom_condition_manager()
except Exception as e:
logger.debug("kwcond recover tick 예외: %s", e)
last_kwcond_recover = now
if now - last_hb >= 60:
alive = [s.name for s in self.strategies if s.is_alive()]
dead = [s.name for s in self.strategies if not s.is_alive()]
ws_session = "OPEN" if self.ws.is_active else "IDLE"
rank_desc = self._mgr_heartbeat_desc(self.ranking_mgr)
cond_desc = self._mgr_heartbeat_desc(self.condition_mgr)
kw_desc = self._mgr_heartbeat_desc(self.kiwoom_condition_mgr)
ls_desc = self._mgr_heartbeat_desc(self.ls_condition_mgr)
logger.info(
"❤️ heartbeat ws=%s rank[%s] cond[%s] kwcond[%s] lscond[%s] "
"alive=%s dead=%s",
ws_session, rank_desc, cond_desc, kw_desc, ls_desc,
alive, dead,
)
if dead:
logger.warning("⚠️ 죽은 전략 쓰레드 감지: %s → 재기동", dead)
self._restart_dead()
try:
self._ops_health_tick()
except Exception as e:
logger.debug("ops_health_tick 예외: %s", e)
last_hb = now
if now - last_pending_fill >= pending_interval:
try:
n = self.order_mgr.poll_pending_fills()
if n:
logger.info("🔄 미체결 재조회 처리 %d", n)
except Exception as e:
logger.debug("poll_pending_fills 예외: %s", e)
last_pending_fill = now
# 장중 자산/리포트 tick (20초 주기)
if now - last_daily_tick >= 20:
try:
self._daily_report_tick()
except Exception as e:
logger.debug("daily_report_tick 예외: %s", e)
last_daily_tick = now
time.sleep(1)
# ------------------------------------------------------------------
# 자산 스냅샷 / 당일·누적 손익 / 장마감 리포트
# ------------------------------------------------------------------
def _fetch_asset_snapshot(self, force: bool = False) -> Dict[str, Any]:
"""
KIS `inquire-balance` 호출하여 예수금/D+2/총자산/보유종목수를 dict 로 반환.
TTL 캐시(기본 2초)로 API 부하 절감.
"""
t = time.time()
if (
not force
and self._asset_cache is not None
and (t - self._asset_cache_ts) < self._asset_cache_ttl
):
return dict(self._asset_cache)
def _parse_amt(v) -> float:
try:
return float(str(v or 0).replace(",", "").strip())
except Exception:
return 0.0
balance = {}
try:
balance = self.client.get_account_balance() or {}
except Exception as e:
logger.debug("get_account_balance 예외: %s", e)
out2_raw = balance.get("output2") or []
if isinstance(out2_raw, dict):
out2 = out2_raw
elif isinstance(out2_raw, list) and out2_raw:
out2 = out2_raw[0]
else:
out2 = {}
out1_raw = balance.get("output1") or []
if isinstance(out1_raw, dict):
out1 = [out1_raw]
elif isinstance(out1_raw, list):
out1 = out1_raw
else:
out1 = []
dnca = _parse_amt(out2.get("dnca_tot_amt"))
d2 = _parse_amt(out2.get("prvs_rcdl_excc_amt"))
# 주식잔고(inquire-balance) output2 에는 주문가능현금 필드가 없음(공식 매핑 확인).
# 주문가능 = 매수가능조회 inquire-psbl-order 의 ord_psbl_cash / nrcvb_buy_amt.
ord_psbl = 0.0
try:
psbl = self.client.inquire_psbl_order(
str(get_env_from_db("PSBL_ORDER_REF_CODE", "005930") or "005930"),
price=0,
ord_dvsn="01",
) or {}
for k in (
"ord_psbl_cash",
"nrcvb_buy_amt",
"max_buy_amt",
):
v = _parse_amt(psbl.get(k))
if v > 0:
ord_psbl = v
break
except Exception as _e:
logger.debug("매수가능조회(ord_psbl) 실패: %s", _e)
if ord_psbl <= 0:
# 폴백: 잔고 output2 에 혹시 있으면 (실전 일부 상품)
for k in (
"ord_psbl_cash", "ord_psbl_amt", "ord_psbl",
"buy_psbl_cash", "buy_psbl_amt",
):
v = _parse_amt(out2.get(k))
if v > 0:
ord_psbl = v
break
# output2.tot_evlu_amt = 예수금+평가액 (없을 경우 output1 로 합산)
tot_evlu = _parse_amt(out2.get("tot_evlu_amt"))
if tot_evlu <= 0:
holdings_value = 0.0
for it in out1:
qty = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY"))
if qty <= 0:
continue
evlu = _parse_amt(it.get("evlu_amt") or it.get("EVLU_AMT"))
if evlu > 0:
holdings_value += evlu
else:
price = _parse_amt(it.get("prpr") or it.get("PRPR"))
holdings_value += price * qty
tot_evlu = (d2 if d2 > 0 else dnca) + holdings_value
hold_count = 0
for it in out1:
q = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY"))
if q > 0:
hold_count += 1
# 주문 상한 기준 (증거금/미수 사용 제어)
# - dnca : 예수금총액만 사용 (증거금 미사용 권장, 기본값)
# - d2 : D+2 예수금 우선
# - ord_psbl: 브로커 주문가능금액 우선
# - min : dnca/d2/ord_psbl 중 양수 최솟값 (가장 보수적)
cash_basis = str(get_env_from_db("ACCOUNT_CASH_BASIS", "dnca") or "dnca").strip().lower()
if cash_basis == "d2":
cash = d2 if d2 > 0 else dnca
elif cash_basis == "ord_psbl":
cash = ord_psbl if ord_psbl > 0 else dnca
elif cash_basis == "min":
cands = [x for x in (dnca, d2, ord_psbl) if x > 0]
cash = min(cands) if cands else 0.0
else:
# 기본: 증거금/미수 사용 방지
cash = dnca if dnca > 0 else (d2 if d2 > 0 else ord_psbl)
snap = {
"cash": cash, # 주문가능(= D+2 or 예수금총액)
"d2": d2, # D+2 예수금
"dnca": dnca, # 예수금총액
"ord_psbl": ord_psbl, # 주문가능금액 (브로커 제공 시)
"cash_basis": cash_basis,
"total_asset": tot_evlu,
"hold_count": hold_count,
}
self._asset_cache = snap
self._asset_cache_ts = t
try:
self.cash_ledger.sync_from_snapshot(snap, source="api")
except Exception as e:
logger.debug("cash_ledger API 동기화 스킵: %s", e)
return dict(snap)
def _trade_db_raw(self):
"""TradeDBExt → raw TradeDB (kv_store 등)."""
return getattr(self.db, "raw", self.db)
def _is_forced_ghost_trade_row(self, row: Dict[str, Any]) -> bool:
reason = str(row.get("sell_reason") or "")
try:
sell_price = float(row.get("sell_price") or 0)
except Exception:
sell_price = 0.0
is_forced = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason)
return is_forced and sell_price <= 0
def _bot_daily_realized_pnl(self, day_yyyy_mm_dd: str) -> tuple[float, int]:
"""
당일 봇 trade_history 실현손익 합 — 웹 「오늘 운영」·실거래 분석과 동일.
Returns: (순실현손익 원, 청산 건수)
"""
day = (day_yyyy_mm_dd or "")[:10]
if len(day) != 10:
day = dt.now().strftime("%Y-%m-%d")
day_start = f"{day} 00:00:00"
day_end = f"{day} 23:59:59"
ex = list(EXCLUDED_STRATEGY_IDS)
sql = (
"SELECT realized_pnl, sell_reason, sell_price FROM trade_history "
f"WHERE strategy NOT IN ({_LEGACY_STRATEGY_SQL}) "
"AND sell_date >= %s AND sell_date <= %s"
)
try:
rows = self._trade_db_raw().conn.execute(
sql, ex + [day_start, day_end],
).fetchall()
except Exception as e:
logger.debug("bot_daily_realized_pnl 조회 실패: %s", e)
return 0.0, 0
total = 0.0
cnt = 0
for raw in rows or []:
row = dict(raw)
if self._is_forced_ghost_trade_row(row):
continue
total += float(row.get("realized_pnl") or 0)
cnt += 1
return total, cnt
def _bot_daily_realized_pnl_by_strategy(
self, day_yyyy_mm_dd: str, strategy_id: str,
) -> tuple[float, int]:
"""
당일 '특정 전략' 실현손익 합 — 체결 알림의 전략별 손익 줄용.
_bot_daily_realized_pnl 과 동일 기준(유령거래 제외, trade_history)에
strategy 필터만 추가한다.
Returns: (순실현손익 원, 청산 건수)
"""
day = (day_yyyy_mm_dd or "")[:10]
if len(day) != 10:
day = dt.now().strftime("%Y-%m-%d")
day_start = f"{day} 00:00:00"
day_end = f"{day} 23:59:59"
# trade_history.strategy 는 canonical id 로 저장되므로 정규화 후 비교
sid = canonical_strategy_id(strategy_id)
sql = (
"SELECT realized_pnl, sell_reason, sell_price FROM trade_history "
"WHERE strategy = %s "
"AND sell_date >= %s AND sell_date <= %s"
)
try:
rows = self._trade_db_raw().conn.execute(
sql, [sid, day_start, day_end],
).fetchall()
except Exception as e:
logger.debug("bot_daily_realized_pnl_by_strategy 조회 실패: %s", e)
return 0.0, 0
total = 0.0
cnt = 0
for raw in rows or []:
row = dict(raw)
if self._is_forced_ghost_trade_row(row):
continue
total += float(row.get("realized_pnl") or 0)
cnt += 1
return total, cnt
def _strategy_daily_pnl_for_notify(self, strategy_id: str):
"""
OrderManager 체결 알림 주입용 — 해당 전략의 '당일' (실현손익, 청산건수) 반환.
실패/예외 시 None 을 돌려 알림 줄을 생략한다.
"""
try:
return self._bot_daily_realized_pnl_by_strategy(
dt.now().strftime("%Y-%m-%d"), strategy_id,
)
except Exception:
return None
def _restore_start_day_asset(self) -> bool:
"""kv 에 저장된 당일 시작 총자산 복원."""
today = dt.now().strftime("%Y-%m-%d")
try:
db = self._trade_db_raw()
kv_date = (db.get_kv(_KV_START_ASSET_DATE) or "").strip()
if kv_date != today:
return False
val_raw = (db.get_kv(_KV_START_ASSET_VALUE) or "").strip()
if not val_raw:
return False
self.start_day_asset = float(val_raw)
logger.info(
"📌 [당일 시작 자산] kv 복원 %s",
f"{self.start_day_asset:,.0f}",
)
return True
except Exception as e:
logger.debug("start_day_asset kv 복원 실패: %s", e)
return False
def _save_start_day_asset(self, value: float) -> None:
"""당일 시작 총자산 1회 기록 — 이미 있으면 덮어쓰지 않음 (재시작 baseline 유지)."""
today = dt.now().strftime("%Y-%m-%d")
try:
db = self._trade_db_raw()
kv_date = (db.get_kv(_KV_START_ASSET_DATE) or "").strip()
if kv_date == today:
val_raw = (db.get_kv(_KV_START_ASSET_VALUE) or "").strip()
if val_raw:
self.start_day_asset = float(val_raw)
return
if value <= 0:
return
self.start_day_asset = float(value)
db.set_kv(_KV_START_ASSET_DATE, today)
db.set_kv(_KV_START_ASSET_VALUE, str(int(round(self.start_day_asset))))
logger.info(
"📌 [당일 시작 자산] %s원 기록 (kv)",
f"{self.start_day_asset:,.0f}",
)
except Exception as e:
logger.debug("start_day_asset kv 저장 실패: %s", e)
def _asset_line_for_notify(self, side: str = "", extra: Optional[Dict] = None) -> str:
"""
체결 알림 하단 요약 (줄바꿈 정리):
- 봇 실현손익: trade_history 당일 합 (웹과 동일, 수수료·세금 반영)
- 계좌평가변동: 현재 총자산 당일 시작 총자산 (전체 계좌 MTM)
"""
try:
self._restore_start_day_asset()
snap = self._fetch_asset_snapshot()
cash = snap.get("cash", 0.0)
total = snap.get("total_asset", 0.0)
hold = snap.get("hold_count", 0)
today = dt.now().strftime("%Y-%m-%d")
acct_day_pnl = (
(total - self.start_day_asset) if self.start_day_asset > 0 else 0.0
)
acct_day_pct = (
(acct_day_pnl / self.start_day_asset * 100)
if self.start_day_asset > 0 else 0.0
)
bot_pnl, bot_cnt = self._bot_daily_realized_pnl(today)
lines = [
"━━━━━━━━━━━━━━━━━━━━",
"💼 **계좌**",
f"예수금 {cash:,.0f}",
f"총자산 {total:,.0f}원 · 보유 {hold}종목",
"",
"📊 **손익 (당일)**",
f"🤖 봇 실현 {bot_pnl:+,.0f}원 · 청산 {bot_cnt}",
f"📈 계좌평가 {acct_day_pnl:+,.0f}원 ({acct_day_pct:+.2f}%)",
]
if self.total_deposit and self.total_deposit > 0:
cum_pnl = total - self.total_deposit
cum_pct = cum_pnl / self.total_deposit * 100
lines.extend([
"",
"💰 **누적 (입금 대비)**",
f"{cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)",
])
lines.append("━━━━━━━━━━━━━━━━━━━━")
return "\n".join(lines)
except Exception as e:
logger.debug("asset line 생성 실패: %s", e)
return ""
def _daily_report_tick(self) -> None:
"""
장중 주기적으로 호출되어:
1) 최초 1회 start_day_asset 기록 (09:00 이후 첫 조회)
2) 15:15 장마감 전 리포트 발송 (하루 1회)
3) 15:35 장마감 최종 리포트 발송 (하루 1회)
"""
now = dt.now()
h, m = now.hour, now.minute
today = now.strftime("%Y-%m-%d")
today_ymd = now.strftime("%Y%m%d")
# 날짜가 바뀌면 상태 리셋
if self._closing_report_date and self._closing_report_date != today:
self.start_day_asset = 0.0 # 새 영업일 기준 재설정
self._start_asset_retry_at = 0.0 # 재시도 백오프도 같이 리셋
try:
kv_date = (self._trade_db_raw().get_kv(_KV_START_ASSET_DATE) or "").strip()
if kv_date and kv_date != today:
self.start_day_asset = 0.0
except Exception:
pass
if self.start_day_asset <= 0:
self._restore_start_day_asset()
# env 에 TOTAL_DEPOSIT 가 있을 수도 있으므로 매일 한번 리로드
try:
latest_td = get_env_float("TOTAL_DEPOSIT", 0.0) or 0.0
if latest_td > 0:
self.total_deposit = latest_td
except Exception:
pass
# 09:00 이후에만 start_day_asset 확보 (장 시작 전 예탁금 왜곡 방지).
# 실패하면 START_ASSET_RETRY_BACKOFF_SEC (기본 300s) 동안 재시도 차단 →
# 모의 서버 500 시 hb 20초마다 inquire-balance 폭주하던 이슈 방지.
if self.start_day_asset <= 0 and (h > 9 or (h == 9 and m >= 0)):
now_t = time.time()
if now_t >= self._start_asset_retry_at:
try:
snap = self._fetch_asset_snapshot(force=True)
if snap.get("total_asset", 0) > 0:
self._save_start_day_asset(float(snap["total_asset"]))
else:
# snap 자체는 받았으나 total=0 (잔고 0 또는 모의 응답 깨짐)
self._start_asset_retry_at = now_t + self._start_asset_backoff_sec
logger.debug(
"start_day_asset 조회 결과 total=0 → %ds 후 재시도",
self._start_asset_backoff_sec,
)
except Exception as e:
self._start_asset_retry_at = now_t + self._start_asset_backoff_sec
logger.debug(
"start_day_asset 설정 실패: %s%ds 후 재시도",
e, self._start_asset_backoff_sec,
)
# 08:35~09:15 개장 전/초기 계좌 평단가·수량 동기화 (하루 1회 — 권리락/액면분할 방어)
if (self._morning_sync_date != today) and ((h == 8 and m >= 35) or (h == 9 and m <= 15)):
try:
from .execution.orphan_reconcile import sync_active_trades_with_broker
res = sync_active_trades_with_broker(self.order_mgr)
self._morning_sync_date = today
if res.get("synced"):
logger.info("🌅 [개장 전/초기 계좌 동기화 완료] %d건 변경 감지 및 보정", len(res["synced"]))
except Exception as e:
logger.error("개장 전/초기 계좌 동기화 실패: %s", e)
# 09:00 장 시작 알림 (09:00~09:10 윈도우, 하루 1회)
# 봇 기동 시점과 무관하게 매일 장 시작에 1회 발송 (아침 알람 용도).
if (h == 9 and 0 <= m <= 10 and self._market_open_report_date != today):
try:
body = self._build_market_open_report(today_ymd)
if body:
self._send_daily_report("장 시작 알림", body)
self._market_open_report_date = today
except Exception as e:
logger.error("장 시작 알림 실패: %s", e)
# 15:15 장마감 전 (15:15~15:20 윈도우, 하루 1회)
if (h == 15 and 15 <= m <= 20 and self._closing_report_date != today):
try:
body = self._build_closing_report(today_ymd)
if body:
self._send_daily_report("장마감 전 현황", body)
self._closing_report_date = today
except Exception as e:
logger.error("장마감 전 리포트 실패: %s", e)
# 15:35 장마감 최종 (15:34~15:45 윈도우, 하루 1회)
if (h == 15 and 34 <= m <= 45 and self._final_report_date != today):
try:
body = self._build_final_report(today_ymd)
if body:
self._send_daily_report("장마감 최종 보고", body)
self._final_report_date = today
except Exception as e:
logger.error("장마감 최종 리포트 실패: %s", e)
# Pre-EOD 고아복구 — 가장 이른 EOD N분 (하루 1회, EOD 청산 전)
if self._orphan_pre_eod_reconcile_date != today:
try:
from .execution.orphan_reconcile import is_pre_eod_reconcile_window
if is_pre_eod_reconcile_window(now):
self._run_orphan_reconcile(today, phase="pre")
self._orphan_pre_eod_reconcile_date = today
except Exception as e:
logger.error("Pre-EOD 고아복구 실패: %s", e)
# 15:36~16:00 장마감 고아복구 (하루 1회 — 매매 없을 때 REST 잔고↔DB 대조)
if self._orphan_reconcile_date != today:
in_reconcile_window = (h == 15 and 36 <= m <= 59) or (h == 16 and m < 30)
if in_reconcile_window:
try:
self._run_orphan_reconcile(today, phase="post")
self._orphan_reconcile_date = today
except Exception as e:
logger.error("장마감 고아복구 실패: %s", e)
def _run_orphan_reconcile(self, today: str, *, phase: str = "post") -> None:
"""봇 고아(active_trades 미기록) 1회 복구. phase=pre|post|startup."""
from .execution.orphan_reconcile import reconcile_orphan_positions
result = reconcile_orphan_positions(self.order_mgr)
n = int(result.get("reconciled_count") or 0)
g = int(result.get("ghost_purged_count") or 0)
stale = int(result.get("stale_purged_count") or 0)
skip_sell = int(result.get("skipped_after_sell_count") or 0)
s = int(result.get("synced_count") or 0)
if n <= 0 and g <= 0 and stale <= 0 and skip_sell <= 0 and s <= 0 and not result.get("error"):
label = {"pre": "Pre-EOD", "post": "장마감", "startup": "기동"}.get(phase, phase)
if phase == "startup":
logger.info("🧩 [%s 잔고=DB 맞춤] 복구·유령삭제 대상 없음", label)
else:
logger.debug("🧩 [%s 고아복구] 복구·유령삭제 대상 없음", label)
return
if result.get("error"):
logger.warning("🧩 [고아복구] 중단: %s", result["error"])
return
title = {
"pre": "Pre-EOD 고아복구",
"post": "장마감 고아복구",
"startup": "기동 잔고=DB 맞춤",
}.get(phase, "고아복구")
lines = [
f"🧩 **[{title}]**",
f"- 복구: {n}종목",
f"- 계좌동기화: {s}종목",
f"- 청산후스킵: {skip_sell}종목",
f"- 청산후유령정리: {stale}종목",
f"- 유령삭제: {g}종목",
f"- 실패: {int(result.get('failed_count') or 0)}",
]
for it in (result.get("reconciled") or [])[:8]:
lines.append(
f" · {it.get('name')}({it.get('code')}) "
f"[{it.get('strategy')}] {it.get('qty')}"
)
if n > 8:
lines.append(f" …외 {n - 8}종목")
for it in (result.get("skipped_after_sell") or [])[:5]:
lines.append(
f" ⏭스킵 {it.get('name')}({it.get('code')}) "
f"{it.get('sell_reason')}@{it.get('sell_date')}"
)
for it in (result.get("stale_purged") or [])[:5]:
lines.append(
f" ✕청산후유령 {it.get('name')}({it.get('code')}) [{it.get('strategy')}]"
)
for it in (result.get("synced") or [])[:5]:
lines.append(
f" 🔄동기화 {it.get('name')}({it.get('code')}) [{it.get('strategy')}] {it.get('old_qty')}{it.get('new_qty')}주 ({it.get('old_avg'):,.0f}{it.get('new_avg'):,.0f}원)"
)
for it in (result.get("ghost_purged") or [])[:5]:
lines.append(
f" ✕유령 {it.get('name')}({it.get('code')}) [{it.get('strategy')}]"
)
body = "\n".join(lines)
try:
from .utils.logger import msg_mm
msg_mm(body, channel_alias=self._system_mm_channel(), jitter=False)
except Exception as e:
logger.debug("고아복구 MM 전송 실패: %s", e)
logger.info(
"🧩 [고아복구] 복구 %d · 계좌동기화 %d · 청산후스킵 %d · 청산후유령정리 %d · 유령삭제 %d → active_trades 반영",
n, s, skip_sell, stale, g,
)
def _build_market_open_report(self, today_ymd: str) -> str:
"""09:00 장 시작 알림 메시지 — 보유종목·예수금·주문가능금액 현황."""
snap = self._fetch_asset_snapshot(force=True)
total = snap.get("total_asset", 0.0)
cash = snap.get("cash", 0.0)
d2 = snap.get("d2", 0.0)
dnca = snap.get("dnca", 0.0)
ord_psbl = snap.get("ord_psbl", 0.0)
basis = str(snap.get("cash_basis", "dnca") or "dnca")
hold = snap.get("hold_count", 0)
active_sids = ", ".join(s.strategy_id for s in self.strategies) or "(없음)"
lines = [
"🌅 **[장 시작 알림 - 09:00]**",
"━━━━━━━━━━━━━━━━━━━━",
f"- 🤖 활성 전략: {active_sids}",
(
f"- 계좌: {('모의' if self.client.mock else '실전')} "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
),
f"- 현재 자산: {total:,.0f}",
f"- 보유 종목: {hold}",
(
f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
f"주문가능금액: {ord_psbl:,.0f}원 | "
f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}"
),
"━━━━━━━━━━━━━━━━━━━━",
"📈 오늘도 안전 매매! 손절 라인 준수.",
]
return "\n".join(lines)
def _build_closing_report(self, today_ymd: str) -> str:
"""15:15 장마감 전 현황 메시지 (기존 kis_short_ver3.py 포맷 계승)."""
snap = self._fetch_asset_snapshot(force=True)
total = snap.get("total_asset", 0.0)
cash = snap.get("cash", 0.0)
d2 = snap.get("d2", 0.0)
dnca = snap.get("dnca", 0.0)
ord_psbl = snap.get("ord_psbl", 0.0)
basis = str(snap.get("cash_basis", "dnca") or "dnca")
hold = snap.get("hold_count", 0)
day_pnl = (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0
day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0
bot_pnl, bot_cnt = self._bot_daily_realized_pnl(
f"{today_ymd[:4]}-{today_ymd[4:6]}-{today_ymd[6:8]}"
)
lines = [
"📈 **[장마감 전 현황 - 15:15]**",
"",
f"- 🤖 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)",
f"- 📈 계좌평가: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
f"- 현재 자산: {total:,.0f}",
f"- 보유 종목: {hold}",
(
f"- 계좌: {('모의' if self.client.mock else '실전')} "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
),
(
f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
f"주문가능금액: {ord_psbl:,.0f}원 | "
f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}"
),
]
return "\n".join(lines)
def _build_final_report(self, today_ymd: str) -> str:
"""15:35 장마감 최종 보고 메시지 (당일/누적 손익 + 매매건수)."""
snap = self._fetch_asset_snapshot(force=True)
total = snap.get("total_asset", 0.0)
cash = snap.get("cash", 0.0)
d2 = snap.get("d2", 0.0)
dnca = snap.get("dnca", 0.0)
ord_psbl = snap.get("ord_psbl", 0.0)
basis = str(snap.get("cash_basis", "dnca") or "dnca")
hold = snap.get("hold_count", 0)
day_pnl = (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0
day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0
bot_pnl, bot_cnt = self._bot_daily_realized_pnl(
f"{today_ymd[:4]}-{today_ymd[4:6]}-{today_ymd[6:8]}"
)
cum_pnl = (total - self.total_deposit) if self.total_deposit > 0 else 0.0
cum_pct = (cum_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0.0
try:
today_trades = self.db.get_trades_by_date(today_ymd) or []
except Exception:
today_trades = []
trade_cnt = len(today_trades)
lines = [
"🏁 **[장마감 최종 보고 - 15:35]**",
"━━━━━━━━━━━━━━━━━━━━",
"📅 **당일 손익**",
f"- 🤖 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)",
f"- 📈 계좌평가: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
f"- 시작 총자산: {self.start_day_asset:,.0f}",
f"- 종료 총자산: {total:,.0f}",
"",
"💰 **누적 (입금 대비)**",
f"- 총 입금: {self.total_deposit:,.0f}",
f"- 현재 자산: {total:,.0f}",
f"- 누적 손익: {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)",
"",
"📊 **거래 현황**",
f"- 오늘 매매: {trade_cnt}",
f"- 보유 종목: {hold}",
(
f"- 계좌: {('모의' if self.client.mock else '실전')} "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
),
(
f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
f"주문가능금액: {ord_psbl:,.0f}원 | "
f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}"
),
"━━━━━━━━━━━━━━━━━━━━",
]
return "\n".join(lines)
def _send_daily_report(self, title: str, body: str) -> None:
"""장시작/장마감 리포트 — 시스템 알림 단일 채널(키스명령봇) + Telegram."""
sys_ch = self._system_mm_channel()
try:
msg_mm(body, channel_alias=sys_ch, jitter=False)
except Exception as e:
logger.debug("리포트 MM(%s) 전송 실패: %s", sys_ch, e)
try:
msg_tg(body, jitter=False)
except Exception as e:
logger.debug("리포트 TG 전송 실패: %s", e)
logger.info("📢 %s 발송 완료", title)
@staticmethod
def _mgr_heartbeat_desc(mgr) -> str:
if mgr is None:
return "off"
try:
cfgs = getattr(mgr, "_configs", [])
parts = [
f"{c['strategy_id']}={len(mgr.get_universe_for(c['strategy_id']))}"
for c in cfgs
]
return ",".join(parts) if parts else "0"
except Exception:
try:
return str(len(mgr.get_universe()))
except Exception:
return "?"
def _restart_dead(self) -> None:
new_list = []
for s in self.strategies:
if s.is_alive():
new_list.append(s)
continue
# 같은 타입으로 새 쓰레드 생성 (공유 인프라 주입)
cls = type(s)
ns = cls(
db=self.db, client=self.market_client, ws=self.ws,
order_mgr=self.order_mgr,
condition_mgr=self.condition_mgr,
ranking_mgr=self.ranking_mgr,
kiwoom_condition_mgr=self.kiwoom_condition_mgr,
ls_condition_mgr=self.ls_condition_mgr,
market_guard=self.market_guard,
)
if getattr(ns, "strategy_id", "") == "US_MOMENTUM" and self.overseas_ws:
ns.overseas_ws = self.overseas_ws
ns.order_worker = self.order_worker
self.order_worker.register_strategy(ns)
ns.start()
new_list.append(ns)
logger.info("🔁 전략 재기동: %s", ns.name)
try:
msg_mm(f"🔁 전략 재기동: {ns.strategy_id}")
except Exception:
pass
self.strategies = new_list
# ------------------------------------------------------------------
def _on_signal(self, *_):
logger.info("🛑 종료 신호 수신 → 정리 시작")
self._stop = True
self.stop()
sys.exit(0)
def _build_shutdown_report(self) -> str:
"""종료 알림 메시지 — 보유종목·예수금·주문가능금액 + 당일 봇 실현손익.
종료는 장중/장후 어느 시점에나 발생할 수 있으므로 잔고 조회가 실패해도
최소 정보는 출력되도록 방어한다. (호출부에서 예외 시 단순 메시지로 폴백)
"""
now = dt.now()
today_ymd = now.strftime("%Y%m%d")
today_dash = now.strftime("%Y-%m-%d")
lines = ["🛑 **kis_trader 종료**", "━━━━━━━━━━━━━━━━━━━━"]
try:
snap = self._fetch_asset_snapshot(force=True)
total = snap.get("total_asset", 0.0)
cash = snap.get("cash", 0.0)
d2 = snap.get("d2", 0.0)
dnca = snap.get("dnca", 0.0)
ord_psbl = snap.get("ord_psbl", 0.0)
basis = str(snap.get("cash_basis", "dnca") or "dnca")
hold = snap.get("hold_count", 0)
# 당일 봇 실현손익 (청산 완료분만)
try:
bot_pnl, bot_cnt = self._bot_daily_realized_pnl(today_dash)
except Exception:
bot_pnl, bot_cnt = 0.0, 0
lines += [
f"- 🕒 종료 시각: {now.strftime('%Y-%m-%d %H:%M:%S')}",
f"- 🤖 당일 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)",
(
f"- 계좌: {('모의' if self.client.mock else '실전')} "
f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
),
f"- 현재 자산: {total:,.0f}",
f"- 보유 종목: {hold}",
(
f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
f"주문가능금액: {ord_psbl:,.0f}원 | "
f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}"
),
]
except Exception as e:
logger.debug("종료 리포트 잔고 조회 실패: %s", e)
lines.append(f"- 🕒 종료 시각: {now.strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("- ⚠️ 잔고 조회 실패 (네트워크/장후 등)")
lines.append("━━━━━━━━━━━━━━━━━━━━")
return "\n".join(lines)
def stop(self) -> None:
for s in self.strategies:
s.stop_loop()
try:
self.order_worker.stop()
except Exception:
pass
for s in self.strategies:
try:
s.join(timeout=5)
except Exception:
pass
try:
if self.condition_mgr:
self.condition_mgr.stop()
except Exception:
pass
try:
if self.kiwoom_condition_mgr:
self.kiwoom_condition_mgr.stop()
if self.ls_condition_mgr:
self.ls_condition_mgr.stop()
except Exception:
pass
try:
if self.ranking_mgr:
self.ranking_mgr.stop()
except Exception:
pass
try:
if self.ws_validator:
self.ws_validator.stop()
except Exception:
pass
try:
if getattr(self, "ls_ws_validator", None):
self.ls_ws_validator.stop()
except Exception:
pass
try:
if self.kiwoom_ws:
self.kiwoom_ws.stop()
except Exception:
pass
try:
if getattr(self, "ls_ws", None):
self.ls_ws.stop()
except Exception:
pass
try:
if self.overseas_ws:
self.overseas_ws.stop()
except Exception:
pass
try:
self.ws.stop()
except Exception:
pass
try:
body = self._build_shutdown_report()
for ch in self._lifecycle_mm_channels():
try:
msg_mm(body, channel_alias=ch, jitter=False)
except Exception as e:
logger.debug("종료 MM(%s) 전송 실패: %s", ch, e)
except Exception:
# 종료 시 잔고 조회 실패 등 어떤 예외라도 최소 종료 메시지는 보낸다.
for ch in self._lifecycle_mm_channels():
try:
msg_mm("🛑 kis_trader 종료", channel_alias=ch, jitter=False)
except Exception:
pass
def main() -> None:
import faulthandler
import signal
faulthandler.enable()
try:
faulthandler.register(signal.SIGUSR1, all_threads=True, chain=False)
except:
pass
orch = TradingOrchestrator()
try:
orch.start()
except KeyboardInterrupt:
orch.stop()
except Exception as e:
logger.exception("오케스트레이터 치명적 예외: %s", e)
orch.stop()
sys.exit(1)
if __name__ == "__main__":
main()