Files
kis_bot/kis_trader/main.py
Hwang 2cb95fc694 feat(ws-validation): 키움 WS 시세는 KIS_MOCK 무관 항상 실키/실전 강제 (KIWOOM_WS_FORCE_REAL)
배경
----
- 모의 도메인(mockapi.kiwoom.com)은 장외 데이터 미제공 + 실시간 표본 빈약 → 시세 비교/마이그레이션 가치 없음
- 사용자 운영 정책: 키움은 실전 도메인에서 시간외 단일가까지 데이터 푸시 → 부하 회피용으로 매매 외 호출만 정지 토글

변경
----
- database.py: ENV_CONFIG_KEYS 에 KIWOOM_WS_FORCE_REAL 추가 (기본 true)
- kis_trader/main.py _start_ws_validator: 토글 true 면 _REAL 컬럼에서 직접 로드 + is_mock=False 강제, false 면 기존 _get_kiwoom_creds() 자동 분기 (디버그)
- test_kiwoom_ws.py: load_kiwoom_creds(force_real=True) 기본, --mock-auto 플래그로 디버그 토글

영향
----
- 매매(KIS) 100% 무영향 — 검증/테스트 채널만 실전 시세 도메인 강제
- KIS_MOCK=true 라도 시세 비교는 진짜 가격으로 진행 가능

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:39:34 +09:00

