feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선

변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -4,7 +4,7 @@ kis_trader/backtest/param_search_optuna.py — Optuna TPE 파라미터 탐색 (
==============================================================================
기존 Grid CLI(tail_param_search.py 등)는 그대로 두고, Bayesian(TPE) add-on.
현재 구현: --strategy tail | momentum | breakout
현재 구현: --strategy tail | momentum | breakout | scalp
실행 예:
# 꼬리
@@ -13,6 +13,8 @@ kis_trader/backtest/param_search_optuna.py — Optuna TPE 파라미터 탐색 (
python3 kis_trader/backtest/param_search_optuna.py --strategy momentum --mode fast --trials 200
# 돌파
python3 kis_trader/backtest/param_search_optuna.py --strategy breakout --mode fast --trials 200
# 스캘핑 RSI V자 (trigger=진입 / exit=청산)
python3 kis_trader/backtest/param_search_optuna.py --strategy scalp --mode trigger --trials 100
Win11 + VM 동시 분산: 같은 study-name · 같은 storage(141/kis_optuna) 로 각각 --trials 실행.
@@ -51,10 +53,13 @@ from database import TradeDB
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.backtest.optuna_common import (
OPTUNA_STRATEGIES,
announce_optuna_json_path,
optuna_run_lock_name,
release_shared_tick_store,
resolve_optuna_storage_url,
resolve_study_name,
)
from kis_trader.backtest.optuna_mode_combo import enrich_out_data_with_mode_combo
from kis_trader.backtest.optuna_breakout import (
apply_best_breakout_trial,
prepare_breakout_search_context,
@@ -65,6 +70,11 @@ from kis_trader.backtest.optuna_momentum import (
prepare_momentum_search_context,
run_momentum_optuna,
)
from kis_trader.backtest.optuna_scalping import (
apply_best_scalp_trial,
prepare_scalp_search_context,
run_scalp_optuna,
)
from kis_trader.backtest.optuna_search_space import suggest_tail_params, tail_grid_axis_keys
from kis_trader.backtest.param_search_cli_common import (
add_portfolio_cli_args,
@@ -92,9 +102,10 @@ _FAIL_OBJECTIVE = -1e18
# 전략별 --mode 허용값 (Grid CLI 와 동일)
STRATEGY_MODES: Dict[str, List[str]] = {
"tail": ["fast", "coarse", "fine", "full", "massive"],
"momentum": ["fast", "rr", "coarse", "fine", "full"],
"breakout": ["fast", "coarse", "fine", "full"],
"tail": ["fast", "coarse", "fine", "wide", "full", "massive"],
"momentum": ["fast", "exit", "rr", "coarse", "fine", "wide", "full"],
"breakout": ["fast", "coarse", "fine", "wide", "full"],
"scalp": ["fast", "trigger", "exit", "coarse", "fine", "full", "wide"],
}
@@ -171,8 +182,8 @@ def prepare_tail_search_context(
"적용" if ob_filter_on else "스킵 — 코어 파라미터 순수 탐색",
)
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
r = dict(row) if row else {}
from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row
r = load_portfolio_env_row(db)
fee_rate, sell_tax, _slot_from_fee = tbc.fee_and_slot_from_env_row(r)
portfolio = tbc.resolve_tail_portfolio_params(
r,
@@ -221,10 +232,15 @@ def prepare_tail_search_context(
base_params = dict(base_params)
base_params["scan_interval_min"] = scan_interval_min
base_params["timeframe"] = tail_tf
base_params.setdefault("backtest_tick_fallback_ohlc", True)
from kis_trader.engine.tail_tick_replay import tail_backtest_use_tick_db as _tail_use_tick
from kis_trader.engine.tail_tick_replay import (
tail_backtest_tick_fallback_ohlc as _tail_tick_fallback,
tail_backtest_use_tick_db as _tail_use_tick,
tail_backtest_use_tick_exit as _tail_use_tick_exit,
)
base_params.setdefault("backtest_tick_fallback_ohlc", _tail_tick_fallback(None))
base_params.setdefault("backtest_use_tick_db", _tail_use_tick(None))
if base_params.get("backtest_use_tick_db"):
base_params.setdefault("backtest_use_tick_exit", _tail_use_tick_exit(None))
if base_params.get("backtest_use_tick_db") or base_params.get("backtest_use_tick_exit"):
logger.info("📌 틱재생(ws_ticks): ON — 실매 체결 정합 모드 (OHLC 낙관편향 제거)")
logger.info(
@@ -452,111 +468,155 @@ def run_tail_optuna(
n_jobs=n_jobs,
show_progress_bar=show_progress,
)
finally:
# 탐색 종료(또는 예외) 시 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치).
_store = getattr(ctx, "shared_tick_store", None)
if _store is not None:
elapsed = time.time() - t0
logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials))
# JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전)
passing: List[Dict[str, Any]] = []
for trial in study.trials:
if trial.state != optuna.trial.TrialState.COMPLETE:
continue
if not trial.user_attrs.get("gates_ok"):
continue
params_raw = trial.user_attrs.get("params_json") or "{}"
try:
_store.unlink()
except Exception:
pass
ctx.shared_tick_store = None
elapsed = time.time() - t0
logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials))
combo = json.loads(params_raw)
except json.JSONDecodeError:
combo = dict(trial.params)
passing.append({
"params": combo,
"apply_cfg": {**ctx.base_params, **combo},
"total_trades": int(trial.user_attrs.get("total_trades") or 0),
"win_rate": float(trial.user_attrs.get("win_rate") or 0),
"total_pnl": float(trial.user_attrs.get("total_pnl") or 0),
"pf": float(trial.user_attrs.get("pf") or 0),
"optuna_trial_number": trial.number,
})
if sort_by == "pnl":
passing.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["win_rate"])))
else:
passing.sort(key=lambda r: (-float(r["win_rate"]), -float(r["total_pnl"])))
# JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전)
passing: List[Dict[str, Any]] = []
for trial in study.trials:
if trial.state != optuna.trial.TrialState.COMPLETE:
continue
if not trial.user_attrs.get("gates_ok"):
continue
params_raw = trial.user_attrs.get("params_json") or "{}"
profitable = [r for r in passing if float(r.get("total_pnl") or 0) > 0]
if profitable:
passing = profitable
out_data = {
"engine": "optuna",
"mode": ctx.mode,
"start": ctx.start,
"end": ctx.end,
"timeframe": ctx.tail_tf,
"universe_source": ctx.universe_source,
"universe_history_slots": ctx.universe_history_slots,
"slot_money": int(ctx.slot_money),
"max_stocks": ctx.max_stocks,
"total_budget_krw": int(ctx.total_budget_krw),
"portfolio_mode": True,
"budget_warning": ctx.portfolio.get("budget_warning"),
"backtest_days": ctx.period_days,
"min_trades": min_trades,
"min_win_rate": min_win_rate,
"min_pf": min_pf,
"sort_by": sort_by,
"grid_keys": ctx.grid_keys,
"grid_axis_hints": {k: TAIL_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in TAIL_GRID_AXIS_HINTS_KO},
"holding_peak_in_candles": ctx.has_holding_peak,
"optuna_study_name": study_name,
"optuna_storage": storage_url,
"optuna_n_trials_requested": n_trials,
"optuna_trials_completed": len(study.trials),
"optuna_best_value": study.best_value if study.best_trial else None,
"optuna_best_trial_number": study.best_trial.number if study.best_trial else None,
"elapsed_sec": round(elapsed, 1),
"results": passing[:5000],
}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_name = f"optuna_tail_{ctx.mode}_{ts}.json"
out_dir = _results_dir_for_write()
out_path = os.path.join(out_dir, out_name)
try:
combo = json.loads(params_raw)
except json.JSONDecodeError:
combo = dict(trial.params)
passing.append({
"params": combo,
"apply_cfg": {**ctx.base_params, **combo},
"total_trades": int(trial.user_attrs.get("total_trades") or 0),
"win_rate": float(trial.user_attrs.get("win_rate") or 0),
"total_pnl": float(trial.user_attrs.get("total_pnl") or 0),
"pf": float(trial.user_attrs.get("pf") or 0),
"optuna_trial_number": trial.number,
})
if sort_by == "pnl":
passing.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["win_rate"])))
else:
passing.sort(key=lambda r: (-float(r["win_rate"]), -float(r["total_pnl"])))
profitable = [r for r in passing if float(r.get("total_pnl") or 0) > 0]
if profitable:
passing = profitable
out_data = {
"engine": "optuna",
"mode": ctx.mode,
"start": ctx.start,
"end": ctx.end,
"timeframe": ctx.tail_tf,
"universe_source": ctx.universe_source,
"universe_history_slots": ctx.universe_history_slots,
"slot_money": int(ctx.slot_money),
"max_stocks": ctx.max_stocks,
"total_budget_krw": int(ctx.total_budget_krw),
"portfolio_mode": True,
"budget_warning": ctx.portfolio.get("budget_warning"),
"backtest_days": ctx.period_days,
"min_trades": min_trades,
"min_win_rate": min_win_rate,
"min_pf": min_pf,
"sort_by": sort_by,
"grid_keys": ctx.grid_keys,
"grid_axis_hints": {k: TAIL_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in TAIL_GRID_AXIS_HINTS_KO},
"holding_peak_in_candles": ctx.has_holding_peak,
"optuna_study_name": study_name,
"optuna_storage": storage_url,
"optuna_n_trials_requested": n_trials,
"optuna_trials_completed": len(study.trials),
"optuna_best_value": study.best_value if study.best_trial else None,
"optuna_best_trial_number": study.best_trial.number if study.best_trial else None,
"elapsed_sec": round(elapsed, 1),
"results": passing[:5000],
}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_name = f"optuna_tail_{ctx.mode}_{ts}.json"
out_dir = _results_dir_for_write()
out_path = os.path.join(out_dir, out_name)
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
except OSError:
fb = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results")
os.makedirs(fb, exist_ok=True)
out_path = os.path.join(fb, out_name)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
logger.warning("⚠️ results/ 쓰기 권한 없음 → 폴백 저장: %s", out_path)
logger.info("💾 Optuna 결과 저장: %s", out_path)
if study.best_trial and study.best_value > _FAIL_OBJECTIVE + 1:
bt = study.best_trial
logger.info(
"🏆 Best trial #%d | objective=%.4g | pnl=%s | wr=%.1f%% | trades=%s",
bt.number,
study.best_value,
bt.user_attrs.get("total_pnl"),
float(bt.user_attrs.get("win_rate") or 0),
bt.user_attrs.get("total_trades"),
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
except OSError:
fb = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results")
os.makedirs(fb, exist_ok=True)
out_path = os.path.join(fb, out_name)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
logger.warning("⚠️ results/ 쓰기 권한 없음 → 폴백 저장: %s", out_path)
announce_optuna_json_path(
out_path, strategy="tail", mode=ctx.mode, note="중간저장(mode 전)", log=logger,
)
else:
logger.info("⚠️ 조건 만족 trial 없음 (min_trades·승률·PF 게이트 확인)")
study._kis_export_path = out_path # type: ignore[attr-defined]
return study
def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return evaluate_tail_param_combo(
combo,
base_params=ctx.base_params,
candles_by_code=ctx.candles_by_code,
fee_rate=ctx.fee_rate,
sell_tax=ctx.sell_tax,
min_trades=1,
min_win_rate=0.0,
min_pf=0.0,
universe_by_slot=ctx.universe_by_slot,
slot_money=ctx.slot_money,
max_stocks=ctx.max_stocks,
total_budget_krw=ctx.total_budget_krw,
period_days=ctx.period_days,
cache_holder=ctx.cache_holder,
ticks_by_code=ctx.ticks_by_code,
orderbook_by_code=ctx.orderbook_by_code,
program_by_code=ctx.program_by_code,
log_verdict_by_code=ctx.log_verdict_by_code,
)
def _save_partial(_data: Dict[str, Any]) -> None:
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(_data, f, indent=2, ensure_ascii=False)
except OSError as exc:
logger.warning("⚠️ mode_combo 부분저장 실패: %s", exc)
return
announce_optuna_json_path(
out_path, strategy="tail", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger,
)
enrich_out_data_with_mode_combo(
out_data,
evaluate_fn=_eval_mode,
grid_keys=ctx.grid_keys,
log=logger,
on_partial_save=_save_partial,
)
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
except OSError as exc:
logger.warning("⚠️ mode_combo 반영 재저장 실패: %s", exc)
announce_optuna_json_path(
out_path, strategy="tail", mode=ctx.mode, note="최종 JSON", log=logger,
)
if study.best_trial and study.best_value > _FAIL_OBJECTIVE + 1:
bt = study.best_trial
logger.info(
"🏆 Best trial #%d | objective=%.4g | pnl=%s | wr=%.1f%% | trades=%s",
bt.number,
study.best_value,
bt.user_attrs.get("total_pnl"),
float(bt.user_attrs.get("win_rate") or 0),
bt.user_attrs.get("total_trades"),
)
else:
logger.info("⚠️ 조건 만족 trial 없음 (min_trades·승률·PF 게이트 확인)")
study._kis_export_path = out_path # type: ignore[attr-defined]
return study
finally:
# mode_combo 실측이 ticks 공유뷰를 쓰므로 optimize 직후 unlink 금지
release_shared_tick_store(ctx, log=logger)
def apply_best_trial(study: optuna.Study, ctx: TailSearchContext) -> bool:
@@ -579,18 +639,18 @@ def apply_best_trial(study: optuna.Study, ctx: TailSearchContext) -> bool:
def main() -> None:
today = datetime.now().strftime("%Y-%m-%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
from kis_trader.backtest.param_search_dates import resolve_param_search_range
week_ago, today = resolve_param_search_range("TAIL", lookback_days=7)
parser = argparse.ArgumentParser(
description="Optuna TPE 파라미터 탐색 (Grid CLI add-on, storage=MariaDB 141 기본)",
)
parser.add_argument(
"--strategy", default="tail", choices=list(OPTUNA_STRATEGIES),
help="전략: tail | momentum | breakout",
help="전략: tail | momentum | breakout | scalp",
)
parser.add_argument("--start", default=week_ago, help="시작일 YYYY-MM-DD")
parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD")
parser.add_argument("--start", default=week_ago, help="시작일 YYYY-MM-DD (거래일 보정)")
parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD (주말·휴장이면 이전 장운영일)")
parser.add_argument("--timeframe", "--tf", default=3, type=int, dest="timeframe",
help="ws_candles 분봉 3·5·15·60")
add_portfolio_cli_args(parser)
@@ -666,7 +726,7 @@ def main() -> None:
strategy = (args.strategy or "tail").strip().lower()
if strategy not in OPTUNA_STRATEGIES:
logger.error("❌ --strategy 는 tail/momentum/breakout 중 하나")
logger.error("❌ --strategy 는 tail/momentum/breakout/scalp 중 하나")
sys.exit(2)
allowed_modes = STRATEGY_MODES.get(strategy, [])
@@ -677,11 +737,11 @@ def main() -> None:
sort_by = (args.sort_by or "").strip().lower()
if not sort_by:
sort_by = "score" if strategy == "momentum" else "pnl"
sort_by = "score" if strategy in ("momentum", "scalp") else "pnl"
momentum_sort = {"score", "pnl", "win_rate"}
basic_sort = {"pnl", "win_rate"}
if strategy == "momentum" and sort_by not in momentum_sort:
logger.error("momentum --sort-by 는 score|pnl|win_rate")
if strategy in ("momentum", "scalp") and sort_by not in momentum_sort:
logger.error("%s --sort-by 는 score|pnl|win_rate", strategy)
sys.exit(2)
if strategy in ("tail", "breakout") and sort_by not in basic_sort:
logger.error("%s --sort-by 는 pnl|win_rate", strategy)
@@ -711,6 +771,7 @@ def main() -> None:
cli_override=args.study_name,
)
study = None
try:
if strategy == "tail":
ctx = prepare_tail_search_context(
@@ -773,6 +834,36 @@ def main() -> None:
if args.apply_best:
apply_best_momentum_trial(study)
elif strategy == "scalp":
ctx_s = prepare_scalp_search_context(
args.start, args.end, mode,
use_fallback_universe=use_fallback,
time_start_hm=args.time_start,
time_end_hm=args.time_end,
slot_money=args.slot_money,
max_stocks=args.max_stocks,
total_budget_krw=args.total_budget,
orderbook_filter=args.orderbook_filter,
)
if ctx_s is None:
sys.exit(1)
study = run_scalp_optuna(
ctx_s,
n_trials=n_trials,
storage_url=storage_url,
study_name=study_name,
min_trades=args.min_trades,
min_win_rate=args.min_win_rate,
min_pf=args.min_pf,
sort_by=sort_by,
sampler_name=sampler_name,
seed=args.seed,
n_jobs=n_jobs,
show_progress=not args.no_progress,
)
if args.apply_best:
apply_best_scalp_trial(study)
else:
ctx_b = prepare_breakout_search_context(
args.start, args.end, mode,
@@ -803,6 +894,17 @@ def main() -> None:
if args.apply_best:
apply_best_breakout_trial(study)
# 종료 직전 절대경로 한 번 더 (로그 끝에서 바로 복사)
export = getattr(study, "_kis_export_path", None) if study is not None else None
if export:
announce_optuna_json_path(
str(export),
strategy=strategy,
mode=mode,
note="CLI 종료·열기용 경로",
log=logger,
)
except KeyboardInterrupt as e:
print(f"\n{e} — 중단", flush=True)
sys.exit(130)