Files
kis_bot/kis_trader/backtest/optuna_common.py
Your Name 2041790658 feat(옵투나): 로그 모달 + 사후합격 카드 확장 + 과적합 Y안
룰 19 사용자 요청 4건 (2026-09-06):

[UI 1] 옵투나 실시간 로그 모달
- backtest.html: 「크게 보기」 버튼 + bootstrap 모달 (max-xl · scrollable)
- backtest.js: optunaOpenLogModal/RefreshLogModal/FilterLogModal
- backend: /api/optuna/log/<job_id>?lines=N 신설 (50~5000줄 tail)
- 3초 auto-refresh · 스크롤 하단 유지 · 실시간 검색 필터
- 원인: 기존 pre#opt_log_tail 은 25줄만 tail → pruned 잔뜩이면 완료 로그 밀림

[UI 2] 사후합격 Top5 카드 파라미터 확장 + 한글 캡션
- optuna_web_jobs.py _ob_whip_ui_from_params: 11개 새 필드 flat 추출
  - 핵심진입: entry_drop_rate/vol_mult/high_chase
  - 핵심청산: exit_min_hold_sec/cooldown_min/max_loss_krw
  - RSI: rsi_period/oversold/overbought
  - 어깨: shoulder_min_high/cut_pct
- backtest.js optunaCalcStats/optunaFormatAvgHtml:
  - keys 배열 확장 (기존 10 → 21)
  - 모든 영문 옆 한글 캡션 (spr→(스프레드) 등)
  - 그룹별 조건부 렌더 (해당 파라미터 있을 때만)

[백엔드 3] 과적합 계산기 Y안 (optuna_common.py)
- 팩터 1 표본 장일: max 40 → 25 (1일 백테도 견고성으로 상쇄 가능)
- 팩터 5 PnL 고원 재해석: 동일 PnL & 파라미터 다양성 계산
  - 다양 (핵심 파라미터 unique_ratio >= 50%) → 감점 -8 (견고)
  - 좁음 → 유지 +12 (TPE 몰빵)
- 팩터 6 신설 param_stability: gated pool 파라미터 mode_share
  - 5개+ ≥60% → -15 (매우 견고)
  - 3~4개 ≥60% → -8 (다소 견고)

[호환 4] 옛 잡 자동 재파싱 fallback (backtest_web.py)
- _inject_extended_params_fallback():
  - result_summary.top5_gated 각 row 에 확장 파라미터 재파싱
  - overfit_diagnostics Y안 재계산
  - top5_gated/top5_stable/compare_rows 각 row 의 overfit_risk_pct 재계산
- /api/optuna/status, /api/optuna/topn 양쪽 적용
- 옛 result JSON 을 재파싱하여 trial_number 매칭

검증:
- 실측 재계산: 옛 잡 (1일·5거래·100%WR) 40% → 25%·상대적으로낮음
- 브라우저 CDP: 배지 27% · 팩터 6 param_stability 노출
- 로그 모달: 174줄·19.8KB·검색·auto-scroll 확인
- 사후합격 카드: 핵심진입/청산/RSI/어깨 그룹 + 한글 캡션 렌더
- 실매 코어 스모크 통과 (test_live_execution_validation 최종:통과 · 완결)

전략 범위: 4전략 공통 (스캘핑/돌파/모멘텀/꼬리) — 공통 optuna_common
- 파라미터 flat 추출은 스캘핑 위주지만 다른 전략에서 해당 파라미터 있으면 자동 표시
- 과적합 Y안은 전략 무관 (전 전략 동일 로직)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 21:07:13 +09:00

