feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가

변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -74,6 +74,22 @@ DEFAULT_STOCK_CONFIG = {
"trail_stop_pct": 8.0,
# 추세 필터 사용 여부 (1=사용, 0=미사용 → 기존 역추세 방식으로 고정)
"trend_filter": 1.0,
# ── UPDOW(kis_trader) 병합용: -1 이면 env_config 의 UPDOW_* 사용 (컬럼 NULL 과 동일 취급)
"updow_body_drop_min_pct": -1.0,
"updow_max_hold_bars": -1.0,
"updow_exit_on_green": -1.0,
# UPDOW 분봉(tf): -1 또는 0 미만이면 env UPDOW_TF_MIN (holding_min_candles.tf 와 일치 권장)
"updow_tf_min": -1.0,
"updow_regime_ma_bars": -1.0,
# ATR 동적 청산(UPDOW): -1 이면 env_config UPDOW_ATR_* 사용
"updow_atr_use_dynamic": -1.0,
"updow_atr_period": -1.0,
"updow_atr_sl_mult": -1.0,
"updow_atr_tp_mult": -1.0,
"updow_atr_sl_min_pct": -1.0,
"updow_atr_sl_max_pct": -1.0,
"updow_atr_tp_min_pct": -1.0,
"updow_atr_tp_max_pct": -1.0,
# ── 컨텍스트 낙폭 필터 (0=비활성화) ──────────────────────────
"ath_drop_min_pct": 0.0,
"year_drop_min_pct": 0.0,
@@ -144,6 +160,7 @@ HOLDING_DDL = [
buy3_ratio FLOAT DEFAULT 40,
slot_money FLOAT DEFAULT 3000000,
atr_period FLOAT DEFAULT 14,
atr_mult FLOAT DEFAULT 3 COMMENT '샹들리에 엑시트 ATR 배수',
ath_drop_min_pct FLOAT DEFAULT 0 COMMENT 'ATH 대비 최소 낙폭%% (0=비활성)',
year_drop_min_pct FLOAT DEFAULT 0 COMMENT '연도고점 대비 최소 낙폭%% (0=비활성)',
w52_drop_min_pct FLOAT DEFAULT 0 COMMENT '52주고점 대비 최소 낙폭%% (0=비활성)',
@@ -151,6 +168,11 @@ HOLDING_DDL = [
ma_slow FLOAT DEFAULT 60 COMMENT '장기 이동평균 기간',
trail_stop_pct FLOAT DEFAULT 8 COMMENT '트레일링 스탑 %% (0=비활성)',
trend_filter FLOAT DEFAULT 1 COMMENT '추세 필터 (1=사용/0=미사용)',
updow_body_drop_min_pct DOUBLE NULL COMMENT 'UPDOW 몸통하락%% 하한 NULL=env',
updow_max_hold_bars INT NULL COMMENT 'UPDOW 최대보유봉 NULL=env',
updow_exit_on_green DOUBLE NULL COMMENT 'UPDOW 양봉청산 1/0 NULL=env',
updow_tf_min INT NULL COMMENT 'UPDOW 분봉 tf NULL·<1=env',
updow_regime_ma_bars INT NULL COMMENT 'UPDOW KOSPI프록시 MA봉수 0=OFF NULL=env',
KEY idx_holding_config_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
@@ -175,6 +197,20 @@ def ensure_holding_tables(db: TradeDB):
"ALTER TABLE holding_stock_config ADD COLUMN ma_slow FLOAT DEFAULT 60 COMMENT '장기 이동평균 기간'",
"ALTER TABLE holding_stock_config ADD COLUMN trail_stop_pct FLOAT DEFAULT 8 COMMENT '트레일링 스탑 %% (0=비활성)'",
"ALTER TABLE holding_stock_config ADD COLUMN trend_filter FLOAT DEFAULT 1 COMMENT '추세 필터 (1=사용/0=미사용)'",
"ALTER TABLE holding_stock_config ADD COLUMN atr_mult FLOAT DEFAULT 3 COMMENT '샹들리에 엑시트 ATR 배수'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_body_drop_min_pct DOUBLE NULL COMMENT 'UPDOW 몸통하락%% NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_max_hold_bars INT NULL COMMENT 'UPDOW 최대보유봉 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_exit_on_green DOUBLE NULL COMMENT 'UPDOW 양봉청산 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_tf_min INT NULL COMMENT 'UPDOW 분봉 tf NULL·<1=env UPDOW_TF_MIN'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_regime_ma_bars INT NULL COMMENT 'UPDOW KOSPI프록시 MA봉수 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_use_dynamic DOUBLE NULL COMMENT 'UPDOW ATR 동적청산 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_period INT NULL COMMENT 'UPDOW ATR 기간 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_sl_mult DOUBLE NULL COMMENT 'UPDOW ATR 손절배수 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_tp_mult DOUBLE NULL COMMENT 'UPDOW ATR 익절배수 NULL=env'",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_sl_min_pct DOUBLE NULL",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_sl_max_pct DOUBLE NULL",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_tp_min_pct DOUBLE NULL",
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_tp_max_pct DOUBLE NULL",
]
for sql in migrations:
try:
@@ -188,18 +224,25 @@ def ensure_holding_tables(db: TradeDB):
# ─────────────────────────────────────────────────────────────────────────────
# 종목별 파라미터 관리
# ─────────────────────────────────────────────────────────────────────────────
def get_stock_config(db: TradeDB, code: str) -> Dict:
"""종목별 최신 파라미터 조회 (없으면 DEFAULT 반환)"""
def get_stock_config_row(db: TradeDB, code: str) -> Optional[Dict]:
"""holding_stock_config 에 해당 코드가 있으면 최신 1행만 dict 로 반환, 없으면 None."""
row = db.conn.execute(
"SELECT * FROM holding_stock_config WHERE code=%s ORDER BY id DESC LIMIT 1",
[code]
[code],
).fetchone()
if not row:
return None
cfg = dict(row)
cfg.pop("id", None)
cfg.pop("created_at", None)
return cfg
def get_stock_config(db: TradeDB, code: str) -> Dict:
"""종목별 최신 파라미터 조회 (없으면 DEFAULT 반환)"""
row = get_stock_config_row(db, code)
if row:
cfg = dict(row)
cfg.pop("id", None)
cfg.pop("created_at", None)
return cfg
# 기본값 반환 (DB에 없으면)
return row
cfg = dict(DEFAULT_STOCK_CONFIG)
cfg["code"] = code
cfg["name"] = ""
@@ -500,7 +543,7 @@ class _KiwoomTokenManager:
(동일 appkey로 kis_ws 에서 이미 발급받은 토큰은 재사용)
"""
try:
from kis_ws import _get_kiwoom_token_cached
from kis_trader.ws.kis_ws import _get_kiwoom_token_cached
is_mock = "mockapi" in self._domain
token = _get_kiwoom_token_cached(self._app_key, self._app_secret, is_mock)
if token:
@@ -514,6 +557,193 @@ class _KiwoomTokenManager:
return self._token
# 키움 ka10080 tic_scope 에 허용되는 분 단위 (API 스펙과 동일)
KIWOOM_MINUTE_TICS: Tuple[int, ...] = (1, 3, 5, 10, 15, 30, 45, 60)
# KIS 해외 시세 API(EXCD) — 주문용 NASD/NYSE 와 분봉 시세용 NAS/NYS 코드가 다름
_KIS_OVERSEAS_PRICE_EXCD_MAP: Dict[str, str] = {
"NASD": "NAS",
"NAS": "NAS",
"NYSE": "NYS",
"NYS": "NYS",
"AMEX": "AMS",
"AMS": "AMS",
"SEHK": "HKS",
"HKS": "HKS",
"SHAA": "SHS",
"SHS": "SHS",
"SZAA": "SZS",
"SZS": "SZS",
}
def _kis_overseas_price_excd(exchange: str) -> str:
"""해외주식 분봉/시세 API용 거래소 코드로 정규화 (예: NASD → NAS)."""
ex = str(exchange or "NAS").strip().upper()
return _KIS_OVERSEAS_PRICE_EXCD_MAP.get(ex, ex)
def _fetch_overseas_min_via_kis(
symbol: str,
exchange: str,
start_date: str,
end_date: str,
tf_min: int = 60,
max_pages: int = 200,
) -> List[Dict]:
"""
KIS 해외주식 분봉조회로 1분봉 수집 후 tf_min 단위로 집계한다.
- URL: /uapi/overseas-price/v1/quotations/inquire-time-itemchartprice
- TR : HHDFS76950200
"""
app_key, app_secret, base_url, is_mock = _get_kis_token(TradeDB())
token = _ensure_token(is_mock)
url = f"{base_url}/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice"
sd = datetime.strptime(start_date, "%Y-%m-%d")
ed = datetime.strptime(end_date, "%Y-%m-%d")
symb = str(symbol or "").strip().upper()
excd = _kis_overseas_price_excd(exchange)
nmin = str(int(tf_min))
next_yn = ""
keyb = ""
rows: List[Dict] = []
reached_start = False
def _first_non_empty(d: Dict, keys: List[str], default: str = "") -> str:
for k in keys:
v = d.get(k)
if v is not None and str(v).strip() != "":
return str(v).strip()
return default
for page in range(max_pages):
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": app_key,
"appsecret": app_secret,
"tr_id": "HHDFS76950200",
}
params = {
"AUTH": "",
"EXCD": excd,
"SYMB": symb,
"NMIN": nmin,
"PINC": "1",
"NEXT": next_yn,
"KEYB": keyb,
"NREC": "120",
"FILL": "Y",
}
data = None
for retry in range(3):
try:
resp = requests.get(url, headers=headers, params=params, timeout=10)
if resp.status_code == 429:
time.sleep((retry + 1) * 1.5)
continue
resp.raise_for_status()
data = resp.json()
if str(data.get("rt_cd", "0")) != "0":
msg = str(data.get("msg1") or data.get("msg_cd") or "")
if "초과" in msg and retry < 2:
time.sleep((retry + 1) * 1.0)
continue
logger.error(f"❌ KIS 해외 분봉 API 오류 ({symb}): {msg}")
data = None
break
except Exception as e:
if retry >= 2:
logger.error(f"❌ KIS 해외 분봉 조회 실패 ({symb}, page {page}): {e}")
else:
time.sleep(1.0)
if data is None:
break
recs = data.get("output2")
if not isinstance(recs, list):
recs = []
if not recs:
break
meta = data.get("output1") if isinstance(data.get("output1"), dict) else {}
for rec in recs:
if not isinstance(rec, dict):
continue
date_s = _first_non_empty(rec, ["xymd", "stck_bsop_date", "date", "base_date"])
time_s = _first_non_empty(rec, ["xhms", "stck_cntg_hour", "time", "hour"])
if not date_s:
continue
if len(time_s) == 4:
time_s = f"{time_s}00"
if len(time_s) < 6:
time_s = "000000"
try:
candle_dt = datetime.strptime(date_s[:8] + time_s[:6], "%Y%m%d%H%M%S")
except Exception:
continue
if candle_dt.date() < sd.date():
reached_start = True
break
if candle_dt.date() > ed.date():
continue
open_v = _first_non_empty(rec, ["open", "oprc", "stck_oprc"], "0")
high_v = _first_non_empty(rec, ["high", "hgpr", "stck_hgpr"], "0")
low_v = _first_non_empty(rec, ["low", "lwpr", "stck_lwpr"], "0")
close_v = _first_non_empty(rec, ["last", "clos", "stck_prpr", "close"], "0")
vol_v = _first_non_empty(rec, ["evol", "tvol", "cntg_vol", "volume"], "0")
try:
o = abs(float(open_v or 0))
h = abs(float(high_v or 0))
l = abs(float(low_v or 0))
c = abs(float(close_v or 0))
v = abs(int(float(vol_v or 0)))
except Exception:
continue
if c <= 0:
continue
rows.append(
{
"candle_date": candle_dt.strftime("%Y-%m-%d %H:%M:%S"),
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
}
)
if reached_start:
break
next_yn = str(meta.get("next") or meta.get("NEXT") or "").strip()
if not next_yn or next_yn in ("", "0", "N"):
break
last_rec = recs[-1] if recs else {}
if isinstance(last_rec, dict):
keyb = (
str(last_rec.get("xymd") or "")[:8]
+ str(last_rec.get("xhms") or "")[:6]
)
if not keyb:
break
time.sleep(0.35)
rows.sort(key=lambda x: x.get("candle_date", ""))
dedup: Dict[str, Dict] = {}
for r in rows:
dedup[r["candle_date"]] = r
result = [dedup[k] for k in sorted(dedup.keys())]
logger.info(f"✅ KIS 해외 {tf_min}분봉 수집 완료: {symb}/{excd} {len(result)}봉 ({start_date}~{end_date})")
return result
def fetch_60min_via_kiwoom(
code: str,
start_date: str, # "YYYY-MM-DD"
@@ -522,26 +752,49 @@ def fetch_60min_via_kiwoom(
kiwoom_secret: str, # DB env_config의 KIWOOM_APP_SECRET_REAL 또는 KIWOOM_APP_SECRET_MOCK
is_mock: bool = False, # True → mockapi.kiwoom.com (모의)
max_pages: int = 50, # 연속조회 최대 횟수 (1페이지 ≈ 900봉)
tf_min: int = 60, # 1/3/5/10/15/30/45/60 — ka10080 tic_scope
market_type: str = "KR", # KR | US (현재 ka10080은 KR 전용)
exchange: str = "KRX", # KRX | NASD | NYSE ...
symbol: str = "", # US 심볼(예: QQQM)
) -> List[Dict]:
"""
키움증권 REST API (ka10080) 로 60분봉 수집 → holding_min_candles 저장용.
키움증권 REST API (ka10080) 로 분봉 수집 → holding_min_candles 저장용.
kiwoom_universe_scanner.py / kiwoom_trader_dual.py 와 동일한
TokenManager + Chart 패턴 사용.
▶ KIS 한계: inquire-time-itemchartprice 는 당일(2일치) 데이터만 제공
▶ 키움 장점: ka10080 1회 호출 = 900봉 (60분봉 기준 약 128영업일)
cont-yn: Y + next-key 연속조회로 2년치 이상 수집 가능
▶ 키움 장점: ka10080 1회 호출 = 900봉 (틱 주기별 상이), cont-yn 연속조회로 긴 구간 수집
반환: [{"candle_date": "YYYY-MM-DD HH:MM:SS", "open": float, "high": float,
"low": float, "close": float, "volume": int}, ...]
최신 → 과거 순, start_date~end_date 범위 필터 적용
"""
mt = str(market_type or "KR").strip().upper()
ex = str(exchange or ("KRX" if mt == "KR" else "NASD")).strip().upper()
sym = str(symbol or code or "").strip().upper()
if mt != "KR":
# 해외는 키움 ka10080 대신 KIS 해외 분봉 endpoint로 라우팅한다.
return _fetch_overseas_min_via_kis(
symbol=sym,
exchange=ex,
start_date=start_date,
end_date=end_date,
tf_min=tf_min,
max_pages=max_pages,
)
from kiwoom_rest_api.koreanstock.chart import Chart
if tf_min not in KIWOOM_MINUTE_TICS:
logger.error(f"❌ 키움 ka10080 미지원 tf_min={tf_min} (허용: {KIWOOM_MINUTE_TICS})")
return []
tic_scope = str(int(tf_min))
mode_str = "모의" if is_mock else "실전"
base_url = "https://mockapi.kiwoom.com" if is_mock else "https://api.kiwoom.com"
logger.info(f"키움 60분봉 수집 시작: {code} [{mode_str}] {start_date}~{end_date}")
target_code = str(code or "").strip().upper()
logger.info(f"키움 {tf_min}분봉 수집 시작: {target_code} [{mode_str}/{mt}] {start_date}~{end_date}")
# ── 1. TokenManager + Chart 초기화 (kiwoom_universe_scanner 와 동일 패턴) ──
try:
@@ -564,19 +817,19 @@ def fetch_60min_via_kiwoom(
for page in range(max_pages):
try:
resp = chart.stock_minute_chart_request_ka10080(
stk_cd = code,
tic_scope = "60", # 60분봉 (1/3/5/10/15/30/45/60 지원)
stk_cd = target_code,
tic_scope = tic_scope, # 1/3/5/10/15/30/45/60 (키움 API)
upd_stkpc_tp = "1", # 수정주가 반영
cont_yn = cont_yn,
next_key = next_key,
)
except Exception as e:
logger.error(f"❌ 키움 ka10080 조회 실패 (page {page}): {e}")
logger.error(f"❌ 키움 ka10080 조회 실패 ({target_code}, page {page}): {e}")
break
records = resp.get("stk_min_pole_chart_qry") or []
if not records:
logger.info(f"키움 60분봉: 더 이상 데이터 없음 (page {page})")
logger.info(f"키움 {tf_min}분봉: 더 이상 데이터 없음 (page {page})")
break
reached_start = False
@@ -624,7 +877,7 @@ def fetch_60min_via_kiwoom(
next_key = new_nkey
time.sleep(0.3) # API 레이트리밋 준수 (초당 3회 이하)
logger.info(f"✅ 키움 60분봉 수집 완료: {code} {len(rows)}봉 ({start_date}~{end_date})")
logger.info(f"✅ 키움 {tf_min}분봉 수집 완료: {target_code} {len(rows)}봉 ({start_date}~{end_date})")
return rows
def fetch_min_ohlcv(
@@ -1131,6 +1384,56 @@ def _calc_context_signals(candles: List[Dict], current_price: float) -> Dict:
"w52_drop_pct": drop_pct(w52_high),
}
def build_daily_candle_display_rows(candles: List[Dict]) -> List[Dict]:
"""
백테스트 구간 일봉(또는 분봉) 표시용 — 최신이 앞.
전일(직전 봉) 종가 대비 등락(원·%). 첫 봉은 전일 종가가 없으면 DB change_rate 로 근사.
dir: 1=상승(빨강), -1=하락(파랑), 0=보합
"""
rows_chrono: List[Dict] = []
prev_close: Optional[float] = None
for c in candles:
ds = str(c.get("candle_date", ""))
date_short = ds[:10] if len(ds) >= 10 else ds
o = float(c.get("open") or 0)
hi = float(c.get("high") or 0)
lo = float(c.get("low") or 0)
cl = float(c.get("close") or 0)
chg_won: Optional[float] = None
chg_pct: Optional[float] = None
direction = 0
if prev_close is not None and prev_close > 0:
chg_won = round(cl - prev_close)
chg_pct = round((cl - prev_close) / prev_close * 100, 2)
if chg_won > 0:
direction = 1
elif chg_won < 0:
direction = -1
else:
cr = float(c.get("change_rate") or 0)
if cr != 0 and cl > 0:
chg_pct = round(cr, 2)
denom = 1.0 + cr / 100.0
prev_est = cl / denom if abs(denom) > 1e-9 else cl
chg_won = round(cl - prev_est)
direction = 1 if chg_won > 0 else (-1 if chg_won < 0 else 0)
rows_chrono.append({
"date": date_short,
"dt": ds[:16] if len(ds) > 10 else date_short,
"open": round(o) if o else 0,
"high": round(hi) if hi else 0,
"low": round(lo) if lo else 0,
"close": round(cl) if cl else 0,
"chg_won": chg_won,
"chg_pct": chg_pct,
"dir": direction,
})
if cl > 0:
prev_close = cl
return list(reversed(rows_chrono))
# ─────────────────────────────────────────────────────────────────────────────
# 백테스트 엔진
# ─────────────────────────────────────────────────────────────────────────────
@@ -1157,7 +1460,11 @@ def run_backtest(
진입가: 신호봉 다음 봉 시가 (1봉 지연)
"""
if len(candles) < 20:
return {"error": "봉 부족", "total_trades": 0}
return {
"error": "봉 부족",
"total_trades": 0,
"candle_rows": build_daily_candle_display_rows(candles),
}
# ── 봉 유형 자동 감지 ─────────────────────────────────────────────
_sample_dt = str(candles[0].get("candle_date", ""))
@@ -1233,6 +1540,47 @@ def run_backtest(
cum_pnl = 0.0
start_i = max(rsi_period + 1, ma_slow_p) # MA_SLOW가 준비된 이후부터 진입
# 구간 일봉이 MA장기보다 짧으면 for 루프가 한 번도 돌지 않아 거래 0건·봇수익률 0%만 나옴
if start_i >= len(candles) - 1:
bnh_pct = (
round((closes[-1] - closes[0]) / closes[0] * 100, 2) if closes[0] > 0 else 0.0
)
bnh_pnl = round(slot_money * bnh_pct / 100)
need = start_i + 2
c0_al = closes[start_i] if start_i < len(closes) else closes[0]
bnh_aligned_pct = (
round((closes[-1] - c0_al) / c0_al * 100, 2) if c0_al > 0 else 0.0
)
bnh_aligned_pnl = round(slot_money * bnh_aligned_pct / 100)
return {
"error": (
f"백테스트 일봉 {len(candles)}개로는 MA장기({ma_slow_p}일) 준비가 안 됩니다 "
f"(시뮬 시작 인덱스≥{start_i}, 마지막 진입 가능 인덱스<{len(candles) - 1}). "
f"시작일을 앞당기거나 MA장기를 {max(5, len(candles) - 15)} 이하로 낮추세요 "
f"(권장: 구간에 거래일 기준 최소 {need}봉 이상)."
),
"summary": {
"total_trades": 0,
"win_trades": 0,
"loss_trades": 0,
"win_rate": 0.0,
"total_pnl": 0.0,
"avg_hold_days": 0.0,
"profit_factor": 9999.0,
"max_drawdown": 0.0,
"bnh_pct": bnh_pct,
"bnh_pnl": bnh_pnl,
"bot_pct": 0.0,
"alpha_pct": round(0.0 - bnh_pct, 2),
"bnh_aligned_pct": bnh_aligned_pct,
"bnh_aligned_pnl": bnh_aligned_pnl,
"alpha_aligned_pct": round(0.0 - bnh_aligned_pct, 2),
},
"equity": [],
"reasons": {},
"trades": [],
"candle_rows": build_daily_candle_display_rows(candles),
}
for i in range(start_i, len(candles) - 1):
rsi = rsis[i]
@@ -1434,13 +1782,20 @@ def run_backtest(
reasons[prefix] = reasons.get(prefix, 0) + 1
# ── Buy & Hold 벤치마크 ──────────────────────────────────────────────
# 첫 봉 종가 매수 → 마지막 봉 종가 매도 (수수료·세금 제외 단순 계산)
# 전체 구간: 첫 봉 종가 → 마지막 봉 종가 (수수료·세금 제외)
bnh_pct = round((closes[-1] - closes[0]) / closes[0] * 100, 2) if closes[0] > 0 else 0.0
bnh_pnl = round(slot_money * bnh_pct / 100)
# 시뮬과 동일 시작점: MA·RSI 워밍업이 끝난 봉(start_i) 종가 → 마지막 종가
c0_al = closes[start_i] if start_i < len(closes) else closes[0]
bnh_aligned_pct = (
round((closes[-1] - c0_al) / c0_al * 100, 2) if c0_al > 0 else 0.0
)
bnh_aligned_pnl = round(slot_money * bnh_aligned_pct / 100)
# 봇 수익률: 투자 원금(slot_money) 대비 실현 손익
bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0
# 알파 = 봇 수익률 - Buy & Hold 수익률 (양수면 봇이 시장 이김)
alpha_pct = round(bot_pct - bnh_pct, 2)
alpha_aligned_pct = round(bot_pct - bnh_aligned_pct, 2)
return {
"summary": {
@@ -1453,14 +1808,18 @@ def run_backtest(
"profit_factor": round(min(pf, 9999.0), 2),
"max_drawdown": round(mdd),
# Buy & Hold 비교
"bnh_pct": bnh_pct, # 기간 동안 그냥 들고 있었을 때 수익률(%)
"bnh_pnl": bnh_pnl, # 그냥 들고 있었을 때 손익(원)
"bot_pct": bot_pct, # 봇 수익률(투자금 대비 %)
"alpha_pct": alpha_pct, # 초과수익(봇 - Buy&Hold, %p)
"bnh_pct": bnh_pct, # 전 구간 첫 종가 → 마지막 종가
"bnh_pnl": bnh_pnl,
"bot_pct": bot_pct,
"alpha_pct": alpha_pct, # 봇 - 전구간 B&H
"bnh_aligned_pct": bnh_aligned_pct, # 시뮬 시작 봉 종가 기준 B&H
"bnh_aligned_pnl": bnh_aligned_pnl,
"alpha_aligned_pct": alpha_aligned_pct, # 봇 - 동일구간 B&H
},
"equity": equity,
"reasons": reasons,
"trades": trades[-200:],
"candle_rows": build_daily_candle_display_rows(candles),
}
# ─────────────────────────────────────────────────────────────────────────────
@@ -1469,17 +1828,20 @@ def run_backtest(
def run_param_search(
candles: List[Dict],
grid: Optional[Dict] = None,
min_trades: int = 2,
base_cfg: Optional[Dict] = None, # 사용자가 설정한 현재 파라미터 (없으면 DEFAULT 사용)
) -> List[Dict]:
min_trades: int = 1,
base_cfg: Optional[Dict] = None, # 사용자가 설정한 현재 파라미터 (그리드 외 키는 이 값으로 고정)
) -> Tuple[List[Dict], Dict]:
"""
종목 하나에 대해 파라미터 Grid Search 실행
반환: 수익 기준 내림차순 정렬된 결과 리스트
반환: (수익 기준 내림차순 결과 리스트, 탐색 메타 dict)
base_cfg: 탐색 그리드에 없는 파라미터들의 기준값.
웹 카드에서 사용자가 직접 설정한 값을 전달하면
rsi_sell, rsi_period, rsi_buy3 등이 그 값으로 고정됨.
None 이면 DEFAULT_STOCK_CONFIG 사용.
min_trades: 이 값보다 거래 수가 적은 조합은 TOP에서 제외.
0이면 거래 수 필터 없음(느려질 수 있음).
"""
from itertools import product as iproduct
@@ -1506,27 +1868,42 @@ def run_param_search(
keys = list(grid.keys())
combos = list(iproduct(*[grid[k] for k in keys]))
results = []
results: List[Dict] = []
# base_cfg가 주어지면 사용자 설정값 우선, 없으면 DEFAULT 사용
_base = dict(DEFAULT_STOCK_CONFIG)
if base_cfg:
_base.update(base_cfg)
skip_ma = 0
skip_rsi = 0
skip_err = 0
skip_low = 0
ran_bt = 0
for vals in combos:
cfg = dict(_base)
cfg.update(dict(zip(keys, vals)))
# MA 단기 < 장기 조건 보정
if cfg["ma_fast"] >= cfg["ma_slow"]:
skip_ma += 1
continue
# RSI buy1 > buy2 조건 보정
if cfg["rsi_buy1"] <= cfg["rsi_buy2"]:
skip_rsi += 1
continue
ran_bt += 1
res = run_backtest(candles, cfg)
s = res.get("summary", {})
if s.get("total_trades", 0) < min_trades:
if res.get("error"):
skip_err += 1
continue
s = res.get("summary", {})
if min_trades > 0 and s.get("total_trades", 0) < min_trades:
skip_low += 1
continue
results.append({
"params": {k: cfg[k] for k in keys},
# 웹 [적용] 버튼: 그리드 외 rsi_sell·rsi_period·낙폭 등 탐색에 실제 사용된 전체 cfg 반영
"apply_cfg": dict(cfg),
"total_pnl": s["total_pnl"],
"win_rate": s["win_rate"],
"total_trades": s["total_trades"],
@@ -1536,7 +1913,21 @@ def run_param_search(
})
results.sort(key=lambda x: x["total_pnl"], reverse=True)
return results
fixed_keys = sorted(set(DEFAULT_STOCK_CONFIG.keys()) - set(keys))
meta = {
"grid_keys": keys,
"fixed_param_keys": fixed_keys,
"cartesian_product": len(combos),
"skipped_invalid_ma": skip_ma,
"skipped_invalid_rsi_order": skip_rsi,
"backtests_run": ran_bt,
"skipped_backtest_error": skip_err,
"skipped_below_min_trades": skip_low,
"min_trades": min_trades,
"passed": len(results),
}
return results, meta
# ─────────────────────────────────────────────────────────────────────────────