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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user