1676 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
#!/usr/bin/env python3
"""
kis_trader/backtest/optuna_common.py — Optuna storage·DB 공통 (MariaDB 141)
=========================================================================
TradeDB(database.py) 와 동일 호스트·계정, 전용 DB kis_optuna 에 study 저장.
Win11·VM 양쪽에서 같은 storage 로 trial 공유·재개 가능.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote_plus
from kis_trader.utils.env import get_env_float, get_env_from_db, get_env_int
logger = logging.getLogger("optuna_common")
# Optuna 전용 MariaDB (매매 DB kis_quant_db 와 분리)
DEFAULT_OPTUNA_DB_NAME = "kis_optuna"
OPTUNA_STRATEGIES = ("tail", "momentum", "us_momentum", "breakout", "scalp", "dart")
# 탐색(TPE 학습): 게이트 OFF(0) — PnL 차이를 샘플러가 보도록.
# 리포트/apply 후보: 아래 REPORT_* 로 사후 필터.
OPTUNA_SEARCH_MIN_WIN_RATE_DEFAULT = 0.0
OPTUNA_SEARCH_MIN_PF_DEFAULT = 0.0
OPTUNA_SEARCH_MIN_TRADES_DEFAULT = 1
# 웹 Optuna: min_trades = 거래일 수 × 하루 최소건 (소수 잭팟 컷)
OPTUNA_MIN_TRADES_PER_DAY_DEFAULT = 2
# 꼬리: 거래 빈도 낮음 — 탐색 min_trades 고정 (기간×일수 대신)
OPTUNA_TAIL_MIN_TRADES_DEFAULT = 1
# 새 score: PnL/(MDD+ADD) × √(min(n,soft_n)/soft_n)
OPTUNA_SCORE_MDD_ADD_DEFAULT = 10000.0
# 구 score: PnL / max(MDD, FLOOR) — sort_by=score_legacy
OPTUNA_SCORE_MDD_FLOOR_DEFAULT = 10000.0
# soft_n 기본 = 하루최소건 × 일수(기본 2) — 짧은 구간에서 15 고정이 과함
OPTUNA_SCORE_TRADE_SOFT_DAYS_DEFAULT = 2
OPTUNA_REPORT_MIN_WIN_RATE_DEFAULT = 40.0
OPTUNA_REPORT_MIN_PF_DEFAULT = 1.0
OPTUNA_SORT_BY_DEFAULT = "score"
OPTUNA_SORT_BY_CHOICES = ("score", "score_legacy", "pnl", "daily_avg", "win_rate")
OPTUNA_WEB_SORT_BY_CHOICES = ("score", "score_legacy", "pnl", "daily_avg")
# 일별 손익 안정성 티어 (results_stable) — 학습1위/gated 와 별도 후보
# 손실일·최악일·일PnL 분산으로 “합산만 큰” 후보를 걸러낸다.
OPTUNA_STABLE_MAX_LOSING_DAYS_DEFAULT = 1
OPTUNA_STABLE_MIN_WORST_DAY_PNL_DEFAULT = -30000.0
OPTUNA_STABLE_LAMBDA_DEFAULT = 1.0
OPTUNA_STABLE_MIN_ACTIVE_DAYS_DEFAULT = 2
def optuna_search_gate_defaults() -> Tuple[float, float, int]:
"""탐색 중 objective 게이트 (기본 0/0/1). CLI 미지정 시 사용."""
return (
float(get_env_float("PARAM_SEARCH_OPTUNA_MIN_WIN_RATE", OPTUNA_SEARCH_MIN_WIN_RATE_DEFAULT)),
float(get_env_float("PARAM_SEARCH_OPTUNA_MIN_PF", OPTUNA_SEARCH_MIN_PF_DEFAULT)),
int(get_env_int("PARAM_SEARCH_OPTUNA_MIN_TRADES", OPTUNA_SEARCH_MIN_TRADES_DEFAULT)),
)
def optuna_min_trades_per_day(strategy: Optional[str] = None) -> int:
"""기간 자동 min_trades 의 하루 기준 건수 (기본 2). 꼬리는 resolve 에서 별도."""
return max(1, int(get_env_int(
"OPTUNA_MIN_TRADES_PER_DAY", OPTUNA_MIN_TRADES_PER_DAY_DEFAULT,
)))
def optuna_tail_min_trades() -> int:
"""꼬리 Optuna 탐색 min_trades (기본 1 — 1주일이어도 후보 0건 방지)."""
return max(1, int(get_env_int(
"OPTUNA_TAIL_MIN_TRADES", OPTUNA_TAIL_MIN_TRADES_DEFAULT,
)))
def resolve_optuna_min_trades(
start: Any,
end: Any,
strategy: Optional[str] = None,
) -> Dict[str, Any]:
"""
웹 Optuna용 min_trades.
- tail: OPTUNA_TAIL_MIN_TRADES (기본 1, 기간 무관)
- 그 외: max(1, 거래일수 × OPTUNA_MIN_TRADES_PER_DAY)
CLI --min_trades 직접 지정 시 이 함수를 쓰지 않아도 됨.
"""
from kis_trader.utils.kr_trading_day import count_kr_trading_days
strat = str(strategy or "").strip().lower()
n_days = count_kr_trading_days(start, end)
if strat == "tail":
min_tr = optuna_tail_min_trades()
return {
"min_trades": int(min_tr),
"n_trading_days": int(n_days),
"min_trades_per_day": 0,
"min_trades_source": "tail_fixed",
}
per_day = optuna_min_trades_per_day(strat)
min_tr = max(1, int(n_days) * int(per_day))
return {
"min_trades": int(min_tr),
"n_trading_days": int(n_days),
"min_trades_per_day": int(per_day),
"min_trades_source": "period_auto",
}
def annotate_optuna_period_daily_avg(out_data: Optional[Dict[str, Any]]) -> None:
"""결과 JSON 행에 기간 일평균 PnL(총손익÷거래일) 붙임. 활성일 mean 과 별개.
2026-09-06: 결과 JSON 에 use_rust 필드도 함께 기록 (룰 29).
→ register_result_json_as_job 이 import 시 뱃지 표기 정합 (❔ 재발 방지).
"""
if not isinstance(out_data, dict):
return
# use_rust: BACKTEST_USE_RUST 환경변수 기준. 이미 기록돼 있으면 유지 (덮어쓰기 금지).
if "use_rust" not in out_data:
import os as _os
out_data["use_rust"] = bool(_os.environ.get("BACKTEST_USE_RUST") == "1")
from kis_trader.utils.kr_trading_day import count_kr_trading_days
start = out_data.get("start")
end = out_data.get("end")
try:
n_days = int(out_data.get("n_trading_days") or 0)
except (TypeError, ValueError):
n_days = 0
if n_days <= 0 and start and end:
n_days = count_kr_trading_days(start, end)
n_days = max(1, int(n_days or 1))
out_data["n_trading_days"] = n_days
if out_data.get("min_trades_per_day") is None:
out_data["min_trades_per_day"] = optuna_min_trades_per_day()
try:
budget = float(
out_data.get("total_budget_krw")
or out_data.get("total_budget")
or 0
)
except (TypeError, ValueError):
budget = 0.0
keys = (
"results", "results_all", "results_gated", "results_stable",
"results_mode", "mode_combo_results",
)
for key in keys:
rows = out_data.get(key)
if not isinstance(rows, list):
continue
for r in rows:
if not isinstance(r, dict):
continue
try:
pnl = float(r.get("total_pnl") or 0)
except (TypeError, ValueError):
pnl = 0.0
r["n_period_trading_days"] = n_days
r["period_daily_avg_pnl"] = round(pnl / float(n_days), 2)
if budget > 0:
r["period_daily_avg_pct"] = round(
pnl / budget * 100.0 / float(n_days), 3,
)
elif r.get("daily_avg_pct") is not None:
r["period_daily_avg_pct"] = r.get("daily_avg_pct")
elif r.get("bot_pct") is not None:
try:
r["period_daily_avg_pct"] = round(
float(r["bot_pct"]) / float(n_days), 3,
)
except (TypeError, ValueError):
pass
def optuna_score_mdd_add() -> float:
"""새 score 분모 MDD+ADD 의 ADD (기본 10000원)."""
return max(1.0, float(get_env_float(
"OPTUNA_SCORE_MDD_ADD", OPTUNA_SCORE_MDD_ADD_DEFAULT,
)))
def optuna_score_mdd_floor(strategy: Optional[str] = None) -> float:
"""
구 score 분모 하한 max(MDD, floor).
전략별 {PREFIX}_SCORE_MDD_FLOOR 가 있으면 우선, 없으면 OPTUNA_SCORE_MDD_FLOOR.
"""
prefix_map = {
"tail": "TAIL",
"momentum": "MOMENTUM",
"us_momentum": "US_MOMENTUM",
"breakout": "BREAKOUT",
"scalp": "SCALP",
}
strat = str(strategy or "").strip().lower()
prefix = prefix_map.get(strat)
if prefix:
raw = str(get_env_from_db(f"{prefix}_SCORE_MDD_FLOOR", "") or "").strip()
if raw:
try:
return max(1.0, float(raw))
except (TypeError, ValueError):
pass
return max(1.0, float(get_env_float(
"OPTUNA_SCORE_MDD_FLOOR", OPTUNA_SCORE_MDD_FLOOR_DEFAULT,
)))
def optuna_score_trade_soft_n() -> int:
"""
새 score 거래수 soft 포화점.
OPTUNA_SCORE_TRADE_SOFT_N 이 있으면 그 값.
없으면 OPTUNA_MIN_TRADES_PER_DAY × OPTUNA_SCORE_TRADE_SOFT_DAYS(기본 2)
→ 하루 2건 × 2일 = 4 (짧은 구간에서 15 고정 과감점 방지).
"""
raw = str(get_env_from_db("OPTUNA_SCORE_TRADE_SOFT_N", "") or "").strip()
if raw:
try:
return max(1, int(float(raw)))
except (TypeError, ValueError):
pass
days = max(1, int(get_env_int(
"OPTUNA_SCORE_TRADE_SOFT_DAYS", OPTUNA_SCORE_TRADE_SOFT_DAYS_DEFAULT,
)))
return max(1, int(optuna_min_trades_per_day()) * int(days))
def normalize_optuna_sort_by(sort_by: Any, *, web: bool = False) -> str:
"""sort_by 정규화. 웹은 score|pnl|daily_avg 만."""
sb = str(sort_by or "").strip().lower()
if sb in ("score_v2", "risk_score"):
sb = "score"
if sb in ("legacy", "score_v1", "score_floor", "pnl_mdd"):
sb = "score_legacy"
if sb in ("period_daily_avg", "daily", "avg_daily"):
sb = "daily_avg"
if sb in ("stability", "stable"):
return "stability"
allowed = OPTUNA_WEB_SORT_BY_CHOICES if web else OPTUNA_SORT_BY_CHOICES
if not sb or sb not in allowed:
return OPTUNA_SORT_BY_DEFAULT
return sb
def optuna_objective_value(
result: Optional[Dict[str, Any]],
sort_by: str = "score",
*,
start: Any = None,
end: Any = None,
n_trading_days: Optional[int] = None,
strategy: Optional[str] = None,
) -> float:
"""
Optuna 목적함수 (maximize).
- score: (PnL / (MDD + ADD)) × √(min(trades, soft_n) / soft_n)
- score_legacy: PnL / max(MDD, FLOOR) — (구) 순익/MDD하한
- daily_avg: PnL ÷ 기간 한국거래일
- pnl: 총손익
- win_rate: 승률 (CLI)
"""
r = result if isinstance(result, dict) else {}
sb = normalize_optuna_sort_by(sort_by, web=False)
if sb == "stability":
sb = OPTUNA_SORT_BY_DEFAULT
try:
pnl = float(r.get("total_pnl") or 0)
except (TypeError, ValueError):
pnl = 0.0
if sb == "win_rate":
try:
return float(r.get("win_rate") or 0)
except (TypeError, ValueError):
return 0.0
if sb == "pnl":
return pnl
if sb == "daily_avg":
n = n_trading_days
if n is None or int(n or 0) <= 0:
if start is not None and end is not None:
from kis_trader.utils.kr_trading_day import count_kr_trading_days
n = count_kr_trading_days(start, end)
else:
n = 1
return pnl / float(max(1, int(n)))
if sb == "score_legacy":
try:
mdd = float(r.get("mdd") or 0)
except (TypeError, ValueError):
mdd = 0.0
floor = optuna_score_mdd_floor(strategy)
return pnl / max(mdd, floor)
# score (수익·낙폭·표본)
try:
mdd = float(r.get("mdd") or 0)
except (TypeError, ValueError):
mdd = 0.0
try:
trades = float(r.get("total_trades") or 0)
except (TypeError, ValueError):
trades = 0.0
add = optuna_score_mdd_add()
soft_n = float(optuna_score_trade_soft_n())
soft = (min(max(0.0, trades), soft_n) / soft_n) ** 0.5
return (pnl / (max(0.0, mdd) + add)) * soft
def optuna_store_trial_score_user_attrs(
trial: Any,
result: Dict[str, Any],
sort_by: str,
*,
start: Any = None,
end: Any = None,
strategy: Optional[str] = None,
) -> float:
"""trial score·score_legacy·일평균 저장 후 sort_by 목적값 반환."""
kw = {"start": start, "end": end, "strategy": strategy}
trial.set_user_attr(
"score",
float(optuna_objective_value(result, "score", **kw)),
)
trial.set_user_attr(
"score_legacy",
float(optuna_objective_value(result, "score_legacy", **kw)),
)
trial.set_user_attr(
"period_daily_avg_pnl",
float(optuna_objective_value(result, "daily_avg", **kw)),
)
return float(optuna_objective_value(result, sort_by, **kw))
def optuna_score_fields_from_trial(trial: Any) -> Dict[str, float]:
"""JSON 행용 score / score_legacy."""
return {
"score": float(trial.user_attrs.get("score") or 0),
"score_legacy": float(trial.user_attrs.get("score_legacy") or 0),
}
def optuna_report_gate_defaults() -> Tuple[float, float, int]:
"""결과 후보·apply 사후 필터 (기본 승률40·PF1.0·min_trades=탐색과 동일)."""
_sw, _sp, min_tr = optuna_search_gate_defaults()
return (
float(get_env_float(
"PARAM_SEARCH_OPTUNA_REPORT_MIN_WIN_RATE", OPTUNA_REPORT_MIN_WIN_RATE_DEFAULT,
)),
float(get_env_float(
"PARAM_SEARCH_OPTUNA_REPORT_MIN_PF", OPTUNA_REPORT_MIN_PF_DEFAULT,
)),
int(get_env_int("PARAM_SEARCH_OPTUNA_REPORT_MIN_TRADES", max(1, min_tr))),
)
def _sort_optuna_rows(rows: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
sb = normalize_optuna_sort_by(sort_by, web=False)
out = list(rows)
def _f(r: Dict[str, Any], k: str) -> float:
try:
return float(r.get(k) or 0)
except (TypeError, ValueError):
return 0.0
if sb == "score":
out.sort(key=lambda r: (-_f(r, "score"), -_f(r, "total_pnl"), -_f(r, "win_rate")))
elif sb == "score_legacy":
out.sort(
key=lambda r: (
-_f(r, "score_legacy" if r.get("score_legacy") is not None else "score"),
-_f(r, "total_pnl"),
-_f(r, "win_rate"),
),
)
elif sb == "daily_avg":
def _avg_key(r: Dict[str, Any]) -> Tuple[float, float, float]:
if r.get("period_daily_avg_pnl") is not None:
avg = _f(r, "period_daily_avg_pnl")
else:
avg = _f(r, "daily_pnl_mean")
return (-avg, -_f(r, "total_pnl"), -_f(r, "win_rate"))
out.sort(key=_avg_key)
elif sb == "win_rate":
out.sort(key=lambda r: (-_f(r, "win_rate"), -_f(r, "total_pnl")))
elif sb == "stability":
# 일평균 λ·표준편차(stability_score) 우선 · 최악일 · 합산 PnL
out.sort(
key=lambda r: (
-_f(r, "stability_score"),
-_f(r, "worst_day_pnl"),
-_f(r, "total_pnl"),
-_f(r, "win_rate"),
),
)
else:
out.sort(key=lambda r: (-_f(r, "total_pnl"), -_f(r, "win_rate")))
return out
def trade_exit_day_key(trade: Dict[str, Any]) -> str:
"""청산 시각 → YYYY-MM-DD (없으면 빈 문자열).
꼬리 백테는 exit_time, 스캘핑·모멘텀·돌파 포트폴리오 백테는 sell_time 을 씀.
sell_time 누락 시 daily_pnl/results_stable 이 전부 비게 됨.
"""
raw = (
trade.get("exit_time")
or trade.get("sell_date")
or trade.get("sell_time") # scalp/momentum/breakout 포트폴리오
or trade.get("exit_ts")
or trade.get("exit_at")
or ""
)
s = str(raw).strip()
if not s:
return ""
digits = "".join(ch for ch in s if ch.isdigit())
if len(digits) >= 8:
return f"{digits[0:4]}-{digits[4:6]}-{digits[6:8]}"
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return ""
def compute_daily_stability_metrics(
trades: List[Dict[str, Any]],
*,
stability_lambda: Optional[float] = None,
) -> Dict[str, Any]:
"""
거래 리스트 → 일별 PnL·안정성 점수.
stability_score = mean(일PnL) λ × std(일PnL)
(λ 기본 OPTUNA_STABLE_LAMBDA / get_env)
"""
from statistics import mean, pstdev
if stability_lambda is None:
_, _, lam, _ = optuna_stable_gate_defaults()
stability_lambda = lam
try:
lam = float(stability_lambda)
except (TypeError, ValueError):
lam = float(OPTUNA_STABLE_LAMBDA_DEFAULT)
by_day: Dict[str, float] = {}
for t in trades or []:
day = trade_exit_day_key(t if isinstance(t, dict) else {})
if not day:
continue
try:
pnl = float((t or {}).get("pnl") or (t or {}).get("realized_pnl") or 0)
except (TypeError, ValueError):
pnl = 0.0
by_day[day] = by_day.get(day, 0.0) + pnl
days_sorted = sorted(by_day.keys())
vals = [float(by_day[d]) for d in days_sorted]
n_days = len(vals)
if n_days <= 0:
return {
"daily_pnl": {},
"n_active_days": 0,
"n_losing_days": 0,
"worst_day_pnl": 0.0,
"best_day_pnl": 0.0,
"daily_pnl_mean": 0.0,
"daily_pnl_std": 0.0,
"stability_score": 0.0,
"stability_lambda": lam,
}
n_lose = sum(1 for v in vals if v < 0)
worst = min(vals)
best = max(vals)
avg = float(mean(vals))
std = float(pstdev(vals)) if n_days >= 2 else 0.0
score = avg - lam * std
return {
"daily_pnl": {d: round(by_day[d], 2) for d in days_sorted},
"n_active_days": n_days,
"n_losing_days": int(n_lose),
"worst_day_pnl": round(worst, 2),
"best_day_pnl": round(best, 2),
"daily_pnl_mean": round(avg, 2),
"daily_pnl_std": round(std, 2),
"stability_score": round(score, 4),
"stability_lambda": lam,
}
def attach_daily_stability(
result: Dict[str, Any],
trades: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""evaluate_* 반환 dict 에 일별 안정성 필드를 붙인다."""
if not isinstance(result, dict):
return result
result.update(compute_daily_stability_metrics(trades or []))
return result
def attach_optional_backtest_trades(
result: Dict[str, Any],
trades: List[Dict[str, Any]],
include_trades: bool = False,
) -> Dict[str, Any]:
"""Optuna 후처리용. include_trades=False 면 기존과 동일(JSON/trial attrs 비대화 방지)."""
if include_trades and isinstance(result, dict):
result["_trades"] = list(trades or [])
return result
def slim_trades_for_optuna_json(
trades: Optional[List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""Optuna 결과 JSON용 체결 요약 — 정합 diff용 최소 필드만 (전체 봉/틱 메타 제외)."""
out: List[Dict[str, Any]] = []
for t in trades or []:
if not isinstance(t, dict):
continue
out.append(
{
"code": t.get("code") or t.get("ticker"),
"buy_time": t.get("buy_time") or t.get("entry_time"),
"sell_time": t.get("sell_time") or t.get("exit_time"),
"pnl": t.get("pnl"),
"sell_reason": (
t.get("sell_reason") or t.get("reason") or t.get("exit_reason")
),
"entry_price": t.get("entry_price") or t.get("buy_price"),
"exit_price": t.get("exit_price") or t.get("sell_price"),
"qty": t.get("qty") or t.get("quantity"),
}
)
return out
def optuna_stable_gate_defaults() -> Tuple[int, float, float, int]:
"""(max_losing_days, min_worst_day_pnl, lambda, min_active_days)."""
return (
int(get_env_int(
"PARAM_SEARCH_OPTUNA_STABLE_MAX_LOSING_DAYS",
OPTUNA_STABLE_MAX_LOSING_DAYS_DEFAULT,
)),
float(get_env_float(
"PARAM_SEARCH_OPTUNA_STABLE_MIN_WORST_DAY_PNL",
OPTUNA_STABLE_MIN_WORST_DAY_PNL_DEFAULT,
)),
float(get_env_float(
"PARAM_SEARCH_OPTUNA_STABLE_LAMBDA",
OPTUNA_STABLE_LAMBDA_DEFAULT,
)),
int(get_env_int(
"PARAM_SEARCH_OPTUNA_STABLE_MIN_ACTIVE_DAYS",
OPTUNA_STABLE_MIN_ACTIVE_DAYS_DEFAULT,
)),
)
def row_passes_report_gates(
row: Dict[str, Any],
*,
min_win_rate: float,
min_pf: float,
min_trades: int,
) -> bool:
try:
wr = float(row.get("win_rate") or 0)
pf = float(row.get("pf") or 0)
nt = int(row.get("total_trades") or 0)
except (TypeError, ValueError):
return False
if nt < int(min_trades):
return False
if wr < float(min_win_rate):
return False
if pf < float(min_pf):
return False
return True
def row_passes_stable_gates(row: Dict[str, Any]) -> bool:
"""
일별 안정성 사후 게이트.
daily_pnl / n_active_days 가 없으면(구 JSON) 통과 불가 → results_stable 빈 목록.
"""
if row.get("daily_pnl") is None and row.get("n_active_days") is None:
return False
max_lose, min_worst, _lam, min_days = optuna_stable_gate_defaults()
try:
n_days = int(row.get("n_active_days") or 0)
n_lose = int(row.get("n_losing_days") or 0)
worst = float(row.get("worst_day_pnl") or 0)
except (TypeError, ValueError):
return False
if n_days < int(min_days):
return False
if n_lose > int(max_lose):
return False
if worst < float(min_worst):
return False
return True
def set_optuna_trial_stability_attrs(trial: Any, result: Dict[str, Any]) -> None:
"""Optuna trial.user_attrs 에 일별 안정성 스냅샷 저장."""
import json as _json
if not result:
return
try:
trial.set_user_attr("n_active_days", int(result.get("n_active_days") or 0))
trial.set_user_attr("n_losing_days", int(result.get("n_losing_days") or 0))
trial.set_user_attr("worst_day_pnl", float(result.get("worst_day_pnl") or 0))
trial.set_user_attr("best_day_pnl", float(result.get("best_day_pnl") or 0))
trial.set_user_attr("daily_pnl_mean", float(result.get("daily_pnl_mean") or 0))
trial.set_user_attr("daily_pnl_std", float(result.get("daily_pnl_std") or 0))
trial.set_user_attr("stability_score", float(result.get("stability_score") or 0))
trial.set_user_attr(
"daily_pnl_json",
_json.dumps(result.get("daily_pnl") or {}, ensure_ascii=False),
)
except Exception:
pass
def stability_fields_from_trial_attrs(trial: Any) -> Dict[str, Any]:
"""trial.user_attrs → 결과 row 안정성 필드."""
import json as _json
raw = trial.user_attrs.get("daily_pnl_json") or "{}"
try:
daily = _json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
daily = {}
if trial.user_attrs.get("n_active_days") is None and not daily:
return {}
return {
"daily_pnl": daily if isinstance(daily, dict) else {},
"n_active_days": int(trial.user_attrs.get("n_active_days") or 0),
"n_losing_days": int(trial.user_attrs.get("n_losing_days") or 0),
"worst_day_pnl": float(trial.user_attrs.get("worst_day_pnl") or 0),
"best_day_pnl": float(trial.user_attrs.get("best_day_pnl") or 0),
"daily_pnl_mean": float(trial.user_attrs.get("daily_pnl_mean") or 0),
"daily_pnl_std": float(trial.user_attrs.get("daily_pnl_std") or 0),
"stability_score": float(trial.user_attrs.get("stability_score") or 0),
}
def build_results_stable_tier(
rows: List[Dict[str, Any]],
*,
top_n: int = 10,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""
안정 Top — 사후합격(gated)·플러스 PnL 과 독립 (마이너스도 상대 순위).
1) 일별 안정 게이트 통과분 → 안정점수순
2) 0건이면 학습풀 전체를 안정점수순 TopN (게이트는 참고·폴백 표시)
→ 3일 전패장에서 max_losing_days=1 이면 게이트 0이어도 표가 비지 않음
"""
max_lose, min_worst, lam, min_days = optuna_stable_gate_defaults()
n = max(1, int(top_n or 10))
all_rows = [r for r in (rows or []) if isinstance(r, dict)]
pool = [r for r in all_rows if row_passes_stable_gates(r)]
fallback = False
if pool:
stable = _sort_optuna_rows(pool, "stability")[:n]
else:
fallback = True
with_stab = [
r for r in all_rows
if r.get("stability_score") is not None
or r.get("n_active_days") is not None
or r.get("daily_pnl") is not None
]
src = with_stab if with_stab else all_rows
stable = _sort_optuna_rows(src, "stability")[:n]
meta = {
"max_losing_days": max_lose,
"min_worst_day_pnl": min_worst,
"stability_lambda": lam,
"min_active_days": min_days,
"score_note": "stability_score = mean(일PnL) λ × std(일PnL)",
"fallback_rank_only": bool(fallback),
"n_gate_pass": len(pool),
"fallback_note": (
"안정 게이트 0건 → 학습풀 안정점수순 TopN (마이너스 PnL 포함 · 상대비교)"
if fallback else ""
),
}
return stable, meta
def resolve_results_stable(
data: Optional[Dict[str, Any]],
*,
top_n: Optional[int] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""JSON results_stable 우선 · 비면 학습풀에서 즉시 재구성 (구잡·gated=0 공용)."""
data = data or {}
try:
n = int(top_n) if top_n is not None else 10
except (TypeError, ValueError):
n = 10
n = max(1, n)
stored = [r for r in list(data.get("results_stable") or []) if isinstance(r, dict)]
gates = dict(data.get("stable_gates") or {})
if stored:
return stored[:n], gates
allr = list(data.get("results_all") or data.get("results") or [])
stable, meta = build_results_stable_tier(allr, top_n=n)
gates.update(meta)
return stable, gates
def resolve_results_mode_consensus(
data: Optional[Dict[str, Any]],
*,
top_n: Optional[int] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""JSON results_mode 우선 · 없으면 mode Top10 즉시 재구성 (구 JSON 호환)."""
data = data or {}
try:
n = int(top_n) if top_n is not None else 10
except (TypeError, ValueError):
n = 10
n = max(1, n)
stored = [r for r in list(data.get("results_mode") or []) if isinstance(r, dict)]
meta = dict(data.get("mode_consensus_meta") or {})
if stored:
return stored[:n], meta
allr = list(data.get("results_all") or data.get("results") or [])
from kis_trader.backtest.optuna_mode_combo import build_results_mode_consensus_tier
rows, built_meta = build_results_mode_consensus_tier(
allr,
top_n=n,
grid_keys=list(data.get("grid_keys") or []),
data=data,
)
meta.update(built_meta)
return rows, meta
def build_optuna_result_tiers(
rows: List[Dict[str, Any]],
*,
sort_by: str,
top_n: int = 5000,
) -> Dict[str, Any]:
"""
탐색 전체 vs 리포트/apply 후보 분리.
- results_all: 완료·게이트통과(탐색게이트) trial 전부 정렬
- results: 하위호환 — 플러스 PnL 우선(없으면 all)
- results_gated: 승률·PF 사후 필터 (apply 후보, PnL>0)
- results_stable: 학습풀 일별 안정성 (gated·플러스 독립 · 게이트0이면 점수순 폴백)
"""
rep_wr, rep_pf, rep_tr = optuna_report_gate_defaults()
all_sorted = _sort_optuna_rows(rows, sort_by)
profitable = [r for r in all_sorted if float(r.get("total_pnl") or 0) > 0]
learning = profitable if profitable else all_sorted
gated = [
r for r in all_sorted
if row_passes_report_gates(
r, min_win_rate=rep_wr, min_pf=rep_pf, min_trades=rep_tr,
)
and float(r.get("total_pnl") or 0) > 0
]
# 안정 TopN 표용 — 전체 풀에서 상위 (gated 잘림·플러스와 무관)
try:
stable_ui_n = max(1, int(get_env_int("OPTUNA_POST_TOP_N", 10)))
except Exception:
stable_ui_n = 10
stable, stable_meta = build_results_stable_tier(all_sorted, top_n=stable_ui_n)
return {
"results_all": all_sorted[:top_n],
"results": learning[:top_n],
"results_gated": gated[:top_n],
"results_stable": stable,
"report_gates": {
"min_win_rate": rep_wr,
"min_pf": rep_pf,
"min_trades": rep_tr,
},
"stable_gates": stable_meta,
"search_gates_note": (
"탐색 min_win_rate/min_pf 기본 0 — TPE가 PnL 차이를 학습. "
"적용·운영 후보는 results_gated(report_gates). "
"들쭉날쭉 완화·상대비교는 results_stable (gated/플러스 독립 · 게이트0이면 점수순 폴백)."
),
"n_results_all": len(all_sorted),
"n_results_learning": len(learning),
"n_results_gated": len(gated),
"n_results_stable": len(stable),
}
def _optuna_overfit_sample_days(data: Dict[str, Any]) -> int:
try:
days = int(data.get("backtest_days") or 0)
except (TypeError, ValueError):
days = 0
if days > 0:
return days
start = str(data.get("start") or "")
end = str(data.get("end") or "")
try:
from datetime import datetime as _dt
return max(
1,
(_dt.strptime(end, "%Y-%m-%d") - _dt.strptime(start, "%Y-%m-%d")).days + 1,
)
except Exception:
return 1
def overfit_risk_pct_for_row(
data: Dict[str, Any],
row: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""
후보 한 줄의 과적합 가능도% (0~100, 높을수록 위험 · 만점=100).
스터디 공통(표본 장일) + 이 trial의 거래수·승률·PF 이상치.
교차검증이 아님. DB apply 게이트와 별개.
"""
def _f(x: Any, default: float = 0.0) -> float:
try:
return float(x)
except (TypeError, ValueError):
return default
def _i(x: Any, default: int = 0) -> int:
try:
return int(x)
except (TypeError, ValueError):
return default
days = _optuna_overfit_sample_days(data or {})
risk = 0.0
# 2026-09-06 Y안 (룰 19 사용자 선택): 표본 일수 max 40 → 25 감소.
# 근거: 다일 확보가 어려운 개발 초기·특정 종목 대응·리허설 백테에서 견고성 지표(팩터 5·6)
# 로 상쇄 가능해야. 이전엔 1일=자동 40점+ → 진짜 견고한 조합도 비권장 뜨는 부작용.
if days <= 1:
risk += 25.0
elif days == 2:
risk += 18.0
elif days <= 4:
risk += 10.0
if not row:
risk = max(0.0, min(100.0, round(risk + 25.0, 1)))
return {
"overfit_risk_pct": risk,
"verdict": "비권장",
"verdict_ui": "위험 · 비권장",
}
nt = _i(row.get("total_trades"))
wr = _f(row.get("win_rate"))
pf = _f(row.get("pf"))
pnl = _f(row.get("total_pnl"))
if nt <= 1:
risk += 25.0
elif nt <= 3:
risk += 18.0
elif nt <= 9:
risk += 10.0
if wr >= 90.0 and nt < 10:
risk += 15.0
elif wr >= 80.0 and nt < 5:
risk += 10.0
if pf >= 50.0 and nt < 10:
risk += 10.0
elif pf >= 10.0 and nt < 5:
risk += 6.0
gated = list((data or {}).get("results_gated") or [])
learn = list((data or {}).get("results") or (data or {}).get("results_all") or [])
pool = gated if gated else learn
if pool and nt > 0:
best_pnl = round(pnl, 0)
same = [r for r in pool if abs(_f(r.get("total_pnl")) - best_pnl) < 1.0]
share = len(same) / max(1, len(pool))
if share >= 0.4 and len(same) >= 5:
risk += 12.0
elif share >= 0.25 and len(same) >= 3:
risk += 6.0
risk = max(0.0, min(100.0, round(risk, 1)))
if risk >= 70.0:
verdict, verd_ui = "비권장", "위험 · 비권장"
elif risk >= 40.0:
verdict, verd_ui = "주의", "주의"
else:
verdict, verd_ui = "상대적으로낮음", "상대적으로 낮음"
return {
"overfit_risk_pct": risk,
"verdict": verdict,
"verdict_ui": verd_ui,
}
def build_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Optuna 결과 → 과적합 위험% · 적용 가능도% · 임계값(파라미터) 분포 표용 dict.
- 통계적 교차검증이 아니라 **운영 휴리스틱**(표본 일수·거래수·승률/PF 이상치·평탄 고원).
- 높을수록 과적합 위험. 적용 가능도 ≈ 100 위험 (하한 0).
- 웹·브리핑·JSON 공통. DB apply 게이트는 바꾸지 않음(표시·판별용).
"""
import statistics
def _f(x: Any, default: float = 0.0) -> float:
try:
return float(x)
except (TypeError, ValueError):
return default
def _i(x: Any, default: int = 0) -> int:
try:
return int(x)
except (TypeError, ValueError):
return default
days = _i(data.get("backtest_days"), 0)
if days <= 0:
start = str(data.get("start") or "")
end = str(data.get("end") or "")
try:
from datetime import datetime as _dt
days = max(
1,
(_dt.strptime(end, "%Y-%m-%d") - _dt.strptime(start, "%Y-%m-%d")).days + 1,
)
except Exception:
days = 1
gated = list(data.get("results_gated") or [])
learn = list(data.get("results") or data.get("results_all") or [])
pool = gated if gated else learn
top = pool[0] if pool else None
n_gated = _i(data.get("n_results_gated"), len(gated))
n_all = _i(data.get("n_results_all"), len(data.get("results_all") or learn))
n_stable = _i(data.get("n_results_stable"), len(data.get("results_stable") or []))
factors: List[Dict[str, Any]] = []
risk = 0.0
# 1) 표본 장일 (2026-09-06 Y안 · 룰 19: max 40 → 25 감소)
# 다일 확보 어려운 개발/리허설·특정 종목 백테는 견고성 지표(팩터 6)로 상쇄 가능해야 함.
if days <= 1:
pts, detail = 25.0, f"거래일≈{days}일 — 단일 장 표본 부족(견고성으로 상쇄 필요)"
elif days == 2:
pts, detail = 18.0, f"거래일≈{days}일 — 이틀만으로는 추세 전환에 취약"
elif days <= 4:
pts, detail = 10.0, f"거래일≈{days}일 — 다일 재검증 권장(≥5일)"
else:
pts, detail = 0.0, f"거래일≈{days}일 — 표본 일수 상대적 양호"
risk += pts
factors.append({"id": "sample_days", "label": "표본 장일", "points": pts, "detail": detail})
nt = _i(top.get("total_trades")) if top else 0
wr = _f(top.get("win_rate")) if top else 0.0
pf = _f(top.get("pf")) if top else 0.0
pnl = _f(top.get("total_pnl")) if top else 0.0
# 2) 거래 표본
if not top:
pts, detail = 25.0, "통과 후보 없음 — 적용 불가"
elif nt <= 1:
pts, detail = 25.0, f"상위 후보 거래 {nt}건 — 우연 승·과적합 가능"
elif nt <= 3:
pts, detail = 18.0, f"상위 후보 거래 {nt}건 — 표본 부족"
elif nt <= 9:
pts, detail = 10.0, f"상위 후보 거래 {nt}건 — 해석 시 주의"
else:
pts, detail = 0.0, f"상위 후보 거래 {nt}건 — 상대적 양호"
risk += pts
factors.append({"id": "trade_count", "label": "거래 표본", "points": pts, "detail": detail})
# 3) 승률/PF 이상치
pts = 0.0
bits: List[str] = []
if top and wr >= 90.0 and nt < 10:
pts += 15.0
bits.append(f"승률 {wr:.1f}% + 거래 {nt}")
elif top and wr >= 80.0 and nt < 5:
pts += 10.0
bits.append(f"승률 {wr:.1f}% + 거래 {nt}")
if top and pf >= 50.0 and nt < 10:
pts += 10.0
bits.append(f"PF {pf:.2f} (소수 거래 폭증)")
elif top and pf >= 10.0 and nt < 5:
pts += 6.0
bits.append(f"PF {pf:.2f}")
detail = " · ".join(bits) if bits else "이상치 없음"
risk += pts
factors.append({"id": "outlier_wr_pf", "label": "승률·PF 이상치", "points": pts, "detail": detail})
# 4) gated 부재 / 거의 전원 통과
pts = 0.0
if n_gated <= 0 and n_all > 0:
pts = 12.0
detail = f"사후합격 0건 (학습 {n_all}) — DB 적용 비권장"
elif n_all > 0 and n_gated / max(1, n_all) >= 0.85 and days <= 2:
pts = 10.0
detail = f"gated/all={n_gated}/{n_all} — 단일에 대부분 통과(필터 느슨·노이즈)"
elif n_gated > 0:
pts = 0.0
detail = f"사후합격 {n_gated}건 · stable {n_stable}"
else:
pts = 8.0
detail = "학습·gated 모두 비어 있음"
risk += pts
factors.append({"id": "gate_coverage", "label": "게이트 커버", "points": pts, "detail": detail})
# 5) PnL 고원(동일 best 반복) — 2026-09-06 Y안 재해석 (룰 19 사용자 선택)
# 기존: 동일 PnL 반복 = 무조건 위험 (+12점)
# 정정: 동일 PnL 반복 & **파라미터도 좁음** = TPE 좁게 튐 (여전히 위험 · 최대 +12)
# 동일 PnL 반복 & **파라미터 다양** = 파라미터 민감도 낮음 = 견고 (감점 -8)
# 판단: same pool 에서 핵심 파라미터(tp_pct/sl_pct/drop_rate/vol_mult) 의
# 고유값 개수 대비 표본 크기 비율(unique_ratio)로 근사.
plateau_share = 0.0
plateau_n = 0
plateau_param_diverse = False
if pool and top:
best_pnl = round(pnl, 0)
same = [
r for r in pool
if abs(_f(r.get("total_pnl")) - best_pnl) < 1.0
]
plateau_n = len(same)
plateau_share = plateau_n / max(1, len(pool))
# 핵심 파라미터의 고유값 다양성 (같은 PnL 이 여러 파라미터 조합에서 도달했나?)
_core_keys = ("tp_pct", "sl_pct", "drop_rate", "vol_mult", "cooldown_min")
_uniq_ratios: List[float] = []
for k in _core_keys:
vals = []
for r in same:
p = r.get("merged_params") or r.get("params") or {}
if isinstance(p, dict) and k in p:
try:
vals.append(round(float(p[k]), 6))
except (TypeError, ValueError):
pass
if len(vals) >= 3:
_uniq_ratios.append(len(set(vals)) / len(vals))
_avg_uniq = sum(_uniq_ratios) / max(1, len(_uniq_ratios)) if _uniq_ratios else 0.0
# unique_ratio 0.5+ = 다양한 파라미터에서 같은 PnL 도달 = 견고
plateau_param_diverse = _avg_uniq >= 0.5 and len(_uniq_ratios) >= 3
if plateau_share >= 0.4 and plateau_n >= 5:
if plateau_param_diverse:
pts = -8.0
detail = (
f"동일 PnL≈{best_pnl:,.0f}원이 {plateau_n}/{len(pool)} "
f"({plateau_share:.0%}) · 핵심 파라미터 다양성 {_avg_uniq:.0%} — 견고(-8)"
)
else:
pts = 12.0
detail = (
f"동일 PnL≈{best_pnl:,.0f}원이 {plateau_n}/{len(pool)} "
f"({plateau_share:.0%}) · 파라미터 좁음({_avg_uniq:.0%}) — TPE 몰빵/위험"
)
elif plateau_share >= 0.25 and plateau_n >= 3:
if plateau_param_diverse:
pts = -4.0
detail = (
f"PnL 고원 {plateau_n}/{len(pool)} ({plateau_share:.0%}) · "
f"파라미터 다양({_avg_uniq:.0%}) — 소폭 견고(-4)"
)
else:
pts = 6.0
detail = f"PnL 고원 {plateau_n}/{len(pool)} ({plateau_share:.0%}) · 파라미터 좁음"
else:
pts = 0.0
detail = f"고원 비율 {plateau_share:.0%} ({plateau_n}건)"
else:
pts, detail = 0.0, "고원 판정 스킵"
risk += pts
factors.append({"id": "pnl_plateau", "label": "PnL 고원 (파라미터 민감도)", "points": pts, "detail": detail})
# 6) 파라미터 안정성 (2026-09-06 Y안 신설 · 사용자 지적: 녹색줄+주황점 일치=견고)
# gated pool 전체에서 핵심 파라미터의 mode_share (최빈값 비율) 를 봄.
# mode_share ≥ 0.6 인 파라미터가 여러 개면 = "여러 trial이 같은 값 선택" = 견고 → 감점
# 판정: threshold_distribution 사전 계산 안 됐을 수 있어 pool 에서 직접 집계.
core_keys_stab = (
"tp_pct", "sl_pct", "drop_rate", "vol_mult", "cooldown_min",
"shoulder_min_high", "shoulder_cut_pct", "high_chase_thr",
"whipsaw_subbar_sec", "whipsaw_lookback_sec",
)
stab_stats: List[Tuple[str, float, int]] = [] # (key, mode_share, n)
pool_for_stab = pool[: min(30, len(pool))] if pool else []
if pool_for_stab:
for k in core_keys_stab:
vals: List[float] = []
for r in pool_for_stab:
p = r.get("merged_params") or r.get("params") or {}
if isinstance(p, dict) and k in p:
try:
vals.append(round(float(p[k]), 6))
except (TypeError, ValueError):
pass
if len(vals) >= 3:
mode_v = max(set(vals), key=vals.count)
mode_n = sum(1 for v in vals if v == mode_v)
stab_stats.append((k, mode_n / len(vals), len(vals)))
strong_stab = [s for s in stab_stats if s[1] >= 0.6]
if len(strong_stab) >= 5:
pts = -15.0
detail = (
f"핵심 파라미터 {len(strong_stab)}개가 mode_share≥60% — "
f"다수 trial 동일값 선택 = 매우 견고(-15)"
)
elif len(strong_stab) >= 3:
pts = -8.0
detail = f"핵심 파라미터 {len(strong_stab)}개가 mode_share≥60% — 다소 견고(-8)"
elif len(stab_stats) >= 3:
pts = 0.0
detail = (
f"핵심 파라미터 {len(stab_stats)}개 집계, 견고 {len(strong_stab)}개 — 중립"
)
else:
pts, detail = 0.0, "표본 부족 — 안정성 판정 스킵"
risk += pts
factors.append({"id": "param_stability", "label": "파라미터 안정성", "points": pts, "detail": detail})
risk = max(0.0, min(100.0, round(risk, 1)))
apply_pct = max(0.0, min(100.0, round(100.0 - risk, 1)))
if risk >= 70.0:
verdict = "비권장"
verdict_ko = "과적합·표본부족 위험 높음 — 실매 DB 즉시 적용 비권장"
elif risk >= 40.0:
verdict = "주의"
verdict_ko = "적용 가능도 중간 — 다일(≥5일) 재검증·웹백테 후 소액만"
else:
verdict = "상대적으로낮음"
verdict_ko = "휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장"
# --- 임계값 분포 (gated 우선, 상위 min(30, len) 행) ---
dist_rows = pool[: min(30, len(pool))]
skip_keys = {
"params", "apply_cfg", "merged_params", "daily_pnl", "optuna_trial_number",
"total_trades", "win_rate", "total_pnl", "pf", "score", "stability_score",
"n_losing_days", "n_active_days", "worst_day_pnl", "best_day_pnl",
"daily_pnl_mean", "daily_pnl_std", "skip_hts_scan_dupes",
}
prefer = list(data.get("grid_keys") or [])
# 꼬리·공통에서 자주 보는 축
prefer_extra = [
"min_drop_rate", "min_recovery_ratio", "tail_ratio_min", "tail_pct_min",
"stop_atr_mult", "target_atr_mult", "atr_sl_min_pct", "atr_sl_max_pct",
"atr_tp_min_pct", "atr_tp_max_pct", "rsi_threshold", "bar_chg_min_pct",
"bar_chg_max_pct", "shoulder_min_high", "shoulder_cut_pct", "cooldown_min",
"max_daily", "whipsaw_filter_enabled", "ratchet_on", "sl_pct", "tp_pct",
]
key_order = []
for k in prefer + prefer_extra:
if k not in key_order:
key_order.append(k)
# 실제 등장 키 수집
value_maps: Dict[str, List[Any]] = {}
for row in dist_rows:
params = row.get("merged_params") or row.get("params") or {}
if not isinstance(params, dict):
continue
for k, v in params.items():
if k in skip_keys or str(k).startswith("_"):
continue
value_maps.setdefault(str(k), []).append(v)
def _percentile(sorted_vals: List[float], p: float) -> float:
if not sorted_vals:
return 0.0
if len(sorted_vals) == 1:
return sorted_vals[0]
idx = (len(sorted_vals) - 1) * p
lo = int(idx)
hi = min(lo + 1, len(sorted_vals) - 1)
w = idx - lo
return sorted_vals[lo] * (1.0 - w) + sorted_vals[hi] * w
threshold_distribution: List[Dict[str, Any]] = []
keys_out = [k for k in key_order if k in value_maps]
# prefer 외 숫자 키 보충 (최대 18개 표시)
for k in sorted(value_maps.keys()):
if k not in keys_out:
keys_out.append(k)
if len(keys_out) >= 18:
break
for k in keys_out:
vals = value_maps.get(k) or []
if not vals:
continue
# bool / categorical
as_num: List[float] = []
for v in vals:
if isinstance(v, bool):
as_num.append(1.0 if v else 0.0)
else:
try:
as_num.append(float(v))
except (TypeError, ValueError):
as_num = []
break
# mode
try:
mode_v = statistics.mode(vals)
except statistics.StatisticsError:
mode_v = vals[0]
mode_n = sum(1 for v in vals if v == mode_v)
mode_share = mode_n / max(1, len(vals))
row_d: Dict[str, Any] = {
"param": k,
"n": len(vals),
"mode": mode_v,
"mode_share": round(mode_share, 3),
}
if as_num:
s = sorted(as_num)
row_d["p25"] = round(_percentile(s, 0.25), 6)
row_d["median"] = round(_percentile(s, 0.50), 6)
row_d["p75"] = round(_percentile(s, 0.75), 6)
row_d["min"] = round(s[0], 6)
row_d["max"] = round(s[-1], 6)
else:
row_d["p25"] = None
row_d["median"] = None
row_d["p75"] = None
row_d["min"] = None
row_d["max"] = None
threshold_distribution.append(row_d)
pool_tag = "results_gated" if gated else "results(learning)"
return {
"overfit_risk_pct": risk,
"apply_readiness_pct": apply_pct,
"verdict": verdict,
"verdict_ko": verdict_ko,
"sample_days": days,
"n_gated": n_gated,
"n_all": n_all,
"n_stable": n_stable,
"top_trades": nt,
"top_win_rate": wr,
"top_pf": pf,
"top_pnl": pnl,
"plateau_share": round(plateau_share, 3),
"plateau_n": plateau_n,
"factors": factors,
"threshold_distribution": threshold_distribution,
"threshold_pool": pool_tag,
"threshold_pool_n": len(dist_rows),
"note": (
"과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. "
"적용 가능도%=100과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다."
),
}
def attach_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]:
"""JSON dict 에 overfit_diagnostics 키를 채운다 (있으면 갱신)."""
try:
data["overfit_diagnostics"] = build_optuna_overfit_diagnostics(data)
except Exception as exc:
logger.warning("⚠️ overfit_diagnostics 생성 실패: %s", exc)
data["overfit_diagnostics"] = {
"overfit_risk_pct": None,
"apply_readiness_pct": None,
"verdict": "error",
"verdict_ko": f"진단 실패: {exc}",
"factors": [],
"threshold_distribution": [],
"note": str(exc),
}
return data
def pick_gated_apply_trial(
study: Any,
*,
sort_by: str = "pnl",
fail_objective: float = -1e18,
) -> Optional[Any]:
"""
--apply-best 용: study.best(탐색 objective)가 아니라
report_gates 통과 trial 중 정렬 1위.
"""
import optuna # noqa: WPS433 — 호출 시에만
rep_wr, rep_pf, rep_tr = optuna_report_gate_defaults()
cand: List[Tuple[Dict[str, Any], Any]] = []
for trial in study.trials:
if trial.state != optuna.trial.TrialState.COMPLETE:
continue
if not trial.user_attrs.get("gates_ok"):
continue
try:
val = float(trial.value) if trial.value is not None else fail_objective
except (TypeError, ValueError):
val = fail_objective
if val <= fail_objective + 1:
continue
row = {
"win_rate": float(trial.user_attrs.get("win_rate") or 0),
"pf": float(trial.user_attrs.get("pf") or 0),
"total_trades": int(trial.user_attrs.get("total_trades") or 0),
"total_pnl": float(trial.user_attrs.get("total_pnl") or 0),
"score": float(trial.user_attrs.get("score") or 0),
"_trial_number": int(trial.number),
}
if not row_passes_report_gates(
row, min_win_rate=rep_wr, min_pf=rep_pf, min_trades=rep_tr,
):
continue
if float(row["total_pnl"]) <= 0:
continue
cand.append((row, trial))
if not cand:
return None
ranked = _sort_optuna_rows([r for r, _ in cand], sort_by)
top_n = int(ranked[0].get("_trial_number") or -1)
for r, t in cand:
if int(r.get("_trial_number") or -2) == top_n:
return t
return cand[0][1]
def ensure_optuna_gate_env_defaults(db: Any = None) -> None:
"""신규 Optuna 게이트 키가 DB에 없으면 env_config_ext 에만 UPSERT (전체 스냅샷 X)."""
defaults = {
"PARAM_SEARCH_OPTUNA_MIN_WIN_RATE": str(OPTUNA_SEARCH_MIN_WIN_RATE_DEFAULT),
"PARAM_SEARCH_OPTUNA_MIN_PF": str(OPTUNA_SEARCH_MIN_PF_DEFAULT),
"PARAM_SEARCH_OPTUNA_MIN_TRADES": str(OPTUNA_SEARCH_MIN_TRADES_DEFAULT),
"OPTUNA_MIN_TRADES_PER_DAY": str(OPTUNA_MIN_TRADES_PER_DAY_DEFAULT),
"OPTUNA_TAIL_MIN_TRADES": str(OPTUNA_TAIL_MIN_TRADES_DEFAULT),
"OPTUNA_SCORE_MDD_ADD": str(int(OPTUNA_SCORE_MDD_ADD_DEFAULT)),
"OPTUNA_SCORE_MDD_FLOOR": str(int(OPTUNA_SCORE_MDD_FLOOR_DEFAULT)),
"OPTUNA_SCORE_TRADE_SOFT_DAYS": str(OPTUNA_SCORE_TRADE_SOFT_DAYS_DEFAULT),
"PARAM_SEARCH_OPTUNA_REPORT_MIN_WIN_RATE": str(OPTUNA_REPORT_MIN_WIN_RATE_DEFAULT),
"PARAM_SEARCH_OPTUNA_REPORT_MIN_PF": str(OPTUNA_REPORT_MIN_PF_DEFAULT),
"PARAM_SEARCH_OPTUNA_BRIEFING_AI": "1",
# 일별 안정성 티어 (results_stable)
"PARAM_SEARCH_OPTUNA_STABLE_MAX_LOSING_DAYS": str(OPTUNA_STABLE_MAX_LOSING_DAYS_DEFAULT),
"PARAM_SEARCH_OPTUNA_STABLE_MIN_WORST_DAY_PNL": str(OPTUNA_STABLE_MIN_WORST_DAY_PNL_DEFAULT),
"PARAM_SEARCH_OPTUNA_STABLE_LAMBDA": str(OPTUNA_STABLE_LAMBDA_DEFAULT),
"PARAM_SEARCH_OPTUNA_STABLE_MIN_ACTIVE_DAYS": str(OPTUNA_STABLE_MIN_ACTIVE_DAYS_DEFAULT),
# Optuna apply 시 다단트레일 추천 → 전략별 *_DAILY_PROFIT_* (탐색 축 아님)
"OPTUNA_DAILY_TRAIL_APPLY_ON_BEST": "true",
"OPTUNA_DAILY_TRAIL_ARM_FRAC": "0.60",
"OPTUNA_DAILY_TRAIL_BEST_FRAC": "0.70",
"OPTUNA_DAILY_TRAIL_ARM_STEP": "5000",
"OPTUNA_DAILY_TRAIL_MIN_ARM": "10000",
"OPTUNA_DAILY_TRAIL_TIER_DROPS": "40,30,20",
"OPTUNA_POST_TOP_N": "10",
"OPTUNA_POST_INCLUDE_MODE": "true",
"OPTUNA_POST_INCLUDE_LIVE": "true",
"OPTUNA_POST_INCLUDE_STABLE": "true",
"OPTUNA_POST_RUN_OB_WHIPSAW": "false",
"OPTUNA_POST_FORCE_OB_WHIPSAW": "false",
"OPTUNA_TPE_INCLUDE_ORDERBOOK": "true",
"OPTUNA_TPE_INCLUDE_WHIPSAW": "true",
"OPTUNA_OB_RECOMMEND_TRIALS": "500",
"OPTUNA_OB_AXIS_TRIALS": "500",
"OPTUNA_OB_COMBO_TRIALS_SINGLE": "150",
"OPTUNA_OB_COMBO_TRIALS_DOUBLE": "200",
"OPTUNA_OB_COMBO_TRIALS_TRIPLE": "250",
"OPTUNA_WHIPSAW_PER_COMBO": "true",
"OPTUNA_WHIPSAW_PER_COMBO_TRIALS": "100",
"OPTUNA_WHIPSAW_PER_COMBO_MIN_TRADES": "3",
"OPTUNA_OB_ENTRY_SPREAD_MIN": "0.1",
"OPTUNA_OB_ENTRY_SPREAD_MAX": "8.0",
"OPTUNA_OB_ENTRY_RATIO_MIN": "0.05",
"OPTUNA_OB_ENTRY_RATIO_MAX": "1.5",
"OPTUNA_OB_ENTRY_ASK_MULT_MIN": "1.0",
"OPTUNA_OB_ENTRY_ASK_MULT_MAX": "80.0",
"OPTUNA_OB_LOOKBACK_MIN": "30",
"OPTUNA_OB_EXIT_HOLD_MIN": "1",
"OPTUNA_OB_EXIT_HOLD_MAX": "5",
"OPTUNA_OB_EXIT_RATIO_MIN": "0.2",
"OPTUNA_OB_EXIT_RATIO_MAX": "0.8",
"OPTUNA_OB_EXIT_PROFIT_MIN": "0.003",
"OPTUNA_OB_EXIT_PROFIT_MAX": "0.02",
"OPTUNA_OB_EXIT_MA_MIN": "3",
"OPTUNA_OB_EXIT_MA_MAX": "10",
"OPTUNA_OB_STOP_HOLD_MIN": "1",
"OPTUNA_OB_STOP_HOLD_MAX": "5",
"OPTUNA_OB_STOP_RATIO_MIN": "0.2",
"OPTUNA_OB_STOP_RATIO_MAX": "0.8",
"OPTUNA_OB_STOP_LOSS_MIN": "0.001",
"OPTUNA_OB_STOP_LOSS_MAX": "0.02",
"OPTUNA_OB_STOP_MA_MIN": "3",
"OPTUNA_OB_STOP_MA_MAX": "10",
"OPTUNA_WHIPSAW_RECOMMEND_TRIALS": "500",
"OPTUNA_OB_HORIZON_MIN": "6",
"OPTUNA_WHIPSAW_LOOKBACK_DAYS": "7",
"OPTUNA_WHIPSAW_TICK_LOOKBACK_SEC": "180",
}
try:
from datetime import datetime
from database import TradeDB
except ImportError:
return
owned = False
if db is None:
db = TradeDB()
owned = True
try:
snap = db.get_merged_env_snapshot() or {}
patch = {}
for k, v in defaults.items():
cur = snap.get(k)
if cur is None or str(cur).strip() == "":
patch[k] = v
if not patch:
return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
n = db._persist_env_config_overflow(patch, now)
try:
from kis_trader.utils.env import invalidate_merged_env_cache
invalidate_merged_env_cache()
except Exception:
pass
logger.info(
"📌 Optuna 게이트 기본값 DB(ext) 반영 %d키: %s",
n, sorted(patch.keys()),
)
except Exception as exc:
logger.warning("⚠️ Optuna 게이트 기본값 DB 반영 실패: %s", exc)
finally:
if owned:
try:
db.conn.close()
except Exception:
pass
def mariadb_creds() -> dict:
"""TradeDB(database.py) 와 동일 우선순위 — env > 기본 141."""
return {
"host": os.environ.get("DB_HOST", "192.168.0.141"),
"port": int(os.environ.get("DB_PORT", "3306")),
"user": os.environ.get("DB_USER", "jae"),
"password": os.environ.get("DB_PASS", "1234"),
}
def resolve_optuna_db_name() -> str:
"""
Optuna storage DB — 기본 kis_optuna (매매 kis_quant_db 와 분리).
env OPTUNA_DB_NAME 로 오버라이드 가능.
"""
raw = get_env_from_db("OPTUNA_DB_NAME", "")
if raw and str(raw).strip() not in ("", "None"):
return str(raw).strip()
env = os.environ.get("OPTUNA_DB_NAME", "")
if env and str(env).strip():
return str(env).strip()
return DEFAULT_OPTUNA_DB_NAME
def build_mariadb_storage_url(db_name: Optional[str] = None) -> str:
"""mysql+pymysql://…@141/optuna 형식 storage URL."""
creds = mariadb_creds()
name = (db_name or resolve_optuna_db_name()).strip()
user = quote_plus(creds["user"])
passwd = quote_plus(creds["password"])
return (
f"mysql+pymysql://{user}:{passwd}@{creds['host']}:{creds['port']}/{name}"
f"?charset=utf8mb4"
)
def ensure_optuna_database(db_name: Optional[str] = None) -> str:
"""
MariaDB 141 — kis_optuna 존재 확인 (없으면 CREATE 시도).
"""
name = (db_name or resolve_optuna_db_name()).strip()
creds = mariadb_creds()
try:
import pymysql
except ImportError as exc:
raise ImportError(
"Optuna MariaDB storage 는 pymysql 필요: pip install PyMySQL"
) from exc
# DB 존재 여부만 확인 (이미 있으면 CREATE 생략)
conn = pymysql.connect(
host=creds["host"],
port=creds["port"],
user=creds["user"],
password=creds["password"],
charset="utf8mb4",
autocommit=True,
connect_timeout=10,
)
try:
with conn.cursor() as cur:
cur.execute("SHOW DATABASES LIKE %s", (name,))
exists = cur.fetchone() is not None
if not exists:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
logger.info(
"📦 Optuna DB 생성: %s@%s:%s/%s",
creds["user"], creds["host"], creds["port"], name,
)
else:
logger.debug(
"📦 Optuna storage DB: %s@%s:%s/%s",
creds["user"], creds["host"], creds["port"], name,
)
except Exception as exc:
logger.error("❌ Optuna DB '%s' 접속/확인 실패: %s", name, exc)
raise
finally:
conn.close()
return name
def resolve_optuna_storage_url(cli_override: Optional[str] = None) -> str:
"""
Storage URL 우선순위:
1) CLI --storage
2) OPTUNA_STORAGE_URL (DB/env)
3) MariaDB 141 / kis_optuna (TradeDB 동일 계정)
"""
if cli_override and str(cli_override).strip():
return str(cli_override).strip()
from_db = get_env_from_db("OPTUNA_STORAGE_URL", "")
if from_db and str(from_db).strip() not in ("", "None"):
return str(from_db).strip()
db_name = ensure_optuna_database()
return build_mariadb_storage_url(db_name)
def resolve_study_name(
*,
strategy: str,
mode: str,
start: str,
end: str,
cli_override: Optional[str] = None,
extra: Optional[str] = None,
) -> str:
"""Study 이름 — 전략·기간·모드 포함. extra=꼬리 진입모드 등(스터디 분리)."""
if cli_override and str(cli_override).strip():
return str(cli_override).strip()
env_key = f"OPTUNA_{strategy.upper()}_STUDY_NAME"
from_db = get_env_from_db(env_key, "")
if from_db and str(from_db).strip() not in ("", "None"):
return str(from_db).strip()
legacy = get_env_from_db("OPTUNA_TAIL_STUDY_NAME", "")
if strategy == "tail" and legacy and str(legacy).strip() not in ("", "None"):
return str(legacy).strip()
extra_s = str(extra or "").strip().lower()
extra_s = f"_{extra_s}" if extra_s else ""
return f"{strategy}_{mode}{extra_s}_{start}_{end}"
def optuna_run_lock_name(strategy: str) -> str:
return f"{strategy}_param_search_optuna"
def release_shared_tick_store(ctx: Any, *, log: Optional[logging.Logger] = None) -> None:
"""
Optuna ctx.shared_tick_store 해제.
주의: ticks_by_code 가 공유메모리 뷰인 경우, unlink 이후 접근하면
SIGBUS/강제종료(트레이스백 없음) 난다. 최빈(mode_combo) 실측·JSON 저장이
끝난 뒤에만 호출할 것. optimize() 직후 즉시 unlink 금지.
"""
lg = log or logger
store = getattr(ctx, "shared_tick_store", None)
if store is None:
return
try:
store.unlink()
except Exception as exc:
lg.warning("⚠️ shared_tick_store unlink 실패: %s", exc)
try:
ctx.shared_tick_store = None
except Exception:
pass
def announce_optuna_json_path(
out_path: str,
*,
strategy: str = "",
mode: str = "",
note: str = "",
log: Optional[logging.Logger] = None,
) -> str:
"""
결과 JSON 절대경로를 터미널·로그에 눈에 띄게 고지.
또한 logs/optuna_<strategy>_<mode>_latest.jsonpath 에 기록 (없으면 strategy만).
note 에 '최종' 이 포함되면 이전장/앞장 브리핑(.briefing.md) 생성.
"""
abs_path = os.path.abspath(str(out_path or "").strip())
lg = log or logger
tag = note.strip() or "결과 JSON"
line = f"📁 [{tag}] {abs_path}"
# logger + print 이중 — nohup 로그·터미널 모두에서 바로 보이게
lg.info("%s", line)
print(line, flush=True)
print(f"OPTUNA_RESULT_JSON={abs_path}", flush=True)
try:
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
logs_dir = os.path.join(root, "logs")
os.makedirs(logs_dir, exist_ok=True)
s = (strategy or "optuna").strip().lower() or "optuna"
m = (mode or "run").strip().lower() or "run"
for name in (
f"optuna_{s}_{m}_latest.jsonpath",
f"optuna_{s}_latest.jsonpath",
"optuna_latest.jsonpath",
):
with open(os.path.join(logs_dir, name), "w", encoding="utf-8") as f:
f.write(abs_path + "\n")
except OSError as exc:
lg.warning("⚠️ jsonpath 사이드카 기록 실패: %s", exc)
# 최종 JSON: 과적합·임계값 분포 진단 부착 후 브리핑
note_l = (note or "").strip()
if "최종" in note_l and abs_path and os.path.isfile(abs_path):
try:
import json as _json
with open(abs_path, "r", encoding="utf-8") as f:
_data = _json.load(f)
attach_optuna_overfit_diagnostics(_data)
with open(abs_path, "w", encoding="utf-8") as f:
_json.dump(_data, f, indent=2, ensure_ascii=False)
diag = _data.get("overfit_diagnostics") or {}
lg.info(
"📊 과적합위험 %s%% · 적용가능도 %s%% · 판정=%s",
diag.get("overfit_risk_pct"),
diag.get("apply_readiness_pct"),
diag.get("verdict"),
)
print(
f"OPTUNA_OVERFIT_RISK_PCT={diag.get('overfit_risk_pct')} "
f"APPLY_READINESS_PCT={diag.get('apply_readiness_pct')} "
f"VERDICT={diag.get('verdict')}",
flush=True,
)
except Exception as exc:
lg.warning("⚠️ overfit_diagnostics JSON 부착 실패: %s", exc)
try:
from kis_trader.backtest.optuna_briefing import write_briefing_for_json
write_briefing_for_json(abs_path, log=lg)
except Exception as exc:
lg.warning("⚠️ Optuna 브리핑 실패: %s", exc)
return abs_path