변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
1847 lines
83 KiB
Python
1847 lines
83 KiB
Python
"""
|
||
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 .network.condition_manager import ConditionSearchManager
|
||
from .network.kiwoom_condition_manager import KiwoomConditionSearchManager
|
||
from .network.market_guard import MarketGuard
|
||
from .network.ranking_manager import VolumeRankManager
|
||
from .network.ws_manager import WSManager
|
||
from .strategies import (
|
||
DbBandStrategy,
|
||
BreakoutStrategy,
|
||
MomentumStrategy,
|
||
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"),
|
||
}
|
||
_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"),
|
||
}
|
||
# 키움 전용 조건식 seq (선택). 이름은 CONDITION_{SID}_NAME 공통 사용 — KIS/키움 HTS 이름이 같으면 NAME 하나로 충분.
|
||
# UNIVERSE_SOURCE=kiwoom_condition 일 때만 KIWOOM_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": "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.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.ranking_mgr: VolumeRankManager | None = None
|
||
# 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 전면 차단).
|
||
# 지수 조회는 모의 도메인 미지원 가능성 → 실키 우선, 폴백 self.client.
|
||
self.market_guard: MarketGuard | None = None
|
||
# 시세 마이그레이션 검증 (WS_PROVIDER=kis_with_validation 시만 기동)
|
||
self.kiwoom_ws = None
|
||
self.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 = ""
|
||
# 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]
|
||
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,
|
||
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")
|
||
|
||
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 not self.strategies:
|
||
logger.error("전략이 하나도 활성화되지 않음 → 종료")
|
||
sys.exit(1)
|
||
|
||
self._setup_daily_profit_halt()
|
||
|
||
def _setup_daily_profit_halt(self) -> None:
|
||
"""일일 익절 목표 가드 — 전략 쓰레드에 공통 주입."""
|
||
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,
|
||
)
|
||
for strat in self.strategies:
|
||
strat.daily_profit_halt = guard
|
||
from .engine.daily_profit_halt import load_global_profit_target
|
||
cfg = load_global_profit_target()
|
||
if cfg.get("enabled") and (
|
||
float(cfg.get("krw") or 0) > 0 or float(cfg.get("pct") or 0) > 0
|
||
):
|
||
logger.info(
|
||
"🎯 [일일익절] 마스터 ON — 목표 %s원 / %s%% (신규매수 중단)",
|
||
f"{float(cfg.get('krw') or 0):,.0f}",
|
||
f"{float(cfg.get('pct') or 0):.2f}",
|
||
)
|
||
|
||
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"),
|
||
"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'."""
|
||
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
|
||
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 ""
|
||
label = "kiwoom_cond" if src == "kiwoom_condition" else "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():
|
||
# ⚠️ 미설정/유효하지 않은 값일 때만 기본값(condition) 주입.
|
||
# 사용자가 명시적으로 고른 소스(ranking/condition/kiwoom_condition)는
|
||
# 재시작해도 덮어쓰지 않는다 (런타임 스위치 유지 — 이 함수가 매 기동
|
||
# 무조건 condition 으로 리셋하면 스위치가 무의미해지므로).
|
||
cur_src = (snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip().lower()
|
||
if cur_src not in ("ranking", "condition", "kiwoom_condition"):
|
||
patch[f"{sid}_UNIVERSE_SOURCE"] = "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)
|
||
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}")
|
||
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)
|
||
|
||
# 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._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}"
|
||
)
|
||
|
||
# 전략 등록 후 쓰레드 시작 (각자 독립 쓰레드)
|
||
self._register_strategies()
|
||
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),
|
||
"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] = []
|
||
|
||
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
|
||
src = self._resolve_source(sid) # 시작 시 '주 소스' (로그/기본값용)
|
||
|
||
# ── ranking 쪽 설정 수집 (항상 등록 — 기본값 fallback 존재) ─────
|
||
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)
|
||
ranking_configs.append({
|
||
"strategy_id": sid,
|
||
"sort": sort_val,
|
||
"limit": limit_val,
|
||
"market": "J",
|
||
})
|
||
|
||
# ── condition 쪽 설정 수집 (NAME 또는 SEQ 가 있을 때만 등록) ────
|
||
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)
|
||
if has_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,
|
||
)
|
||
|
||
logger.info(
|
||
"🔀 [%s] 시작소스=%s (매니저등록: ranking=✓ condition=%s kiwoom=%s) "
|
||
"— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환",
|
||
sid, src, ("✓" if has_cond else "✗"),
|
||
("✓" if has_kw_cond 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 실시간 CNSRREQ/REAL) ──────────
|
||
# KIS 와 달리 웹소켓 실시간 지원. 토큰은 키움 REST(au10001) 로 발급하며
|
||
# _get_kiwoom_token_cached 캐시를 재사용한다. KIS 계정과 무관한 별도 키.
|
||
# env: KIWOOM_APP_KEY_REAL / KIWOOM_APP_SECRET_REAL (구독성 데이터 → 무조건 실키·실전 도메인)
|
||
if kiwoom_condition_configs:
|
||
# ⚠️ 조건검색은 시세/구독성 데이터 → KIS 조건검색·랭킹, 키움 시세 WS
|
||
# (KIWOOM_WS_FORCE_REAL) 와 동일하게 **KIWOOM_MOCK 와 무관하게 무조건 실키**
|
||
# 로 매칭한다. 모의 도메인은 조건검색 미지원/불안정.
|
||
kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
|
||
kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
|
||
# 접미사 없는 공통 키로 폴백 (시세 WS 블록과 동일 규칙)
|
||
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(kiwoom_condition_configs),
|
||
)
|
||
else:
|
||
try:
|
||
self.kiwoom_condition_mgr = KiwoomConditionSearchManager(
|
||
app_key=kw_key,
|
||
app_secret=kw_secret,
|
||
is_mock=False, # 조건검색은 항상 실전 도메인
|
||
configs=kiwoom_condition_configs,
|
||
db=self.db,
|
||
)
|
||
if not self.kiwoom_condition_mgr.start():
|
||
self.kiwoom_condition_mgr = None
|
||
except Exception as e:
|
||
logger.error("키움 조건검색 매니저 기동 실패: %s", e)
|
||
self.kiwoom_condition_mgr = None
|
||
else:
|
||
logger.info("ℹ️ kiwoom_condition 소스 쓰는 전략 없음 → 키움 조건검색 매니저 비활성")
|
||
|
||
# ------------------------------------------------------------------
|
||
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↔키움 가격 검증기.
|
||
|
||
- ``WS_PROVIDER=kis_with_validation`` : 검증기 + 키움 WS
|
||
- ``WS_SUBSCRIBE_KIS_MINIMAL=true`` : KIS 구독 최소화용 키움 WS (검증기는 provider 에 따라)
|
||
"""
|
||
from .utils.env import get_env_from_db
|
||
|
||
minimal = get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False)
|
||
provider = (get_env_from_db("WS_PROVIDER", "kis_only") or "kis_only").strip().lower()
|
||
|
||
need_kiwoom = bool(minimal or provider == "kis_with_validation")
|
||
need_validator = provider == "kis_with_validation"
|
||
|
||
if not need_kiwoom:
|
||
if provider not in ("kis_only", "kis_with_validation"):
|
||
logger.warning(
|
||
"⚠️ WS_PROVIDER='%s' 알 수 없음 → kis_only 로 취급",
|
||
provider,
|
||
)
|
||
logger.info("ℹ️ 키움 WS 미기동 (WS_PROVIDER=kis_only & WS_SUBSCRIBE_KIS_MINIMAL=false)")
|
||
return
|
||
|
||
if minimal and not need_validator:
|
||
logger.info(
|
||
"ℹ️ WS_SUBSCRIBE_KIS_MINIMAL=true → 키움 WS 기동 (WS_PROVIDER=kis_only: 검증기 비활성)",
|
||
)
|
||
|
||
# 키움 키 로드 — 검증/분리 시세 모두 실키·실전 권장
|
||
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:
|
||
if force_real:
|
||
row = self.db.conn.execute(
|
||
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
if row:
|
||
r = dict(row)
|
||
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
|
||
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()
|
||
logger.info("🔬 [시세 검증] WS_PROVIDER=%s — ws_price_validation 기록", provider)
|
||
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 _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.codes_by_market(self.db, "US", enabled_only=True)
|
||
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 not ok:
|
||
self.overseas_ws = None
|
||
except Exception as e:
|
||
logger.error("해외 WS 기동 실패 (봇은 정상 동작): %s", e)
|
||
self.overseas_ws = None
|
||
|
||
def _wire_ws_split_feed_if_needed(self) -> None:
|
||
"""``WS_SUBSCRIBE_KIS_MINIMAL`` : 후보 틱→CandleAggregator + KIS/키움 분리 구독."""
|
||
if not get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False):
|
||
return
|
||
if not self.kiwoom_ws:
|
||
logger.warning(
|
||
"WS_SUBSCRIBE_KIS_MINIMAL=true 이지만 키움 WS 미기동 — "
|
||
"KIWOOM_APP_KEY_REAL 등 확인 (WS_PROVIDER=kis_with_validation 병행 권장)",
|
||
)
|
||
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:
|
||
self.kiwoom_ws.attach_tick_recorder(tr)
|
||
tsr = getattr(self.ws, "trigger_snapshot_recorder", None)
|
||
if tsr is not None:
|
||
self.kiwoom_ws.attach_trigger_snapshot_recorder(tsr)
|
||
self.ws.set_kiwoom_ws(self.kiwoom_ws)
|
||
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
|
||
pending_interval = float(get_env_int("PENDING_FILL_POLL_INTERVAL_SEC", 10))
|
||
while not self._stop:
|
||
now = time.time()
|
||
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)
|
||
logger.info(
|
||
"❤️ heartbeat ws=%s rank[%s] cond[%s] alive=%s dead=%s",
|
||
ws_session, rank_desc, cond_desc, alive, dead,
|
||
)
|
||
if dead:
|
||
logger.warning("⚠️ 죽은 전략 쓰레드 감지: %s → 재기동", dead)
|
||
self._restart_dead()
|
||
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"))
|
||
# 브로커 응답에 주문가능금액 키가 계좌/상품별로 다를 수 있어 후보를 넓게 본다.
|
||
ord_psbl = 0.0
|
||
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,
|
||
)
|
||
|
||
# 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)
|
||
|
||
# 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)
|
||
except Exception as e:
|
||
logger.error("장마감 고아복구 실패: %s", e)
|
||
|
||
def _run_orphan_reconcile(self, today: str) -> None:
|
||
"""장마감 후 봇 고아(active_trades 미기록) 1회 복구."""
|
||
from .execution.orphan_reconcile import reconcile_orphan_positions
|
||
|
||
result = reconcile_orphan_positions(self.order_mgr)
|
||
self._orphan_reconcile_date = today
|
||
n = int(result.get("reconciled_count") or 0)
|
||
if n <= 0 and not result.get("error"):
|
||
logger.debug("🧩 [고아복구] 복구 대상 없음")
|
||
return
|
||
if result.get("error"):
|
||
logger.warning("🧩 [고아복구] 중단: %s", result["error"])
|
||
return
|
||
lines = [
|
||
"🧩 **[장마감 고아복구]**",
|
||
f"- 복구: {n}종목",
|
||
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}종목")
|
||
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종목 active_trades 반영", n)
|
||
|
||
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,
|
||
)
|
||
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()
|
||
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()
|
||
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 self.kiwoom_ws:
|
||
self.kiwoom_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:
|
||
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()
|