feat: Enhance Optuna integration and logging for backtesting framework
Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
This commit is contained in:
700
kis_trader/backtest/optuna_study_store.py
Normal file
700
kis_trader/backtest/optuna_study_store.py
Normal file
@@ -0,0 +1,700 @@
|
||||
#!/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 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 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
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user