변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
363 lines
12 KiB
Python
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())
|