feat: Enhance trading system with new permanent subscription features and order book management
Changes: - Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically. - Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database. - Introduced a mechanism to handle master subscription states, improving the management of subscription statuses. - Updated the database schema to include new fields for managing subscription states and order book filtering. Impact: - These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies. 히스토리 align 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
This commit is contained in:
@@ -191,6 +191,17 @@ def attach_daily_stability(
|
||||
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 optuna_stable_gate_defaults() -> Tuple[int, float, float, int]:
|
||||
"""(max_losing_days, min_worst_day_pnl, lambda, min_active_days)."""
|
||||
return (
|
||||
@@ -360,6 +371,301 @@ def build_optuna_result_tiers(
|
||||
}
|
||||
|
||||
|
||||
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) 표본 장일
|
||||
if days <= 1:
|
||||
pts, detail = 40.0, f"거래일≈{days}일 — 단일 장 과적합 위험 최대"
|
||||
elif days == 2:
|
||||
pts, detail = 28.0, f"거래일≈{days}일 — 이틀만으로는 추세 전환에 취약"
|
||||
elif days <= 4:
|
||||
pts, detail = 16.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 반복)
|
||||
plateau_share = 0.0
|
||||
plateau_n = 0
|
||||
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))
|
||||
if plateau_share >= 0.4 and plateau_n >= 5:
|
||||
pts = 12.0
|
||||
detail = (
|
||||
f"동일 PnL≈{best_pnl:,.0f}원이 후보 {plateau_n}/{len(pool)} "
|
||||
f"({plateau_share:.0%}) — 파라미터 민감도 낮음/고원"
|
||||
)
|
||||
elif plateau_share >= 0.25 and plateau_n >= 3:
|
||||
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})
|
||||
|
||||
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_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,
|
||||
*,
|
||||
@@ -431,6 +737,39 @@ def ensure_optuna_gate_env_defaults(db: Any = None) -> None:
|
||||
"OPTUNA_DAILY_TRAIL_ARM_STEP": "5000",
|
||||
"OPTUNA_DAILY_TRAIL_MIN_ARM": "10000",
|
||||
"OPTUNA_DAILY_TRAIL_TIER_DROPS": "40,30,20",
|
||||
"OPTUNA_POST_TOP_N": "5",
|
||||
"OPTUNA_POST_INCLUDE_MODE": "true",
|
||||
"OPTUNA_POST_INCLUDE_LIVE": "true",
|
||||
"OPTUNA_POST_RUN_OB_WHIPSAW": "true",
|
||||
"OPTUNA_OB_RECOMMEND_TRIALS": "1000",
|
||||
"OPTUNA_OB_AXIS_TRIALS": "0",
|
||||
"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
|
||||
@@ -581,8 +920,9 @@ def resolve_study_name(
|
||||
start: str,
|
||||
end: str,
|
||||
cli_override: Optional[str] = None,
|
||||
extra: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Study 이름 — 전략·기간·모드 포함."""
|
||||
"""Study 이름 — 전략·기간·모드 포함. extra=꼬리 진입모드 등(스터디 분리)."""
|
||||
if cli_override and str(cli_override).strip():
|
||||
return str(cli_override).strip()
|
||||
env_key = f"OPTUNA_{strategy.upper()}_STUDY_NAME"
|
||||
@@ -592,7 +932,9 @@ def resolve_study_name(
|
||||
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()
|
||||
return f"{strategy}_{mode}_{start}_{end}"
|
||||
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:
|
||||
@@ -660,9 +1002,32 @@ def announce_optuna_json_path(
|
||||
except OSError as exc:
|
||||
lg.warning("⚠️ jsonpath 사이드카 기록 실패: %s", exc)
|
||||
|
||||
# 최종 JSON 저장 후 브리핑 (이전 장 / 앞으로 장)
|
||||
# 최종 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)
|
||||
|
||||
Reference in New Issue
Block a user