Files
kis_bot/kis_trader/backtest/optuna_study_store.py
Your Name 9ba9ab73b6 feat(backtest): 대대적인 Optuna 백테스트 웹 UI 및 백엔드 파이프라인 개편
- Web UI:
  - Optuna 탭 추가 및 mode_combo (최빈값 조합), 사후합격 Top 10 시각화 기능
  - 파라미터 분포(p25~p75, median, mode) 히스토그램 및 과적합(Overfit) 위험도 진단 UI 신설
  - 체크박스 렌더링 깨짐 현상을 네이티브(appearance: auto)로 강제 복구 (CSS)
  - 다단 트레일링 스탑, 꼬리 진입/돌파 손절 등 고급 조건 설정 폼 UI 고도화

- Backend (Optuna Jobs):
  - CLI 환경에서 구동된 Optuna json 결과물을 웹 대시보드로 읽어오는 import 기능 강화
  - JSON 메타데이터에 sort_by, mode, 호가 적용 여부 등 핵심 파라미터 파싱 누락 수정
  - optuna_mode_combo.py 등 최빈값 조합 및 후보군 2차 검증을 위한 신규 모듈 추가

- DB & Execution:
  - WebSocket 호가/틱 피드 수집 통계(api_feed_collect_stats) 메모리 캐시 최적화
  - KIS client 접속 키(approval_key) 등 인프라스트럭처 안정성 및 공유 관리 구조 개선
  - 테스트 및 디버깅용 briefing 마크다운 자동 생성 기능 추가
2026-09-01 02:47:51 +09:00

