한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
1206 lines
45 KiB
Python
1206 lines
45 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Optuna 완료 후 — gated TopN + mode_combo + 실매(참고) 호가/휩쏘/트레일 후처리.
|
|
|
|
실매 엔진·봉 정합은 건드리지 않는다. JSON·웹 표시 + apply 시 합의 트레일만.
|
|
실매 행은 과적합%에 넣지 않는다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import statistics
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from kis_trader.utils.env import get_env_bool, get_env_int
|
|
|
|
logger = logging.getLogger("optuna_postprocess_topn")
|
|
|
|
EvalFn = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
|
|
|
|
|
|
def _krw_int(v: Any) -> int:
|
|
try:
|
|
x = float(v)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
if x != x or abs(x) >= 1e15:
|
|
return 0
|
|
return int(x)
|
|
|
|
|
|
def resolve_post_top_n(default: int = 5) -> int:
|
|
return max(1, int(get_env_int("OPTUNA_POST_TOP_N", int(default))))
|
|
|
|
|
|
def _include_mode() -> bool:
|
|
return bool(get_env_bool("OPTUNA_POST_INCLUDE_MODE", True))
|
|
|
|
|
|
def _include_live() -> bool:
|
|
return bool(get_env_bool("OPTUNA_POST_INCLUDE_LIVE", True))
|
|
|
|
|
|
def _include_stable() -> bool:
|
|
return bool(get_env_bool("OPTUNA_POST_INCLUDE_STABLE", True))
|
|
|
|
|
|
def _run_ob_whipsaw_full() -> bool:
|
|
"""Optuna 최종 저장 기본 ON. 웹 light 경로는 호출측에서 False."""
|
|
return bool(get_env_bool("OPTUNA_POST_RUN_OB_WHIPSAW", True))
|
|
|
|
|
|
def _pop_trades(result: Optional[Dict[str, Any]]) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
if not isinstance(result, dict):
|
|
return result, []
|
|
fills = result.pop("_trades", None)
|
|
if not isinstance(fills, list):
|
|
fills = []
|
|
return result, fills
|
|
|
|
|
|
def _slim_stats(st: Any) -> Dict[str, Any]:
|
|
if not isinstance(st, dict):
|
|
return {}
|
|
out = dict(st)
|
|
if "pnl" in out:
|
|
out["pnl"] = _krw_int(out.get("pnl"))
|
|
if "pnl_diff" in out:
|
|
out["pnl_diff"] = _krw_int(out.get("pnl_diff"))
|
|
return out
|
|
|
|
|
|
def _slim_axis(ax: Any) -> Dict[str, Any]:
|
|
if not isinstance(ax, dict):
|
|
return {"ok": False, "reason": "none", "params": {}, "recommended_stats": {}}
|
|
rs = _slim_stats(ax.get("recommended_stats"))
|
|
os_ = _slim_stats(ax.get("orig_stats"))
|
|
return {
|
|
"ok": bool(ax.get("ok")),
|
|
"reason": ax.get("reason") or "",
|
|
"params": dict(ax.get("params") or {}),
|
|
"recommended_stats": rs,
|
|
"orig_stats": os_,
|
|
"n_trials": ax.get("n_trials"),
|
|
"combo_id": ax.get("combo_id") or "",
|
|
"mask": dict(ax.get("mask") or {}),
|
|
}
|
|
|
|
|
|
def _slim_ob(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
if not isinstance(rec, dict):
|
|
return {"ok": False, "reason": "none"}
|
|
combos_in = rec.get("combos") if isinstance(rec.get("combos"), dict) else {}
|
|
combos_out = {str(k): _slim_axis(v) for k, v in combos_in.items()}
|
|
out = {
|
|
"ok": bool(rec.get("ok")),
|
|
"reason": rec.get("reason") or "",
|
|
"trade_count": int(rec.get("trade_count") or 0),
|
|
"params": dict(rec.get("params") or {}),
|
|
"orig_stats": _slim_stats(rec.get("orig_stats")),
|
|
"recommended_stats": _slim_stats(rec.get("recommended_stats")),
|
|
"entry": _slim_axis(rec.get("entry")),
|
|
"exit": _slim_axis(rec.get("exit")),
|
|
"stop": _slim_axis(rec.get("stop")),
|
|
"combos": combos_out,
|
|
}
|
|
return out
|
|
|
|
|
|
def _slim_ws(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
return _slim_ob(rec)
|
|
|
|
|
|
def _slim_trail(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
if not isinstance(rec, dict):
|
|
return {"ok": False, "reason": "none", "arm_krw": 0, "anchor_krw": 0, "tiers": ""}
|
|
return {
|
|
"ok": bool(rec.get("ok")),
|
|
"reason": rec.get("reason") or "",
|
|
"prefix": rec.get("prefix"),
|
|
"arm_krw": _krw_int(rec.get("arm_krw")),
|
|
"anchor_krw": _krw_int(rec.get("anchor_krw")),
|
|
"tiers": rec.get("tiers") or "",
|
|
"mode": rec.get("mode") or "trailing",
|
|
"enabled": bool(rec.get("enabled")),
|
|
}
|
|
|
|
|
|
def _combo_from_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
p = row.get("params") or row.get("merged_params") or {}
|
|
return dict(p) if isinstance(p, dict) else {}
|
|
|
|
|
|
def _replay_fills(evaluate_fn: Optional[EvalFn], combo: Dict[str, Any], lg: logging.Logger) -> List[Dict[str, Any]]:
|
|
if evaluate_fn is None or not combo:
|
|
return []
|
|
try:
|
|
raw = evaluate_fn(dict(combo))
|
|
_res, fills = _pop_trades(raw)
|
|
return fills
|
|
except Exception as exc:
|
|
lg.warning("⚠️ TopN 백테 재실행 실패: %s", exc)
|
|
return []
|
|
|
|
|
|
def _trail_for_pnl(strategy: str, pnl: Any) -> Dict[str, Any]:
|
|
from kis_trader.backtest.optuna_daily_trail_recommend import recommend_daily_trail_tiers
|
|
|
|
v = float(pnl or 0)
|
|
return recommend_daily_trail_tiers(
|
|
top_pnls=[v] if v > 0 else [],
|
|
mode_pnl=v if v > 0 else None,
|
|
best_pnl=v if v > 0 else None,
|
|
strategy=strategy,
|
|
)
|
|
|
|
|
|
def _ob_for_anchor(
|
|
*,
|
|
strategy: str,
|
|
out_data: Dict[str, Any],
|
|
fills: Optional[List[Dict[str, Any]]],
|
|
live: bool,
|
|
lg: logging.Logger,
|
|
n_trials: int = 0,
|
|
) -> Dict[str, Any]:
|
|
from kis_trader.backtest.optuna_orderbook_recommend import recommend_orderbook_parameters
|
|
|
|
hist = (
|
|
out_data.get("universe_history_source")
|
|
or out_data.get("_universe_history_source")
|
|
or out_data.get("history_source")
|
|
)
|
|
ob_src = out_data.get("ob_source") or out_data.get("orderbook_source")
|
|
try:
|
|
rec = recommend_orderbook_parameters(
|
|
strategy=strategy,
|
|
n_trials=int(n_trials or 0),
|
|
history_source=hist,
|
|
ob_source=ob_src,
|
|
log=lg,
|
|
raw_fills=None if live else (fills or []),
|
|
date_from=str(out_data.get("start") or "") or None,
|
|
date_to=str(out_data.get("end") or "") or None,
|
|
)
|
|
except Exception as exc:
|
|
return {"ok": False, "reason": str(exc)}
|
|
return _slim_ob(rec)
|
|
|
|
|
|
def _ws_for_anchor(
|
|
*,
|
|
strategy: str,
|
|
out_data: Dict[str, Any],
|
|
fills: Optional[List[Dict[str, Any]]],
|
|
live: bool,
|
|
lg: logging.Logger,
|
|
) -> Dict[str, Any]:
|
|
from kis_trader.backtest.optuna_whipsaw_recommend import recommend_whipsaw_parameters
|
|
|
|
strat_u = str(strategy or "").strip().upper()
|
|
try:
|
|
rec = recommend_whipsaw_parameters(
|
|
strategy=strat_u,
|
|
n_trials=0,
|
|
log=lg,
|
|
raw_fills=None if live else (fills or []),
|
|
date_from=str(out_data.get("start") or "") or None,
|
|
date_to=str(out_data.get("end") or "") or None,
|
|
market="US" if "US" in strat_u else "KR",
|
|
)
|
|
except Exception as exc:
|
|
return {"ok": False, "reason": str(exc)}
|
|
return _slim_ws(rec)
|
|
|
|
|
|
def _median_num(vals: List[Any]) -> Optional[float]:
|
|
nums: List[float] = []
|
|
for v in vals:
|
|
try:
|
|
nums.append(float(v))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not nums:
|
|
return None
|
|
return float(statistics.median(nums))
|
|
|
|
|
|
def _mode_val(vals: List[Any]) -> Any:
|
|
clean = [v for v in vals if v is not None]
|
|
if not clean:
|
|
return None
|
|
try:
|
|
return statistics.mode(clean)
|
|
except statistics.StatisticsError:
|
|
return clean[0]
|
|
|
|
|
|
def _consensus_axis(pool: List[Dict[str, Any]], axis: str, keys: Tuple[str, ...], bool_keys: Tuple[str, ...], int_keys: Tuple[str, ...]) -> Dict[str, Any]:
|
|
params_list: List[Dict[str, Any]] = []
|
|
stats_pnls: List[int] = []
|
|
stats_cnt: List[int] = []
|
|
stats_wrs: List[float] = []
|
|
for a in pool:
|
|
ob = a.get("orderbook") or {}
|
|
nested = ob.get(axis) if isinstance(ob.get(axis), dict) else {}
|
|
if nested.get("ok"):
|
|
params_list.append(dict(nested.get("params") or {}))
|
|
rs = nested.get("recommended_stats") or {}
|
|
stats_pnls.append(_krw_int(rs.get("pnl")))
|
|
stats_cnt.append(int(rs.get("count") or 0))
|
|
try:
|
|
stats_wrs.append(float(rs.get("win_rate")))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
elif axis == "entry" and ob.get("ok") and not nested:
|
|
# 구 JSON: 합쳐진 params
|
|
p = dict(ob.get("params") or {})
|
|
if p.get("orderbook_max_spread_pct") is not None:
|
|
params_list.append(p)
|
|
cons: Dict[str, Any] = {}
|
|
if params_list:
|
|
for k in keys:
|
|
vs = [p.get(k) for p in params_list if k in p]
|
|
if not vs:
|
|
continue
|
|
if k in bool_keys:
|
|
cons[k] = bool(_mode_val([bool(x) for x in vs]))
|
|
elif k in int_keys:
|
|
m = _median_num(vs)
|
|
cons[k] = int(m) if m is not None else vs[0]
|
|
else:
|
|
m = _median_num(vs)
|
|
cons[k] = round(m, 4) if m is not None else vs[0]
|
|
rec_st: Dict[str, Any] = {}
|
|
if stats_pnls:
|
|
rec_st = {
|
|
"count": int(statistics.median(stats_cnt)) if stats_cnt else 0,
|
|
"pnl": _krw_int(statistics.median(stats_pnls)),
|
|
}
|
|
if stats_wrs:
|
|
rec_st["win_rate"] = round(float(statistics.median(stats_wrs)), 1)
|
|
return {"ok": bool(cons), "params": cons, "n": len(params_list), "recommended_stats": rec_st}
|
|
|
|
|
|
_COMBO_LABELS: Dict[str, str] = {
|
|
"base": "000 타점만",
|
|
"e": "100 진입만",
|
|
"x": "010 익절만",
|
|
"s": "001 손절만",
|
|
"ex": "110 진입+익절",
|
|
"es": "101 진입+손절",
|
|
"xs": "011 익절+손절",
|
|
"exs": "111 전부",
|
|
}
|
|
# 호가 8방 = 진입×익절×손절 (2³). 휩쏘는 8방 밖.
|
|
_COMBO_IDS = ("base", "e", "x", "s", "ex", "es", "xs", "exs")
|
|
_COMBO_MASK = {
|
|
"base": (False, False, False),
|
|
"e": (True, False, False),
|
|
"x": (False, True, False),
|
|
"s": (False, False, True),
|
|
"ex": (True, True, False),
|
|
"es": (True, False, True),
|
|
"xs": (False, True, True),
|
|
"exs": (True, True, True),
|
|
}
|
|
|
|
|
|
def _median_params(params_list: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
if not params_list:
|
|
return {}
|
|
all_keys: set = set()
|
|
for p in params_list:
|
|
all_keys.update(p.keys())
|
|
bool_keys = {
|
|
"orderbook_filter_enabled", "exit_ob_enabled", "stop_ob_enabled",
|
|
"whipsaw_filter_enabled", "entry_on", "exit_on", "stop_on",
|
|
}
|
|
int_keys = {
|
|
"exit_ob_min_hold_bars", "exit_ob_ma_window",
|
|
"stop_ob_min_hold_bars", "stop_ob_ma_window",
|
|
"whipsaw_subbar_sec", "whipsaw_lookback_sec",
|
|
}
|
|
cons: Dict[str, Any] = {}
|
|
for k in all_keys:
|
|
vs = [p[k] for p in params_list if k in p and p[k] is not None]
|
|
if not vs:
|
|
continue
|
|
if k in bool_keys or all(isinstance(v, bool) for v in vs):
|
|
cons[k] = sum(1 for v in vs if v) >= (len(vs) / 2.0)
|
|
elif k in int_keys or all(isinstance(v, int) and not isinstance(v, bool) for v in vs):
|
|
m = _median_num(vs)
|
|
cons[k] = int(m) if m is not None else vs[0]
|
|
else:
|
|
m = _median_num(vs)
|
|
cons[k] = round(m, 4) if m is not None else vs[0]
|
|
return cons
|
|
|
|
|
|
def _anchor_combos_map(ob: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|
"""앵커 orderbook → combo_id dict. 구 JSON은 e/x/s 단독만 복원."""
|
|
if not isinstance(ob, dict):
|
|
return {}
|
|
raw = ob.get("combos")
|
|
if isinstance(raw, dict) and raw:
|
|
return {str(k): dict(v) for k, v in raw.items() if isinstance(v, dict)}
|
|
out: Dict[str, Dict[str, Any]] = {}
|
|
for cid, nest_key in (("e", "entry"), ("x", "exit"), ("s", "stop")):
|
|
nested = ob.get(nest_key)
|
|
if isinstance(nested, dict) and nested.get("ok"):
|
|
out[cid] = nested
|
|
if not out and ob.get("ok") and ob.get("params"):
|
|
out["e"] = {"ok": True, "params": dict(ob.get("params") or {}), "recommended_stats": ob.get("recommended_stats") or {}}
|
|
return out
|
|
|
|
|
|
def _combo_median_stats(recs: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
stats_pnls: List[int] = []
|
|
stats_cnt: List[int] = []
|
|
stats_wrs: List[float] = []
|
|
for r in recs:
|
|
rs = r.get("recommended_stats") or {}
|
|
stats_pnls.append(_krw_int(rs.get("pnl")))
|
|
stats_cnt.append(int(rs.get("count") or 0))
|
|
try:
|
|
stats_wrs.append(float(rs.get("win_rate")))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if not stats_pnls:
|
|
return {}
|
|
out: Dict[str, Any] = {
|
|
"count": int(statistics.median(stats_cnt)) if stats_cnt else 0,
|
|
"pnl": _krw_int(statistics.median(stats_pnls)),
|
|
}
|
|
if stats_wrs:
|
|
out["win_rate"] = round(float(statistics.median(stats_wrs)), 1)
|
|
return out
|
|
|
|
|
|
def _axis_slice_from_combo(
|
|
combo_params: Dict[str, Any],
|
|
*,
|
|
use: bool,
|
|
axis: str,
|
|
n: int,
|
|
rec_st: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
if not use:
|
|
return {"ok": False, "params": {}, "recommended_stats": {}, "n": 0}
|
|
p = dict(combo_params or {})
|
|
if axis == "entry":
|
|
keys = ("orderbook_filter_enabled", "orderbook_max_spread_pct", "orderbook_min_bid_ask_ratio", "orderbook_entry_ask_max_mult")
|
|
elif axis == "exit":
|
|
keys = ("exit_ob_enabled", "exit_ob_min_hold_bars", "exit_ob_min_profit_pct", "exit_ob_ratio_min", "exit_ob_ma_window")
|
|
else:
|
|
keys = ("stop_ob_enabled", "stop_ob_min_hold_bars", "stop_ob_min_loss_pct", "stop_ob_ratio_min", "stop_ob_ma_window")
|
|
sub = {k: p[k] for k in keys if k in p}
|
|
ok = bool(sub) and (axis != "entry" or sub.get("orderbook_max_spread_pct") is not None)
|
|
return {"ok": ok, "params": sub, "recommended_stats": dict(rec_st), "n": n}
|
|
|
|
|
|
def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dict[str, Any]:
|
|
"""gated+mode. live·stable 제외. 호가=8방 중 median PnL 최고 방 + median 파라미터."""
|
|
pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")]
|
|
by_combo: Dict[str, List[Dict[str, Any]]] = {cid: [] for cid in _COMBO_IDS}
|
|
for a in pool:
|
|
combos = _anchor_combos_map(a.get("orderbook") or {})
|
|
for cid in _COMBO_IDS:
|
|
c = combos.get(cid)
|
|
if isinstance(c, dict) and c.get("ok"):
|
|
by_combo[cid].append(c)
|
|
|
|
best_cid: Optional[str] = None
|
|
best_med_pnl = -10**15
|
|
for cid in _COMBO_IDS:
|
|
if cid == "base":
|
|
continue
|
|
recs = by_combo.get(cid) or []
|
|
if not recs:
|
|
continue
|
|
pnls = [_krw_int((r.get("recommended_stats") or {}).get("pnl")) for r in recs]
|
|
med = float(statistics.median(pnls)) if pnls else -10**15
|
|
if med > best_med_pnl:
|
|
best_med_pnl = med
|
|
best_cid = cid
|
|
|
|
combo_cons: Dict[str, Any] = {"ok": False, "combo_id": "", "label": "", "params": {}, "n": 0}
|
|
entry: Dict[str, Any] = {"ok": False, "params": {}, "n": 0}
|
|
exit_c: Dict[str, Any] = {"ok": False, "params": {}, "n": 0}
|
|
stop_c: Dict[str, Any] = {"ok": False, "params": {}, "n": 0}
|
|
ob_merged: Dict[str, Any] = {}
|
|
note = "합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외."
|
|
|
|
if best_cid:
|
|
recs = by_combo[best_cid]
|
|
params = _median_params([dict(r.get("params") or {}) for r in recs])
|
|
use_e, use_x, use_s = _COMBO_MASK[best_cid]
|
|
params["orderbook_filter_enabled"] = bool(use_e)
|
|
params["exit_ob_enabled"] = bool(use_x)
|
|
params["stop_ob_enabled"] = bool(use_s)
|
|
rec_st = _combo_median_stats(recs)
|
|
combo_cons = {
|
|
"ok": True,
|
|
"combo_id": best_cid,
|
|
"label": _COMBO_LABELS.get(best_cid, best_cid),
|
|
"mask": {"entry": bool(use_e), "exit": bool(use_x), "stop": bool(use_s)},
|
|
"params": params,
|
|
"recommended_stats": rec_st,
|
|
"n": len(recs),
|
|
}
|
|
ob_merged = dict(params)
|
|
entry = _axis_slice_from_combo(params, use=use_e, axis="entry", n=len(recs), rec_st=rec_st)
|
|
exit_c = _axis_slice_from_combo(params, use=use_x, axis="exit", n=len(recs), rec_st=rec_st)
|
|
stop_c = _axis_slice_from_combo(params, use=use_s, axis="stop", n=len(recs), rec_st=rec_st)
|
|
elif pool:
|
|
# 구 JSON(8방 없음): 축별 median 폴백 — apply 구스크립트 호환
|
|
entry = _consensus_axis(
|
|
pool, "entry",
|
|
("orderbook_filter_enabled", "orderbook_max_spread_pct", "orderbook_min_bid_ask_ratio", "orderbook_entry_ask_max_mult"),
|
|
("orderbook_filter_enabled",),
|
|
(),
|
|
)
|
|
exit_c = _consensus_axis(
|
|
pool, "exit",
|
|
("exit_ob_enabled", "exit_ob_min_hold_bars", "exit_ob_min_profit_pct", "exit_ob_ratio_min", "exit_ob_ma_window"),
|
|
("exit_ob_enabled",),
|
|
("exit_ob_min_hold_bars", "exit_ob_ma_window"),
|
|
)
|
|
stop_c = _consensus_axis(
|
|
pool, "stop",
|
|
("stop_ob_enabled", "stop_ob_min_hold_bars", "stop_ob_min_loss_pct", "stop_ob_ratio_min", "stop_ob_ma_window"),
|
|
("stop_ob_enabled",),
|
|
("stop_ob_min_hold_bars", "stop_ob_ma_window"),
|
|
)
|
|
ob_merged = dict(entry.get("params") or {})
|
|
ob_merged.update(exit_c.get("params") or {})
|
|
ob_merged.update(stop_c.get("params") or {})
|
|
note = "합의=구JSON 축분리 median(8방 없음). 후처리 재실행 권장."
|
|
|
|
ws_params_list = [
|
|
dict((a.get("whipsaw") or {}).get("params") or {})
|
|
for a in pool if (a.get("whipsaw") or {}).get("ok")
|
|
]
|
|
trail_ok = [a.get("trail") or {} for a in pool if (a.get("trail") or {}).get("ok")]
|
|
|
|
ws_cons: Dict[str, Any] = {}
|
|
if ws_params_list:
|
|
ws_cons = _median_params(ws_params_list)
|
|
if ws_cons:
|
|
ws_cons["whipsaw_filter_enabled"] = True
|
|
|
|
trail_cons: Dict[str, Any] = {"ok": False}
|
|
if trail_ok:
|
|
arms = [_krw_int(t.get("arm_krw")) for t in trail_ok]
|
|
med_arm = int(statistics.median(arms)) if arms else 0
|
|
tiers_vals = [str(t.get("tiers") or "") for t in trail_ok if t.get("tiers")]
|
|
tiers = _mode_val(tiers_vals) if tiers_vals else ""
|
|
prefix = str(trail_ok[0].get("prefix") or "")
|
|
trail_cons = {
|
|
"ok": med_arm > 0 and bool(tiers),
|
|
"prefix": prefix,
|
|
"arm_krw": med_arm,
|
|
"anchor_krw": _krw_int(statistics.median([_krw_int(t.get("anchor_krw")) for t in trail_ok])),
|
|
"tiers": tiers or "",
|
|
"mode": "trailing",
|
|
"enabled": True,
|
|
"note": "gated+mode 합의(median/최빈). 실매 행 제외. 타점 적용과 별도 버튼.",
|
|
}
|
|
|
|
return {
|
|
"combo": combo_cons,
|
|
"entry": entry,
|
|
"exit": exit_c,
|
|
"stop": stop_c,
|
|
"orderbook": {
|
|
"ok": bool(ob_merged),
|
|
"params": ob_merged,
|
|
"combo_id": combo_cons.get("combo_id") or "",
|
|
"n": combo_cons.get("n") or entry.get("n") or 0,
|
|
},
|
|
"whipsaw": {"ok": bool(ws_cons), "params": ws_cons, "n": len(ws_params_list)},
|
|
"trail": trail_cons,
|
|
"strategy": strategy,
|
|
"note": note,
|
|
}
|
|
|
|
|
|
def _dispersion_points(vals: List[float]) -> Tuple[float, str]:
|
|
if len(vals) < 2:
|
|
return 0.0, "표본 1 이하"
|
|
med = abs(statistics.median(vals)) or 1.0
|
|
try:
|
|
iqr = statistics.quantiles(vals, n=4)[2] - statistics.quantiles(vals, n=4)[0]
|
|
except Exception:
|
|
iqr = max(vals) - min(vals)
|
|
rel = abs(iqr) / med
|
|
if rel >= 0.5:
|
|
return 8.0, f"상대IQR {rel:.2f} (제각각)"
|
|
if rel >= 0.25:
|
|
return 4.0, f"상대IQR {rel:.2f}"
|
|
return 0.0, f"상대IQR {rel:.2f} (비슷)"
|
|
|
|
|
|
def _postprocess_overfit_extra(anchors: List[Dict[str, Any]]) -> Tuple[float, List[Dict[str, Any]]]:
|
|
pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")]
|
|
factors: List[Dict[str, Any]] = []
|
|
extra = 0.0
|
|
|
|
spreads = []
|
|
ratios = []
|
|
exit_ratios: List[float] = []
|
|
stop_ratios: List[float] = []
|
|
dips = []
|
|
arms = []
|
|
for a in pool:
|
|
ob = a.get("orderbook") or {}
|
|
combos = _anchor_combos_map(ob)
|
|
entry_p: Dict[str, Any] = {}
|
|
exit_p: Dict[str, Any] = {}
|
|
stop_p: Dict[str, Any] = {}
|
|
if combos:
|
|
for cid, keys, dest in (
|
|
("e", ("orderbook_max_spread_pct", "orderbook_min_bid_ask_ratio"), entry_p),
|
|
("x", ("exit_ob_ratio_min",), exit_p),
|
|
("s", ("stop_ob_ratio_min",), stop_p),
|
|
):
|
|
c = combos.get(cid) or {}
|
|
if c.get("ok"):
|
|
p = dict(c.get("params") or {})
|
|
for k in keys:
|
|
if p.get(k) is not None:
|
|
dest[k] = p[k]
|
|
if not entry_p:
|
|
op = ob.get("params") or {}
|
|
entry_p = dict((ob.get("entry") or {}).get("params") or op)
|
|
if not exit_p:
|
|
exit_p = dict((ob.get("exit") or {}).get("params") or {})
|
|
if not stop_p:
|
|
stop_p = dict((ob.get("stop") or {}).get("params") or {})
|
|
if ob.get("ok") or combos or ((ob.get("entry") or {}).get("ok")):
|
|
if entry_p.get("orderbook_max_spread_pct") is not None:
|
|
spreads.append(float(entry_p["orderbook_max_spread_pct"]))
|
|
if entry_p.get("orderbook_min_bid_ask_ratio") is not None:
|
|
ratios.append(float(entry_p["orderbook_min_bid_ask_ratio"]))
|
|
if exit_p.get("exit_ob_ratio_min") is not None:
|
|
exit_ratios.append(float(exit_p["exit_ob_ratio_min"]))
|
|
if stop_p.get("stop_ob_ratio_min") is not None:
|
|
stop_ratios.append(float(stop_p["stop_ob_ratio_min"]))
|
|
wp = (a.get("whipsaw") or {}).get("params") or {}
|
|
if (a.get("whipsaw") or {}).get("ok") and wp.get("whipsaw_dip_pct") is not None:
|
|
dips.append(float(wp["whipsaw_dip_pct"]))
|
|
if (a.get("trail") or {}).get("ok"):
|
|
arms.append(float((a.get("trail") or {}).get("arm_krw") or 0))
|
|
|
|
for fid, label, seq in (
|
|
("ob_spread_disp", "후처리 진입 스프레드 분산", spreads),
|
|
("ob_ratio_disp", "후처리 진입 잔량비 분산", ratios),
|
|
("ob_exit_ratio_disp", "후처리 익절호가 OR 분산", exit_ratios),
|
|
("ob_stop_ratio_disp", "후처리 손절호가 OR 분산", stop_ratios),
|
|
("ws_dip_disp", "후처리 휩쏘 dip 분산", dips),
|
|
("trail_arm_disp", "후처리 트레일 ARM 분산", arms),
|
|
):
|
|
pts, detail = _dispersion_points(seq) if seq else (0.0, "해당 후처리 없음")
|
|
extra += pts
|
|
factors.append({"id": fid, "label": label, "points": pts, "detail": detail})
|
|
extra = max(0.0, min(25.0, extra))
|
|
return extra, factors
|
|
|
|
|
|
def _verdict(risk: float) -> Tuple[str, str]:
|
|
if risk >= 70.0:
|
|
return "비권장", "위험 · 비권장"
|
|
if risk >= 40.0:
|
|
return "주의", "주의"
|
|
return "상대적으로낮음", "상대적으로 낮음"
|
|
|
|
|
|
def _reuse_ob_ws(anchors: List[Dict[str, Any]], trial: Any) -> Optional[Dict[str, Any]]:
|
|
if trial is None:
|
|
return None
|
|
try:
|
|
tn = int(trial)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
for a in anchors:
|
|
try:
|
|
at = int(a.get("optuna_trial_number"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if at != tn:
|
|
continue
|
|
ob = a.get("orderbook") or {}
|
|
if ob.get("ok") or (ob.get("entry") or {}).get("ok"):
|
|
return a
|
|
return None
|
|
|
|
|
|
def append_stable_postprocess_anchors(
|
|
data: Dict[str, Any],
|
|
anchors: List[Dict[str, Any]],
|
|
*,
|
|
strat: str,
|
|
strat_u: str,
|
|
top_n: int,
|
|
do_ob: bool,
|
|
evaluate_fn: Optional[EvalFn],
|
|
lg: logging.Logger,
|
|
ob_n_trials: int = 0,
|
|
) -> None:
|
|
"""results_stable TopN 을 후처리 표 앵커로 붙인다. 같은 trial 은 gated 호가 재사용."""
|
|
if not _include_stable():
|
|
return
|
|
if any(str(a.get("role") or "") == "stable" for a in anchors):
|
|
return
|
|
stable = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))]
|
|
for i, row in enumerate(stable, start=1):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
combo = _combo_from_row(row)
|
|
trial = row.get("optuna_trial_number") or row.get("_trial_number")
|
|
reused = _reuse_ob_ws(anchors, trial)
|
|
fills: List[Dict[str, Any]] = []
|
|
pnl = row.get("total_pnl")
|
|
trail = _slim_trail(_trail_for_pnl(strat, pnl))
|
|
if do_ob:
|
|
from kis_trader.backtest import optuna_post_progress as opp
|
|
opp.next_unit(
|
|
f"stable#{i}",
|
|
"호가재사용" if reused is not None else "백테재실행·호가",
|
|
)
|
|
if reused is not None:
|
|
ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
|
|
ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
|
|
if do_ob:
|
|
lg.info("📌 [후처리] stable#%d 호가 재사용 (gated와 동일 trial)", i)
|
|
else:
|
|
ob = {"ok": False, "reason": "light_skip"}
|
|
ws = {"ok": False, "reason": "light_skip"}
|
|
if do_ob and evaluate_fn is not None:
|
|
lg.info("📌 [후처리] stable#%d 백테 재실행 (호가/휩쏘 체결)", i)
|
|
fills = _replay_fills(evaluate_fn, combo, lg)
|
|
if do_ob:
|
|
if fills:
|
|
ob = _ob_for_anchor(
|
|
strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials,
|
|
)
|
|
ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg)
|
|
elif evaluate_fn is None:
|
|
ob = {"ok": False, "reason": "no_replay_fills"}
|
|
ws = {"ok": False, "reason": "no_replay_fills"}
|
|
else:
|
|
ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
|
|
ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
|
|
anchors.append({
|
|
"id": f"stable#{i}",
|
|
"role": "stable",
|
|
"rank": i,
|
|
"optuna_trial_number": trial,
|
|
"total_pnl": _krw_int(pnl),
|
|
"total_trades": int(row.get("total_trades") or 0),
|
|
"win_rate": row.get("win_rate"),
|
|
"pf": row.get("pf"),
|
|
"orderbook": ob,
|
|
"whipsaw": ws,
|
|
"trail": trail,
|
|
"note": "안정 후보" + (" · gated와 동일 trial 호가 재사용" if reused is not None else ""),
|
|
})
|
|
|
|
|
|
def ensure_stable_postprocess_on_payload(data: Dict[str, Any]) -> None:
|
|
"""구 JSON(gated만 있는 후처리)에도 안정 앵커를 붙여 웹 표가 나오게."""
|
|
topn = (data or {}).get("postprocess_topn")
|
|
if not isinstance(topn, dict):
|
|
return
|
|
anchors = list(topn.get("postprocess_by_anchor") or [])
|
|
if not anchors:
|
|
return
|
|
strat = str(data.get("strategy") or "momentum").strip().lower()
|
|
strat_u = strat.upper()
|
|
if strat_u in ("TAIL", "SHORT"):
|
|
strat_u = "TAIL"
|
|
elif strat_u in ("SCALPING", "SCALP"):
|
|
strat_u = "SCALP"
|
|
before = len(anchors)
|
|
append_stable_postprocess_anchors(
|
|
data, anchors,
|
|
strat=strat, strat_u=strat_u, top_n=resolve_post_top_n(5),
|
|
do_ob=False, evaluate_fn=None, lg=logger, ob_n_trials=0,
|
|
)
|
|
if len(anchors) != before:
|
|
topn["postprocess_by_anchor"] = anchors
|
|
|
|
|
|
def attach_topn_postprocess(
|
|
out_data: Dict[str, Any],
|
|
*,
|
|
evaluate_fn: Optional[EvalFn] = None,
|
|
mode_fills: Optional[List[Dict[str, Any]]] = None,
|
|
log: Optional[logging.Logger] = None,
|
|
run_ob_whipsaw: Optional[bool] = None,
|
|
ob_n_trials: int = 0,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
out_data 에 postprocess_by_anchor / consensus / apply_overfit_pct 기록.
|
|
|
|
evaluate_fn 있으면 gated(+mode 미캐시) 백테 1회씩 재실행해 체결→호가/휩쏘.
|
|
없으면 트레일만(구 JSON 웹 요약). 실매 엔진 호출 없음.
|
|
"""
|
|
lg = log or logger
|
|
data = out_data or {}
|
|
strat = str(data.get("strategy") or "momentum").strip().lower()
|
|
strat_u = strat.upper()
|
|
if strat_u in ("TAIL", "SHORT"):
|
|
strat_u = "TAIL"
|
|
elif strat_u in ("SCALPING", "SCALP"):
|
|
strat_u = "SCALP"
|
|
top_n = resolve_post_top_n(5)
|
|
do_ob = _run_ob_whipsaw_full() if run_ob_whipsaw is None else bool(run_ob_whipsaw)
|
|
|
|
gated = list(data.get("results_gated") or [])[:top_n]
|
|
anchors: List[Dict[str, Any]] = []
|
|
|
|
from kis_trader.backtest import optuna_post_progress as opp
|
|
|
|
stable_preview = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] if _include_stable() else []
|
|
n_units = len(gated) + (len(stable_preview) if _include_stable() else 0)
|
|
if _include_mode():
|
|
n_units += 1
|
|
if _include_live():
|
|
n_units += 1
|
|
if do_ob:
|
|
opp.begin_job(lg, max(1, n_units))
|
|
|
|
for i, row in enumerate(gated, start=1):
|
|
combo = _combo_from_row(row)
|
|
fills: List[Dict[str, Any]] = []
|
|
if do_ob:
|
|
opp.next_unit(f"gated#{i}", "백테재실행·호가")
|
|
if do_ob and evaluate_fn is not None:
|
|
lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)
|
|
fills = _replay_fills(evaluate_fn, combo, lg)
|
|
pnl = row.get("total_pnl")
|
|
trail = _slim_trail(_trail_for_pnl(strat, pnl))
|
|
ob = {"ok": False, "reason": "light_skip"}
|
|
ws = {"ok": False, "reason": "light_skip"}
|
|
if do_ob:
|
|
if fills:
|
|
ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials)
|
|
ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg)
|
|
elif evaluate_fn is None:
|
|
ob = {"ok": False, "reason": "no_replay_fills"}
|
|
ws = {"ok": False, "reason": "no_replay_fills"}
|
|
else:
|
|
ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
|
|
ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
|
|
anchors.append({
|
|
"id": f"gated#{i}",
|
|
"role": "gated",
|
|
"rank": i,
|
|
"optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"),
|
|
"total_pnl": _krw_int(pnl),
|
|
"total_trades": int(row.get("total_trades") or 0),
|
|
"win_rate": row.get("win_rate"),
|
|
"pf": row.get("pf"),
|
|
"orderbook": ob,
|
|
"whipsaw": ws,
|
|
"trail": trail,
|
|
"note": "사후합격 후보",
|
|
})
|
|
|
|
append_stable_postprocess_anchors(
|
|
data, anchors,
|
|
strat=strat, strat_u=strat_u, top_n=top_n,
|
|
do_ob=do_ob, evaluate_fn=evaluate_fn, lg=lg, ob_n_trials=ob_n_trials,
|
|
)
|
|
|
|
if _include_mode():
|
|
mc = data.get("mode_combo") or {}
|
|
bt = mc.get("backtest") or {}
|
|
mode_pnl = bt.get("total_pnl")
|
|
fills_m = list(mode_fills or [])
|
|
if do_ob:
|
|
from kis_trader.backtest import optuna_post_progress as opp
|
|
opp.next_unit("mode", "mode_combo 호가")
|
|
if do_ob and not fills_m and evaluate_fn is not None:
|
|
mode_params = dict(mc.get("params") or {})
|
|
if mode_params:
|
|
lg.info("📌 [후처리] mode_combo 백테 재실행")
|
|
fills_m = _replay_fills(evaluate_fn, mode_params, lg)
|
|
trail = _slim_trail(_trail_for_pnl(strat, mode_pnl))
|
|
ob = {"ok": False, "reason": "light_skip"}
|
|
ws = {"ok": False, "reason": "light_skip"}
|
|
if do_ob:
|
|
if fills_m:
|
|
ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills_m, live=False, lg=lg, n_trials=ob_n_trials)
|
|
ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills_m, live=False, lg=lg)
|
|
else:
|
|
ob = {"ok": False, "reason": "no_replay_fills"}
|
|
ws = {"ok": False, "reason": "no_replay_fills"}
|
|
anchors.append({
|
|
"id": "mode",
|
|
"role": "mode",
|
|
"rank": None,
|
|
"optuna_trial_number": None,
|
|
"total_pnl": _krw_int(mode_pnl),
|
|
"total_trades": int(bt.get("total_trades") or 0),
|
|
"win_rate": bt.get("win_rate"),
|
|
"pf": bt.get("pf"),
|
|
"orderbook": ob,
|
|
"whipsaw": ws,
|
|
"trail": trail,
|
|
"note": "축별 최빈 조각 모음(trial 없음)",
|
|
})
|
|
|
|
if _include_live():
|
|
live_ob = {"ok": False, "reason": "light_skip"}
|
|
live_ws = {"ok": False, "reason": "light_skip"}
|
|
if do_ob:
|
|
from kis_trader.backtest import optuna_post_progress as opp
|
|
opp.next_unit("live", "실매참고 호가")
|
|
live_ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=None, live=True, lg=lg, n_trials=ob_n_trials)
|
|
live_ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=None, live=True, lg=lg)
|
|
live_pnl = None
|
|
if (live_ob.get("orig_stats") or {}).get("pnl") is not None:
|
|
live_pnl = live_ob["orig_stats"]["pnl"]
|
|
elif (live_ws.get("orig_stats") or {}).get("pnl") is not None:
|
|
live_pnl = live_ws["orig_stats"]["pnl"]
|
|
trail = _slim_trail(_trail_for_pnl(strat, live_pnl))
|
|
anchors.append({
|
|
"id": "live",
|
|
"role": "live",
|
|
"rank": None,
|
|
"optuna_trial_number": None,
|
|
"total_pnl": _krw_int(live_pnl),
|
|
"total_trades": int((live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("count") or 0),
|
|
"win_rate": (live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("win_rate"),
|
|
"orderbook": live_ob,
|
|
"whipsaw": live_ws,
|
|
"trail": trail,
|
|
"note": "실매 trade_history 참고 — Optuna 칸과 섞지 않음 · 과적합% 제외",
|
|
})
|
|
|
|
consensus = _consensus_from_anchors(anchors, strat)
|
|
extra, extra_factors = _postprocess_overfit_extra(anchors)
|
|
|
|
base_risk = 0.0
|
|
try:
|
|
from kis_trader.backtest.optuna_common import build_optuna_overfit_diagnostics
|
|
diag = build_optuna_overfit_diagnostics(data)
|
|
base_risk = float(diag.get("overfit_risk_pct") or 0)
|
|
data["overfit_diagnostics"] = diag
|
|
except Exception as exc:
|
|
lg.warning("⚠️ overfit_diagnostics 재계산 실패: %s", exc)
|
|
diag = data.get("overfit_diagnostics") or {}
|
|
try:
|
|
base_risk = float(diag.get("overfit_risk_pct") or 0)
|
|
except (TypeError, ValueError):
|
|
base_risk = 0.0
|
|
|
|
apply_pct = max(0.0, min(100.0, round(base_risk + extra, 1)))
|
|
verd, verd_ui = _verdict(apply_pct)
|
|
|
|
payload = {
|
|
"postprocess_by_anchor": anchors,
|
|
"postprocess_consensus": consensus,
|
|
"apply_overfit_pct": apply_pct,
|
|
"apply_overfit_verdict": verd,
|
|
"apply_overfit_verdict_ui": verd_ui,
|
|
"apply_overfit_base_pct": round(base_risk, 1),
|
|
"apply_overfit_post_extra": round(extra, 1),
|
|
"apply_overfit_factors": extra_factors,
|
|
"apply_overfit_note": (
|
|
"과적합%=이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정). AI 아님. "
|
|
"실매 참고행은 점수에 넣지 않음. 적용=호가 8방 중 하나(또는 휩쏘/트레일 별도)."
|
|
),
|
|
"run_ob_whipsaw": bool(do_ob),
|
|
}
|
|
data["postprocess_topn"] = payload
|
|
data["apply_overfit_pct"] = apply_pct
|
|
data["apply_overfit_verdict"] = verd
|
|
|
|
# 하위호환: 기존 단일 키 = 합의 (apply 스크립트·웹 구표)
|
|
if consensus.get("orderbook", {}).get("ok"):
|
|
cnote = consensus.get("note") or "TopN 합의(gated+mode). 실매 단독 아님."
|
|
cid = (consensus.get("combo") or {}).get("combo_id") or consensus.get("orderbook", {}).get("combo_id")
|
|
if cid:
|
|
cnote = f"{_COMBO_LABELS.get(str(cid), cid)} · {cnote}"
|
|
data["orderbook_recommend"] = {
|
|
"ok": True,
|
|
"strategy": strat_u,
|
|
"params": consensus["orderbook"]["params"],
|
|
"combo_id": cid or "",
|
|
"note": cnote,
|
|
}
|
|
if consensus.get("whipsaw", {}).get("ok"):
|
|
data["whipsaw_recommend"] = {
|
|
"ok": True,
|
|
"strategy": strat_u,
|
|
"params": consensus["whipsaw"]["params"],
|
|
"note": "TopN 합의(gated+mode). 실매 단독 아님.",
|
|
}
|
|
trc = consensus.get("trail") or {}
|
|
if trc.get("ok"):
|
|
data["daily_trail_recommend"] = {
|
|
"ok": True,
|
|
"strategy": strat,
|
|
"prefix": trc.get("prefix"),
|
|
"arm_krw": trc.get("arm_krw"),
|
|
"anchor_krw": trc.get("anchor_krw"),
|
|
"tiers": trc.get("tiers"),
|
|
"mode": "trailing",
|
|
"enabled": True,
|
|
"note": trc.get("note"),
|
|
}
|
|
mc = data.get("mode_combo")
|
|
if isinstance(mc, dict):
|
|
mc["daily_trail_recommend"] = data["daily_trail_recommend"]
|
|
if data.get("orderbook_recommend"):
|
|
mc["orderbook_recommend"] = data["orderbook_recommend"]
|
|
if data.get("whipsaw_recommend"):
|
|
mc["whipsaw_recommend"] = data["whipsaw_recommend"]
|
|
|
|
lg.info(
|
|
"📌 [후처리 TopN] anchors=%d · 과적합%%=%.1f(%s) · ob_whipsaw=%s",
|
|
len(anchors), apply_pct, verd, do_ob,
|
|
)
|
|
if do_ob:
|
|
from kis_trader.backtest import optuna_post_progress as opp
|
|
opp.finish_job(lg)
|
|
return data
|
|
|
|
|
|
# 구 UI 누적 upto → 방 id (하위호환). whipsaw=111방 + 휩쏘 ON.
|
|
_UPTO_TO_COMBO = {
|
|
"base": "base",
|
|
"entry": "e",
|
|
"exit": "ex",
|
|
"stop": "exs",
|
|
"whipsaw": "exs",
|
|
}
|
|
_UPTO_ORDER = ("base", "entry", "exit", "stop", "whipsaw") # 레거시 별칭만
|
|
|
|
|
|
def pick_postprocess_anchor(
|
|
data: Dict[str, Any],
|
|
source: str,
|
|
rank: int,
|
|
) -> Optional[Dict[str, Any]]:
|
|
topn = data.get("postprocess_topn") if isinstance(data, dict) else None
|
|
anchors = list((topn or {}).get("postprocess_by_anchor") or [])
|
|
src = str(source or "gated").strip().lower()
|
|
rk = max(1, int(rank or 1))
|
|
if src == "mode":
|
|
for a in anchors:
|
|
if str(a.get("role") or "") == "mode":
|
|
return a
|
|
return None
|
|
if src == "live":
|
|
return None
|
|
role = "stable" if src == "stable" else "gated"
|
|
for a in anchors:
|
|
if str(a.get("role") or "") == role and int(a.get("rank") or 0) == rk:
|
|
return a
|
|
return None
|
|
|
|
|
|
def _normalize_strat_u(strategy: str, data: Optional[Dict[str, Any]] = None) -> str:
|
|
strat_u = str(strategy or (data or {}).get("strategy") or "").strip().upper()
|
|
if strat_u in ("TAIL", "SHORT"):
|
|
return "TAIL"
|
|
if strat_u in ("SCALPING", "SCALP"):
|
|
return "SCALP"
|
|
if strat_u in ("US_MOMENTUM",):
|
|
return "US_MOMENTUM"
|
|
if strat_u in ("MOMENTUM",):
|
|
return "MOMENTUM"
|
|
return strat_u
|
|
|
|
|
|
def _resolve_combo_id(raw: str) -> Tuple[str, bool]:
|
|
"""반환: (combo_id, include_whipsaw)."""
|
|
u = str(raw or "base").strip().lower()
|
|
if u == "trail":
|
|
return "base", False
|
|
if u == "whipsaw":
|
|
return "exs", True
|
|
if u in _COMBO_IDS:
|
|
return u, False
|
|
if u in _UPTO_TO_COMBO:
|
|
return _UPTO_TO_COMBO[u], False
|
|
# 비트 표기 허용
|
|
bit_map = {
|
|
"000": "base", "100": "e", "010": "x", "001": "s",
|
|
"110": "ex", "101": "es", "011": "xs", "111": "exs",
|
|
}
|
|
if u in bit_map:
|
|
return bit_map[u], False
|
|
raise ValueError(
|
|
f"combo/upto 는 {_COMBO_IDS}+whipsaw|trail|000~111 만 (got {raw})"
|
|
)
|
|
|
|
|
|
def build_combo_env_patch(
|
|
*,
|
|
data: Dict[str, Any],
|
|
source: str,
|
|
rank: int,
|
|
combo: str,
|
|
strategy: str,
|
|
include_whipsaw: Optional[bool] = None,
|
|
) -> Tuple[Dict[str, str], List[str]]:
|
|
"""
|
|
8방 중 하나 적용. 켠 축만 ON·숫자 반영, 끈 축은 ENABLED=false.
|
|
휩쏘는 8방 밖 — include_whipsaw=True 일 때만 붙임.
|
|
"""
|
|
from kis_trader.backtest.optuna_orderbook_recommend import (
|
|
_env_pfx,
|
|
build_entry_ob_env_patch,
|
|
build_exit_ob_env_patch,
|
|
build_stop_ob_env_patch,
|
|
)
|
|
from kis_trader.backtest.optuna_whipsaw_recommend import build_whipsaw_env_patch
|
|
|
|
cid, whip_default = _resolve_combo_id(combo)
|
|
do_whip = bool(whip_default if include_whipsaw is None else include_whipsaw)
|
|
notes: List[str] = []
|
|
strat_u = _normalize_strat_u(strategy, data)
|
|
epfx = _env_pfx(strat_u)
|
|
use_e, use_x, use_s = _COMBO_MASK[cid]
|
|
|
|
anchor = pick_postprocess_anchor(data, source, rank)
|
|
if not anchor:
|
|
notes.append("후처리 앵커 없음(구 JSON이면 재실행 필요)")
|
|
return {}, notes
|
|
|
|
ob = dict(anchor.get("orderbook") or {})
|
|
combos = ob.get("combos") if isinstance(ob.get("combos"), dict) else {}
|
|
c = combos.get(cid) if isinstance(combos.get(cid), dict) else None
|
|
# 구 JSON(combos 없음): entry/exit/stop 중첩으로 폴백
|
|
if not (c and c.get("ok")):
|
|
legacy_map = {
|
|
"e": ("entry",),
|
|
"x": ("exit",),
|
|
"s": ("stop",),
|
|
"ex": ("exit", "entry"),
|
|
"es": ("stop", "entry"),
|
|
"xs": ("stop", "exit"),
|
|
"exs": ("stop", "exit", "entry"),
|
|
}
|
|
if cid == "base":
|
|
c = {"ok": True, "params": {}}
|
|
else:
|
|
merged_p: Dict[str, Any] = {}
|
|
ok_any = False
|
|
for ax in legacy_map.get(cid, ()):
|
|
nested = ob.get(ax) if isinstance(ob.get(ax), dict) else {}
|
|
if nested.get("ok"):
|
|
ok_any = True
|
|
merged_p.update(dict(nested.get("params") or {}))
|
|
c = {"ok": ok_any, "params": merged_p} if ok_any else None
|
|
if cid != "base" and not (c and c.get("ok")):
|
|
notes.append(f"방 {cid} 추천 없음")
|
|
return {}, notes
|
|
|
|
params = dict((c or {}).get("params") or {})
|
|
# 마스크로 enabled 강제 (방 정의가 진실)
|
|
params["orderbook_filter_enabled"] = bool(use_e)
|
|
params["exit_ob_enabled"] = bool(use_x)
|
|
params["stop_ob_enabled"] = bool(use_s)
|
|
view = {"strategy": strat_u, "params": params, "entry": {"ok": True, "params": params},
|
|
"exit": {"ok": True, "params": params}, "stop": {"ok": True, "params": params}}
|
|
|
|
patch: Dict[str, str] = {}
|
|
if not epfx:
|
|
notes.append("전략 prefix 없음")
|
|
return {}, notes
|
|
|
|
if use_e:
|
|
ep = build_entry_ob_env_patch(view, strat_u)
|
|
if ep:
|
|
patch.update(ep)
|
|
else:
|
|
notes.append("진입 숫자 없음 → 진입 OFF")
|
|
patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
|
|
else:
|
|
patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
|
|
|
|
if epfx in ("MOMENTUM", "BREAKOUT"):
|
|
if use_x:
|
|
xp = build_exit_ob_env_patch(view, strat_u)
|
|
if xp:
|
|
patch.update(xp)
|
|
else:
|
|
notes.append("익절 숫자 없음 → 익절 OFF")
|
|
patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
|
|
else:
|
|
patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
|
|
if use_s:
|
|
sp = build_stop_ob_env_patch(view, strat_u)
|
|
if sp:
|
|
patch.update(sp)
|
|
else:
|
|
notes.append("손절 숫자 없음 → 손절 OFF")
|
|
patch[f"{epfx}_STOP_OB_ENABLED"] = "false"
|
|
else:
|
|
patch[f"{epfx}_STOP_OB_ENABLED"] = "false"
|
|
elif use_x or use_s:
|
|
notes.append("익절/손절호가 해당없음(전략)")
|
|
|
|
if do_whip:
|
|
ws = dict(anchor.get("whipsaw") or {})
|
|
ws["strategy"] = strat_u
|
|
wp = build_whipsaw_env_patch(ws)
|
|
if wp:
|
|
patch.update(wp)
|
|
elif strat_u in ("TAIL", "SHORT", "BREAKOUT"):
|
|
notes.append("휩쏘 DB 스킵(전략 특성)")
|
|
else:
|
|
notes.append("휩쏘 추천 없음")
|
|
elif epfx == "MOMENTUM":
|
|
patch[f"{epfx}_WHIPSAW_FILTER_ENABLED"] = "false"
|
|
|
|
notes.append(f"방 {cid} 적용 (진입={int(use_e)} 익절={int(use_x)} 손절={int(use_s)} 휩쏘={int(do_whip)})")
|
|
return patch, notes
|
|
|
|
|
|
def build_upto_env_patch(
|
|
*,
|
|
data: Dict[str, Any],
|
|
source: str,
|
|
rank: int,
|
|
upto: str,
|
|
strategy: str,
|
|
) -> Tuple[Dict[str, str], List[str]]:
|
|
"""하위호환: 구 누적 upto → 8방 combo 패치."""
|
|
u = str(upto or "base").strip().lower()
|
|
if u == "trail":
|
|
return {}, ["trail은 다단트레일 전용 경로"]
|
|
return build_combo_env_patch(
|
|
data=data, source=source, rank=rank, combo=u, strategy=strategy,
|
|
)
|
|
|
|
|
|
def _later_axes_off_patch(strat_u: str, upto: str) -> Dict[str, str]:
|
|
"""레거시 누적 off. 신규는 build_combo_env_patch 마스크 사용."""
|
|
from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
|
|
|
|
pfx = _env_pfx(strat_u)
|
|
if not pfx:
|
|
return {}
|
|
try:
|
|
cid, do_whip = _resolve_combo_id(upto)
|
|
except ValueError:
|
|
return {}
|
|
use_e, use_x, use_s = _COMBO_MASK[cid]
|
|
patch: Dict[str, str] = {}
|
|
if not use_e:
|
|
patch[f"{pfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
|
|
if not use_x and pfx in ("MOMENTUM", "BREAKOUT"):
|
|
patch[f"{pfx}_EXIT_OB_ENABLED"] = "false"
|
|
if not use_s and pfx in ("MOMENTUM", "BREAKOUT"):
|
|
patch[f"{pfx}_STOP_OB_ENABLED"] = "false"
|
|
if (not do_whip) and pfx == "MOMENTUM":
|
|
patch[f"{pfx}_WHIPSAW_FILTER_ENABLED"] = "false"
|
|
return patch
|