Files
kis_trader/scripts/market_open_sim_smoke.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

363 lines
12 KiB
Python

#!/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())