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:
Your Name
2026-08-15 23:01:14 +09:00
parent 4a18ce2697
commit 36a3e2b4a1
94 changed files with 6368 additions and 1639 deletions

View File

@@ -29,6 +29,44 @@ PY = ROOT / ".venv" / "bin" / "python"
_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")
# #region agent log
def _agent_dbg(hid: str, loc: str, msg: str, data: Optional[Dict[str, Any]] = None) -> None:
try:
payload = {
"sessionId": "4f9616",
"hypothesisId": hid,
"location": loc,
"message": msg,
"data": data or {},
"timestamp": int(time.time() * 1000),
}
with open("/home/hoon/kis_bot/.cursor/debug-4f9616.log", "a", encoding="utf-8") as _df:
_df.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
def _normalize_tail_entry_modes(raw: Any) -> List[str]:
"""웹/CLI: align, limit_atr. 빈값이면 align 1개(기존 TPE와 동일)."""
from kis_trader.backtest.optuna_tail_tpe_space import normalize_tpe_tail_entry_mode
items: List[str] = []
if raw is None or raw is False:
seq: List[Any] = []
elif isinstance(raw, str):
seq = [x for x in raw.replace(",", " ").split() if x.strip()]
elif isinstance(raw, (list, tuple)):
seq = list(raw)
else:
seq = [raw]
for x in seq:
em = normalize_tpe_tail_entry_mode(x)
if em not in items:
items.append(em)
return items or ["align"]
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
@@ -270,6 +308,19 @@ def _study_progress(study_name: str, trials_total: int) -> Dict[str, Any]:
out["study_ok"] = True
except Exception as exc:
out["error"] = str(exc)[:200]
# #region agent log
_agent_dbg(
"A",
"optuna_web_jobs.py:_study_progress",
"study_progress",
{
"study_name": study_name,
"study_ok": out.get("study_ok"),
"trials_done": out.get("trials_done"),
"error": out.get("error"),
},
)
# #endregion
return out
@@ -378,6 +429,38 @@ def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]:
except Exception:
trail_rec = None
# 과적합·임계값 분포 (JSON에 없어도 웹에서 즉시 계산 — 구결과·진행중 완료 공용)
overfit = data.get("overfit_diagnostics")
if not isinstance(overfit, dict) or overfit.get("overfit_risk_pct") is None:
try:
from kis_trader.backtest.optuna_common import build_optuna_overfit_diagnostics
overfit = build_optuna_overfit_diagnostics(data)
except Exception:
overfit = None
post_topn = data.get("postprocess_topn")
if not isinstance(post_topn, dict) or not post_topn.get("postprocess_by_anchor"):
try:
from kis_trader.backtest.optuna_postprocess_topn import attach_topn_postprocess
# 웹 새로고침: 호가 1000회 재탐색 금지(DB/CPU). 트레일·과적합%만.
attach_topn_postprocess(
data, evaluate_fn=None, log=None, run_ob_whipsaw=False,
)
post_topn = data.get("postprocess_topn")
except Exception:
post_topn = None
apply_overfit_pct = None
apply_overfit_verdict = None
apply_overfit_verdict_ui = None
if isinstance(post_topn, dict):
apply_overfit_pct = post_topn.get("apply_overfit_pct")
apply_overfit_verdict = post_topn.get("apply_overfit_verdict")
apply_overfit_verdict_ui = post_topn.get("apply_overfit_verdict_ui")
if apply_overfit_pct is None and isinstance(overfit, dict):
apply_overfit_pct = overfit.get("overfit_risk_pct")
apply_overfit_verdict = overfit.get("verdict")
return {
"strategy": data.get("strategy"),
"mode": data.get("mode"),
@@ -399,14 +482,23 @@ def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]:
"top5_learn": top5_learn,
"top5_stable": top5_stable,
"mode_combo_note": mc.get("note"),
"daily_trail_recommend": trail_rec,
# 후처리 추천 (탐색 trial 아님) — 웹 Optuna 탭 표용. STOP_OB 합의는 아직 없음.
"daily_trail_recommend": (
data.get("daily_trail_recommend")
if isinstance(data.get("daily_trail_recommend"), dict)
else trail_rec
),
# 후처리 추천 (탐색 trial 아님) — 웹 Optuna 탭 표용.
"orderbook_recommend": data.get("orderbook_recommend")
if isinstance(data.get("orderbook_recommend"), dict)
else (mc.get("orderbook_recommend") if isinstance(mc.get("orderbook_recommend"), dict) else None),
"whipsaw_recommend": data.get("whipsaw_recommend")
if isinstance(data.get("whipsaw_recommend"), dict)
else (mc.get("whipsaw_recommend") if isinstance(mc.get("whipsaw_recommend"), dict) else None),
"postprocess_topn": post_topn if isinstance(post_topn, dict) else None,
"apply_overfit_pct": apply_overfit_pct,
"apply_overfit_verdict": apply_overfit_verdict,
"apply_overfit_verdict_ui": apply_overfit_verdict_ui,
"overfit_diagnostics": overfit,
"apply_ready": bool(gate0) and float(gate0.get("total_pnl") or 0) > 0,
"apply_stable_ready": bool(stab0) and float(stab0.get("total_pnl") or 0) > 0,
"briefing_md": briefing,
@@ -511,11 +603,14 @@ def apply_optuna_result(
symbol: Optional[str] = None,
exchange: Optional[str] = None,
stock_group: Optional[str] = None,
upto: str = "base",
) -> Dict[str, Any]:
"""
Optuna JSON 후보 → 전략별 apply_params_to_db.
source: gated | stable | learn | mode
rank: gated/stable/learn 1-based
upto: base|entry|exit|stop|whipsaw|trail
base=타점만. entry…whipsaw=누적 후처리. trail=합의 트레일만(타점 미적용).
symbol: us_momentum 종목 cfg 적용 시 (없으면 job/JSON 메타 또는 전역)
"""
path = result_json
@@ -548,6 +643,9 @@ def apply_optuna_result(
src = str(source or "gated").strip().lower()
rank = max(1, int(rank or 1))
upto_s = str(upto or "base").strip().lower() or "base"
if upto_s not in ("base", "entry", "exit", "stop", "whipsaw", "trail"):
raise ValueError("upto 는 base|entry|exit|stop|whipsaw|trail 만")
item: Optional[Dict[str, Any]] = None
merged: Dict[str, Any] = {}
@@ -592,7 +690,7 @@ def apply_optuna_result(
"worst_day_pnl": item.get("worst_day_pnl"),
}
if pnl <= 0 and not allow_non_positive_pnl:
if upto_s != "trail" and pnl <= 0 and not allow_non_positive_pnl:
raise RuntimeError(
f"total_pnl={pnl} ≤ 0 — DB 미적용. 강제 시 allow_non_positive_pnl=true",
)
@@ -606,51 +704,65 @@ def apply_optuna_result(
).strip().upper()
env_id = None
if strat == "momentum":
from kis_trader.backtest.param_search_momentum import apply_params_to_db
env_id = apply_params_to_db(merged)
elif strat == "us_momentum":
from kis_trader.backtest.param_search_momentum import apply_params_to_db_us
if exchange:
merged["exchange"] = str(exchange).strip() or merged.get("exchange")
if stock_group:
merged["stock_group"] = str(stock_group).strip() or merged.get("stock_group")
env_id = apply_params_to_db_us(merged, symbol=sym)
elif strat == "breakout":
from kis_trader.backtest.param_search_breakout import apply_params_to_db
apply_params_to_db(merged)
elif strat == "scalp":
from kis_trader.backtest.param_search_scalping import apply_params_to_db
apply_params_to_db(merged)
elif strat == "tail":
from kis_trader.backtest.tail_param_search import apply_params_to_db
apply_params_to_db(merged)
else:
raise ValueError(strat)
# apply 시에만 다단트레일 추천값 → 전략별 *_DAILY_PROFIT_* (탐색 축 아님)
# 종목 cfg 적용 시 전역 다단트레일 오염 금지
axis_patch: Dict[str, str] = {}
axis_notes: List[str] = []
trail_apply: Dict[str, Any] = {"applied": False}
if not sym:
try:
from kis_trader.backtest.optuna_daily_trail_recommend import (
apply_daily_trail_recommend_from_optuna_json,
recommend_from_optuna_out_data,
)
if not data.get("daily_trail_recommend"):
data["daily_trail_recommend"] = recommend_from_optuna_out_data(data)
trail_apply = apply_daily_trail_recommend_from_optuna_json(
str(path), strategy=strat,
)
except Exception as exc:
trail_apply = {"applied": False, "error": str(exc)}
if upto_s == "trail":
if sym:
trail_apply = {"applied": False, "reason": f"stock_cfg:{sym}"}
else:
try:
from kis_trader.backtest.optuna_daily_trail_recommend import (
apply_daily_trail_recommend_from_optuna_json,
recommend_from_optuna_out_data,
)
if not data.get("daily_trail_recommend"):
data["daily_trail_recommend"] = recommend_from_optuna_out_data(data)
trail_apply = apply_daily_trail_recommend_from_optuna_json(
str(path), strategy=strat,
)
except Exception as exc:
trail_apply = {"applied": False, "error": str(exc)}
else:
trail_apply = {"applied": False, "reason": f"stock_cfg:{sym}"}
if strat == "momentum":
from kis_trader.backtest.param_search_momentum import apply_params_to_db
env_id = apply_params_to_db(merged)
elif strat == "us_momentum":
from kis_trader.backtest.param_search_momentum import apply_params_to_db_us
if exchange:
merged["exchange"] = str(exchange).strip() or merged.get("exchange")
if stock_group:
merged["stock_group"] = str(stock_group).strip() or merged.get("stock_group")
env_id = apply_params_to_db_us(merged, symbol=sym)
elif strat == "breakout":
from kis_trader.backtest.param_search_breakout import apply_params_to_db
apply_params_to_db(merged)
elif strat == "scalp":
from kis_trader.backtest.param_search_scalping import apply_params_to_db
apply_params_to_db(merged)
elif strat == "tail":
from kis_trader.backtest.tail_param_search import apply_params_to_db
apply_params_to_db(merged)
else:
raise ValueError(strat)
if not sym:
from kis_trader.backtest.optuna_postprocess_topn import build_upto_env_patch
axis_patch, axis_notes = build_upto_env_patch(
data=data, source=src, rank=rank, upto=upto_s, strategy=strat,
)
if axis_patch:
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
apply_env_patch(axis_patch)
else:
axis_notes.append(f"종목cfg({sym}) — 후처리 env 전역 패치 생략")
if meta is not None:
meta["applied_at"] = _now_iso()
meta["applied_source"] = src
meta["applied_rank"] = rank
meta["applied_upto"] = upto_s
meta["applied_trial"] = metrics.get("optuna_trial_number")
meta["applied_symbol"] = sym or None
meta["daily_trail_applied"] = bool(trail_apply.get("applied"))
@@ -663,25 +775,36 @@ def apply_optuna_result(
next((k for k in patch if k.endswith("_DAILY_PROFIT_TRAIL_TIERS")), ""),
"",
)
trail_note = f" · 다단트레일 추천 반영 tiers={tiers}"
elif trail_apply.get("reason"):
trail_note = f" · 다단트레일 합의 반영 tiers={tiers}"
elif trail_apply.get("reason") and upto_s == "trail":
trail_note = f" · 다단트레일 미반영({trail_apply.get('reason')})"
axis_note = ""
if axis_patch:
axis_note = " · 후처리키 " + ",".join(sorted(axis_patch.keys()))
elif axis_notes:
axis_note = " · " + "; ".join(axis_notes)
apply_target = f"stock_config:{sym}" if (strat == "us_momentum" and sym) else "global"
return {
"ok": True,
"strategy": strat,
"source": src,
"rank": rank,
"upto": upto_s,
"symbol": sym or None,
"apply_target": apply_target,
"env_id": env_id,
"metrics": metrics,
"result_json": str(path),
"daily_trail_apply": trail_apply,
"axis_patch": axis_patch,
"axis_notes": axis_notes,
"note": (
"TIME_* 는 session_env_patch 기본 OFF — 운영 시간창 유지"
+ ("" if upto_s == "trail" else " · 타점 적용(upto=" + upto_s + ")")
+ trail_note
+ axis_note
),
}
@@ -786,13 +909,78 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
m["briefing_mds"] = paths.get("briefing_mds", [])
prog = _study_progress(str(m.get("study_name") or ""), int(m.get("trials") or 0))
# 순차(seq/seq4): 마스터 로그에서 현재 전략 힌트
# 순차(seq/seq4): 마스터 로그는 START/DONE만 찍힘 → trial 로그·study는 전략별 파일
active_log = log_path
if m.get("kind") in ("seq4", "seq") and log_path:
tail = _tail_text(log_path, 30)
mm = re.findall(r"\[(momentum|us_momentum|tail|breakout|scalp)\] START", tail)
# master + 전략 로그에 START 줄이 흩어질 수 있어 둘 다 스캔
master_blob = (_tail_text(log_path, 80) or "") + "\n"
try:
side = ROOT / "logs" / "optuna_4strat_tpe_latest_master.logpath"
if side.is_file():
master_file = Path(side.read_text(encoding="utf-8").strip())
if master_file.is_file():
master_blob += _tail_text(str(master_file), 80) or ""
except Exception:
pass
mm = re.findall(
r"\[(momentum|us_momentum|tail|breakout|scalp)\] START[^\n]*",
master_blob,
)
mm_slash = re.findall(
r"\[(momentum|us_momentum|tail|breakout|scalp)(?:/[^\]]+)?\] START[^\n]*",
master_blob,
)
# #region agent log
_agent_dbg(
"A",
"optuna_web_jobs.py:refresh_job_status:regex",
"seq_start_regex",
{
"job_id": m.get("job_id"),
"meta_study": m.get("study_name"),
"mm_n": len(mm),
"mm_last": (mm[-1] if mm else None),
"mm_slash_n": len(mm_slash),
"mm_slash_last": (mm_slash[-1] if mm_slash else None),
"sidecar_tail_exists": (ROOT / "logs" / "optuna_tail_tpe_latest.study").is_file(),
},
)
# #endregion
if mm:
m["current_strategy"] = mm[-1]
if "ALL DONE" in tail:
last = mm[-1]
m_cs = re.match(
r"\[?(momentum|us_momentum|tail|breakout|scalp)\]?",
last,
)
if m_cs:
m["current_strategy"] = m_cs.group(1)
# START 줄에 study=... 있으면 그 study로 진행률 (seq_* 가상명은 DB에 없음)
sm = re.search(r"study=([^\s]+)", last)
if sm:
m["active_study_name"] = sm.group(1).strip()
prog = _study_progress(m["active_study_name"], int(m.get("trials") or 0))
cs = str(m.get("current_strategy") or "").strip().lower()
if cs:
# 전략별 로그/study 사이드카 (스크립트가 갱신)
for side_name, key in (
(f"optuna_{cs}_tpe_latest.logpath", "active_log_path"),
(f"optuna_{cs}_tpe_latest.study", "active_study_name"),
):
sp = ROOT / "logs" / side_name
try:
if sp.is_file():
val = sp.read_text(encoding="utf-8").strip()
if val:
m[key] = val
except Exception:
pass
if m.get("active_study_name") and not prog.get("study_ok"):
prog = _study_progress(
str(m["active_study_name"]), int(m.get("trials") or 0)
)
if m.get("active_log_path") and Path(str(m["active_log_path"])).is_file():
active_log = str(m["active_log_path"])
if "ALL DONE" in master_blob:
m["status"] = "done"
alive = False
@@ -802,6 +990,21 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
if rj and Path(str(rj)).is_file() and (
"OPTUNA_RESULT_JSON=" in (_tail_text(log_path, 40) or "")
):
# #region agent log
_agent_dbg(
"B",
"optuna_web_jobs.py:refresh_job_status:force_dead",
"result_json_forces_not_alive",
{
"job_id": m.get("job_id"),
"kind": m.get("kind"),
"pid": pid,
"pid_was_alive": alive,
"rj": str(rj)[-80:],
"status_before": m.get("status"),
},
)
# #endregion
alive = False
if alive:
m["status"] = "running"
@@ -866,7 +1069,32 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
m["progress"] = prog
m["pid_alive"] = alive
m["log_tail"] = _tail_text(log_path, 25)
# #region agent log
_agent_dbg(
"A",
"optuna_web_jobs.py:refresh_job_status:out",
"refresh_out",
{
"job_id": m.get("job_id"),
"kind": m.get("kind"),
"status": m.get("status"),
"pid_alive": alive,
"current_strategy": m.get("current_strategy"),
"active_study": m.get("active_study_name"),
"meta_study": m.get("study_name"),
"study_ok": prog.get("study_ok"),
"trials_done": prog.get("trials_done"),
"pct": prog.get("pct"),
"prog_err": prog.get("error"),
},
)
# #endregion
# seq: 웹 미리보기는 전략별 trial 로그 (마스터는 START만 있어 “안 올라가는” 것처럼 보임)
m["log_tail"] = _tail_text(active_log, 25)
if active_log and active_log != log_path:
m["display_log_path"] = active_log
else:
m["display_log_path"] = log_path or None
m["result_summary"] = _summarize_result_json(m.get("result_json"))
if m.get("briefing_md") and Path(str(m["briefing_md"])).is_file():
try:
@@ -958,8 +1186,10 @@ def start_optuna_job(
mode: str = "tpe",
symbol: Optional[str] = None,
universe_history_source: Optional[str] = None,
candle_source: Optional[str] = None,
tick_source: Optional[str] = None,
ob_source: Optional[str] = None,
entry_modes: Optional[Any] = None,
) -> Dict[str, Any]:
"""
subprocess 로 Optuna 시작. apply-best 없음.
@@ -967,6 +1197,8 @@ def start_optuna_job(
(레거시 strategy='all' → 국내 4순차, 해외 미포함)
symbol: us_momentum 종목 cfg Optuna (1종목 유니버스). 순차잡과 병행 불가.
universe_history_source: kiwoom|ls (저장 후보 이력 테이블).
candle_source: ''|kis|kiwoom — CANDLE_SOURCE / --candle-source (실매 읽기쌍과 동일).
entry_modes: 꼬리 TPE 고정 진입 align|limit_atr. 둘 다=순차 2스터디(한 스터디에 섞지 않음).
"""
_ensure_dirs()
running = find_running_jobs()
@@ -993,6 +1225,11 @@ def start_optuna_job(
if sym:
if len(picked) != 1 or picked[0] != "us_momentum":
raise ValueError("종목 Optuna(--symbol)는 us_momentum 단독만 가능")
candle_source = str(candle_source or "").strip().lower() or None
if candle_source and candle_source not in ("kis", "kiwoom"):
raise ValueError("candle_source 는 kis|kiwoom|빈값만")
tick_source = str(tick_source or "").strip().lower() or None
ob_source = str(ob_source or "").strip().lower() or None
from kis_trader.backtest.universe_history_source import (
resolve_backtest_universe_history_source,
@@ -1020,7 +1257,13 @@ def start_optuna_job(
"scalp": "스캘핑",
}
if len(picked) >= 2:
tail_ems = _normalize_tail_entry_modes(entry_modes)
if "tail" not in picked:
tail_ems = ["align"]
tail_dual = "tail" in picked and len(tail_ems) >= 2
use_seq = len(picked) >= 2 or tail_dual
if use_seq:
job_id = f"opt_{ts}_seq"
log_path = ROOT / "logs" / f"optuna_web_seq_{ts}.log"
study_name = f"seq_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
@@ -1037,6 +1280,7 @@ def start_optuna_job(
env["MIN_TRADES"] = "1"
env["STRATEGIES"] = " ".join(picked)
env["UNIVERSE_HISTORY_SOURCE"] = hist_src
env["TAIL_OPTUNA_ENTRY_MODES"] = " ".join(tail_ems if "tail" in picked else ["align"])
if candle_source:
env["CANDLE_SOURCE"] = candle_source
if tick_source:
@@ -1044,7 +1288,10 @@ def start_optuna_job(
if ob_source:
env["OB_SOURCE"] = ob_source
kind = "seq"
label = "순차(" + "+".join(_labels.get(s, s) for s in picked) + ")"
_lab = "+".join(_labels.get(s, s) for s in picked)
if "tail" in picked and tail_ems:
_lab = _lab.replace("꼬리", "꼬리(" + "+".join(tail_ems) + ")")
label = "순차(" + _lab + ")"
strat_field = ",".join(picked)
else:
strat = picked[0]
@@ -1055,6 +1302,13 @@ def start_optuna_job(
)
log_path = ROOT / "logs" / f"optuna_web_usmom_{sym}_{ts}.log"
label = f"해외모멘텀·종목 {sym}"
elif strat == "tail":
_em = tail_ems[0]
study_name = (
f"{strat}_{_em}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
)
log_path = ROOT / "logs" / f"optuna_web_{strat}_{_em}_{ts}.log"
label = f"꼬리({_em})"
else:
study_name = f"{strat}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
log_path = ROOT / "logs" / f"optuna_web_{strat}_{ts}.log"
@@ -1078,6 +1332,8 @@ def start_optuna_job(
"--sort-by", sort_by,
"--universe-history-source", hist_src,
]
if strat == "tail":
cmd.extend(["--entry-mode", tail_ems[0]])
if candle_source:
cmd.extend(["--candle-source", candle_source])
env["CANDLE_SOURCE"] = candle_source
@@ -1115,6 +1371,10 @@ def start_optuna_job(
"study_name": study_name,
"symbol": sym or None,
"universe_history_source": hist_src,
"candle_source": candle_source,
"tick_source": tick_source,
"ob_source": ob_source,
"tail_entry_modes": tail_ems if "tail" in picked else None,
"log_path": str(log_path),
"pid": int(proc.pid),
"status": "running",
@@ -1135,6 +1395,93 @@ def start_optuna_job(
return refresh_job_status(meta)
def start_postprocess_rerun(job_id: str) -> Dict[str, Any]:
"""완료 잡의 result JSON 에 축별 후처리를 백그라운드로 다시 붙인다. 실매 DB 미적용."""
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(f"job not found: {job_id}")
if meta.get("status") != "done":
raise RuntimeError("완료된 Optuna 잡만 후처리 재실행 가능")
running = find_running_jobs()
if running:
raise RuntimeError(
f"이미 실행 중 job={running[0].get('job_id')} — 끝난 뒤 후처리 재실행"
)
pp = dict(meta.get("postprocess_rerun") or {})
if str(pp.get("status") or "") == "running" and _pid_alive(pp.get("pid")):
raise RuntimeError("이미 이 잡 후처리 재실행 중")
path = str(meta.get("result_json") or "")
if not path or not Path(path).is_file():
raise FileNotFoundError("result_json 없음")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = ROOT / "logs" / f"optuna_postprocess_rerun_{job_id}_{ts}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
import sys as _sys
py_bin = str(PY) if PY.is_file() else _sys.executable
cmd = [
py_bin, "-u",
str(ROOT / "kis_trader" / "backtest" / "optuna_rerun_postprocess.py"),
"--result-json", path,
]
log_f = open(log_path, "w", encoding="utf-8")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
meta["postprocess_rerun"] = {
"status": "running",
"pid": int(proc.pid),
"log_path": str(log_path),
"started_at": _now_iso(),
"result_json": path,
}
save_job(meta)
def _pp_reaper() -> None:
rc: Optional[int] = None
try:
rc = int(proc.wait())
except Exception:
rc = int(proc.poll()) if proc.poll() is not None else None
try:
if log_f is not None and hasattr(log_f, "closed") and not log_f.closed:
log_f.flush()
log_f.close()
except Exception:
pass
try:
m2 = load_job(job_id)
if not m2:
return
info = dict(m2.get("postprocess_rerun") or {})
info["status"] = "done" if rc == 0 else "error"
info["exit_code"] = rc
info["finished_at"] = _now_iso()
if rc not in (None, 0):
info["error"] = "exit_code=%s" % rc
m2["postprocess_rerun"] = info
save_job(m2)
refresh_job_status(m2)
except Exception:
pass
threading.Thread(target=_pp_reaper, daemon=True).start()
return {
"ok": True,
"job_id": job_id,
"log_path": str(log_path),
"pid": int(proc.pid),
"note": "후처리 재실행 중. 캔들/틱 DB 재사용. 끝나면 JSON에 축별 추천이 저장됩니다.",
}
def stop_optuna_job(job_id: str) -> Dict[str, Any]:
"""프로세스 그룹 kill (선택). 결과는 보장하지 않음."""
meta = load_job(job_id)