Files
kis_bot/kis_trader/main.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

1903 lines
86 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 .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"),
}
# 키움 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.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._pending_kiwoom_condition_configs: List[dict] = []
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 = ""
# Pre-EOD 고아복구 중복 실행 가드 (당일 1회)
self._orphan_pre_eod_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 (
describe_profit_guard_startup,
load_global_profit_target,
)
cfg = load_global_profit_target()
logger.info(describe_profit_guard_startup(cfg, scope="마스터"))
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"] = 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}")
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()
# 키움 조건검색은 시세 WS 와 **동일 세션** 공유 → 시세 WS 기동 후에만 등록
self._start_kiwoom_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}"
)
# 전략 등록 후 쓰레드 시작 (각자 독립 쓰레드)
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 쪽 설정 수집 (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,
)
logger.info(
"🔀 [%s] 시작소스=%s (매니저등록: ranking=%s condition=%s kiwoom=%s) "
"— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환 "
"(KIS ranking/condition REST 는 시작소스 일치 시만)",
sid, src, ("" if register_ranking else ""),
("" if register_kis_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 기동 후 _start_kiwoom_condition_manager() 에서 등록 ──
self._pending_kiwoom_condition_configs = kiwoom_condition_configs
if not kiwoom_condition_configs:
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 _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_PROVIDER=kis_with_validation 또는 WS_SUBSCRIBE_KIS_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
except Exception as e:
logger.error("키움 조건검색 매니저 기동 실패: %s", e)
self.kiwoom_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.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)
# 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."""
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)
if n <= 0 and g <= 0 and not result.get("error"):
label = "Pre-EOD" if phase == "pre" else "장마감"
logger.debug("🧩 [%s 고아복구] 복구·유령삭제 대상 없음", label)
return
if result.get("error"):
logger.warning("🧩 [고아복구] 중단: %s", result["error"])
return
title = "Pre-EOD 고아복구" if phase == "pre" else "장마감 고아복구"
lines = [
f"🧩 **[{title}]**",
f"- 복구: {n}종목",
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("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 → active_trades 반영", n, 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,
)
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()