#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 내일(다음 거래일) 장 시작(09:05)을 가정한 격리 스모크. - 실매매 서비스(WS/주문)는 건드리지 않음 - FORCE_MARKET_OPEN 을 DB/실매에 쓰지 않음 (프로세스 내 datetime 패치만) - 계좌 조회(REST) + 엔진/DB 기본값 + 장시작 리포트 문자열 + 직전 거래일 백테 스모크 로그: logs/market_open_sim_smoke.log """ from __future__ import annotations import logging import os import sys import traceback from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, List, Tuple from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) LOG_PATH = ROOT / "logs" / "market_open_sim_smoke.log" LOG_PATH.parent.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="[%(asctime)s] %(message)s", datefmt="%H:%M:%S", handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler(LOG_PATH, encoding="utf-8"), ], ) log = logging.getLogger("market_open_sim") errors: List[str] = [] oks: List[str] = [] def _ok(msg: str) -> None: oks.append(msg) log.info("✅ %s", msg) def _err(msg: str) -> None: errors.append(msg) log.error("❌ %s", msg) def _next_trading_day(from_d: date) -> date: from kis_trader.utils.kr_trading_day import is_kr_trading_day d = from_d + timedelta(days=1) for _ in range(21): if is_kr_trading_day(d): return d d += timedelta(days=1) raise RuntimeError("next trading day not found") def step_calendar() -> datetime: from kis_trader.utils.kr_trading_day import ( clamp_to_prev_kr_trading_day, is_kr_trading_day, trading_dates_payload, ) today = date.today() tom = _next_trading_day(today) assert is_kr_trading_day(tom), tom prev = clamp_to_prev_kr_trading_day(today) payload = trading_dates_payload(7) _ok( f"달력: today={today} next_open={tom} prev_td={prev} " f"web_defaults={payload.get('start')}~{payload.get('end')}" ) # 장시작 09:05 가정 return datetime(tom.year, tom.month, tom.day, 9, 5, 0) def step_strategy_flags() -> None: from kis_trader.utils.env import get_env_bool flags = { "SCALP": get_env_bool("STRATEGY_SCALP_ENABLED", True), "SHORT": get_env_bool("STRATEGY_SHORT_ENABLED", True), "MOMENTUM": get_env_bool("STRATEGY_MOMENTUM_ENABLED", False), "BREAKOUT": get_env_bool("STRATEGY_BREAKOUT_ENABLED", False), "RANGE_BREAK": get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False), "UPDOW": get_env_bool("STRATEGY_UPDOW_ENABLED", False), "DBBAND": get_env_bool("STRATEGY_DBBAND_ENABLED", False), } on = [k for k, v in flags.items() if v] off = [k for k, v in flags.items() if not v] _ok(f"전략 ON={on} OFF={off}") # HTS 스킵은 false 유지 규칙 for key in ( "TAIL_SKIP_HTS_SCAN_DUPES", "SHORT_SKIP_HTS_SCAN_DUPES", "MOMENTUM_SKIP_HTS_SCAN_DUPES", "BREAKOUT_SKIP_HTS_SCAN_DUPES", "SCALP_SKIP_HTS_SCAN_DUPES", ): if get_env_bool(key, False): _err(f"{key}=true (기본 false 유지 규칙 위반)") else: _ok(f"{key}=false") def step_market_hours(fake_now: datetime) -> None: from kis_trader.strategies.base import BaseStrategy from kis_trader.network.market_guard import MarketGuard class _Dummy(BaseStrategy): strategy_id = "SHORT" def __init__(self): # Thread/풀 초기화 우회: 최소 속성만 self.strategy_id = "SHORT" def check_buy(self, *a, **k): # pragma: no cover return False def check_sell_signals(self, *a, **k): # pragma: no cover return None def run(self): # pragma: no cover return None with patch("kis_trader.strategies.base.dt") as mock_dt, patch( "kis_trader.network.market_guard.dt" ) as mock_dt2: mock_dt.now.return_value = fake_now mock_dt2.now.return_value = fake_now d = _Dummy() # BaseStrategy.check_market_status 는 self 만 필요 open_ok = BaseStrategy.check_market_status(d) buy_ok = BaseStrategy.check_buy_allowed(d) mg_ok = MarketGuard._is_market_hours() if open_ok and buy_ok and mg_ok: _ok(f"장시간 판정 (fake {fake_now}): market=True buy=True guard=True") else: _err( f"장시간 판정 실패: market={open_ok} buy={buy_ok} guard={mg_ok} " f"fake={fake_now}" ) def step_engine_defaults() -> None: try: from kis_trader.engine import momentum_engine as me from kis_trader.engine import scalping_engine as se from kis_trader.engine import tail_engine as te from kis_trader.strategies import breakout as bo te_d = te.get_tail_defaults_from_db() if hasattr(te, "get_tail_defaults_from_db") else None me_d = me.get_momentum_defaults_from_db() # scalping / breakout if hasattr(se, "get_scalping_defaults_from_db"): se_d = se.get_scalping_defaults_from_db() else: se_d = {"ok": True} from kis_trader.backtest import breakout_backtest_common as bbc from kis_trader.utils.env import get_merged_env_dict env_row = get_merged_env_dict() or {} if hasattr(bbc, "get_breakout_defaults_from_env_row"): bo_d = bbc.get_breakout_defaults_from_env_row(env_row) else: bo_d = {} _ok( f"엔진 DB 기본값 로드: tail_keys={len(te_d or {})} " f"mom={len(me_d or {})} scalp={len(se_d or {})} bo={len(bo_d or {})}" ) # 손절 키 존재 스모크 for name, d in (("mom", me_d),): if d and "sl_pct" in d and float(d["sl_pct"]) <= 0: _err(f"{name} sl_pct 비정상: {d.get('sl_pct')}") except Exception as e: _err(f"엔진 기본값 로드 실패: {e}") log.error(traceback.format_exc()) def step_account_and_open_report() -> None: """REST 잔고만 — WS/주문 없음. 장시작 리포트 문자열 생성.""" try: from kis_trader.execution.kis_client import KISClient client = KISClient() # TradingBot._fetch_asset_snapshot 과 유사하게 잔고 조회 bal = None for meth in ("get_balance", "inquire_balance", "account_balance"): fn = getattr(client, meth, None) if callable(fn): try: bal = fn() break except Exception: continue if bal is None: # inquire-balance 계열 탐색 for name in dir(client): if "balance" in name.lower() and callable(getattr(client, name)): try: bal = getattr(client, name)() if bal: break except Exception: continue mock = getattr(client, "mock", None) acct = getattr(client, "account_no", "") or "" _ok(f"KISClient 생성 ok mock={mock} acct=***{str(acct)[-4:]}") if bal is not None: _ok(f"잔고 조회 응답 type={type(bal).__name__}") else: # 주말이면 모의/실전 REST 가 비정상일 수 있음 — 치명으로 안 봄 log.warning("⚠️ 잔고 조회 메서드 미확인/실패 (장외 REST 가능) — 계속") # 장시작 리포트 포맷만 검증 (MM 미전송) lines = [ "🌅 **[장 시작 알림 - 09:00]** (SIM)", f"- 🤖 활성 전략: (smoke)", f"- 계좌: {'모의' if mock else '실전'}", "📈 오늘도 안전 매매! 손절 라인 준수.", ] body = "\n".join(lines) assert "장 시작" in body _ok("장시작 리포트 문자열 생성 OK (미전송)") except Exception as e: _err(f"계좌/장시작 리포트 실패: {e}") log.error(traceback.format_exc()) def step_verify_three_paths() -> None: try: from kis_trader.scripts import verify_three_paths as v3 bad = 0 for fn_name in ("verify_momentum", "verify_breakout", "verify_scalping", "verify_tail"): fn = getattr(v3, fn_name, None) if not callable(fn): continue n = int(fn() or 0) bad += n if n: _err(f"{fn_name} mismatch={n}") else: _ok(f"{fn_name} parity OK") if bad == 0: _ok("실매↔웹↔파람 변환 정합 스모크 통과") except Exception as e: _err(f"verify_three_paths 실패: {e}") log.error(traceback.format_exc()) def step_prev_day_backtest_smoke() -> None: """직전 거래일 1일 · 꼬리+돌파 웹 API 경로 (Flask test_client, 서버 불필요).""" from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day day = clamp_to_prev_kr_trading_day(date.today()) try: from backtest_web import app with app.test_client() as c: for label, path in ( ("꼬리", "/api/backtest/tail"), ("돌파", "/api/backtest/breakout"), ): r = c.get( path, query_string={ "start": day, "end": day, "universe": "history", }, ) if r.status_code != 200: _err(f"{label} 백테 HTTP {r.status_code}") continue d = r.get_json(silent=True) or {} if d.get("error"): _err(f"{label} 백테 error: {d.get('error')}") continue s = d.get("summary") or {} _ok( f"{label} 백테 {day}: trades={s.get('total_trades', '?')} " f"pnl={s.get('total_pnl', '?')}" ) except Exception as e: _err(f"웹 백테 스모크 실패: {e}") log.error(traceback.format_exc()) def step_holdings_db() -> None: try: from kis_trader.utils.env import _get_db db = _get_db() if not db: _err("TradeDB 연결 실패") return cols = db.conn.execute("SHOW COLUMNS FROM active_trades").fetchall() col_names = [ (c["Field"] if isinstance(c, dict) else c[0]) for c in (cols or []) ] if "status" in col_names: rows = db.conn.execute( "SELECT strategy, COUNT(*) AS n FROM active_trades " "WHERE status=%s GROUP BY strategy", ("HOLDING",), ).fetchall() else: rows = db.conn.execute( "SELECT strategy, COUNT(*) AS n FROM active_trades GROUP BY strategy" ).fetchall() summary = [] for r in rows or []: if isinstance(r, dict): summary.append(f"{r.get('strategy')}={r.get('n')}") else: summary.append(f"{r[0]}={r[1]}") _ok(f"active_trades HOLDING: {', '.join(summary) or '(없음)'}") except Exception as e: _err(f"active_trades 조회 실패: {e}") log.error(traceback.format_exc()) def main() -> int: log.info("=== market_open_sim_smoke START ===") log.info("log=%s", LOG_PATH) # 실매 FORCE 오염 방지 os.environ.pop("FORCE_MARKET_OPEN", None) os.environ.pop("FORCE_BUY_TEST", None) try: fake_now = step_calendar() step_strategy_flags() step_market_hours(fake_now) step_engine_defaults() step_holdings_db() step_account_and_open_report() step_verify_three_paths() step_prev_day_backtest_smoke() except Exception as e: _err(f"치명: {e}") log.error(traceback.format_exc()) log.info("=== SUMMARY ok=%d err=%d ===", len(oks), len(errors)) for e in errors: log.info("ERR: %s", e) if errors: log.info("RESULT: FAIL") return 1 log.info("RESULT: PASS") return 0 if __name__ == "__main__": raise SystemExit(main())