룰 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>
1676 lines
61 KiB
Python
1676 lines
61 KiB
Python
#!/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
|
||
|