feat(옵투나): score·min_trades·후처리 재탐색 및 웹 job 개선

PnL/(MDD+ADD) score·legacy 정렬·거래일×min_trades 게이트를 공통화한다.
후처리 ob_modes·study store·4전략 TPE 순차 스크립트와 문서를 갱신한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-08-28 16:46:27 +09:00
parent a0fe66bc11
commit 1387fbdf47
10 changed files with 583 additions and 136 deletions

View File

@@ -25,8 +25,21 @@ OPTUNA_STRATEGIES = ("tail", "momentum", "us_momentum", "breakout", "scalp", "da
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 분산으로 “합산만 큰” 후보를 걸러낸다.
@@ -45,6 +58,260 @@ def optuna_search_gate_defaults() -> Tuple[float, float, int]:
)
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 과 별개."""
if not isinstance(out_data, dict):
return
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()
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)
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()
@@ -60,7 +327,7 @@ def optuna_report_gate_defaults() -> Tuple[float, float, int]:
def _sort_optuna_rows(rows: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
sb = (sort_by or "pnl").strip().lower()
sb = normalize_optuna_sort_by(sort_by, web=False)
out = list(rows)
def _f(r: Dict[str, Any], k: str) -> float:
@@ -71,9 +338,25 @@ def _sort_optuna_rows(rows: List[Dict[str, Any]], sort_by: str) -> List[Dict[str
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 in ("stability", "stable"):
elif sb == "stability":
# 일평균 λ·표준편차(stability_score) 우선 · 최악일 · 합산 PnL
out.sort(
key=lambda r: (
@@ -917,6 +1200,11 @@ def ensure_optuna_gate_env_defaults(db: Any = None) -> None:
"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",