809 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Optuna 스터디 결과 · 총 횟수 후처리 게이트 (kis_quant_db.optuna_study_result).
kis_optuna 공식 studies/trials 는 건드리지 않는다.
실매 봇 루프에 넣지 않는다.
"""
from __future__ import annotations
import json
import logging
import os
import socket
from typing import Any, Callable, Dict, Optional, Tuple
logger = logging.getLogger("optuna_study_store")
KIS_STUDY_TRIALS_ATTR = "kis_study_trials"
_DDL = """
CREATE TABLE IF NOT EXISTS optuna_study_result (
study_name VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)',
strategy VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp',
mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tpe 등',
start_date VARCHAR(10) NULL COMMENT '백테 시작 YYYY-MM-DD',
end_date VARCHAR(10) NULL COMMENT '백테 종료 YYYY-MM-DD',
job_id VARCHAR(64) NULL COMMENT '웹 잡 id (참고)',
symbol VARCHAR(16) NULL COMMENT '해외 종목핀일 때만',
study_trials INT NOT NULL DEFAULT 0 COMMENT '스터디 총 시도 목표(COMPLETE+PRUNED+FAIL). 0이면 미설정',
n_complete INT NOT NULL DEFAULT 0 COMMENT '끝난 시도 수 캐시(COMPLETE+PRUNED+FAIL, 후처리 게이트)',
n_running INT NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)',
payload_json LONGTEXT NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit (웹 요약과 동일 키)',
briefing_md MEDIUMTEXT NULL COMMENT '브리핑 마크다운',
pp_needed TINYINT NOT NULL DEFAULT 0 COMMENT '1=목표 도달, 후처리 대기',
pp_status VARCHAR(16) NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error',
pp_for_n INT NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete',
pp_lock_until DATETIME NULL COMMENT '락 만료 (죽은 워커 복구)',
pp_lock_owner VARCHAR(64) NULL COMMENT '잠근 호스트/pid',
pp_error VARCHAR(500) NULL COMMENT '후처리 실패 메시지',
leftover_note VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성',
PRIMARY KEY (study_name),
KEY idx_pp_needed (pp_needed, pp_status),
KEY idx_job (job_id),
KEY idx_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)'
"""
def _db():
from database import TradeDB
return TradeDB()
def ensure_optuna_study_result_table() -> None:
db = _db()
db.conn.execute_long(_DDL, timeout_sec=120)
def count_study_states(study: Any) -> Tuple[int, int, int]:
"""(COMPLETE 수, RUNNING 수, 끝난 시도 수).
끝난 시도 = COMPLETE + PRUNED + FAIL.
후처리·남은횟수 게이트는 「돌린 횟수」(끝난 시도) 기준.
문제출제 오류(pruned: tp_max<tp 등)는 목표에서 빼지 않고 시도로 친다.
"""
n_complete = 0
n_running = 0
n_finished = 0
try:
import optuna
complete = optuna.trial.TrialState.COMPLETE
running = optuna.trial.TrialState.RUNNING
pruned = optuna.trial.TrialState.PRUNED
fail = optuna.trial.TrialState.FAIL
except Exception:
complete = running = pruned = fail = None
for t in list(getattr(study, "trials", None) or []):
st = getattr(t, "state", None)
if complete is not None and st == complete:
n_complete += 1
n_finished += 1
elif running is not None and st == running:
n_running += 1
elif pruned is not None and st == pruned:
n_finished += 1
elif fail is not None and st == fail:
n_finished += 1
return n_complete, n_running, n_finished
def parse_study_trials_value(raw: Any) -> int:
"""빈값/0 = 미사용. 음수 없음."""
if raw is None:
return 0
s = str(raw).strip()
if not s or s.lower() in ("none", "null"):
return 0
try:
n = int(float(s))
except (TypeError, ValueError):
return 0
return max(0, n)
def resolve_cli_study_trials(cli_value: Any = None) -> int:
"""CLI > 프로세스 env. DB 전역 기본 200 넣지 않음."""
n = parse_study_trials_value(cli_value)
if n > 0:
return n
n = parse_study_trials_value(os.environ.get("KIS_OPTUNA_STUDY_TRIALS"))
if n > 0:
return n
n = parse_study_trials_value(os.environ.get("PARAM_SEARCH_OPTUNA_STUDY_TRIALS"))
if n > 0:
return n
try:
from kis_trader.utils.env import get_env_from_db
n = parse_study_trials_value(get_env_from_db("PARAM_SEARCH_OPTUNA_STUDY_TRIALS", "") or "")
except Exception:
n = 0
return n
def apply_kis_study_trials(
study: Any,
*,
n_trials: int,
log: Optional[logging.Logger] = None,
cli_study_trials: Optional[int] = None,
) -> int:
"""첫 워커가 user_attr 기록. 이후 워커는 저장된 목표 유지(더 작은 값으로 덮지 않음)."""
lg = log or logger
requested = parse_study_trials_value(
cli_study_trials if cli_study_trials is not None else resolve_cli_study_trials()
)
fallback = max(1, int(n_trials or 0))
stored = 0
try:
raw = (study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR)
stored = parse_study_trials_value(raw)
except Exception:
stored = 0
if stored > 0:
if requested > 0 and requested != stored:
lg.warning(
"⚠️ kis_study_trials=%s 유지 (요청 %s 무시 — 목표를 낮춰 조기 후처리하지 않음)",
stored, requested,
)
return stored
target = requested if requested > 0 else fallback
try:
study.set_user_attr(KIS_STUDY_TRIALS_ATTR, int(target))
except Exception as exc:
lg.warning("⚠️ kis_study_trials user_attr 저장 실패: %s", exc)
lg.info("📌 스터디 총 횟수 목표 kis_study_trials=%s (이번 --trials=%s)", target, n_trials)
return int(target)
def bind_study_trials(study: Any, *, n_trials: int, log: Optional[logging.Logger] = None) -> int:
return apply_kis_study_trials(study, n_trials=n_trials, log=log)
def leftover_trials(study_trials: int, n_finished: int) -> int:
"""남은 횟수 = 목표 끝난 시도(COMPLETE+PRUNED+FAIL)."""
t = int(study_trials or 0)
c = int(n_finished or 0)
if t <= 0:
return 0
return max(0, t - c)
def clamp_optimize_n_trials(
study: Any,
n_trials: int,
*,
log: Optional[logging.Logger] = None,
) -> int:
"""
Optuna study.optimize(n_trials=N) 은 **추가** N회.
study-trials 목표가 있으면 남은 횟수로 잘라서, 목표 초과 연타를 막는다.
"""
lg = log or logger
try:
n = max(0, int(n_trials or 0))
except (TypeError, ValueError):
n = 0
try:
goal = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR))
except Exception:
goal = 0
if goal <= 0:
return max(1, n) if n > 0 else 0
_c, _r, n_f = count_study_states(study)
left = leftover_trials(goal, n_f)
if left <= 0:
lg.info(
"📌 study-trials 목표 도달 — 추가 trial 생략 (끝난시도=%s / 목표=%s)",
n_f, goal,
)
return 0
if n <= 0 or n > left:
lg.warning(
"⚠️ --trials=%s → leftover %s 로 축소 (끝난시도=%s / study-trials=%s)",
n, left, n_f, goal,
)
return left
return n
def make_study_goal_stop_callback(log: Optional[logging.Logger] = None):
"""trial 끝날 때마다 study-trials 도달이면 study.stop()."""
lg = log or logger
def _cb(study: Any, _trial: Any) -> None:
try:
goal = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR))
except Exception:
return
if goal <= 0:
return
_c, _r, n_f = count_study_states(study)
if n_f >= goal:
lg.info(
"📌 study-trials 도달 → optimize 중지 (끝난시도=%s / 목표=%s)",
n_f, goal,
)
try:
study.stop()
except Exception:
pass
return _cb
def leftover_note(
study_trials: int,
n_finished: int,
*,
n_complete: Optional[int] = None,
) -> str:
left = leftover_trials(study_trials, n_finished)
t = int(study_trials or 0)
done = int(n_finished or 0)
ok = int(n_complete) if n_complete is not None else done
if t <= 0:
return ""
if left > 0:
return "남은 %s회 (시도 %s / 목표 %s · COMPLETE %s)" % (left, done, t, ok)
return "목표 도달 (시도 %s / 목표 %s · COMPLETE %s)" % (done, t, ok)
def _owner() -> str:
host = (socket.gethostname() or "host")[:40]
return ("%s:%s" % (host, os.getpid()))[:64]
def _lock_minutes() -> int:
try:
from kis_trader.utils.env import get_env_int
return max(1, int(get_env_int("OPTUNA_STUDY_PP_LOCK_MINUTES", 30)))
except Exception:
return 30
def load_row(study_name: str) -> Optional[Dict[str, Any]]:
name = str(study_name or "").strip()
if not name:
return None
try:
ensure_optuna_study_result_table()
cur = _db().conn.execute(
"SELECT * FROM optuna_study_result WHERE study_name=%s",
(name,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception as exc:
logger.warning("⚠️ optuna_study_result 조회 실패: %s", exc)
return None
def load_payload_dict(study_name: str) -> Optional[Dict[str, Any]]:
row = load_row(study_name)
if not row:
return None
raw = row.get("payload_json")
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except Exception:
return None
def load_phase1_study_for_job(job_id: str) -> Optional[str]:
"""step job_id → 1차 study_name (MariaDB). refine1 우선."""
jid = str(job_id or "").strip()[:64]
if not jid:
return None
try:
ensure_optuna_study_result_table()
cur = _db().conn.execute(
"SELECT study_name FROM optuna_study_result "
"WHERE job_id=%s AND study_name LIKE %s "
"ORDER BY updated_at DESC LIMIT 1",
(jid, "%refine1%"),
)
row = cur.fetchone()
if row:
name = str(row.get("study_name") or "").strip()
if name:
return name
cur = _db().conn.execute(
"SELECT study_name FROM optuna_study_result "
"WHERE job_id=%s ORDER BY updated_at DESC LIMIT 1",
(jid,),
)
row = cur.fetchone()
if row:
return str(row.get("study_name") or "").strip() or None
except Exception as exc:
logger.warning("⚠️ load_phase1_study_for_job 실패 job=%s: %s", jid, exc)
return None
def phase1_payload_ready(study_name: str) -> bool:
"""1차 Top10 narrow 입력용 payload 가 DB에 있는지."""
name = str(study_name or "").strip()
if not name:
return False
data = load_payload_dict(name)
return payload_has_rows(data)
def upsert_counts(
*,
study_name: str,
strategy: str = "",
mode: str = "",
start: str = "",
end: str = "",
job_id: str = "",
symbol: str = "",
study_trials: int = 0,
n_complete: int = 0,
n_running: int = 0,
leftover_n_complete: Optional[int] = None,
) -> None:
"""n_complete = 끝난 시도(게이트). leftover_n_complete = COMPLETE 실수(안내)."""
name = str(study_name or "").strip()
if not name:
return
note = leftover_note(
study_trials,
n_complete,
n_complete=leftover_n_complete if leftover_n_complete is not None else None,
)
needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0
jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
sym = (symbol or "")[:16] or None
try:
ensure_optuna_study_result_table()
_db().conn.execute(
"""
INSERT INTO optuna_study_result (
study_name, strategy, mode, start_date, end_date, job_id, symbol,
study_trials, n_complete, n_running, leftover_note, pp_needed
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
mode=IF(VALUES(mode)='', mode, VALUES(mode)),
start_date=COALESCE(VALUES(start_date), start_date),
end_date=COALESCE(VALUES(end_date), end_date),
job_id=COALESCE(VALUES(job_id), job_id),
symbol=COALESCE(VALUES(symbol), symbol),
study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
n_complete=VALUES(n_complete),
n_running=VALUES(n_running),
leftover_note=VALUES(leftover_note),
pp_needed=VALUES(pp_needed)
""",
(
name,
str(strategy or "")[:32],
str(mode or "")[:32],
(start or "")[:10] or None,
(end or "")[:10] or None,
jid,
sym,
int(study_trials or 0),
int(n_complete or 0),
int(n_running or 0),
note[:200] if note else None,
int(needed),
),
)
except Exception as exc:
logger.warning("⚠️ optuna_study_result upsert 실패: %s", exc)
def payload_has_rows(data: Optional[Dict[str, Any]]) -> bool:
if not isinstance(data, dict):
return False
if data.get("results_gated") or data.get("results_all") or data.get("results") or data.get("results_stable"):
return True
mc = data.get("mode_combo") or {}
return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest")))
def payload_row_count(data: Optional[Dict[str, Any]]) -> int:
if not isinstance(data, dict):
return 0
return max(
len(list(data.get("results_all") or [])),
len(list(data.get("results") or [])),
len(list(data.get("results_gated") or [])),
len(list(data.get("results_stable") or [])),
)
def _payload_has_postprocess(data: Optional[Dict[str, Any]]) -> bool:
if not isinstance(data, dict):
return False
topn = data.get("postprocess_topn") or {}
if isinstance(topn, dict) and topn.get("postprocess_by_anchor"):
return True
mc = data.get("mode_combo") or {}
return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest")))
def save_payload(
*,
study_name: str,
out_data: Dict[str, Any],
n_complete: int = 0,
briefing_text: Optional[str] = None,
pp_status: str = "idle",
pp_error: Optional[str] = None,
job_id: str = "",
) -> None:
name = str(study_name or "").strip()
if not name or not isinstance(out_data, dict):
return
payload = json.dumps(out_data, ensure_ascii=False)
brief = briefing_text
if brief is None:
bp = str(out_data.get("briefing_md_path") or "")
if bp and os.path.isfile(bp):
try:
with open(bp, encoding="utf-8") as f:
brief = f.read()
except Exception:
brief = None
md = str(out_data.get("briefing_md") or "")
if brief is None and md and str(md).endswith(".md") and os.path.isfile(md):
try:
with open(md, encoding="utf-8") as f:
brief = f.read()
except Exception:
brief = None
strat = str(out_data.get("strategy") or "")[:32]
mode = str(out_data.get("mode") or "")[:32]
start = (str(out_data.get("start") or "")[:10] or None)
end = (str(out_data.get("end") or "")[:10] or None)
jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
sym = str(out_data.get("symbol") or "")[:16] or None
st_goal = parse_study_trials_value(out_data.get("optuna_study_trials"))
# 게이트용 = 끝난 시도. COMPLETE만 쓰지 말 것(pruned 있으면 이어돌리기 오판).
n_done = int(
n_complete
or out_data.get("optuna_n_finished")
or out_data.get("optuna_trials_completed")
or out_data.get("optuna_n_complete")
or 0
)
n_ok = int(out_data.get("optuna_n_complete") or 0)
st = str(pp_status or "idle")[:16]
needed = 0 if st == "done" else (1 if leftover_trials(st_goal, n_done) > 0 else 0)
note = leftover_note(st_goal, n_done, n_complete=n_ok if n_ok else None)
try:
ensure_optuna_study_result_table()
_db().conn.execute(
"""
INSERT INTO optuna_study_result (
study_name, strategy, mode, start_date, end_date, job_id, symbol,
study_trials, n_complete, leftover_note, payload_json, briefing_md,
pp_status, pp_error, pp_for_n, pp_needed
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
mode=IF(VALUES(mode)='', mode, VALUES(mode)),
start_date=COALESCE(VALUES(start_date), start_date),
end_date=COALESCE(VALUES(end_date), end_date),
job_id=COALESCE(VALUES(job_id), job_id),
symbol=COALESCE(VALUES(symbol), symbol),
study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
n_complete=IF(VALUES(n_complete)>0, VALUES(n_complete), n_complete),
leftover_note=COALESCE(VALUES(leftover_note), leftover_note),
payload_json=VALUES(payload_json),
briefing_md=COALESCE(VALUES(briefing_md), briefing_md),
pp_status=VALUES(pp_status),
pp_error=VALUES(pp_error),
pp_for_n=IF(VALUES(pp_status)='done', VALUES(pp_for_n), pp_for_n),
pp_needed=IF(VALUES(pp_status)='done', 0, VALUES(pp_needed)),
pp_lock_until=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_until),
pp_lock_owner=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_owner)
""",
(
name, strat, mode, start, end, jid, sym,
int(st_goal or 0), n_done, (note[:200] if note else None), payload, brief,
st, (str(pp_error)[:500] if pp_error else None),
n_done if st == "done" else 0, needed,
),
)
except Exception as exc:
logger.warning("⚠️ optuna_study_result payload 저장 실패: %s", exc)
def ingest_out_data(
data: Dict[str, Any],
*,
job_id: str = "",
replace: bool = False,
) -> Dict[str, Any]:
"""JSON/out_data 한 건을 optuna_study_result 에 넣는다. 더 빈약한 파일로 덮지 않음."""
if not isinstance(data, dict):
return {"ok": False, "reason": "not_dict"}
name = str(data.get("optuna_study_name") or data.get("study_name") or "").strip()
if not name:
return {"ok": False, "reason": "no_study_name"}
old = load_payload_dict(name)
if old and not replace:
old_n = payload_row_count(old)
new_n = payload_row_count(data)
old_pp = _payload_has_postprocess(old)
new_pp = _payload_has_postprocess(data)
if old_pp and not new_pp and old_n >= new_n:
return {"ok": True, "skipped": "keep_richer", "study_name": name}
if old_n > new_n and not new_pp:
return {"ok": True, "skipped": "keep_more_rows", "study_name": name}
n_done = int(
data.get("optuna_n_finished")
or data.get("optuna_trials_completed")
or 0
)
n_ok = int(data.get("optuna_n_complete") or 0)
if n_done <= 0:
n_done = n_ok or payload_row_count(data) or 0
st_goal = parse_study_trials_value(data.get("optuna_study_trials"))
upsert_counts(
study_name=name,
strategy=str(data.get("strategy") or ""),
mode=str(data.get("mode") or ""),
start=str(data.get("start") or ""),
end=str(data.get("end") or ""),
job_id=job_id,
symbol=str(data.get("symbol") or ""),
study_trials=st_goal,
n_complete=n_done,
n_running=0,
leftover_n_complete=n_ok if n_ok else None,
)
pp_done = _payload_has_postprocess(data)
save_payload(
study_name=name,
out_data=data,
n_complete=n_done,
pp_status="done" if pp_done else "idle",
job_id=job_id,
)
return {"ok": True, "study_name": name, "n": payload_row_count(data), "pp": pp_done}
def migrate_optuna_json_dir(
results_dir: str,
*,
jobs_dir: str = "",
) -> Dict[str, Any]:
"""results/*.json + 웹 잡 result_json 을 테이블로 이관."""
from pathlib import Path
seen = set()
ok_n = 0
skip_n = 0
err_n = 0
paths = []
rd = Path(results_dir)
if rd.is_dir():
paths.extend(sorted(rd.glob("optuna_*.json"), key=lambda p: p.stat().st_mtime))
jd = Path(jobs_dir) if jobs_dir else None
if jd and jd.is_dir():
for jp in jd.glob("*.json"):
try:
meta = json.loads(jp.read_text(encoding="utf-8"))
except Exception:
continue
rp = str((meta or {}).get("result_json") or "")
if rp and Path(rp).is_file():
paths.append(Path(rp))
for extra in list((meta or {}).get("result_jsons") or []):
if extra and Path(str(extra)).is_file():
paths.append(Path(str(extra)))
for p in paths:
try:
key = str(p.resolve())
except Exception:
key = str(p)
if key in seen or str(p.name).endswith("_from_db.json"):
continue
seen.add(key)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception as exc:
err_n += 1
logger.warning("⚠️ migrate JSON 읽기 실패 %s: %s", p, exc)
continue
out = ingest_out_data(data, job_id="")
if out.get("skipped"):
skip_n += 1
elif out.get("ok"):
ok_n += 1
else:
err_n += 1
logger.warning("⚠️ migrate skip %s: %s", p.name, out.get("reason"))
return {"ok": True, "ingested": ok_n, "skipped": skip_n, "errors": err_n, "files": len(seen)}
def try_lock_postprocess(study_name: str) -> bool:
name = str(study_name or "").strip()
if not name:
return False
owner = _owner()
mins = _lock_minutes()
try:
ensure_optuna_study_result_table()
cur = _db().conn.execute(
f"""
UPDATE optuna_study_result
SET pp_status='running',
pp_lock_owner=%s,
pp_lock_until=DATE_ADD(NOW(), INTERVAL {int(mins)} MINUTE),
pp_error=NULL
WHERE study_name=%s
AND pp_status<>'running'
AND (pp_lock_until IS NULL OR pp_lock_until < NOW())
""",
(owner, name),
)
return int(getattr(cur, "rowcount", 0) or 0) > 0
except Exception as exc:
logger.warning("⚠️ 후처리 락 실패: %s", exc)
return False
def set_study_trials_target(study_name: str, n: int, storage_url: Optional[str] = None) -> None:
"""확정: 목표를 현재 완료 수로 내린다."""
name = str(study_name or "").strip()
target = max(0, int(n or 0))
if not name:
return
try:
ensure_optuna_study_result_table()
_db().conn.execute(
"""
UPDATE optuna_study_result
SET study_trials=%s,
leftover_note=%s,
pp_needed=0
WHERE study_name=%s
""",
(target, leftover_note(target, target), name),
)
except Exception as exc:
logger.warning("⚠️ study_trials 확정 실패: %s", exc)
if storage_url:
try:
import optuna
st = optuna.load_study(study_name=name, storage=storage_url)
st.set_user_attr(KIS_STUDY_TRIALS_ATTR, target)
except Exception as exc:
logger.warning("⚠️ kis_study_trials user_attr 확정 실패: %s", exc)
def should_run_postprocess(n_finished: int, study_trials: int) -> bool:
"""목표 도달 = 끝난 시도(COMPLETE+PRUNED+FAIL) ≥ kis_study_trials."""
t = int(study_trials or 0)
if t <= 0:
return True
return int(n_finished or 0) >= t
def finalize_optuna_export(
study: Any,
*,
out_data: Dict[str, Any],
out_path: str,
strategy: str,
mode: str,
enrich_fn: Callable[[], None],
log: Optional[logging.Logger] = None,
symbol: str = "",
) -> bool:
"""optimize 이후. 시도 목표 미달이면 후처리 스킵, 도달이면 락 후 기존 enrich."""
lg = log or logger
name = str(getattr(study, "study_name", "") or out_data.get("optuna_study_name") or "")
n_c, n_r, n_f = count_study_states(study)
try:
target = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR))
except Exception:
target = 0
if target <= 0:
target = max(1, int(out_data.get("optuna_n_trials_requested") or 0) or n_f or n_c)
start = str(out_data.get("start") or "")
end = str(out_data.get("end") or "")
# DB n_complete 컬럼 = 끝난 시도(게이트·남은횟수). COMPLETE 실수는 out_data 에.
upsert_counts(
study_name=name,
strategy=str(out_data.get("strategy") or strategy or ""),
mode=str(out_data.get("mode") or mode or ""),
start=start,
end=end,
symbol=str(out_data.get("symbol") or symbol or ""),
study_trials=target,
n_complete=n_f,
n_running=n_r,
leftover_n_complete=n_c,
)
out_data["optuna_study_trials"] = target
out_data["optuna_n_complete"] = n_c
out_data["optuna_n_finished"] = n_f
try:
from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg
annotate_optuna_period_daily_avg(out_data)
except Exception:
pass
row = load_row(name) or {}
if str(row.get("pp_status") or "") == "done" and row.get("payload_json") and int(row.get("pp_for_n") or 0) >= target:
lg.info("📌 후처리 이미 완료 (pp_for_n=%s, 목표=%s) — 스킵", row.get("pp_for_n"), target)
return False
if not should_run_postprocess(n_f, target):
save_payload(
study_name=name,
out_data=out_data,
n_complete=n_f,
pp_status="idle",
)
lg.info(
"📌 스터디 총 횟수 미달 — 후처리 스킵 "
"(시도 %s / 목표 %s · COMPLETE %s). 이어 돌리기 또는 확정.",
n_f, target, n_c,
)
return False
if not try_lock_postprocess(name):
lg.info("📌 다른 워커가 후처리 중/완료 — 이 프로세스는 후처리 생략")
return False
try:
enrich_fn()
save_payload(
study_name=name,
out_data=out_data,
n_complete=n_f,
pp_status="done",
)
lg.info(
"📌 optuna_study_result payload 저장 study=%s 시도=%s COMPLETE=%s",
name, n_f, n_c,
)
return True
except Exception as exc:
save_payload(
study_name=name,
out_data=out_data,
n_complete=n_f,
pp_status="error",
pp_error=str(exc),
)
raise
def flags_for_web(study_name: str) -> Dict[str, Any]:
row = load_row(study_name) or {}
t = int(row.get("study_trials") or 0)
# n_complete 컬럼 = 끝난 시도(게이트용). COMPLETE 실수는 leftover_note 참고.
done = int(row.get("n_complete") or 0)
left = leftover_trials(t, done)
pp = str(row.get("pp_status") or "idle")
has_payload = bool(row.get("payload_json"))
note = row.get("leftover_note") or leftover_note(t, done)
return {
"study_name": study_name,
"study_trials": t,
"n_complete": done,
"n_running": int(row.get("n_running") or 0),
"leftover_trials": left,
"leftover_note": note,
"pp_status": pp,
"pp_needed": int(row.get("pp_needed") or 0),
"has_payload": has_payload,
"can_continue": left > 0,
"can_confirm": left > 0,
}