961 lines
42 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)
- ``STRATEGY_SHORT_ENABLED`` (기본 true)
- ``STRATEGY_BREAKOUT_ENABLED`` (기본 false)
- ``STRATEGY_SAME_CODE_POLICY`` (block|allow, 기본 block — OrderManager 에서 사용)
유니버스 소스 스위치 (전략별, 런타임 교체 가능):
- ``SCALP_UNIVERSE_SOURCE`` (기본 "ranking")
- ``SHORT_UNIVERSE_SOURCE`` (기본 "ranking")
- ``BREAKOUT_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)
- 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``
공통:
- ``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
# 패키지로 실행되지 않은 경우(스크립트 직접 실행) 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 .database.db_manager import get_db
from .execution.kis_client import KISClient
from .execution.order_manager import OrderManager
from .network.condition_manager import ConditionSearchManager
from .network.market_guard import MarketGuard
from .network.ranking_manager import VolumeRankManager
from .network.ws_manager import WSManager
from .strategies import BreakoutStrategy, ScalpingStrategy, TailCatchStrategy
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
logger = get_logger("kis_trader.main")
class TradingOrchestrator:
"""전체 라이프사이클 관리 (공유 인프라 초기화 → 전략 쓰레드 기동 → 정리)."""
# 전략별 UNIVERSE_SOURCE 기본값 (BaseStrategy 와 동기화)
_DEFAULT_SOURCE = {
"SCALP": "ranking",
"SHORT": "ranking",
"BREAKOUT": "condition",
}
_DEFAULT_RANK_SORT = {
"SCALP": "volume",
"SHORT": "volume",
"BREAKOUT": "trading_value",
}
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.order_mgr = OrderManager(client=self.client, db=self.db)
self.ws = WSManager(db=self.db, kis_client=self.market_client)
self.condition_mgr: ConditionSearchManager | 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 = ""
# 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
# ------------------------------------------------------------------
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,
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_BREAKOUT_ENABLED", False):
self.strategies.append(BreakoutStrategy(**shared))
logger.info("✅ [전략 등록] BREAKOUT (%s)",
self._universe_tag("BREAKOUT"))
else:
logger.info("⏸ [전략 비활성] BREAKOUT")
if not self.strategies:
logger.error("전략이 하나도 활성화되지 않음 → 종료")
sys.exit(1)
def _resolve_source(self, strategy_id: str) -> str:
"""{STRATEGY}_UNIVERSE_SOURCE env → 'ranking' | 'condition'."""
key = f"{strategy_id}_UNIVERSE_SOURCE"
default = self._DEFAULT_SOURCE.get(strategy_id, "ranking")
src = (get_env_from_db(key, default) or default).strip().lower()
if src not in ("ranking", "condition"):
src = default
return src
def _universe_tag(self, strategy_id: str) -> str:
"""로그용 — 전략이 어떤 유니버스를 쓰는지 한눈에."""
src = self._resolve_source(strategy_id)
mgr = self.ranking_mgr if src == "ranking" else self.condition_mgr
if mgr is None:
return f"universe={src}(unavailable→DB)"
try:
cfgs = getattr(mgr, "_configs", [])
for c in cfgs:
if c.get("strategy_id") == strategy_id:
if src == "ranking":
return (
f"universe=rank[{c.get('sort')} x{c.get('limit')}]"
)
return f"universe=cond[{c.get('name') or c.get('seq')}]"
except Exception:
pass
return f"universe={src}(unmapped→DB)"
# ------------------------------------------------------------------
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)
# 유니버스 매니저 (ranking/condition) 기동
self._start_universe_managers()
# 시장 급락 서킷브레이커 매니저 기동 (활성/비활성 무관, 토글로 즉시 전환)
self._start_market_guard()
# 시세 WS 마이그레이션 검증 (WS_PROVIDER 토글에 따라 키움 WS 동시 기동)
self._start_ws_validator()
# 전략 등록 후 쓰레드 시작 (각자 독립 쓰레드)
self._register_strategies()
for strat in self.strategies:
strat.start() # threading.Thread.start()
logger.info("▶ 쓰레드 기동: %s", strat.name)
# 알림 (시작)
try:
msg_mm("🚀 kis_trader 통합 봇 시작 — 전략: " +
", ".join([s.strategy_id for s in self.strategies]))
msg_tg("🚀 kis_trader 통합 봇 시작 — 전략: " +
", ".join([s.strategy_id for s in self.strategies]))
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 기동.
두 매니저 모두 전략에 주입되고, 전략은 본인 소스 선택 값으로 조회함.
"""
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),
}
ranking_configs: List[dict] = []
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"),
}
rank_env_keys = {
"SCALP": ("RANK_SCALP_SORT", "RANK_SCALP_LIMIT"),
"SHORT": ("RANK_SHORT_SORT", "RANK_SHORT_LIMIT"),
"BREAKOUT": ("RANK_BREAKOUT_SORT", "RANK_BREAKOUT_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,
)
logger.info(
"🔀 [%s] 시작소스=%s (매니저등록: ranking=✓ condition=%s) "
"— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환",
sid, src, ("" if has_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 소스 쓰는 전략 없음 → 조건검색 매니저 비활성")
# ------------------------------------------------------------------
def _start_market_guard(self) -> None:
"""
시장 급락 서킷브레이커 매니저 기동.
- ``MARKET_GUARD_ENABLED=false`` (기본값) 여도 thread 자체는 띄움
→ 운영 중 DB 토글로 즉시 켤 수 있음 (재시작 불필요).
- 지수 조회 (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 마이그레이션 검증 — 키움 WS + Validator 백그라운드 기동.
``WS_PROVIDER`` env 토글:
kis_only : 미기동 (기본). 운영 100% 동일.
kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음).
kiwoom_only : 추후 단계 (검증 통과 후 시세 키움 전환).
매매·시세 의사결정은 항상 KIS WS 만 사용. 키움 WS 는 ``ws_price_validation``
테이블에 기록만 함. 1~2주 누적 후 통계 분석으로 마이그레이션 결정.
"""
from .utils.env import get_env_from_db
provider = (get_env_from_db("WS_PROVIDER", "kis_only") or "kis_only").strip().lower()
if provider == "kis_only":
logger.info(" WS_PROVIDER=kis_only → 키움 WS 검증 비활성")
return
if provider not in ("kis_with_validation",):
logger.warning(
"⚠️ WS_PROVIDER='%s' 알 수 없음 → kis_only 로 폴백 (검증 비활성)",
provider,
)
return
# 키움 키 로드.
# 정책: 시세 검증/마이그레이션은 항상 실키/실전 도메인을 사용해야 함.
# - 모의 도메인(mockapi.kiwoom.com) 은 장외 데이터 X, 실시간 표본도 빈약.
# - KIS_MOCK=true 라도 시세는 실전을 써야 진짜 가격 비교가 됨.
# KIWOOM_WS_FORCE_REAL=true (기본) 이면 DB env_config 의 _REAL 컬럼에서 직접 로드.
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 kis_ws import _get_kiwoom_creds # type: ignore
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("키움 자격증명 로드 실패 → 검증 비활성: %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
# 키움 WS 인스턴스
try:
from kiwoom_ws import KiwoomWebSocketPriceCache # type: ignore
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 핸들 가져오기 (WSManager.ws_cache)
kis_ws_handle = getattr(self.ws, "ws_cache", None)
if kis_ws_handle is None:
logger.warning("KIS WS 핸들 미발견 → Validator 비활성 (검증 불가)")
return
# Validator 기동
try:
from .network.ws_validator import WSPriceValidator
self.ws_validator = WSPriceValidator(
kis_ws=kis_ws_handle,
kiwoom_ws=self.kiwoom_ws,
db=self.db,
)
self.ws_validator.start()
logger.info("🔬 [시세 검증 모드] WS_PROVIDER=%s — KIS↔키움 동시 운영", provider)
except Exception as e:
logger.warning("Validator 기동 실패: %s", e)
self.ws_validator = None
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
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
# 장중 자산/리포트 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"))
# 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
cash = d2 if d2 > 0 else dnca
snap = {
"cash": cash, # 주문가능(= D+2 or 예수금총액)
"d2": d2, # D+2 예수금
"dnca": dnca, # 예수금총액
"total_asset": tot_evlu,
"hold_count": hold_count,
}
self._asset_cache = snap
self._asset_cache_ts = t
return dict(snap)
def _asset_line_for_notify(self, side: str = "", extra: Optional[Dict] = None) -> str:
"""
체결 알림에 덧붙일 2줄 요약:
예수금 XXX원 | 총자산 XXX원 | 보유 N종목
당일손익 ±XXX원 (±X.XX%) | 누적손익 ±XXX원 (±X.XX%)
- start_day_asset 이 아직 0 이면 현재 총자산을 기준점으로 '최초 설정'
- total_deposit 이 없으면 누적손익 줄은 생략
"""
try:
snap = self._fetch_asset_snapshot()
cash = snap.get("cash", 0.0)
total = snap.get("total_asset", 0.0)
hold = snap.get("hold_count", 0)
if self.start_day_asset <= 0 and total > 0:
self.start_day_asset = total
logger.info("📌 [당일 시작 자산] %s원 최초 기록", f"{total:,.0f}")
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
line1 = f"예수금 {cash:,.0f}원 | 총자산 {total:,.0f}원 | 보유 {hold}종목"
line2 = f"당일손익 {day_pnl:+,.0f}원 ({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
line2 += f" | 누적손익 {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)"
return line1 + "\n" + line2
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 # 재시도 백오프도 같이 리셋
# 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.start_day_asset = float(snap["total_asset"])
logger.info(
"📌 [당일 시작 자산] %s원 기록",
f"{self.start_day_asset:,.0f}",
)
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,
)
# 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)
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)
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
lines = [
"📈 **[장마감 전 현황 - 15:15]**",
"",
f"- 당일 손익: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
f"- 현재 자산: {total:,.0f}",
f"- 보유 종목: {hold}",
f"- 예수금(주문가능): {cash:,.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)
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
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"- 시작: {self.start_day_asset:,.0f}",
f"- 종료: {total:,.0f}",
f"- 손익: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
"",
"💰 **누적 손익 (총 입금액 대비)**",
f"- 총 입금: {self.total_deposit:,.0f}",
f"- 현재 자산: {total:,.0f}",
f"- 누적 손익: {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)",
"",
"📊 **거래 현황**",
f"- 오늘 매매: {trade_cnt}",
f"- 보유 종목: {hold}",
f"- 예수금(주문가능): {cash:,.0f}원 | D+2예수금: {d2:,.0f}",
"━━━━━━━━━━━━━━━━━━━━",
]
return "\n".join(lines)
def _send_daily_report(self, title: str, body: str) -> None:
"""리포트를 SCALP·SHORT(TAIL) 양쪽 MM 채널 + Telegram 에 전송."""
scalp_ch = str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping") or "scalping")
short_ch = str(get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock")
try:
msg_mm(body, channel_alias=scalp_ch, jitter=False)
except Exception as e:
logger.debug("리포트 MM(scalp) 전송 실패: %s", e)
# SCALP 와 SHORT 채널이 동일하면 중복 발송 방지
if short_ch != scalp_ch:
try:
msg_mm(body, channel_alias=short_ch, jitter=False)
except Exception as e:
logger.debug("리포트 MM(short) 전송 실패: %s", 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 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.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:
self.ws.stop()
except Exception:
pass
try:
msg_mm("🛑 kis_trader 종료")
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()