From 78edb75e011e46054ba22f605f165656f4afc805 Mon Sep 17 00:00:00 2001 From: Hwang Date: Mon, 6 Jul 2026 19:11:34 +0900 Subject: [PATCH] feat: Add new files and enhance backtesting functionality Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation. --- --mode | 0 --start | 0 --strategy | 0 --study-name | 0 __pycache__/database.cpython-312.pyc | Bin 159712 -> 160218 bytes backtest_web.py | 132 +++++- database.py | 12 + .../backtest/breakout_portfolio_backtest.py | 5 +- .../backtest/momentum_portfolio_backtest.py | 11 +- .../backtest/param_search_apply_snapshot.py | 36 +- kis_trader/backtest/param_search_breakout.py | 6 + kis_trader/backtest/param_search_momentum.py | 2 + kis_trader/backtest/param_search_optuna.py | 1 - kis_trader/backtest/tail_param_search.py | 1 - kis_trader/engine/daily_profit_halt.py | 68 +++- kis_trader/engine/momentum_engine.py | 16 +- kis_trader/engine/momentum_env_keys.py | 2 + kis_trader/engine/tail_engine.py | 19 +- kis_trader/engine/tail_env_keys.py | 15 + kis_trader/execution/order_manager.py | 1 - kis_trader/main.py | 128 +++--- .../network/kiwoom_condition_manager.py | 221 +++++++++- kis_trader/network/ws_manager.py | 378 ++++++++++++++---- kis_trader/strategies/base.py | 273 ++++++++++++- kis_trader/strategies/breakout.py | 34 +- kis_trader/strategies/momentum.py | 13 +- kis_trader/strategies/tail_catch.py | 13 +- kis_trader/strategies/updow_strategy.py | 8 +- kis_trader/web/live_config_schema.py | 81 +++- kis_trader/ws/kiwoom_ws.py | 128 ++++-- scripts/wsl_bootstrap.sh | 13 + static/js/backtest.js | 131 +++--- templates/backtest.html | 34 ++ 33 files changed, 1479 insertions(+), 303 deletions(-) create mode 100644 --mode create mode 100644 --start create mode 100644 --strategy create mode 100644 --study-name create mode 100755 scripts/wsl_bootstrap.sh diff --git a/--mode b/--mode new file mode 100644 index 0000000..e69de29 diff --git a/--start b/--start new file mode 100644 index 0000000..e69de29 diff --git a/--strategy b/--strategy new file mode 100644 index 0000000..e69de29 diff --git a/--study-name b/--study-name new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index 3272582ee2f676f3c432b02d9e435492f58ef540..851237b8fff87571288c1082a649d7aad97360b7 100644 GIT binary patch delta 9633 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 delta 9118 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 AR{#J2 diff --git a/backtest_web.py b/backtest_web.py index cfb0f19..d62f69a 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -304,13 +304,35 @@ def _trade_exit_sort_key(t: Dict[str, Any]): ) +def _int_display_price(v: Any) -> int: + """거래내역 표시용 — 주식 가격 정수(원).""" + try: + return int(round(float(v or 0))) + except (TypeError, ValueError): + return 0 + + +def _trade_with_int_prices(trade: Dict) -> Dict: + """웹 거래표 매수가·매도가 — 소수점 제거.""" + out = dict(trade) + for k in ( + "buy_price", "sell_price", "entry_price", "exit_price", + "entry", "exit", "avg_price", + "pnl", "realized_pnl", "unrealized_pnl", "cum_pnl", + ): + if k in out and out[k] is not None and out[k] != "": + out[k] = _int_display_price(out[k]) + return out + + def _trades_recent_first(trades: List[Dict], limit: int = 200) -> List[Dict]: """매도 시각 기준 최신순 상위 limit 건 (가상 거래 내역 표시용).""" if not trades: return [] ordered = sorted(trades, key=_trade_exit_sort_key, reverse=True) cap = max(0, int(limit)) - return ordered[:cap] if cap else ordered + sliced = ordered[:cap] if cap else ordered + return [_trade_with_int_prices(t) for t in sliced] def _momentum_source_label(kind: str, src: str) -> str: @@ -544,8 +566,8 @@ def api_actual(): "code": row.get("code"), "name": row.get("name"), "strategy": row.get("strategy"), - "buy_price": buy_px, - "sell_price": cur_px, + "buy_price": _int_display_price(buy_px), + "sell_price": _int_display_price(cur_px), "qty": qty, "realized_pnl": None, "unrealized_pnl": round(unrealized), @@ -1076,6 +1098,7 @@ from kis_trader.strategies.breakout import ( normalize_breakout_max_loss_krw, run_breakout_backtest as _bo_run_backtest, ) +from kis_trader.strategies.base import is_strategy_eod_bar def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: @@ -1335,6 +1358,12 @@ def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: ("MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"), 0.45, ), + "eod_enabled": pick_bool(("MOMENTUM_EOD_ENABLED",), True), + "eod_hm": ( + str(env.get("MOMENTUM_EOD_HM") or "15:25").strip() + if env.get("MOMENTUM_EOD_HM") not in (None, "", "None") + else "15:25" + ), **_momentum_exit_ui_from_engine(), } @@ -1652,6 +1681,21 @@ def _bo_defaults_from_db() -> Dict[str, Any]: max_stocks_v = int(portfolio["max_stocks"]) total_budget_v = int(float(portfolio["total_budget_krw"])) + eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip() + if eod_raw in ("", "None"): + eod_hm = "15:15" + elif ":" in eod_raw: + eod_hm = eod_raw + elif len(eod_raw) == 4 and eod_raw.isdigit(): + eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}" + else: + eod_hm = eod_raw + eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED") + if eod_enabled_raw in (None, "", "None"): + eod_enabled = True + else: + eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") + return { "lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))), "vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))), @@ -1677,6 +1721,8 @@ def _bo_defaults_from_db() -> Dict[str, Any]: "ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""), "time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))), "time_end_hm": time_end_hm, + "eod_enabled": eod_enabled, + "eod_hm": eod_hm, "max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))), "cooldown_min": cooldown_min, "max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float), @@ -1892,6 +1938,27 @@ def _backtest_filter_toggle(raw: Any) -> Optional[bool]: return str(raw).strip().lower() in ("1", "true", "y", "yes", "on") +def _eod_params_from_request( + req: Any, + defaults: Dict[str, Any], + *, + default_enabled: bool = True, + default_hm: str = "15:25", +) -> Dict[str, Any]: + """웹 백테 쿼리 → 실매와 동일 ``eod_enabled`` / ``eod_hm`` (비우면 DB 기본값).""" + raw_en = req.args.get("eod_enabled") + if raw_en in (None, ""): + eod_enabled = bool(defaults.get("eod_enabled", default_enabled)) + else: + eod_enabled = str(raw_en).strip().lower() in ("1", "true", "t", "y", "yes", "on") + raw_hm = req.args.get("eod_hm") + if raw_hm not in (None, ""): + eod_hm = str(raw_hm).strip() or default_hm + else: + eod_hm = str(defaults.get("eod_hm") or default_hm).strip() or default_hm + return {"eod_enabled": eod_enabled, "eod_hm": eod_hm} + + def _daily_trail_params_from_request(req: Any) -> Dict[str, Any]: """백테 탭 '당일 누적손익 트레일 익절' 입력 → 시뮬 파라미터(전용 daily_trail_* 키). @@ -2035,9 +2102,16 @@ def api_backtest_scalping(): use_macd_cross = str(_use_macd).strip().lower() in ("1", "true", "y", "yes", "on") _force_eod_raw = request.args.get("force_eod_exit") if _force_eod_raw in (None, ""): - force_eod_exit = False + _mom_eod_src = _mom_def if _mom_def is not None else _def + eod_patch = _eod_params_from_request(request, _mom_eod_src, default_hm="15:25") else: - force_eod_exit = str(_force_eod_raw).strip().lower() in ("1", "true", "y", "yes", "on") + # 레거시 force_eod_exit 쿼리 (하위호환) + eod_patch = { + "eod_enabled": str(_force_eod_raw).strip().lower() in ("1", "true", "y", "yes", "on"), + "eod_hm": str( + (_mom_def or _def).get("eod_hm") or "15:25" + ).strip() or "15:25", + } # ── 모드 분기: reversal(기존, V자 반전) vs momentum(추격형, SCALP_MODE=momentum 백테스트) ── # 실매매 봇이 ``check_buy_signal_momentum_live`` 를 사용 중이면 백테스트도 @@ -2170,7 +2244,7 @@ def api_backtest_scalping(): "min_margin": min_margin, "use_defense_filters": use_defense_filters, "use_macd_cross": use_macd_cross, - "force_eod_exit": force_eod_exit, + **eod_patch, "macd_fast": int(_def.get("macd_fast", 12)), "macd_slow": int(_def.get("macd_slow", 26)), "macd_signal": int(_def.get("macd_signal", 5)), @@ -2475,7 +2549,7 @@ def api_backtest_scalping(): "shoulder_cut_pct": shoulder_cut_pct * 100, "min_hold_sec": min_hold_sec, "time_window": f"{time_start_hm:04d}-{time_end_hm:04d}", - "force_eod_exit": force_eod_exit, + **eod_patch, "max_daily": max_daily, "codes_analyzed": len(codes), "universe_source": universe_source, @@ -2690,6 +2764,13 @@ def _tail_engine_dict_to_ui( "daily_profit_mode": str(snap.get("SHORT_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing", "ratchet_presets": str(snap.get("BT_RATCHET_PRESETS") or "").strip(), "daily_trail_presets": str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip(), + "eod_enabled": ( + str(snap.get("TAIL_EOD_ENABLED") or "1").strip().lower() + in ("1", "true", "t", "y", "yes", "on") + if snap.get("TAIL_EOD_ENABLED") not in (None, "", "None") + else True + ), + "eod_hm": str(snap.get("TAIL_EOD_HM") or "15:25").strip() or "15:25", } @@ -3351,6 +3432,15 @@ def _momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if "pg_filter" in body: patch["MOMENTUM_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter")) + if "eod_enabled" in body: + patch["MOMENTUM_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) + x = gv("eod_hm") + if x is not None: + eod_s = str(x).strip() + if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit(): + eod_s = f"{eod_s[:2]}:{eod_s[2:]}" + patch["MOMENTUM_EOD_HM"] = eod_s + x = gv("trail_pct") if x is not None: patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0) @@ -3569,6 +3659,18 @@ def _breakout_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if x is not None: patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) + if "eod_enabled" in body: + patch["BREAKOUT_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) + x = gv("eod_hm") + if x is not None: + eod_s = str(x).strip() + if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit(): + eod_s = f"{eod_s[:2]}:{eod_s[2:]}" + patch["BREAKOUT_EOD_HM"] = eod_s + x = gv("max_hold_bars") + if x is not None: + patch["BREAKOUT_MAX_HOLD_BARS"] = str(int(float(x))) + x = gv("time_start_hm") if x is not None: patch["BREAKOUT_TIME_START"] = str(int(float(x))) @@ -3877,8 +3979,12 @@ def api_backtest_tail(): time_start_hm = int( request.args.get("time_start", _def.get("time_start_hm", 930))) time_end_hm = int( request.args.get("time_end", _def.get("time_end_hm", 1500))) max_daily = int( request.args.get("max_daily", _def.get("max_daily", 3))) - _force_eod_raw = str(request.args.get("force_eod_exit", "0")).strip().lower() - force_eod_exit = _force_eod_raw in ("1", "true", "y", "yes", "on") + eod_patch = _eod_params_from_request(request, _def, default_hm="15:25") + _legacy_force_eod = request.args.get("force_eod_exit") + if _legacy_force_eod not in (None, ""): + eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in ( + "1", "true", "y", "yes", "on", + ) # V3 방어 파라미터 연동 min_price = float(request.args.get("min_price", _def.get("min_price", 1000.0))) @@ -4003,7 +4109,7 @@ def api_backtest_tail(): )), "min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut, "risk_pct": risk_pct, "kelly_mult": kelly_mult, "min_hold_sec": min_hold_sec, "capital": capital, - "force_eod_exit": force_eod_exit, + **eod_patch, "max_stocks": max_stocks, "total_budget_krw": total_budget_krw, "portfolio_mode": True, @@ -4117,8 +4223,7 @@ def api_backtest_tail(): running_low = min(running_low, lo) # ── 마지막 봉 여부 ──────────────────────────────────────── - is_eod_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + is_eod = is_strategy_eod_bar(c["candle_time"], params, "TAIL") # ───────────────────────────────────────────────────────── # 포지션 보유 중: 청산 체크 @@ -4351,7 +4456,7 @@ def api_backtest_tail(): "time_start_hm": time_start_hm, "time_end_hm": time_end_hm, "time_window": f"{time_start_hm:04d}-{time_end_hm:04d}", - "force_eod_exit": force_eod_exit, + **eod_patch, "max_daily": max_daily, "min_price": min_price, "max_daily_change": max_daily_change, @@ -4472,6 +4577,7 @@ def api_backtest_breakout(): ui["use_ema_filter"] = bool(_def.get("use_ema_filter", False)) ui["ema_fast_period"] = _arg("ema_fast_period", _def.get("ema_fast_period", 9), lambda v: int(float(v))) ui["ema_slow_period"] = _arg("ema_slow_period", _def.get("ema_slow_period", 21), lambda v: int(float(v))) + ui.update(_eod_params_from_request(request, _def, default_hm="15:15")) engine = _bo_ui_to_engine_params(ui) # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값) diff --git a/database.py b/database.py index 21a796b..992f842 100644 --- a/database.py +++ b/database.py @@ -596,6 +596,15 @@ ENV_CONFIG_KEYS = ( # WS 갭보정 파라미터 (KIS_FALLBACK 은 기본 false — 모의서버 500 폭탄 회피) "WS_GAP_FILL_OFF_HOURS", "WS_GAP_FILL_LIMIT", "WS_TIMEFRAMES", "WS_GAP_FILL_KIS_FALLBACK", + "WS_GAP_FILL_MAX_RETRIES", "WS_GAP_FILL_RETRY_DELAY_SEC", + "WS_GAP_BULK_REFILL_DEBOUNCE_SEC", + "WS_GAP_FILL_PRIORITY_TFS", + "WS_GAP_FILL_TF_SLEEP_MIN_SEC", "WS_GAP_FILL_TF_SLEEP_MAX_SEC", + "WS_GAP_FILL_CODE_SLEEP_MIN_SEC", "WS_GAP_FILL_CODE_SLEEP_MAX_SEC", + "WS_GAP_FILL_PHASE_PAUSE_SEC", + "WS_GAP_FILL_WORKERS", + "KIWOOM_CNSRREQ_GAP_MIN_SEC", "KIWOOM_CNSRREQ_GAP_MAX_SEC", + "KIWOOM_CNSRREQ_MAX_RETRIES", "KIWOOM_CNSRREQ_RETRY_SEC", # 종목당 최대 매수금액 하드캡 (손절%가 작으면 포지션 사이즈 폭주 방지) "MAX_BUY_AMOUNT_PER_STOCK", "SCALP_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT", @@ -637,6 +646,9 @@ ENV_CONFIG_KEYS = ( "BREAKOUT_TRAIL_ARM_PCT", "BREAKOUT_SHOULDER_MIN_HIGH_PCT", "BREAKOUT_SHOULDER_CUT_PCT", "BREAKOUT_RATCHET_TIERS", "BREAKOUT_MAX_HOLD_BARS", + "BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", + "MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", + "TAIL_EOD_ENABLED", "TAIL_EOD_HM", # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%) "BREAKOUT_SL_MODE", "BREAKOUT_ATR_PERIOD", "BREAKOUT_ATR_SL_MULT", "BREAKOUT_ATR_SL_MIN_PCT", "BREAKOUT_ATR_SL_MAX_PCT", diff --git a/kis_trader/backtest/breakout_portfolio_backtest.py b/kis_trader/backtest/breakout_portfolio_backtest.py index 82d0695..9e98041 100644 --- a/kis_trader/backtest/breakout_portfolio_backtest.py +++ b/kis_trader/backtest/breakout_portfolio_backtest.py @@ -31,6 +31,7 @@ from kis_trader.strategies.breakout import ( check_sell_signal_breakout_live, normalize_breakout_max_loss_krw, ) +from kis_trader.strategies.base import is_strategy_eod_bar from kis_trader.engine.tail_engine import compute_atr_series @@ -134,7 +135,6 @@ def run_breakout_backtest_portfolio( _mode = breakout_entry_mode(params) min_bars = need_n + (0 if _mode in ("intrabar", "b", "live_b", "hts") else 1) - force_eod_exit = _to_bool(params.get("force_eod_exit"), False) cooldown_min = float(params.get("cooldown_min", 30)) max_daily = int(params.get("max_daily", 1)) max_stocks = _max_stocks_from_params(params) @@ -261,8 +261,7 @@ def run_breakout_backtest_portfolio( day = t[:8] cl = float(c["close"]) - is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + is_eod = is_strategy_eod_bar(t, params, "BREAKOUT") pos = portfolio[code] if t == pos["entry_time"]: diff --git a/kis_trader/backtest/momentum_portfolio_backtest.py b/kis_trader/backtest/momentum_portfolio_backtest.py index 967d543..50aadb6 100644 --- a/kis_trader/backtest/momentum_portfolio_backtest.py +++ b/kis_trader/backtest/momentum_portfolio_backtest.py @@ -25,6 +25,7 @@ from kis_trader.engine.momentum_engine import ( effective_tp_pct_from_params, eval_momentum_buy_at_index, ) +from kis_trader.strategies.base import is_strategy_eod_bar from kis_trader.engine.indicator_cache import ( attach_indicator_caches_to_params, get_indicator_cache_from_params, @@ -244,7 +245,6 @@ def _process_sells_for_scan( scan_key: str, *, params: Dict[str, Any], - force_eod_exit: bool, ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], all_trades: List[Dict], tick_exit_count: int, @@ -253,6 +253,7 @@ def _process_sells_for_scan( ) -> Tuple[int, int]: """스캔 시각까지 틱·OHLC 청산 (실매 루프: 매도 먼저).""" bar_t = scan_key[:12] + is_eod = is_strategy_eod_bar(bar_t, params, "MOMENTUM") for code in list(portfolio.keys()): ctx = ctx_by_code.get(code) if ctx is None: @@ -265,9 +266,6 @@ def _process_sells_for_scan( pos = portfolio[code] if str(pos.get("entry_time") or "")[:12] == bar_t: continue - day = bar_t[:8] - is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit and _is_minute_tail_scan(scan_key, scan_sec) entry_time = str(pos.get("entry_time") or "") sold = False @@ -500,7 +498,6 @@ def run_momentum_backtest_portfolio( """시각순 포트폴리오 백테스트 — MOMENTUM 전용.""" rsi_period = int(params.get("rsi_period", 3)) min_bars = max(rsi_period + 5, 6) - force_eod_exit = _to_bool(params.get("force_eod_exit"), False) sl_pct = abs(float(params.get("sl_pct", 0.015))) tp_pct = effective_tp_pct_from_params(params) max_stocks = _max_stocks_from_params(params) @@ -574,7 +571,6 @@ def run_momentum_backtest_portfolio( tick_exit_count, ohlc_exit_count = _process_sells_for_scan( portfolio, ctx_by_code, scan_key, params=params, - force_eod_exit=force_eod_exit, ticks_by_code=ticks_by_code, all_trades=all_trades, tick_exit_count=tick_exit_count, @@ -680,8 +676,7 @@ def run_momentum_backtest_portfolio( day = t[:8] if str(portfolio[code]["entry_time"])[:12] == str(t)[:12]: continue - is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + is_eod = is_strategy_eod_bar(t, params, "MOMENTUM") cur_c_info = { "open": float(c["open"]), "high": float(c["high"]), diff --git a/kis_trader/backtest/param_search_apply_snapshot.py b/kis_trader/backtest/param_search_apply_snapshot.py index 41c7b78..15906c8 100644 --- a/kis_trader/backtest/param_search_apply_snapshot.py +++ b/kis_trader/backtest/param_search_apply_snapshot.py @@ -7,7 +7,10 @@ param_search_apply_snapshot.py — 파라미터 탐색 결과 JSON → insert_en MOMENTUM search_momentum_*.json top[].merged_params SCALP search_*.json (rsi_oversold) top[].db_snapshot 우선, 없으면 merged에서 생성 BREAKOUT search_breakout_*.json top[].merged_params + BREAKOUT optuna_breakout_*.json results[N-1] (Optuna — top 없음) + MOMENTUM optuna_momentum_*.json results[N-1] (Optuna) TAIL search_tail_*.json results[N-1].params (정렬된 순서) + TAIL optuna_tail_*.json results[N-1] (Optuna) UPDOW updow_param_*.json top[N-1].apply_cfg + tf 사용 예: @@ -44,6 +47,17 @@ from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402 ) +def _ranked_items(data: Dict[str, Any]) -> List[Dict[str, Any]]: + """Grid ``top[]`` 또는 Optuna ``results[]`` — rank 1 = index 0.""" + top = data.get("top") + if isinstance(top, list) and top: + return top + results = data.get("results") + if isinstance(results, list) and results: + return results + return [] + + def _env_bool_10(v: Any) -> str: if isinstance(v, bool): return "1" if v else "0" @@ -61,10 +75,12 @@ def _detect_strategy(data: Dict[str, Any], path: str) -> str: return "UPDOW" if base.startswith("search_tail_") or base.startswith("tail_search_"): return "TAIL" - if base.startswith("search_breakout_"): + if base.startswith("search_breakout_") or base.startswith("optuna_breakout_"): return "BREAKOUT" - if base.startswith("search_momentum_"): + if base.startswith("search_momentum_") or base.startswith("optuna_momentum_"): return "MOMENTUM" + if base.startswith("optuna_tail_"): + return "TAIL" if isinstance(data.get("code"), str) and len(str(data.get("code")).strip()) == 6: if "tf" in data and isinstance(data.get("top"), list): @@ -546,16 +562,18 @@ def main(argv: Optional[List[str]] = None) -> int: print("⚠️ JSON 에 code 없음 → updow_stock_config 건너뜀") return 0 - # ── top[] 기반 (MOMENTUM / SCALP / BREAKOUT) ───────────────── - top = data.get("top") or [] - if not top: - print("❌ JSON 에 top 배열이 없습니다.") + # ── top[] (Grid) 또는 results[] (Optuna) — MOMENTUM / SCALP / BREAKOUT ── + ranked = _ranked_items(data) + if not ranked: + print("❌ JSON 에 top[] 또는 results[] 배열이 없습니다.") return 8 - if rank > len(top): - print(f"❌ rank 범위 초과 (1~{len(top)})") + if rank > len(ranked): + print(f"❌ rank 범위 초과 (1~{len(ranked)})") return 5 - item = top[rank - 1] + item = ranked[rank - 1] + if str(data.get("engine") or "").lower() == "optuna": + print(f" (Optuna results[{rank - 1}], trial=#{item.get('optuna_trial_number')})") pnl = int(item.get("total_pnl") or 0) if pnl <= 0 and not args.allow_non_positive_pnl: print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl") diff --git a/kis_trader/backtest/param_search_breakout.py b/kis_trader/backtest/param_search_breakout.py index 0a8be66..b5a10b5 100644 --- a/kis_trader/backtest/param_search_breakout.py +++ b/kis_trader/backtest/param_search_breakout.py @@ -248,6 +248,12 @@ def _bo_fixed_defaults() -> Dict[str, Any]: "atr_sl_mult": float(_pick("BREAKOUT_ATR_SL_MULT", default=2.0)), "atr_sl_min_pct": float(_pick("BREAKOUT_ATR_SL_MIN_PCT", default=0.8)), "atr_sl_max_pct": float(_pick("BREAKOUT_ATR_SL_MAX_PCT", default=6.0)), + "eod_enabled": str(_pick("BREAKOUT_EOD_ENABLED", default="1")).strip().lower() in ( + "1", "true", "t", "y", "yes", "on", + ), + "eod_hm": ( + str(_pick("BREAKOUT_EOD_HM", default="15:15")).strip() or "15:15" + ), } diff --git a/kis_trader/backtest/param_search_momentum.py b/kis_trader/backtest/param_search_momentum.py index c84f940..19b8a25 100644 --- a/kis_trader/backtest/param_search_momentum.py +++ b/kis_trader/backtest/param_search_momentum.py @@ -263,6 +263,8 @@ def _mom_fixed_defaults() -> Dict[str, Any]: "use_daily_range_filter": bool(_d.get("use_daily_range_filter", False)), "ema_fast_period": int(_d.get("ema_fast_period", 9)), "ema_slow_period": int(_d.get("ema_slow_period", 21)), + "eod_enabled": bool(_d.get("eod_enabled", True)), + "eod_hm": str(_d.get("eod_hm") or "15:25").strip() or "15:25", } diff --git a/kis_trader/backtest/param_search_optuna.py b/kis_trader/backtest/param_search_optuna.py index 9f42456..b0eff03 100644 --- a/kis_trader/backtest/param_search_optuna.py +++ b/kis_trader/backtest/param_search_optuna.py @@ -220,7 +220,6 @@ 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("force_eod_exit", False) 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 base_params.setdefault("backtest_use_tick_db", _tail_use_tick(None)) diff --git a/kis_trader/backtest/tail_param_search.py b/kis_trader/backtest/tail_param_search.py index 67a2358..d3f8114 100644 --- a/kis_trader/backtest/tail_param_search.py +++ b/kis_trader/backtest/tail_param_search.py @@ -798,7 +798,6 @@ def run_search( base_params = dict(base_params) base_params["scan_interval_min"] = scan_interval_min base_params["timeframe"] = tail_tf - base_params.setdefault("force_eod_exit", False) base_params.setdefault("backtest_tick_fallback_ohlc", True) # ── 틱재생(ws_ticks) — 기본 ON (실매 체결 정합) ────────────────────── # 3분봉 OHLC(open→high→low→close 가정)는 손절보다 익절을 먼저 체결하는 diff --git a/kis_trader/engine/daily_profit_halt.py b/kis_trader/engine/daily_profit_halt.py index f733e36..65f60f4 100644 --- a/kis_trader/engine/daily_profit_halt.py +++ b/kis_trader/engine/daily_profit_halt.py @@ -7,6 +7,9 @@ kis_trader/engine/daily_profit_halt.py — 일일 익절 목표 달성 시 신 목표 판정: 원(KRW) · 운용한도 대비 %(PCT) **둘 중 하나라도** 달성 시 트리거. 분모(%) : ``DAILY_PROFIT_TARGET_BUDGET_KRW`` 또는 ON 전략 ``*_TOTAL_BUDGET_KRW`` 합. +**활성화(``*_TARGET_ENABLED``)** ON + 목표 설정 → 달성 시 **무조건** 신규매수 차단. +**신규매수 중단(``*_HALT_NEW_BUYS``)** = 목표와 무관한 **수동** 매수 잠금(별도 스위치). + 매도(손절·익절)는 계속 — **신규 매수만** 차단. 모든 임계값 env/DB — 하드코딩 금지. """ @@ -73,13 +76,23 @@ def _mode(common_key: str, strategy_key: str, default: str = "fixed") -> str: return (g or default).lower() +def _halt_new_buys_flag(common_key: str, strategy_key: str, default: bool = False) -> bool: + """수동 신규매수 중단 — 전략 서브값 우선 → 마스터 → 기본 OFF.""" + raw = str(get_env_from_db(strategy_key, "")).strip() + if raw != "": + return get_env_bool(strategy_key, default) + return get_env_bool(common_key, default) + + def load_global_profit_target() -> Dict[str, Any]: return { "enabled": get_env_bool("DAILY_PROFIT_TARGET_ENABLED", False), "krw": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_KRW", 0.0))), "pct": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_PCT", 0.0))), "budget_krw": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_BUDGET_KRW", 0.0))), - "halt_new_buys": get_env_bool("DAILY_PROFIT_HALT_NEW_BUYS", True), + "halt_new_buys": _halt_new_buys_flag( + "DAILY_PROFIT_HALT_NEW_BUYS", "DAILY_PROFIT_HALT_NEW_BUYS", False, + ), "notify_mm": get_env_bool("DAILY_PROFIT_NOTIFY_MM", True), # 트레일링 익절 (당일 손익 고점 추적) — fixed 기본이라 미설정 시 동작 불변 "mode": _mode("DAILY_PROFIT_MODE", "DAILY_PROFIT_MODE", "fixed"), @@ -110,7 +123,11 @@ def load_strategy_profit_target(strategy_id: str) -> Dict[str, Any]: 0.0, ), "budget_env": _STRATEGY_BUDGET_ENV.get(pfx, f"{pfx}_TOTAL_BUDGET_KRW"), - "halt_new_buys": get_env_bool(f"{pfx}_DAILY_PROFIT_HALT_NEW_BUYS", True), + "halt_new_buys": _halt_new_buys_flag( + "DAILY_PROFIT_HALT_NEW_BUYS", + f"{pfx}_DAILY_PROFIT_HALT_NEW_BUYS", + False, + ), # 트레일링 익절 — 전략 서브값 우선 → 마스터 폴백 (fixed 기본) "mode": _mode("DAILY_PROFIT_MODE", f"{pfx}_DAILY_PROFIT_MODE", "fixed"), # 다단계 트레일 tier — 전략 서브값 우선 → 마스터 폴백 (비면 단일 drop_pct) @@ -290,6 +307,31 @@ def _format_hit_detail( return " · ".join(parts) +def describe_profit_guard_startup(cfg: Dict[str, Any], *, scope: str = "마스터") -> str: + """기동 로그용 — 설정이 매수에 미치는 영향을 한 줄로.""" + if cfg.get("halt_new_buys"): + return ( + f"⛔ [일일익절·{scope}] 수동 신규매수 중단 ON — " + f"목표·손익과 무관하게 신규매수 차단 (매도·손절 유지)" + ) + if not cfg.get("enabled"): + return ( + f"ℹ️ [일일익절·{scope}] 손익 감시 OFF — " + f"목표 달성 시에도 신규매수 차단 없음" + ) + if not _guard_active(cfg): + return ( + f"ℹ️ [일일익절·{scope}] 손익 감시 ON 이지만 목표 미설정 " + f"(금액·%·트레일 없음) — 달성 차단 없음" + ) + krw = float(cfg.get("krw") or 0) + pct = float(cfg.get("pct") or 0) + return ( + f"🎯 [일일익절·{scope}] 손익 감시 ON — 목표 {krw:,.0f}원 / {pct:.2f}% " + f"· 달성 시 신규매수 차단 (매도·손절 유지)" + ) + + class DailyProfitHaltGuard: """ Orchestrator 가 주입 — ``buy_allowed(strategy_id)`` 로 신규 매수 차단 여부 판단. @@ -329,7 +371,17 @@ class DailyProfitHaltGuard: sid = _strategy_prefix(strategy_id) gcfg = load_global_profit_target() - if gcfg.get("halt_new_buys", True) and _guard_active(gcfg): + + # 1) 수동 신규매수 중단 (목표·손익과 무관) + if gcfg.get("halt_new_buys"): + self._throttled_log( + sid, + "⛔ [신규매수중단·총합] 수동 중단 ON → 신규매수 차단", + ) + return False, "탈락-신규매수중단(총합)" + + # 2) 일일익절 — 활성화 ON이면 목표 달성 시 무조건 신규매수 차단 + if _guard_active(gcfg): gpnl, gcnt = self._global_pnl_fn(today) gbudget = resolve_global_operating_budget_krw(self._active_strategies_fn()) gpeak = self._update_peak(f"global:{today}", today, gpnl) @@ -341,7 +393,15 @@ class DailyProfitHaltGuard: return False, "탈락-일일익절(총합)" scfg = load_strategy_profit_target(sid) - if scfg.get("halt_new_buys", True) and _guard_active(scfg): + + if scfg.get("halt_new_buys"): + self._throttled_log( + sid, + f"⛔ [신규매수중단·{sid}] 수동 중단 ON → 해당전략 신규매수 차단", + ) + return False, f"탈락-신규매수중단({sid})" + + if _guard_active(scfg): spnl, scnt = self._strategy_pnl_fn(today, sid) sbudget = resolve_strategy_budget_krw(sid) speak = self._update_peak(f"{sid}:{today}", today, spnl) diff --git a/kis_trader/engine/momentum_engine.py b/kis_trader/engine/momentum_engine.py index bdc3217..37eb6fc 100644 --- a/kis_trader/engine/momentum_engine.py +++ b/kis_trader/engine/momentum_engine.py @@ -32,7 +32,8 @@ from kis_trader.engine.momentum_env_keys import ( momentum_env_int, _legacy_float, ) -from kis_trader.utils.env import get_env_from_db, get_env_int +from kis_trader.strategies.base import is_strategy_eod_bar +from kis_trader.utils.env import get_env_int MOMENTUM_STRATEGY_ID = "MOMENTUM" @@ -194,6 +195,10 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: live_align = momentum_env_bool(r, "MOMENTUM_LIVE_BACKTEST_ALIGN", True) lookback_bars = momentum_env_int(r, "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", 1) force_eod = momentum_env_bool(r, "MOMENTUM_FORCE_EOD_EXIT", False) + eod_enabled = momentum_env_bool(r, "MOMENTUM_EOD_ENABLED", True) + if r.get("MOMENTUM_EOD_ENABLED") in (None, "", "None") and r.get("MOMENTUM_FORCE_EOD_EXIT") not in (None, "", "None"): + eod_enabled = force_eod + eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:25").strip() or "15:25" portfolio_mode = True except Exception: rsi_period, mom_rsi_min, mom_rsi_max = 3, 50.0, 80.0 @@ -218,6 +223,7 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: slot_money, max_stocks, total_budget = 3_000_000, 3, 0 min_hold_sec, live_align, lookback_bars = 30.0, True, 1 force_eod, portfolio_mode = False, True + eod_enabled, eod_hm = True, "15:25" finally: if own_db is not None: try: @@ -274,7 +280,9 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: "min_hold_sec": min_hold_sec, "live_backtest_align": live_align, "live_signal_lookback_bars": lookback_bars, - "force_eod_exit": force_eod, + "eod_enabled": eod_enabled, + "eod_hm": eod_hm, + "force_eod_exit": eod_enabled, "portfolio_mode": portfolio_mode, "scan_interval_min": 1, } @@ -805,7 +813,6 @@ def run_momentum_backtest( time_start_hm = int(params.get("time_start_hm", 900)) time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430))) max_loss_krw = float(params.get("max_loss_krw", 200_000.0)) - force_eod_exit = _to_bool(params.get("force_eod_exit"), False) all_trades: List[Dict] = [] @@ -834,8 +841,7 @@ def run_momentum_backtest( if day != cur_day: cur_day = day - is_eod_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + is_eod = is_strategy_eod_bar(c["candle_time"], params, "MOMENTUM") if position is not None: if str(position["entry_time"])[:12] == str(c["candle_time"])[:12]: diff --git a/kis_trader/engine/momentum_env_keys.py b/kis_trader/engine/momentum_env_keys.py index d25884e..9b20305 100644 --- a/kis_trader/engine/momentum_env_keys.py +++ b/kis_trader/engine/momentum_env_keys.py @@ -94,6 +94,8 @@ MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", "MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", + "MOMENTUM_EOD_ENABLED", + "MOMENTUM_EOD_HM", "MOMENTUM_FORCE_EOD_EXIT", }) diff --git a/kis_trader/engine/tail_engine.py b/kis_trader/engine/tail_engine.py index eec297b..90e41b6 100644 --- a/kis_trader/engine/tail_engine.py +++ b/kis_trader/engine/tail_engine.py @@ -67,6 +67,7 @@ from kis_trader.engine.tail_tick_replay import ( tail_timeframe_min, try_limit_fill_on_bar_with_ticks, ) +from kis_trader.strategies.base import is_strategy_eod_bar from kis_trader.engine.tail_env_keys import ( tail_env_bool, tail_env_float, @@ -186,6 +187,8 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: from kis_trader.utils.env import get_env_from_db ratchet_tiers = str(r.get("TAIL_RATCHET_TIERS") or get_env_from_db("TAIL_RATCHET_TIERS", "") or "").strip() max_hold_bars = tail_env_int(r, "TAIL_MAX_HOLD_BARS", 0) + eod_enabled = tail_env_bool(r, "TAIL_EOD_ENABLED", True) + eod_hm = str(r.get("TAIL_EOD_HM") or "15:25").strip() or "15:25" # ws_ticks 진입가 재생 기본 ON — tail_tick_replay·모멘텀·돌파와 동일 (실매 체결 정합, env=0 일 때만 OFF) backtest_use_tick_db = tail_env_bool(r, "TAIL_BACKTEST_USE_TICK_DB", True) backtest_tick_fallback_ohlc = tail_env_bool(r, "TAIL_BACKTEST_TICK_FALLBACK_OHLC", True) @@ -214,6 +217,7 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: tail_vol_mult, tail_vol_win = 0.0, 5 max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0 ratchet_tiers, max_hold_bars = "", 0 + eod_enabled, eod_hm = True, "15:25" backtest_use_tick_db, backtest_tick_fallback_ohlc = False, True trail_pct, trail_arm_pct = 0.0, 0.0 _pat = _load_tail_pattern_params_from_row({}) @@ -239,6 +243,7 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: tail_vol_mult, tail_vol_win = 0.0, 5 max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0 ratchet_tiers, max_hold_bars = "", 0 + eod_enabled, eod_hm = True, "15:25" backtest_use_tick_db, backtest_tick_fallback_ohlc = False, True trail_pct, trail_arm_pct = 0.0, 0.0 _pat = _load_tail_pattern_params_from_row({}) @@ -328,6 +333,9 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: "backtest_vol_fill_cap_pct": tail_env_float(r, "TAIL_BACKTEST_VOL_FILL_CAP_PCT", 0.0) if r else 0.0, "trail_pct": trail_pct, "trail_arm_pct": trail_arm_pct, + "eod_enabled": eod_enabled, + "eod_hm": eod_hm, + "force_eod_exit": eod_enabled, **_pat, } @@ -1382,7 +1390,6 @@ def run_tail_backtest_portfolio( """ rsi_period = int(params.get("rsi_period", 14)) min_bars = rsi_period + 5 - force_eod_exit = _to_bool(params.get("force_eod_exit"), False) max_stocks = _tail_max_stocks_from_params(params) slot_money = resolve_tail_invest_amount_krw(params) total_budget = _tail_total_budget_from_params(params) @@ -1581,8 +1588,7 @@ def run_tail_backtest_portfolio( cl = float(c["close"]) op = float(c["open"]) - is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + is_eod = is_strategy_eod_bar(candles[idx]["candle_time"], params, "TAIL") pos = portfolio[code] max_p = max(float(pos.get("max_price", 0) or 0), hi) @@ -1795,10 +1801,6 @@ def run_tail_backtest( time_start_hm = int(params.get("time_start_hm", 930)) time_end_hm = int(params.get("time_end_hm", 1500)) cooldown_min = float(params.get("cooldown_min", 15)) - # 백테스트 EOD 강제청산 여부: - # - True : 기존 동작 유지(당일 마지막 봉에서 청산) - # - False: 실매와 유사하게 포지션 오픈 유지(백테 결과에서 미청산은 미기록) - force_eod_exit = _to_bool(params.get("force_eod_exit"), False) max_daily = int(params.get("max_daily", 3)) # 방어 로직 (동적 계산용) @@ -1849,8 +1851,7 @@ def run_tail_backtest( if lo > 0: running_low = min(running_low, lo) - is_eod_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + is_eod = is_strategy_eod_bar(c["candle_time"], params, "TAIL") # ── 1. 청산 검사 (포지션 보유 중일 때) ── if position is not None: diff --git a/kis_trader/engine/tail_env_keys.py b/kis_trader/engine/tail_env_keys.py index b575e9f..c687dfd 100644 --- a/kis_trader/engine/tail_env_keys.py +++ b/kis_trader/engine/tail_env_keys.py @@ -238,6 +238,16 @@ def params_to_tail_env_patch(p: Dict[str, Any]) -> Dict[str, str]: _set("TAIL_TIME_END", int(float(p["time_end_hm"]))) if "max_daily" in p and p["max_daily"] is not None: _set("TAIL_MAX_DAILY", int(float(p["max_daily"]))) + if "eod_enabled" in p: + v = p.get("eod_enabled") + patch["TAIL_EOD_ENABLED"] = ( + "1" if str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on") else "0" + ) + if "eod_hm" in p and p["eod_hm"] is not None: + eod_s = str(p["eod_hm"]).strip() + if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit(): + eod_s = f"{eod_s[:2]}:{eod_s[2:]}" + patch["TAIL_EOD_HM"] = eod_s if "min_price" in p and p["min_price"] is not None: _set("TAIL_MIN_PRICE", float(p["min_price"])) if "slot_money" in p and p["slot_money"] is not None: @@ -374,6 +384,11 @@ def web_body_to_tail_env_patch(body: Dict[str, Any]) -> Dict[str, str]: p[k] = v if "ratchet_tiers" in body: p["ratchet_tiers"] = str(body.get("ratchet_tiers") or "").strip() + if "eod_enabled" in body: + p["eod_enabled"] = body.get("eod_enabled") + eod_hm = _get("eod_hm") + if eod_hm is not None: + p["eod_hm"] = str(eod_hm).strip() mdloss = _get("min_drop_pct_for_loss_cut") if mdloss is not None: p["min_drop_pct_for_loss_cut"] = float(mdloss) diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py index 8fed6f8..c9c2dd8 100644 --- a/kis_trader/execution/order_manager.py +++ b/kis_trader/execution/order_manager.py @@ -732,7 +732,6 @@ class OrderManager: False, reason="ghost_cooldown", request=req, - extra={"purge_holdings": True}, ) self._ghost_purged_at[key] = now if log_tag == "broker_response": diff --git a/kis_trader/main.py b/kis_trader/main.py index 2376eb8..c841f95 100644 --- a/kis_trader/main.py +++ b/kis_trader/main.py @@ -182,6 +182,7 @@ class TradingOrchestrator: self.overseas_ws = None self.condition_mgr: ConditionSearchManager | None = None self.kiwoom_condition_mgr: KiwoomConditionSearchManager | None = None + self._pending_kiwoom_condition_configs: List[dict] = [] self.ranking_mgr: VolumeRankManager | None = None # 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 전면 차단). # 지수 조회는 모의 도메인 미지원 가능성 → 실키 우선, 폴백 self.client. @@ -310,16 +311,12 @@ class TradingOrchestrator: ) for strat in self.strategies: strat.daily_profit_halt = guard - from .engine.daily_profit_halt import load_global_profit_target + from .engine.daily_profit_halt import ( + describe_profit_guard_startup, + load_global_profit_target, + ) cfg = load_global_profit_target() - if cfg.get("enabled") and ( - float(cfg.get("krw") or 0) > 0 or float(cfg.get("pct") or 0) > 0 - ): - logger.info( - "🎯 [일일익절] 마스터 ON — 목표 %s원 / %s%% (신규매수 중단)", - f"{float(cfg.get('krw') or 0):,.0f}", - f"{float(cfg.get('pct') or 0):.2f}", - ) + logger.info(describe_profit_guard_startup(cfg, scope="마스터")) def _daily_profit_notify_mm( self, body: str, strategy_id: Optional[str] = None, @@ -516,6 +513,8 @@ class TradingOrchestrator: # 시세 WS 마이그레이션 검증 / WS_SUBSCRIBE_KIS_MINIMAL 시 키움 WS 기동 self._start_ws_validator() + # 키움 조건검색은 시세 WS 와 **동일 세션** 공유 → 시세 WS 기동 후에만 등록 + self._start_kiwoom_condition_manager() self._wire_ws_split_feed_if_needed() # 예수금 캐시: 전략 기동 전 API 선동기화 (재시작 직후 stale kv 사용 방지) @@ -645,26 +644,32 @@ class TradingOrchestrator: continue src = self._resolve_source(sid) # 시작 시 '주 소스' (로그/기본값용) - # ── ranking 쪽 설정 수집 (항상 등록 — 기본값 fallback 존재) ───── + # ── ranking 쪽 설정 수집 (UNIVERSE_SOURCE=ranking 일 때만 KIS REST 폴링) ───── + # kiwoom_condition / condition 소스 전략은 거래량순위 REST 폴링에 등록하지 않는다. sort_key, limit_key = rank_env_keys[sid] sort_default = self._DEFAULT_RANK_SORT.get(sid, "volume") sort_val = ( get_env_from_db(sort_key, sort_default) or sort_default ).strip().lower() limit_val = get_env_int(limit_key, 20) - ranking_configs.append({ - "strategy_id": sid, - "sort": sort_val, - "limit": limit_val, - "market": "J", - }) + register_ranking = src == "ranking" + if register_ranking: + ranking_configs.append({ + "strategy_id": sid, + "sort": sort_val, + "limit": limit_val, + "market": "J", + }) - # ── condition 쪽 설정 수집 (NAME 또는 SEQ 가 있을 때만 등록) ──── + # ── condition 쪽 설정 수집 (UNIVERSE_SOURCE=condition 일 때만 KIS REST 폴링) ──── + # CONDITION_{SID}_NAME 은 키움 조건식과 HTS 동일명 공유용으로도 쓰임. + # kiwoom_condition / ranking 소스 전략은 KIS psearch REST 폴링에 등록하지 않는다. name_key, seq_key = cond_env_keys[sid] nm = (get_env_from_db(name_key, "") or "").strip() sq = (get_env_from_db(seq_key, "") or "").strip() has_cond = bool(nm or sq) - if has_cond: + register_kis_cond = has_cond and src == "condition" + if register_kis_cond: condition_configs.append({ "strategy_id": sid, "name": nm or None, @@ -698,9 +703,11 @@ class TradingOrchestrator: ) logger.info( - "🔀 [%s] 시작소스=%s (매니저등록: ranking=✓ condition=%s kiwoom=%s) " - "— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환", - sid, src, ("✓" if has_cond else "✗"), + "🔀 [%s] 시작소스=%s (매니저등록: ranking=%s condition=%s kiwoom=%s) " + "— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환 " + "(KIS ranking/condition REST 는 시작소스 일치 시만)", + sid, src, ("✓" if register_ranking else "✗"), + ("✓" if register_kis_cond else "✗"), ("✓" if has_kw_cond else "✗"), sid, ) @@ -765,39 +772,9 @@ class TradingOrchestrator: else: logger.info("ℹ️ condition 소스 쓰는 전략 없음 → 조건검색 매니저 비활성") - # ── 키움 조건검색 매니저 기동 (WS 실시간 CNSRREQ/REAL) ────────── - # KIS 와 달리 웹소켓 실시간 지원. 토큰은 키움 REST(au10001) 로 발급하며 - # _get_kiwoom_token_cached 캐시를 재사용한다. KIS 계정과 무관한 별도 키. - # env: KIWOOM_APP_KEY_REAL / KIWOOM_APP_SECRET_REAL (구독성 데이터 → 무조건 실키·실전 도메인) - if kiwoom_condition_configs: - # ⚠️ 조건검색은 시세/구독성 데이터 → KIS 조건검색·랭킹, 키움 시세 WS - # (KIWOOM_WS_FORCE_REAL) 와 동일하게 **KIWOOM_MOCK 와 무관하게 무조건 실키** - # 로 매칭한다. 모의 도메인은 조건검색 미지원/불안정. - kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip() - kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip() - # 접미사 없는 공통 키로 폴백 (시세 WS 블록과 동일 규칙) - kw_key = kw_key or (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip() - kw_secret = kw_secret or (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip() - if not (kw_key and kw_secret): - logger.warning( - "⚠️ KIWOOM_APP_KEY_REAL/SECRET_REAL 미설정 → 키움 조건검색 매니저 비활성 " - "(%d개 전략 다른 소스 폴백)", len(kiwoom_condition_configs), - ) - else: - try: - self.kiwoom_condition_mgr = KiwoomConditionSearchManager( - app_key=kw_key, - app_secret=kw_secret, - is_mock=False, # 조건검색은 항상 실전 도메인 - configs=kiwoom_condition_configs, - db=self.db, - ) - if not self.kiwoom_condition_mgr.start(): - self.kiwoom_condition_mgr = None - except Exception as e: - logger.error("키움 조건검색 매니저 기동 실패: %s", e) - self.kiwoom_condition_mgr = None - else: + # ── 키움 조건검색: 시세 WS 기동 후 _start_kiwoom_condition_manager() 에서 등록 ── + self._pending_kiwoom_condition_configs = kiwoom_condition_configs + if not kiwoom_condition_configs: logger.info("ℹ️ kiwoom_condition 소스 쓰는 전략 없음 → 키움 조건검색 매니저 비활성") # ------------------------------------------------------------------ @@ -920,6 +897,49 @@ class TradingOrchestrator: elif need_validator and not kis_ws_handle: logger.warning("KIS WS 핸들 미발견 → Validator 비활성") + def _start_kiwoom_condition_manager(self) -> None: + """키움 조건검색 — 시세 WS(KiwoomWebSocketPriceCache) 와 단일 세션 공유. + + 키움은 동일 OAuth 토큰으로 WS 2접속 시 먼저 붙은 쪽에 Bye(1000)를 보내 끊는다. + 조건검색 전용 소켓을 따로 열면 시세 WS 가 5초마다 끊기는 증상이 발생한다. + """ + configs = self._pending_kiwoom_condition_configs or [] + if not configs: + return + + kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip() + kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip() + kw_key = kw_key or (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip() + kw_secret = kw_secret or (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip() + if not (kw_key and kw_secret): + logger.warning( + "⚠️ KIWOOM_APP_KEY_REAL/SECRET_REAL 미설정 → 키움 조건검색 매니저 비활성 " + "(%d개 전략 다른 소스 폴백)", len(configs), + ) + return + + shared = self.kiwoom_ws + if shared is None: + logger.warning( + "⚠️ 키움 시세 WS 미기동 → 조건검색 단독 접속 시도 " + "(WS_PROVIDER=kis_with_validation 또는 WS_SUBSCRIBE_KIS_MINIMAL 권장)" + ) + + try: + self.kiwoom_condition_mgr = KiwoomConditionSearchManager( + app_key=kw_key, + app_secret=kw_secret, + is_mock=False, + configs=configs, + db=self.db, + shared_ws=shared, + ) + if not self.kiwoom_condition_mgr.start(): + self.kiwoom_condition_mgr = None + except Exception as e: + logger.error("키움 조건검색 매니저 기동 실패: %s", e) + self.kiwoom_condition_mgr = None + def _wire_short_holding_peak_provider(self) -> None: """SHORT 보유 종목 max_price → ws_candles.holding_peak (백테·tail_engine 정합).""" ca = getattr(self.ws, "candle_agg", None) diff --git a/kis_trader/network/kiwoom_condition_manager.py b/kis_trader/network/kiwoom_condition_manager.py index b8de729..83553e6 100644 --- a/kis_trader/network/kiwoom_condition_manager.py +++ b/kis_trader/network/kiwoom_condition_manager.py @@ -35,12 +35,13 @@ kis_trader/network/kiwoom_condition_manager.py — 키움 조건검색 기반 from __future__ import annotations import json +import random import threading import time -from typing import Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set from .condition_manager import ConditionSearchManager -from ..utils.env import get_env_from_db, get_env_int +from ..utils.env import get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger from ..ws.kis_ws import _get_kiwoom_token_cached @@ -76,6 +77,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager): configs: Optional[List[Dict]] = None, db=None, on_change=None, + shared_ws: Any = None, ): # 부모 초기화: client 는 REST 미사용이므로 None, user_id 는 로깅용 placeholder. # configs 정규화·EXIT grace·name_map·_lock 등은 부모가 세팅. @@ -90,6 +92,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._app_secret = (app_secret or "").strip() self._is_mock = bool(is_mock) self._token: Optional[str] = None + # 시세 WS(KiwoomWebSocketPriceCache) 와 **단일 세션 공유** — 별도 접속 시 Bye 루프 + self._shared_ws: Any = shared_ws + self._shared_mode: bool = False + self._shared_handlers_bound: bool = False # WS URL (실전/모의) — env 로 오버라이드 가능. if self._is_mock: @@ -123,6 +129,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager): # 최초 CNSRLST 처리 + CNSRREQ 시도 완료 신호 (start() 동기 대기용) self._ready = threading.Event() self._start_ok = False + # CNSRREQ 발송·응답 추적 (연속 발송 시 응답 누락 → 재발송) + self._cnsrreq_pending: Set[str] = set() + self._cnsrreq_confirmed: Set[str] = set() + self._cnsrreq_retry_timer: Optional[threading.Timer] = None # ------------------------------------------------------------------ # Public API (오버라이드) — 부모 start() 는 REST 폴링이므로 사용 안 함 @@ -136,12 +146,51 @@ class KiwoomConditionSearchManager(ConditionSearchManager): logger.warning("키움 앱키/시크릿 누락 → 조건검색 매니저 비활성") return False + # 시세 WS 가 이미 떠 있으면 **같은 소켓**으로 조건검색 (키움 1세션 정책) + if self._shared_ws is not None and getattr(self._shared_ws, "is_available", lambda: False)(): + return self._start_shared() + try: import websocket # noqa: F401 (websocket-client 존재 확인) except Exception as e: logger.warning("websocket-client 미설치 → 키움 조건검색 비활성: %s", e) return False + return self._start_own_connection() + + def _start_shared(self) -> bool: + """KiwoomWebSocketPriceCache 세션에 CNSR* 핸들러만 부착 (별도 접속 없음).""" + self._shared_mode = True + self._running = True + self._token = _get_kiwoom_token_cached( + self._app_key, self._app_secret, self._is_mock + ) + if not self._token: + logger.warning("키움 토큰 발급 실패 → 조건검색 매니저 비활성") + return False + + self._bind_shared_handlers() + # 이미 LOGIN 된 상태면 즉시 CNSRLST + if getattr(self._shared_ws, "is_authenticated", lambda: False)(): + self._send_cnsrlst() + + ready_timeout = float(get_env_int("KIWOOM_COND_START_TIMEOUT_SEC", 10)) + self._ready.wait(timeout=ready_timeout) + + if self._start_ok: + logger.info( + "✅ 키움 조건검색 실시간 시작 [공유WS] (%d개, mock=%s, exit_grace=%ds, history=%s)", + len(self._active_seqs), self._is_mock, int(self._exit_grace_sec), + "ON" if (self.history_enabled and self.db is not None) else "OFF", + ) + else: + logger.warning( + "⚠️ 키움 조건검색 [공유WS] 초기 등록 미완료(타임아웃) — LOGIN 재접속 시 자동 재시도" + ) + return True + + def _start_own_connection(self) -> bool: + """레거시: 단독 WS (shared_ws 없을 때만 — 중복 접속 주의).""" self._token = _get_kiwoom_token_cached( self._app_key, self._app_secret, self._is_mock ) @@ -155,8 +204,6 @@ class KiwoomConditionSearchManager(ConditionSearchManager): ) self._ws_thread.start() - # 최초 CNSRLST 해결 + CNSRREQ 시도까지 잠깐 동기 대기 (KIS start() 가 - # seq 해결을 동기로 하는 것과 UX 정합). 타임아웃돼도 백그라운드는 계속 시도. ready_timeout = float(get_env_int("KIWOOM_COND_START_TIMEOUT_SEC", 10)) self._ready.wait(timeout=ready_timeout) @@ -170,17 +217,78 @@ class KiwoomConditionSearchManager(ConditionSearchManager): logger.warning( "⚠️ 키움 조건검색 실시간 초기 등록 미완료(타임아웃) — 백그라운드 재시도 지속" ) - # 스레드는 떠 있으므로 True 반환 (등록은 비동기로 계속 시도/재접속). return True def stop(self) -> None: self._running = False + if self._shared_mode: + self._unbind_shared_handlers() + return try: if self._ws is not None: self._ws.close() except Exception: pass + def _bind_shared_handlers(self) -> None: + if self._shared_handlers_bound or not self._shared_ws: + return + ws = self._shared_ws + ws.register_trnm_handler("CNSRLST", self._on_shared_trnm) + ws.register_trnm_handler("CNSRREQ", self._on_shared_trnm) + ws.register_trnm_handler("CNSRCLR", self._on_shared_trnm) + ws.register_trnm_handler("REAL", self._on_shared_real) + ws.add_on_login_callback(self._on_shared_login) + self._shared_handlers_bound = True + logger.info("🔗 키움 조건검색 → 시세 WS 세션 공유 (중복 접속 방지)") + + def _unbind_shared_handlers(self) -> None: + if not self._shared_handlers_bound or not self._shared_ws: + return + ws = self._shared_ws + ws.unregister_trnm_handler("CNSRLST", self._on_shared_trnm) + ws.unregister_trnm_handler("CNSRREQ", self._on_shared_trnm) + ws.unregister_trnm_handler("CNSRCLR", self._on_shared_trnm) + ws.unregister_trnm_handler("REAL", self._on_shared_real) + ws.remove_on_login_callback(self._on_shared_login) + self._shared_handlers_bound = False + + def _send_cnsrlst(self) -> None: + if self._shared_ws: + self._shared_ws.send_json({"trnm": "CNSRLST"}) + + def _on_shared_login(self, ws) -> None: + """시세 WS 재접속마다 조건식 목록 재조회 → CNSRREQ 재등록.""" + if not self._running: + return + self._ready.clear() + self._start_ok = False + try: + ws.send(json.dumps({"trnm": "CNSRLST"})) + logger.debug("키움 조건검색 [공유WS] LOGIN → CNSRLST") + except Exception as e: + logger.debug("키움 조건검색 CNSRLST 발송 실패: %s", e) + + def _on_shared_trnm(self, ws, msg: dict) -> None: + trnm = msg.get("trnm") + if trnm == "CNSRLST": + self._handle_condition_list(ws, msg.get("data") or []) + elif trnm == "CNSRREQ": + self._handle_cnsrreq(msg) + elif trnm == "CNSRCLR": + logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", msg.get("return_code")) + + def _on_shared_real(self, ws, msg: dict) -> None: + """조건검색 편입/이탈 REAL — 843 필드 있는 항목만 처리.""" + rows = msg.get("data") or [] + cond_rows = [] + for it in rows: + vals = it.get("values") if isinstance(it, dict) else None + if isinstance(vals, dict) and "843" in vals: + cond_rows.append(it) + if cond_rows: + self._handle_real(cond_rows) + # ------------------------------------------------------------------ # WS 라이프사이클 # ------------------------------------------------------------------ @@ -321,22 +429,101 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._sid_by_seq = sid_by_seq self._active_seqs = list(sid_by_seq.keys()) - # 실시간(search_type=1) 등록 — unique seq 별 1회. stex_tp=K (KRX) + # 실시간(search_type=1) 등록 — seq 별 순차 발송 (레이트리밋·응답 누락 방지) + self._send_cnsrreq_all(ws) + + def _send_one_cnsrreq(self, ws, seq: str) -> bool: + """단일 seq CNSRREQ 발송.""" + payload = { + "trnm": "CNSRREQ", + "seq": seq, + "search_type": "1", + "stex_tp": "K", + } + try: + if self._shared_mode and self._shared_ws: + return bool(self._shared_ws.send_json(payload)) + ws.send(json.dumps(payload)) + return True + except Exception as e: + logger.warning("키움 CNSRREQ 발송 실패 (seq=%s): %s", seq, e) + return False + + def _send_cnsrreq_all(self, ws) -> None: + """active seq 목록을 간격 두고 순차 CNSRREQ — 미응답 seq 는 타이머로 재발송.""" + with self._kw_lock: + seqs = list(self._active_seqs) + try: + seqs.sort(key=lambda x: int(x)) + except Exception: + pass + + gap_lo = float(get_env_float("KIWOOM_CNSRREQ_GAP_MIN_SEC", 0.8)) + gap_hi = float(get_env_float("KIWOOM_CNSRREQ_GAP_MAX_SEC", 1.5)) + if gap_hi < gap_lo: + gap_lo, gap_hi = gap_hi, gap_lo + + with self._kw_lock: + self._cnsrreq_pending = set(seqs) + self._cnsrreq_confirmed.clear() + sent = 0 - for seq in self._active_seqs: - try: - ws.send(json.dumps({ - "trnm": "CNSRREQ", - "seq": seq, - "search_type": "1", - "stex_tp": "K", - })) + for i, seq in enumerate(seqs): + if i > 0: + time.sleep(random.uniform(gap_lo, gap_hi)) + if self._send_one_cnsrreq(ws, seq): sent += 1 - except Exception as e: - logger.debug("키움 CNSRREQ 발송 실패 (seq=%s): %s", seq, e) + logger.debug("키움 CNSRREQ 발송 (seq=%s)", seq) self._start_ok = sent > 0 self._ready.set() + self._schedule_cnsrreq_retry(ws, attempt=1) + + def _schedule_cnsrreq_retry(self, ws, *, attempt: int) -> None: + """CNSRREQ 응답이 안 온 seq 만 간격 두고 재발송.""" + max_retries = get_env_int("KIWOOM_CNSRREQ_MAX_RETRIES", 3) + retry_delay = float(get_env_float("KIWOOM_CNSRREQ_RETRY_SEC", 5.0)) + gap_lo = float(get_env_float("KIWOOM_CNSRREQ_GAP_MIN_SEC", 0.8)) + gap_hi = float(get_env_float("KIWOOM_CNSRREQ_GAP_MAX_SEC", 1.5)) + if gap_hi < gap_lo: + gap_lo, gap_hi = gap_hi, gap_lo + + if self._cnsrreq_retry_timer: + try: + self._cnsrreq_retry_timer.cancel() + except Exception: + pass + self._cnsrreq_retry_timer = None + + def _retry() -> None: + if not self._running: + return + with self._kw_lock: + missing = sorted( + self._cnsrreq_pending - self._cnsrreq_confirmed, + key=lambda x: int(x) if str(x).isdigit() else 0, + ) + if not missing: + return + if attempt > max_retries: + logger.warning( + "⚠️ 키움 CNSRREQ 미응답 seq=%s — 최대 재시도 초과", + missing, + ) + return + logger.warning( + "⚠️ 키움 CNSRREQ 미응답 seq=%s → %.0fs 후 재발송 (%d/%d)", + missing, retry_delay, attempt, max_retries, + ) + for i, seq in enumerate(missing): + if i > 0: + time.sleep(random.uniform(gap_lo, gap_hi)) + self._send_one_cnsrreq(ws, seq) + self._schedule_cnsrreq_retry(ws, attempt=attempt + 1) + + self._cnsrreq_retry_timer = threading.Timer(retry_delay, _retry) + self._cnsrreq_retry_timer.daemon = True + self._cnsrreq_retry_timer.start() def _handle_cnsrreq(self, data: Dict) -> None: """CNSRREQ 초기 응답: 현재 매칭 종목 리스트로 seq universe 초기화.""" @@ -363,6 +550,8 @@ class KiwoomConditionSearchManager(ConditionSearchManager): codes[code] = code # 초기 응답엔 종목명 없음 → code 로 대체 with self._kw_lock: self._seq_codes[seq] = codes + self._cnsrreq_confirmed.add(seq) + self._cnsrreq_pending.discard(seq) self._publish_seq(seq) logger.info( "✅ 키움 실시간 등록 (seq=%s) 초기 매칭 %d종목", seq, len(codes) diff --git a/kis_trader/network/ws_manager.py b/kis_trader/network/ws_manager.py index dd4be9f..57a7b6f 100644 --- a/kis_trader/network/ws_manager.py +++ b/kis_trader/network/ws_manager.py @@ -115,12 +115,18 @@ class WSManager: # (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가 # 수 분간 블로킹됨 → 백그라운드 워커 큐로 이관) self._gap_q: "queue.Queue[str]" = queue.Queue(maxsize=1024) + self._gap_prio_q: "queue.Queue[str]" = queue.Queue(maxsize=512) + self._gap_mode: Dict[str, str] = {} # code → "1m" | "full" self._gap_filled: Set[str] = set() # 이미 갭보정 완료한 코드 self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드 + self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터 + self._gap_tf_ok: Dict[str, Set[int]] = {} # 종목별 성공한 TF (재시도 시 스킵) self._gap_lock = threading.Lock() - self._gap_worker_thread: Optional[threading.Thread] = None + self._gap_worker_threads: List[threading.Thread] = [] + self._gap_worker_boot_logged = False # 전체 재갭보정(재접속 시) 중복 트리거 방지 self._bulk_refill_running = False + self._bulk_refill_last_ts: float = 0.0 # 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기) self._share_cache: Dict[str, Dict[str, int]] = {} self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024) @@ -209,8 +215,9 @@ class WSManager: self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async) logger.info( - "✅ WSManager 활성 (tfs=%s, permanent=%d, gap_worker=ON)", + "✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)", tfs, len(self._permanent_codes), + max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)), ) return True except Exception as e: @@ -308,7 +315,11 @@ class WSManager: if self._kiwoom_ws.subscribe(code): added_kw.append(code) for code in added_kw: - self._enqueue_gap_fill(code) + if code in self._permanent_codes: + self._enqueue_gap_fill(code) + else: + # 후보 종목: 1M 우선 갭보정을 큐 앞쪽에 — BREAKOUT 매수체크 즉시 가능 + self._enqueue_gap_fill(code, priority=True, mode="1m") for code in sorted(kis_want - kis_now): self.ws_cache.subscribe(code) @@ -522,40 +533,78 @@ class WSManager: # 내부: 갭 보정 — 백그라운드 워커 파이프라인 # ------------------------------------------------------------------ def _start_gap_worker(self) -> None: - """갭보정 전담 데몬 워커 스레드 기동 (단일 워커 → REST 레이트리밋 자연 직렬화).""" - if self._gap_worker_thread and self._gap_worker_thread.is_alive(): + """갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진.""" + want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)) + alive = [t for t in self._gap_worker_threads if t.is_alive()] + if len(alive) >= want: return - t = threading.Thread( - target=self._gap_worker_loop, - name="WS-GapFillWorker", - daemon=True, + start_id = len(self._gap_worker_threads) + for i in range(start_id, want): + t = threading.Thread( + target=self._gap_worker_loop, + args=(i,), + name=f"WS-GapFillWorker-{i}", + daemon=True, + ) + t.start() + self._gap_worker_threads.append(t) + logger.info( + "✅ 갭보정 워커 %d개 시작 (queue 병렬, WS_GAP_FILL_WORKERS=%d)", + want, want, ) - t.start() - self._gap_worker_thread = t - logger.info("✅ 갭보정 워커 스레드 시작 (queue 기반 비동기 처리)") - def _enqueue_gap_fill(self, code: str) -> None: + def _enqueue_gap_fill( + self, + code: str, + *, + force: bool = False, + priority: bool = False, + mode: str = "full", + ) -> None: """구독 직후 호출 — 갭보정 큐에 논블로킹 등록. 중복 방지: - - 이미 완료(`_gap_filled`) → 스킵 + - 이미 완료(`_gap_filled`) → 스킵 (force=True 시 재시도) - 이미 큐/처리 중(`_gap_inflight`) → 스킵 + + Args: + priority: True 이면 우선 큐(후보 종목 1M 웜업 등) + mode: ``"1m"`` = 1분봉만 먼저, ``"full"`` = 설정된 전 TF """ if not code: return + fill_mode = "1m" if str(mode).strip().lower() == "1m" else "full" with self._gap_lock: - if code in self._gap_filled or code in self._gap_inflight: + if code in self._gap_inflight: return + if code in self._gap_filled and not force: + return + if force: + self._gap_filled.discard(code) self._gap_inflight.add(code) + self._gap_mode[code] = fill_mode + target_q = self._gap_prio_q if priority else self._gap_q try: - self._gap_q.put_nowait(code) + target_q.put_nowait(code) except queue.Full: # 큐가 가득 차면 inflight 해제 후 포기 (WS 틱으로 자연 누적) with self._gap_lock: self._gap_inflight.discard(code) + self._gap_mode.pop(code, None) logger.warning("⚠️ 갭보정 큐 full → %s 스킵 (WS 실시간 누적으로 대체)", code) self._enqueue_share_meta(code) + def _dequeue_gap_fill(self) -> tuple[Optional[str], bool]: + """우선 큐 → 일반 큐 순으로 (code, from_priority) 반환.""" + try: + return self._gap_prio_q.get_nowait(), True + except queue.Empty: + pass + try: + return self._gap_q.get(timeout=1.0), False + except queue.Empty: + return None, False + def _start_share_meta_worker(self) -> None: """유통주식수(ka10001) 전담 워커 — 장외에도 동작, 전략 공통.""" if self._share_worker_thread and self._share_worker_thread.is_alive(): @@ -661,6 +710,15 @@ class WSManager: return if self._bulk_refill_running: return + debounce = float(get_env_int("WS_GAP_BULK_REFILL_DEBOUNCE_SEC", 120)) + now = time.time() + if debounce > 0 and (now - self._bulk_refill_last_ts) < debounce: + logger.debug( + "🔄 [갭보정-전체] 스킵 (디바운스 %.0fs, 마지막 %.0fs 전)", + debounce, now - self._bulk_refill_last_ts, + ) + return + self._bulk_refill_last_ts = now self._bulk_refill_running = True def _bulk(): @@ -673,6 +731,7 @@ class WSManager: # 재접속이므로 모든 종목 갭보정 재실행 with self._gap_lock: self._gap_filled.clear() + self._gap_tf_ok.clear() logger.info( "🔄 [갭보정-전체] WS 재접속 → %d종목 큐 재등록", len(codes), ) @@ -683,59 +742,140 @@ class WSManager: threading.Thread(target=_bulk, name="WS-BulkRefill", daemon=True).start() - def _gap_worker_loop(self) -> None: - """단일 워커 루프: 큐에서 코드 꺼내 순차 처리 → REST 레이트리밋 자연 완충.""" - # 크레덴셜은 첫 작업 시점에 1회 조회 후 캐시 (env 변경 무시하고 세션 유지) + def _gap_worker_loop(self, worker_id: int = 0) -> None: + """워커 루프: 공유 큐에서 코드 꺼내 ka10080 갭보정 (N워커 병렬).""" kw_key = kw_secret = None kw_mock = False - kw_resolved = False while True: - try: - code = self._gap_q.get(timeout=1.0) - except queue.Empty: + code, from_prio = self._dequeue_gap_fill() + if not code: continue - if code is None: # 종료 시그널 - return + with self._gap_lock: + gap_mode = self._gap_mode.get(code, "full") + only_1m = gap_mode == "1m" # 장중만 실행 (장외면 완료 마커 찍고 다음) if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False): with self._gap_lock: self._gap_inflight.discard(code) + self._gap_mode.pop(code, None) self._gap_filled.add(code) - self._gap_q.task_done() + if from_prio: + self._gap_prio_q.task_done() + else: + self._gap_q.task_done() continue - if not kw_resolved: + if kw_key is None: kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials() - use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None) - if use_kiwoom: - kw_status = f"✅ ({'모의' if kw_mock else '실전'})" - else: - kw_status = "❌" - logger.info( - "🔧 [갭보정-워커] kiwoom=%s, KIS_fallback=%s", - kw_status, - "ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF", - ) - kw_resolved = True + with self._gap_lock: + if not self._gap_worker_boot_logged: + use_kiwoom = bool( + kw_key and kw_secret and get_kiwoom_candles_df is not None + ) + if use_kiwoom: + if get_env_bool("KIWOOM_WS_FORCE_REAL", True): + kw_status = "✅ (실전·시세)" + else: + kw_status = f"✅ ({'모의' if kw_mock else '실전'})" + else: + kw_status = "❌" + n_workers = max( + 1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4), + ) + logger.info( + "🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s", + n_workers, + kw_status, + "ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF", + ) + self._gap_worker_boot_logged = True try: - self._fill_gap_for_code( + only_tfs = {1} if only_1m else None + ok = self._fill_gap_for_code( code, kw_key=kw_key, kw_secret=kw_secret, kw_mock=kw_mock, + only_tfs=only_tfs, ) except Exception as e: logger.debug("갭보정 워커 예외 (%s): %s", code, e) + ok = False finally: with self._gap_lock: self._gap_inflight.discard(code) - self._gap_filled.add(code) - self._gap_q.task_done() + self._gap_mode.pop(code, None) - # 종목 간 짧은 sleep (REST 레이트리밋 완충) - time.sleep(random.uniform(0.2, 0.4)) + if only_1m: + # 1M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서 + have_1m = 1 in self._gap_tf_ok.get(code, set()) + if have_1m: + need = set(self.candle_agg.timeframes) + have = self._gap_tf_ok.get(code, set()) + if not need.issubset(have): + threading.Thread( + target=lambda c=code: self._enqueue_gap_fill( + c, mode="full", + ), + daemon=True, + ).start() + else: + self._gap_filled.add(code) + self._gap_retry_count.pop(code, None) + else: + retries = self._gap_retry_count.get(code, 0) + 1 + max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3) + self._gap_retry_count[code] = retries + if retries < max_retries: + delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8)) + logger.warning( + "⚠️ [갭보정] %s 1M 실패 → %ds 후 우선 재시도 (%d/%d)", + code, int(delay), retries, max_retries, + ) + threading.Timer( + delay, + lambda c=code: self._enqueue_gap_fill( + c, force=True, priority=True, mode="1m", + ), + ).start() + else: + logger.warning( + "⚠️ [갭보정] %s 1M 최대 재시도 초과 — WS 틱 누적으로 대체", + code, + ) + elif ok: + self._gap_filled.add(code) + self._gap_retry_count.pop(code, None) + else: + retries = self._gap_retry_count.get(code, 0) + 1 + max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3) + self._gap_retry_count[code] = retries + if retries < max_retries: + delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8)) + logger.warning( + "⚠️ [갭보정] %s 일부 TF 실패 → %ds 후 재시도 (%d/%d)", + code, int(delay), retries, max_retries, + ) + threading.Timer( + delay, + lambda c=code: self._enqueue_gap_fill(c, force=True), + ).start() + else: + logger.warning( + "⚠️ [갭보정] %s 최대 재시도 초과 — WS 틱 누적으로 대체", + code, + ) + self._gap_filled.add(code) + + if from_prio: + self._gap_prio_q.task_done() + else: + self._gap_q.task_done() + + # 종목 간 sleep (REST 레이트리밋 완충) + self._gap_code_sleep() @staticmethod def _is_market_hours() -> bool: @@ -753,20 +893,25 @@ class WSManager: def _get_kiwoom_credentials(self): """ - 키움 분봉 갭보정용 키 조회 — **KIS 처럼 모의/실전 토글 가능**. + 키움 분봉 갭보정·유통주식수(ka10001)용 키 조회. + + 시세 REST 는 **매매 KIS_MOCK 과 분리** — WS·조건검색과 동일하게 실키 우선. 토글 결정 우선순위 ------------------ - 1) ``KIWOOM_MOCK`` (있으면 단독 사용 — 키움만 별도 토글하고 싶을 때) - 2) 미지정 시 ``KIS_MOCK`` 폴백 (한 번만 설정해도 동기화) + 1) ``KIWOOM_WS_FORCE_REAL=true`` (기본) → **항상 실키·api.kiwoom.com** + (KIS_MOCK·KIWOOM_MOCK 무시 — 갭보정/시세 전용) + 2) ``KIWOOM_WS_FORCE_REAL=false`` 일 때만: + a) ``KIWOOM_MOCK`` 명시값 + b) 미지정 시 ``KIS_MOCK`` 폴백 키 슬롯 매핑 ----------- mock=True → ``KIWOOM_APP_KEY_MOCK`` → 없으면 ``KIWOOM_APP_KEY`` (레거시) 폴백 mock=False → ``KIWOOM_APP_KEY_REAL`` → 없으면 ``KIWOOM_APP_KEY`` (레거시) 폴백 - 키 없으면 ``(None, None, is_mock)`` 반환 → 키움 비활성, KIS REST fallback 사용 - (``WS_GAP_FILL_KIS_FALLBACK`` 권장 ON) + 키 없으면 ``(None, None, is_mock)`` 반환 → 키움 비활성 + (``WS_GAP_FILL_KIS_FALLBACK`` ON 일 때만 KIS REST 폴백) 주의 ---- @@ -780,14 +925,22 @@ class WSManager: if get_kiwoom_candles_df is None: return None, None, False try: - # ── 1. 토글 결정 ────────────────────────────────────────── - kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower() - if kw_mock_raw in ("true", "1", "yes", "y", "on"): - is_mock = True - elif kw_mock_raw in ("false", "0", "no", "n", "off"): + # ── 1. 토글 결정 (시세 REST = WS 와 동일 정책) ─────────────── + force_real_str = ( + get_env_from_db("KIWOOM_WS_FORCE_REAL", "true") or "true" + ).strip().lower() + force_real = force_real_str in ("true", "1", "yes", "y", "on") + + if force_real: is_mock = False else: - is_mock = get_env_bool("KIS_MOCK", True) + kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower() + if kw_mock_raw in ("true", "1", "yes", "y", "on"): + is_mock = True + elif kw_mock_raw in ("false", "0", "no", "n", "off"): + is_mock = False + else: + is_mock = get_env_bool("KIS_MOCK", True) # ── 2. 키 슬롯 선택 (모의/실전) ─────────────────────────── if is_mock: @@ -818,6 +971,63 @@ class WSManager: logger.debug("키움 크레덴셜 조회 예외: %s", e) return None, None, False + @staticmethod + def _parse_tf_csv(raw: str, fallback: str) -> List[int]: + """콤마 구분 분봉 목록 파싱 (예: ``1,3`` → [1, 3]).""" + try: + src = str(raw if str(raw or "").strip() else fallback) + out = [int(x.strip()) for x in src.split(",") if x.strip()] + return sorted(set(out)) + except Exception: + return [int(x) for x in fallback.split(",")] + + def _gap_priority_tfs(self) -> Set[int]: + """갭보정 1차 우선 TF — 기본 1M·3M (BREAKOUT·SHORT 핵심).""" + raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3") + return set(self._parse_tf_csv(raw, "1,3")) + + def _resolve_gap_fill_tf_order(self) -> List[int]: + """우선 TF(1M·3M) 먼저, 이후 15M/60M — 레이트리밋 시 핵심 봉 선확보.""" + all_tfs = list(self.candle_agg.timeframes) + priority = self._gap_priority_tfs() + ordered: List[int] = [tf for tf in self._parse_tf_csv( + get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3"), "1,3", + ) if tf in all_tfs] + for tf in all_tfs: + if tf not in priority: + ordered.append(tf) + return ordered + + def _gap_tf_sleep(self) -> None: + """TF 간 REST 호출 간격 — 키움 ka10080 레이트리밋 완충.""" + lo = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MIN_SEC", 0.6)) + hi = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MAX_SEC", 1.2)) + if hi < lo: + lo, hi = hi, lo + time.sleep(random.uniform(lo, hi)) + + def _gap_code_sleep(self) -> None: + """종목 간 REST 호출 간격.""" + lo = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MIN_SEC", 0.4)) + hi = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MAX_SEC", 0.8)) + if hi < lo: + lo, hi = hi, lo + time.sleep(random.uniform(lo, hi)) + + def _gap_tf_already_ok(self, code: str, tf: int) -> bool: + with self._gap_lock: + return tf in self._gap_tf_ok.get(code, set()) + + def _mark_gap_tf_ok(self, code: str, tf: int) -> None: + with self._gap_lock: + self._gap_tf_ok.setdefault(code, set()).add(tf) + + def _all_gap_tfs_ok(self, code: str) -> bool: + need = set(self.candle_agg.timeframes) + with self._gap_lock: + have = self._gap_tf_ok.get(code, set()) + return need.issubset(have) + def _fill_gap_for_code( self, code: str, @@ -825,28 +1035,53 @@ class WSManager: kw_key: Optional[str] = None, kw_secret: Optional[str] = None, kw_mock: bool = False, - ) -> None: + only_tfs: Optional[Set[int]] = None, + ) -> bool: """ 단일 종목 갭 보정 — 워커 스레드 전용 (전략 쓰레드에서 직접 호출 금지). - 정책 (기존 kis_scalping_ver2._fill_all_gaps 개선): - [1] 키움 ka10080 우선 (1/3/15/60분봉 native + 과거봉 확보) - [2] 키움 실패 시 KIS fallback — **기본 OFF** (`WS_GAP_FILL_KIS_FALLBACK=false`) - → KIS 모의 서버가 장중에도 HTTP 500 을 자주 반환해 로그 오염 + 백오프 지연. - 키움 있으면 굳이 안 쳐도 됨. WS 틱이 쌓여 자연 보완됨. - → env 로 true 지정 시에만 tf<=3 한정 KIS 호출. - [3] 어느 경로든 실패 → WS 실시간 틱으로 자연 누적 (CandleAggregator) + 1M·3M 우선 → phase pause → 15M/60M 순. + 성공한 TF는 ``_gap_tf_ok`` 에 기록해 재시도 시 REST 중복 호출을 줄인다. + + Returns: + True if all configured timeframes got REST data; False if any TF empty. """ if not (self.ws_cache and self.candle_agg): - return + return False if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False): - return + return False + + if self._all_gap_tfs_ok(code): + return True limit = get_env_int("WS_GAP_FILL_LIMIT", 120) use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None) kis_fallback_on = get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) + priority = self._gap_priority_tfs() + ordered_tfs = self._resolve_gap_fill_tf_order() + if only_tfs is not None: + ordered_tfs = [tf for tf in ordered_tfs if tf in only_tfs] + phase_pause = float(get_env_float("WS_GAP_FILL_PHASE_PAUSE_SEC", 1.5)) + prev_tf: Optional[int] = None + + for tf in ordered_tfs: + if self._gap_tf_already_ok(code, tf): + prev_tf = tf + continue + + # 우선(1M·3M) → 장기(15M·60M) 전환 전 추가 휴식 + if ( + prev_tf is not None + and prev_tf in priority + and tf not in priority + and phase_pause > 0 + ): + logger.debug( + "[갭보정] %s 우선TF 완료 → %ds pause 후 %dM", + code, int(phase_pause), tf, + ) + time.sleep(phase_pause) - for tf in self.candle_agg.timeframes: df = None if use_kiwoom: @@ -856,7 +1091,7 @@ class WSManager: is_mock=kw_mock, n=limit, ) except Exception as e: - logger.debug("키움 갭보정 실패 (%s %dM): %s", code, tf, e) + logger.warning("⚠️ [갭보정] 키움 실패 (%s %dM): %s", code, tf, e) # KIS fallback — env 로 명시적 ON 일 때만 (1/3분봉 한정) if (df is None or df.empty) and kis_fallback_on and tf <= 3: @@ -869,9 +1104,16 @@ class WSManager: if df is not None and not df.empty: self.candle_agg.fill_gap_from_rest(code, tf, df) + self._mark_gap_tf_ok(code, tf) + else: + logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf) - # 같은 종목 내 타임프레임 전환 사이 짧은 sleep (차트 API 레이트리밋) - time.sleep(random.uniform(0.15, 0.3)) + prev_tf = tf + self._gap_tf_sleep() + + return self._all_gap_tfs_ok(code) if only_tfs is None else ( + all(tf in self._gap_tf_ok.get(code, set()) for tf in only_tfs) + ) def get_share_denom(self, code: str) -> float: """ diff --git a/kis_trader/strategies/base.py b/kis_trader/strategies/base.py index e81d34a..a446dad 100644 --- a/kis_trader/strategies/base.py +++ b/kis_trader/strategies/base.py @@ -61,6 +61,128 @@ def _is_non_stock(name: str, code: str) -> bool: return False +def parse_eod_hm(raw: str, default: str = "15:25") -> Tuple[int, int]: + """EOD 시각 문자열 → (시, 분). ``1515`` / ``15:15`` 모두 허용.""" + s = str(raw or default).strip() + if not s or s.lower() == "none": + s = default + if ":" in s: + parts = s.split(":", 1) + try: + return int(parts[0]), int(parts[1]) + except (ValueError, TypeError): + pass + if len(s) == 4 and s.isdigit(): + return int(s[:2]), int(s[2:]) + try: + hh, mm = [int(x) for x in s.split(":")] + return hh, mm + except Exception: + return 15, 25 + + +def is_live_eod_now( + enabled: bool, + eod_hm: str, + now: dt, + *, + default_hm: str = "15:25", +) -> bool: + """실매 EOD 당일청산 시각 도달 여부.""" + if not enabled: + return False + eod_hh, eod_mm = parse_eod_hm(eod_hm, default_hm) + return (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm) + + +# 전략별 EOD env 키 — 실매·백테·파라서치 공통 +_STRATEGY_EOD_SPEC: Dict[str, Tuple[str, str, bool, str, str]] = { + "BREAKOUT": ("BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", True, "15:15", ""), + "MOMENTUM": ("MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", True, "15:25", "MOMENTUM_FORCE_EOD_EXIT"), + "TAIL": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"), + "SHORT": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"), +} + + +def _params_truthy_bool(val: Any, default: bool) -> bool: + if val is None or val == "" or val == "None": + return default + if isinstance(val, bool): + return val + return str(val).strip().lower() in ("1", "true", "t", "y", "yes", "on") + + +def resolve_strategy_eod_params( + params: Dict[str, Any], + strategy_id: str, +) -> Tuple[bool, str]: + """params → (eod_enabled, eod_hm). UI ``eod_enabled``/``eod_hm`` 우선, 없으면 env 키.""" + sid = str(strategy_id or "").strip().upper() + if sid == "SHORT": + sid = "TAIL" + spec = _STRATEGY_EOD_SPEC.get(sid) + if spec is None: + return False, "15:25" + en_key, hm_key, def_en, def_hm, leg_key = spec + + if "eod_enabled" in params: + enabled = _params_truthy_bool(params.get("eod_enabled"), def_en) + elif en_key in params: + enabled = _params_truthy_bool(params.get(en_key), def_en) + elif leg_key and leg_key in params: + enabled = _params_truthy_bool(params.get(leg_key), def_en) + else: + enabled = def_en + + raw_hm = params.get("eod_hm") + if raw_hm not in (None, "", "None"): + eod_hm = str(raw_hm).strip() + elif params.get(hm_key) not in (None, "", "None"): + eod_hm = str(params.get(hm_key)).strip() + else: + eod_hm = def_hm + return enabled, eod_hm + + +def is_backtest_eod_bar( + candle_time: str, + enabled: bool, + eod_hm: str, + *, + default_hm: str = "15:25", +) -> bool: + """백테 1분봉/스캔키 — 실매 ``is_live_eod_now`` 와 동일 시각 기준.""" + if not enabled: + return False + eod_hh, eod_mm = parse_eod_hm(eod_hm, default_hm) + t = str(candle_time).strip() + if len(t) < 12: + return False + try: + bar_hh = int(t[8:10]) + bar_mm = int(t[10:12]) + except (ValueError, TypeError): + return False + return (bar_hh > eod_hh) or (bar_hh == eod_hh and bar_mm >= eod_mm) + + +def is_strategy_eod_bar( + candle_time: str, + params: Dict[str, Any], + strategy_id: str, +) -> bool: + """전략 params + 봉시각 → EOD 청산 여부 (실매와 동일 키·시각).""" + sid = str(strategy_id or "").strip().upper() + if sid == "SHORT": + sid = "TAIL" + spec = _STRATEGY_EOD_SPEC.get(sid) + if spec is None: + return False + _, _, _, def_hm, _ = spec + enabled, eod_hm = resolve_strategy_eod_params(params, sid) + return is_backtest_eod_bar(candle_time, enabled, eod_hm, default_hm=def_hm) + + class BaseStrategy(ABC, threading.Thread): """ 모든 전략의 공통 부모 클래스. threading.Thread 상속 → start() 시 독립 쓰레드. @@ -126,8 +248,10 @@ class BaseStrategy(ABC, threading.Thread): self.universe_source = default self._running = False - # 보유 종목 (DB active_trades 로부터 로드 — 전략별 필터) + # 보유 종목 — 매 루프 DB active_trades 와 동기화 (진실의 원천 = DB) self.holdings: Dict[str, dict] = {} + # 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지) + self._runtime: Dict[str, dict] = {} # 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프) self.recently_sold: Dict[str, float] = {} # 당일 매매불가 종목 (다음 후보로 넘어감) @@ -136,7 +260,7 @@ class BaseStrategy(ABC, threading.Thread): # 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF) self.daily_profit_halt: Any = None - self._load_holdings_from_db() + self._sync_holdings_from_db(log_restore=True) # ------------------------------------------------------------------ # 외부 인터페이스 @@ -235,6 +359,9 @@ class BaseStrategy(ABC, threading.Thread): # 설정 리로드 (DB env_config 실시간 반영) self.reload_config() + # 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합) + self._sync_holdings_from_db() + # ── [1] 매도 먼저 ──────────────────────────────── sell_signals = self.check_sell_signals() if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True): @@ -258,6 +385,9 @@ class BaseStrategy(ABC, threading.Thread): if candidates and active_cnt < max_stocks and self.check_buy_allowed(): self._scan_and_buy(candidates, max_stocks, active_cnt) + # 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge) + self._capture_runtime_overlay() + time.sleep(self._scan_sleep("loop")) except KeyboardInterrupt: @@ -470,6 +600,7 @@ class BaseStrategy(ABC, threading.Thread): "name": req.name, "size_class": req.size_class or "", } + self._capture_runtime_overlay() elif result.success and signal.get("use_limit_buy"): self.on_limit_buy_submitted(signal, result) else: @@ -496,16 +627,14 @@ class BaseStrategy(ABC, threading.Thread): result = self.order_mgr.place(req) if result.success: self.recently_sold[req.code] = time.time() - self.holdings.pop(req.code, None) - elif result.reason in ("broker_no_position", "ghost_cooldown") or ( - result.extra and result.extra.get("purge_holdings") - ): + self._drop_local_position(req.code) + elif result.reason == "broker_no_position": if req.code in self.holdings: self.logger.info( "🧹 [유령정리] %s %s — 로컬 holdings 제거 (%s)", req.name, req.code, result.reason, ) - self.holdings.pop(req.code, None) + self._drop_local_position(req.code) return result # ------------------------------------------------------------------ @@ -599,19 +728,84 @@ class BaseStrategy(ABC, threading.Thread): return tail_cd return get_env_int("REENTRY_COOLDOWN_SEC", 300) - def _load_holdings_from_db(self) -> None: - """DB active_trades 에서 본 전략 소유 포지션 로드. + # DB sync 시 holdings 에 합치지 않고 _runtime 만 유지하는 장중 오버레이 키 + _RUNTIME_OVERLAY_KEYS: Tuple[str, ...] = ( + "max_price", "session_low", + "updow_entry_bar_key", "box_low", "box_high", + ) - ETN/ETF/스팩 등 비본주는 KIS·키움 API에서 가격 조회 자체가 막혀 매분 - ``[매도-가격없음]`` 로그를 무한 반복하므로 holdings 에서 자동 제외한다. - (사용자가 한투 HTS 에서 직접 처분 — 봇은 매수/매도 시도 없음.) + def _load_holdings_from_db(self, *, log_restore: bool = False) -> None: + """DB → holdings 동기화 (지정가 체결 등 이벤트 시 호출).""" + self._sync_holdings_from_db(log_restore=log_restore) + + def _drop_local_position(self, code: str) -> None: + """매도·유령정리 후 메모리 보유·런타임 오버레이 제거.""" + self.holdings.pop(code, None) + self._runtime.pop(code, None) + + def _merge_runtime_overlay( + self, code: str, avg_bp: float, db_max: float, db_sess_low: float, + ) -> Tuple[float, float]: + """DB 행 + _runtime → max_price/session_low (퇴행 방지).""" + rt = self._runtime.get(code) or {} + max_p = max( + avg_bp, + float(db_max or 0), + float(rt.get("max_price") or 0), + ) + sess_candidates = [ + v for v in ( + avg_bp, + float(db_sess_low or 0), + float(rt.get("session_low") or 0), + ) if v > 0 + ] + sess_low = min(sess_candidates) if sess_candidates else avg_bp + return max_p, sess_low + + def _apply_runtime_extra_fields(self, code: str, holding: Dict[str, Any]) -> None: + """UPDOW 등 전략 부가 필드를 _runtime → holdings 로 복원.""" + rt = self._runtime.get(code) or {} + for k in self._RUNTIME_OVERLAY_KEYS: + if k in ("max_price", "session_low"): + continue + if k in rt and rt[k] is not None: + holding[k] = rt[k] + + def _capture_runtime_overlay(self) -> None: + """매도 판단 루프가 갱신한 고점·저점을 _runtime 에 저장.""" + for code, h in self.holdings.items(): + rt = self._runtime.setdefault(code, {}) + mp = float(h.get("max_price") or 0) + if mp > float(rt.get("max_price") or 0): + rt["max_price"] = mp + sl = float(h.get("session_low") or 0) + if sl > 0: + prev = float(rt.get("session_low") or 0) + rt["session_low"] = sl if prev <= 0 else min(prev, sl) + for k in self._RUNTIME_OVERLAY_KEYS: + if k in ("max_price", "session_low"): + continue + if k in h and h[k] is not None: + rt[k] = h[k] + + def _after_holdings_sync(self) -> None: + """서브클래스 훅 — DB sync 직후 (UPDOW entry_bar_key 등).""" + return None + + def _sync_holdings_from_db(self, *, log_restore: bool = False) -> None: + """DB active_trades → holdings (진실의 원천). 장중 고점은 _runtime 과 merge. + + - DB에 없는 종목은 holdings·_runtime 에서 제거 (양방향 정합) + - poll_pending 매수 체결·재시작 후에도 다음 루프에 자동 반영 + - ETN/ETF/스팩 등 비본주는 가격 API 불가 → 자동 제외 """ try: prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id rows = self.db.get_active_trades(strategy_prefix=prefix) skipped_non_stock: list[str] = [] + new_holdings: Dict[str, dict] = {} for code, t in rows.items(): - # 같은 prefix 라도 정확한 strategy 매칭만 가져감 (SCALP* 과 SHORT* 충돌 방지) if t.get("strategy") and t["strategy"] != self.strategy_id: continue if get_env_bool("EXCLUDE_NON_STOCK", True): @@ -619,29 +813,64 @@ class BaseStrategy(ABC, threading.Thread): if _is_non_stock(name, code): skipped_non_stock.append(f"{code}({name})") continue - avg_bp = float(t.get("avg_buy_price", 0) or 0) - self.holdings[code] = { + avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0) + qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0) + if qty <= 0 or avg_bp <= 0: + continue + db_max = float(t.get("max_price") or 0) + db_sess = float(t.get("session_low") or 0) + max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess) + holding = { "buy_price": avg_bp, - "qty": t.get("current_qty", 0), + "qty": qty, "stop_price": t.get("stop_price", 0), "target_price": t.get("target_price", 0), - "max_price": float(t.get("max_price") or avg_bp or 0), - "session_low": float(t.get("session_low") or avg_bp or 0), + "max_price": max_p, + "session_low": sess_low, "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)), "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), "name": t.get("name", code), "size_class": t.get("size_class", ""), } - if self.holdings: - self.logger.info("📂 [DB 복원] 보유 %d종목 (%s)", - len(self.holdings), self.strategy_id) + self._apply_runtime_extra_fields(code, holding) + new_holdings[code] = holding + + db_codes = set(new_holdings.keys()) + for code in list(self._runtime.keys()): + if code not in db_codes: + del self._runtime[code] + + prev_codes = set(self.holdings.keys()) + self.holdings.clear() + self.holdings.update(new_holdings) + self._after_holdings_sync() + + if log_restore and self.holdings: + self.logger.info( + "📂 [DB 복원] 보유 %d종목 (%s)", + len(self.holdings), self.strategy_id, + ) + elif not log_restore: + added = db_codes - prev_codes + if added: + self.logger.info( + "📂 [DB동기화] +%d종목 (%s) poll/체결 반영: %s", + len(added), self.strategy_id, ",".join(sorted(added)[:5]), + ) + removed = prev_codes - db_codes + if removed: + self.logger.debug( + "📂 [DB동기화] -%d종목 (%s) 청산 반영: %s", + len(removed), self.strategy_id, ",".join(sorted(removed)[:5]), + ) + if skipped_non_stock: self.logger.warning( "⚠️ ETN/ETF 보유 자동 제외(매수/매도 모두 봇이 안 건드림 — 한투 HTS에서 직접 처분 권장): %s", ", ".join(skipped_non_stock), ) except Exception as e: - self.logger.warning("DB holdings 로드 실패: %s", e) + self.logger.warning("DB holdings 동기화 실패: %s", e) def _load_candidates(self) -> List[Dict]: """ diff --git a/kis_trader/strategies/breakout.py b/kis_trader/strategies/breakout.py index aa1d5fd..6099657 100644 --- a/kis_trader/strategies/breakout.py +++ b/kis_trader/strategies/breakout.py @@ -46,7 +46,7 @@ from ..engine.whipsaw_filter import whipsaw_reject_for_signal from ..engine.orderbook_filter import orderbook_reject_for_entry from ..engine.program_filter import program_reject_for_entry from ..share.stock_share import share_denom_for_code -from .base import BaseStrategy +from .base import BaseStrategy, is_strategy_eod_bar def _to_bool(v: Any, default: bool = True) -> bool: @@ -956,6 +956,9 @@ def breakout_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]: "use_ema_filter": _ui_bool("use_ema_filter", False), "ema_fast_period": int(float(ui.get("ema_fast_period", 9) or 9)), "ema_slow_period": int(float(ui.get("ema_slow_period", 21) or 21)), + # EOD — 실매 BREAKOUT_EOD_* 와 동일 (백테·파서치 공용) + "eod_enabled": _ui_bool("eod_enabled", True), + "eod_hm": str(ui.get("eod_hm") or "15:15").strip() or "15:15", } @@ -1304,7 +1307,7 @@ def run_breakout_backtest( day = c["candle_time"][:8] cl = float(c["close"]) - is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day) + is_eod = is_strategy_eod_bar(c["candle_time"], params, "BREAKOUT") # ── 보유 중: 청산 체크 (intrabar — 실매 current_price 폴링 정렬) ── if position is not None: @@ -1467,6 +1470,8 @@ class BreakoutStrategy(BaseStrategy): # 래칫 단계 트레일(기본 OFF) · 시간컷(기본 OFF) — 청산 일원화에서 사용 self.ratchet_tiers_raw = get_env_from_db("BREAKOUT_RATCHET_TIERS", "") self.max_hold_bars = get_env_int("BREAKOUT_MAX_HOLD_BARS", 0) + # EOD 당일청산 — OFF면 15:15 강제청산 비활성(익절·손절·시간컷만) + self.eod_enabled = get_env_bool("BREAKOUT_EOD_ENABLED", True) self.eod_hm = get_env_from_db("BREAKOUT_EOD_HM", "15:15") # ── ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 'fixed'=기존 고정% 손절) ── # 돌파봉 변동성에 손절폭을 비례시켜 휩쏘(속임수 하락)에 시드 헌납하는 것을 줄인다. @@ -1591,7 +1596,16 @@ class BreakoutStrategy(BaseStrategy): if mode in _INTRABAR_ENTRY_MODES: curr_price = self._live_current_price(code) if curr_price <= 0: - self.logger.info("🔍 [가격없음] %s(%s) B안", name, code) + # 저유동 B안: 체결 틱 없음 — 정상 스킵. 기본 debug (SCAN_REJECT_LOG_VERBOSE 시 60s 1회 info) + if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False): + if not hasattr(self, "_no_price_log"): + self._no_price_log = {} + last = self._no_price_log.get(code, 0) + if time.time() - last >= 60: + self._no_price_log[code] = time.time() + self.logger.info("🔍 [가격없음] %s(%s) B안", name, code) + else: + self.logger.debug("🔍 [가격없음] %s(%s) B안", name, code) return None forming = None @@ -1644,7 +1658,15 @@ class BreakoutStrategy(BaseStrategy): if p > 0: curr_price = p if curr_price <= 0: - self.logger.info("🔍 [가격없음] %s(%s)", name, code) + if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False): + if not hasattr(self, "_no_price_log"): + self._no_price_log = {} + last = self._no_price_log.get(code, 0) + if time.time() - last >= 60: + self._no_price_log[code] = time.time() + self.logger.info("🔍 [가격없음] %s(%s)", name, code) + else: + self.logger.debug("🔍 [가격없음] %s(%s)", name, code) return None invest_cap = self._breakout_invest_cap_krw() @@ -1705,7 +1727,9 @@ class BreakoutStrategy(BaseStrategy): eod_hh, eod_mm = 15, 15 now = dt.now() - is_eod = (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm) + is_eod = False + if self.eod_enabled: + is_eod = (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm) signals: List[Dict] = [] for code, holding in list(self.holdings.items()): diff --git a/kis_trader/strategies/momentum.py b/kis_trader/strategies/momentum.py index 58c5cee..25f6e13 100644 --- a/kis_trader/strategies/momentum.py +++ b/kis_trader/strategies/momentum.py @@ -18,9 +18,9 @@ from datetime import datetime as dt from typing import Dict, List, Optional from ..engine import momentum_engine as me -from ..utils.env import get_env_bool, get_env_float, get_env_int +from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.position_sizing import invest_qty_for_price -from .base import BaseStrategy +from .base import BaseStrategy, is_live_eod_now class MomentumStrategy(BaseStrategy): @@ -53,6 +53,8 @@ class MomentumStrategy(BaseStrategy): self.mom_rsi_min = float(base.get("mom_rsi_min", 50.0)) self.mom_rsi_max = float(base.get("mom_rsi_max", 80.0)) self.max_daily = int(base.get("max_daily", 5)) + self.eod_enabled = get_env_bool("MOMENTUM_EOD_ENABLED", True) + self.eod_hm = get_env_from_db("MOMENTUM_EOD_HM", "15:25") except Exception as e: self.logger.debug("momentum_engine defaults 조회 실패: %s", e) self._engine_params = {} @@ -197,7 +199,12 @@ class MomentumStrategy(BaseStrategy): signals: List[Dict] = [] now = dt.now() - is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15 + is_eod = is_live_eod_now( + getattr(self, "eod_enabled", True), + getattr(self, "eod_hm", "15:25"), + now, + default_hm="15:25", + ) params = dict(self._engine_params or me.get_momentum_defaults_from_db()) for code, holding in list(self.holdings.items()): diff --git a/kis_trader/strategies/tail_catch.py b/kis_trader/strategies/tail_catch.py index 19158d9..b2287ca 100644 --- a/kis_trader/strategies/tail_catch.py +++ b/kis_trader/strategies/tail_catch.py @@ -25,8 +25,8 @@ from ..engine.limit_entry_common import ( should_cancel_unfilled_limit, tail_limit_params, ) -from ..utils.env import get_env_bool, get_env_float, get_env_int -from .base import BaseStrategy +from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int +from .base import BaseStrategy, is_live_eod_now class TailCatchStrategy(BaseStrategy): @@ -63,6 +63,8 @@ class TailCatchStrategy(BaseStrategy): self._engine_params = p except Exception as e: self.logger.debug("tail_engine defaults 조회 실패: %s", e) + self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True) + self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:25") def _candidate_filter(self, candidate: Dict) -> bool: """tail_on 이 True 인 후보만 대상 (SCALP 과 분리).""" @@ -377,7 +379,12 @@ class TailCatchStrategy(BaseStrategy): return [] now = dt.now() - is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15 + is_eod = is_live_eod_now( + getattr(self, "eod_enabled", True), + getattr(self, "eod_hm", "15:25"), + now, + default_hm="15:25", + ) try: params = te.get_tail_defaults_from_db(self.db) except Exception: diff --git a/kis_trader/strategies/updow_strategy.py b/kis_trader/strategies/updow_strategy.py index 09a09d9..ac00713 100644 --- a/kis_trader/strategies/updow_strategy.py +++ b/kis_trader/strategies/updow_strategy.py @@ -362,12 +362,15 @@ class UpdowStrategy(BaseStrategy): return sc[len(self._ENTRY_TAG_PREFIX) :] return "" - def _load_holdings_from_db(self) -> None: - super()._load_holdings_from_db() + def _after_holdings_sync(self) -> None: for _code, h in self.holdings.items(): ek = self._parse_entry_bar_key(h) if ek: h["updow_entry_bar_key"] = ek + self._runtime.setdefault(_code, {})["updow_entry_bar_key"] = ek + + def _load_holdings_from_db(self, *, log_restore: bool = False) -> None: + super()._load_holdings_from_db(log_restore=log_restore) def _submit_buy(self, signal: Dict): code = signal.get("code") or "" @@ -388,6 +391,7 @@ class UpdowStrategy(BaseStrategy): self.holdings[code]["box_low"] = float(ef["box_low"]) if ef.get("box_high"): self.holdings[code]["box_high"] = float(ef["box_high"]) + self._capture_runtime_overlay() return result def _merged_cfg(self, code: str) -> Dict: diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py index 0702d2d..f9ef738 100644 --- a/kis_trader/web/live_config_schema.py +++ b/kis_trader/web/live_config_schema.py @@ -98,9 +98,19 @@ def build_live_config_groups() -> List[GroupDef]: { "id": "daily_profit_global", "title": "일일 익절 — 총합 (마스터)", - "hint": "env_config · 달성 시 전 전략 신규 매수 중단 (매도·손절은 유지)", + "hint": ( + "① 손익 감시 켜기 = 당일 실현손익을 목표와 비교 " + "(ON이면 목표 달성 시 신규매수 자동 차단) " + "② 신규매수 수동 중단 = 목표·손익과 무관하게 매수만 잠금 " + "(긴급 일시정지용, 기본 OFF). 매도·손절은 항상 유지" + ), "fields": [ - _f("DAILY_PROFIT_TARGET_ENABLED", "활성화", "bool"), + _f( + "DAILY_PROFIT_TARGET_ENABLED", + "손익 감시 켜기 (일일익절)", + "bool", + hint="ON = 목표 달성 시 신규매수 자동 차단 · OFF = 목표와 무관하게 매수 계속", + ), _f("DAILY_PROFIT_TARGET_KRW", "목표 금액(원)", "int", default=0), _f("DAILY_PROFIT_TARGET_PCT", "목표 수익률(%)", "float", default=0, hint="퍼센트 그대로 (예: 2.5 = 2.5%) · 0=미사용"), @@ -111,7 +121,16 @@ def build_live_config_groups() -> List[GroupDef]: default=0, hint="0 = ON 전략 TOTAL_BUDGET 합 자동", ), - _f("DAILY_PROFIT_HALT_NEW_BUYS", "신규 매수 중단", "bool", default=True), + _f( + "DAILY_PROFIT_HALT_NEW_BUYS", + "신규매수 수동 중단", + "bool", + default=False, + hint=( + "목표·손익과 무관한 수동 매수 잠금. " + "ON=즉시 신규매수 전면 차단 / OFF=정상 (기본)" + ), + ), _f("DAILY_PROFIT_NOTIFY_MM", "달성 시 MM 알림", "bool", default=True), _f("DAILY_PROFIT_MODE", "익절 방식", "text", default="fixed", hint="fixed=고정목표 / trailing=고점대비 되돌림 / both=둘 중 먼저"), @@ -184,8 +203,9 @@ def build_live_config_groups() -> List[GroupDef]: dp_fields.extend([ _f( _sid_key(sid, "DAILY_PROFIT_TARGET_ENABLED"), - f"{lbl} 익절 활성", + f"{lbl} 손익 감시 켜기", "bool", + hint="OFF = 해당 전략 일일익절 비활성 (매수 차단 없음)", ), _f( _sid_key(sid, "DAILY_PROFIT_TARGET_KRW"), @@ -222,11 +242,21 @@ def build_live_config_groups() -> List[GroupDef]: "int", hint="이 이상 벌어야 트레일 감시 · 비우면 마스터 상속", ), + _f( + _sid_key(sid, "DAILY_PROFIT_HALT_NEW_BUYS"), + f"{lbl} 신규매수 수동 중단", + "bool", + default=False, + hint="목표 무관 수동 잠금 · 비우면 마스터 상속 · OFF=정상", + ), ]) groups.append({ "id": "daily_profit_strategy", "title": "일일 익절 — 전략별 (서브)", - "hint": "비어 있으면 마스터 값 상속 · 달성 시 해당 전략만 매수 중단", + "hint": ( + "전략별 손익만 따로 감시. 비어 있으면 마스터 값 상속. " + "손익 감시 ON이면 목표 달성 시 해당 전략 신규매수 자동 차단" + ), "fields": dp_fields, }) @@ -391,6 +421,47 @@ def build_live_config_groups() -> List[GroupDef]: ], }) + groups.append({ + "id": "breakout_exit", + "title": "돌파(BREAKOUT) — EOD·시간컷", + "hint": ( + "env_config · 저장 후 다음 매도 루프부터 반영(재시작 불필요). " + "EOD OFF 시 당일 15:15 강제청산 없음 — 손절·익절·어깨·시간컷만." + ), + "fields": [ + _f("BREAKOUT_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True, + hint="ON=설정 시각 이후 보유 전량 eod 청산 · OFF=익절·손절·시간컷만"), + _f("BREAKOUT_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:15", + hint="이 시각 이후 보유 전량 청산 · 예: 15:15"), + _f("BREAKOUT_MAX_HOLD_BARS", "최대보유(분)", "int", default=0, + hint="0=OFF · 진입 후 N분 경과 시 max_hold 청산"), + ], + }) + + groups.append({ + "id": "momentum_exit", + "title": "모멘텀(MOMENTUM) — EOD", + "hint": "env_config · 저장 후 다음 매도 루프부터 반영(재시작 불필요).", + "fields": [ + _f("MOMENTUM_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True, + hint="ON=설정 시각 이후 보유 전량 장마감청산 · OFF=익절·손절·시간컷만"), + _f("MOMENTUM_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:25", + hint="이 시각 이후 보유 전량 청산 · 예: 15:25"), + ], + }) + + groups.append({ + "id": "tail_exit", + "title": "꼬리(SHORT/TAIL) — EOD", + "hint": "env_config · 저장 후 다음 매도 루프부터 반영(재시작 불필요).", + "fields": [ + _f("TAIL_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True, + hint="ON=설정 시각 이후 보유 전량 장마감 청산 · OFF=익절·손절·시간컷만"), + _f("TAIL_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:25", + hint="이 시각 이후 보유 전량 청산 · 예: 15:25"), + ], + }) + groups.append({ "id": "portfolio_hold", "title": "보유·매도 / 고아복구", diff --git a/kis_trader/ws/kiwoom_ws.py b/kis_trader/ws/kiwoom_ws.py index 8483475..5f3ae31 100644 --- a/kis_trader/ws/kiwoom_ws.py +++ b/kis_trader/ws/kiwoom_ws.py @@ -53,7 +53,8 @@ import json import logging import threading import time -from typing import Any, Dict, Iterable, List, Optional, Set +from collections import defaultdict +from typing import Any, Callable, Dict, Iterable, List, Optional, Set logger = logging.getLogger("KiwoomWebSocket") @@ -192,6 +193,12 @@ class KiwoomWebSocketPriceCache: self._reg_timer: Optional[threading.Timer] = None self._reg_timer_lock = threading.Lock() + # 조건검색 등 외부 모듈 — **동일 WS 세션 공유** (키움은 토큰당 1접속) + self._ext_handler_lock = threading.Lock() + self._ext_handlers: Dict[str, List[Callable]] = defaultdict(list) + self._login_callbacks: List[Callable] = [] + self._login_cb_lock = threading.Lock() + # websocket-client lib try: import websocket as _ws_lib # type: ignore @@ -267,6 +274,73 @@ class KiwoomWebSocketPriceCache: def _program_ws_enabled(self) -> bool: return get_env_bool("KIWOOM_WS_PROGRAM_ENABLED", True) + def is_available(self) -> bool: + """websocket-client 설치 및 키 설정 여부.""" + return bool(self._available and self.app_key and self.app_secret) + + def is_authenticated(self) -> bool: + """LOGIN OK 이후 REG/조건검색 전송 가능.""" + return bool(self._connected and self._authenticated) + + def register_trnm_handler(self, trnm: str, fn: Callable) -> None: + """외부 모듈(조건검색 CNSR* 등)용 trnm 핸들러 — 시세 WS 와 세션 공유.""" + key = str(trnm or "").strip().upper() + if not key or not callable(fn): + return + with self._ext_handler_lock: + if fn not in self._ext_handlers[key]: + self._ext_handlers[key].append(fn) + + def unregister_trnm_handler(self, trnm: str, fn: Callable) -> None: + key = str(trnm or "").strip().upper() + with self._ext_handler_lock: + lst = self._ext_handlers.get(key) + if lst and fn in lst: + lst.remove(fn) + + def add_on_login_callback(self, fn: Callable) -> None: + """LOGIN OK 직후(재접속마다) 호출 — 조건검색 CNSRLST 등.""" + if not callable(fn): + return + with self._login_cb_lock: + if fn not in self._login_callbacks: + self._login_callbacks.append(fn) + + def remove_on_login_callback(self, fn: Callable) -> None: + with self._login_cb_lock: + if fn in self._login_callbacks: + self._login_callbacks.remove(fn) + + def send_json(self, msg: dict) -> bool: + """인증된 WS 에 JSON 전송 (조건검색 CNSRREQ 등).""" + if not self.is_authenticated() or not self._ws: + return False + try: + self._ws.send(json.dumps(msg)) + return True + except Exception as e: + logger.debug("키움 WS send_json 실패: %s", e) + return False + + def _fire_login_callbacks(self, ws) -> None: + with self._login_cb_lock: + cbs = list(self._login_callbacks) + for cb in cbs: + try: + cb(ws) + except Exception as e: + logger.debug("키움 WS login callback 예외: %s", e) + + def _dispatch_ext_handlers(self, trnm: str, ws, msg: dict) -> None: + key = str(trnm or "").strip().upper() + with self._ext_handler_lock: + handlers = list(self._ext_handlers.get(key, [])) + for fn in handlers: + try: + fn(ws, msg) + except Exception as e: + logger.debug("키움 WS ext handler(%s) 예외: %s", key, e) + def _reg_types(self) -> List[str]: """REG/REMOVE 실시간 타입 — 0B 체결 + (옵션) 0D 호가 + 0w 프로그램.""" types = [self.SUB_TYPE] @@ -482,7 +556,8 @@ class KiwoomWebSocketPriceCache: ) self._last_connect_time = time.time() # blocking — 연결 종료까지 여기서 대기 - self._ws.run_forever(ping_interval=30, ping_timeout=10) + # ping_interval=0 : 키움은 JSON {"trnm":"PING"} keep-alive (프로토콜 ping 비호환) + self._ws.run_forever(ping_interval=0) def _on_open(self, token: str): """on_open 콜백 팩토리 — token 캡처 후 LOGIN 발송.""" @@ -533,8 +608,8 @@ class KiwoomWebSocketPriceCache: self._reg_batch_codes.clear() if pending: self._send_reg_chunked(pending) - # 안정 연결 카운터 초기화 (지속 5분 이상 연결됐다면) - # 여기선 LOGIN 직후라 의미 없음, _periodic_reset_ok() 에서 처리 + # 조건검색 등 공유 세션 모듈 — LOGIN 직후 CNSRLST 재등록 + self._fire_login_callbacks(ws) else: logger.warning("❌ 키움 WS LOGIN 실패 rc=%s msg=%s", rc, rm) try: @@ -554,8 +629,12 @@ class KiwoomWebSocketPriceCache: if trnm == "REAL": self._handle_real(msg) + self._dispatch_ext_handlers("REAL", ws, msg) return + # 조건검색 CNSRLST / CNSRREQ / CNSRCLR 등 + self._dispatch_ext_handlers(trnm, ws, msg) + def _handle_real(self, msg: dict) -> None: """실시간 데이터 처리 — 0B 체결 + 0D 호가잔량 + 0w 프로그램매매.""" items = msg.get("data") or [] @@ -617,27 +696,30 @@ class KiwoomWebSocketPriceCache: with self._cache_lock: self._cache[code] = {"data": data_compat, "ts": time.time()} - # ── CandleAggregator (KIS WS 와 동일 HHMMSS → on_tick) ───── + # tick_time / tick_vol — CandleAggregator·TickRecorder 공용 (보유-only도 recorder 수집) + tt_raw = str(values.get(self.FID_TICK_TIME, "") or "").strip() + if len(tt_raw) >= 6: + tick_time = tt_raw[-6:] + else: + import datetime as _dt + tick_time = _dt.datetime.now().strftime("%H%M%S") + try: + tick_vol = int( + abs(float(str(values.get(self.FID_TICK_VOL, "0")).replace(",", ""))) + ) + except (ValueError, TypeError): + tick_vol = 0 + + # ── CandleAggregator: 후보만 (tick_to_agg = 후보−보유) ───── if self._candle_agg is not None: filt = self._candle_agg_codes - if filt is not None and code not in filt: - return - tt_raw = str(values.get(self.FID_TICK_TIME, "") or "").strip() - if len(tt_raw) >= 6: - tick_time = tt_raw[-6:] - else: - import datetime as _dt - tick_time = _dt.datetime.now().strftime("%H%M%S") - try: - tick_vol = int( - abs(float(str(values.get(self.FID_TICK_VOL, "0")).replace(",", ""))) - ) - except (ValueError, TypeError): - tick_vol = 0 - try: - self._candle_agg.on_tick(code, price, tick_vol, tick_time) - except Exception as ex: - logger.debug("키움→CandleAggregator on_tick 실패 %s: %s", code, ex) + if filt is None or code in filt: + try: + self._candle_agg.on_tick(code, price, tick_vol, tick_time) + except Exception as ex: + logger.debug("키움→CandleAggregator on_tick 실패 %s: %s", code, ex) + + # ── TickRecorder: 보유 포함 전 구독 종목 (백테 틱청산·B안 폴백) ───── if self._tick_recorder is not None: try: self._tick_recorder.on_tick( diff --git a/scripts/wsl_bootstrap.sh b/scripts/wsl_bootstrap.sh new file mode 100755 index 0000000..59798ba --- /dev/null +++ b/scripts/wsl_bootstrap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# WSL — git clone + venv + pip (PowerShell 때랑 동일 흐름) +set -euo pipefail +cd ~ +sudo apt-get update +sudo apt-get install -y git python3.12 python3.12-venv python3-pip +[[ -d kis_bot/.git ]] || git clone ssh://git@192.168.0.141:30009/hoon/kis_bot.git kis_bot +cd kis_bot +python3.12 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt pymysql +echo "끝. .env 만 있으면 실행: source .venv/bin/activate && python3 kis_trader/main.py" diff --git a/static/js/backtest.js b/static/js/backtest.js index 337f2eb..6da513e 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -8,7 +8,17 @@ const fmtWon = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; return Math.round(Number(n)).toLocaleString('ko-KR'); }; -const fmtPct = n => n == null ? '-' : (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; +/** 원화 금액·손익 — 소수점 없이 정수 + '원' (대시보드·요약 카드) */ +const fmtKrw = n => { + const w = fmtWon(n); + return w === '—' ? w : w + '원'; +}; +/** 수익률(%) — 거래내역·가상체결 테이블 공용 */ +const fmtPct = n => { + if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; + const v = Number(n); + return (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; +}; /** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (API가 최신순이어도 프론트에서 재정렬) */ function tradeExitSortKey(t) { const keys = ['exit_time', 'sell_time', 'sell_date', 'buy_time', 'entry_time', 'buy_date']; @@ -56,10 +66,10 @@ function _tradeTimeSortKey(raw) { function normalizeVirtualTrade(t, meta) { meta = meta || {}; - const ep = Number(t.buy_price ?? t.avg_price ?? t.entry ?? 0); - const xp = Number(t.sell_price ?? t.exit_price ?? t.exit ?? 0); + const ep = Math.round(Number(t.buy_price ?? t.avg_price ?? t.entry ?? 0)); + const xp = Math.round(Number(t.sell_price ?? t.exit_price ?? t.exit ?? 0)); const qty = Number(t.qty || 0) || (ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0); - const pnl = Number(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0); + const pnl = Math.round(Number(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0)); let rateNum = Number(t.profit_rate); if (!Number.isFinite(rateNum) && ep > 0) rateNum = (xp - ep) / ep * 100; if (!Number.isFinite(rateNum)) rateNum = 0; @@ -87,7 +97,7 @@ function normalizeVirtualTrade(t, meta) { entrySource: t.entry_source_label || t.entry_source || '', exitSource: t.exit_source_label || t.exit_source || '', debugTick: t.debug_tick || '', - cumPnl: Number(t.cum_pnl ?? NaN), + cumPnl: (() => { const v = Number(t.cum_pnl); return Number.isFinite(v) ? Math.round(v) : NaN; })(), cumReturnPct: Number(t.cum_return_pct ?? NaN), }; } @@ -141,7 +151,7 @@ function renderVirtualTrades(tbodyId, trades, opts) { ? `${r.entrySource || '-'}→${r.exitSource || '-'}` : '-'); const cumPnlHtml = Number.isFinite(r.cumPnl) - ? `${fmt(r.cumPnl)}` + ? `${fmtWon(r.cumPnl)}` : '-'; const cumPctHtml = Number.isFinite(r.cumReturnPct) ? `${fmtPct(r.cumReturnPct)}` @@ -150,17 +160,17 @@ function renderVirtualTrades(tbodyId, trades, opts) { ? '보유중' : `${fmtTradeTime(r.sellRaw)}`; const exitPxCell = r.isOpen - ? `${fmt(r.xp)} 현재가` - : fmt(r.xp); + ? `${fmtWon(r.xp)} 현재가` + : fmtWon(r.xp); const pnlCell = r.isOpen - ? `${fmt(r.pnl)} 평가` - : fmt(r.pnl); + ? `${fmtWon(r.pnl)} 평가` + : fmtWon(r.pnl); const rowStyle = r.isOpen ? ' style="background:rgba(210,153,34,.06)"' : ''; let html = ` ${nameCell} ${fmtTradeTime(r.buyRaw)} ${sellCell} - ${fmt(r.ep)} + ${fmtWon(r.ep)} ${exitPxCell} ${r.qty} ${pnlCell} @@ -416,8 +426,7 @@ async function permDelete(code) { // 오늘 운영 대시보드 // ──────────────────────────────────────────── function dashFmtKrw(n) { - const v = Number(n) || 0; - return v.toLocaleString('ko-KR') + '원'; + return fmtKrw(n); } function dashFmtPct(n) { const v = Number(n) || 0; @@ -984,6 +993,11 @@ function initDefaultDates() { setM('bo_shoulder_scut', b.shoulder_cut_pct); setM('bo_time_start', b.time_start_hm); setM('bo_time_end', b.time_end_hm); + if (b.eod_enabled !== undefined && $('bo_eod_enabled')) { + $('bo_eod_enabled').checked = !!b.eod_enabled; + } + if (b.eod_hm != null) setM('bo_eod_hm', b.eod_hm); + if (b.max_hold_bars != null) setM('bo_max_hold', b.max_hold_bars); setM('bo_max_daily', b.max_daily); setM('bo_cooldown', b.cooldown_min); setM('bo_max_chg', b.max_daily_chg); @@ -1083,13 +1097,13 @@ function renderActual(d) { $('a_winrate').textContent = s.win_rate + '%'; colorPnl($('a_winrate'), s.win_rate - 50); - $('a_pnl').textContent = fmt(s.total_pnl) + '원'; + $('a_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('a_pnl'), s.total_pnl); $('a_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor; colorPnl($('a_pf'), s.profit_factor - 1); - $('a_mdd').textContent = '-' + fmt(s.max_drawdown) + '원'; + $('a_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('a_hold').textContent = s.avg_hold_min + '분'; const openCnt = Number((d.meta || {}).open_count || 0); const closedCnt = Number((d.meta || {}).closed_count || 0); @@ -1102,11 +1116,11 @@ function renderActual(d) { const tb = Number(p.total_budget_krw || 0); const peak = Number(s.peak_cum_pnl || 0); const peakAt = s.peak_cum_at || ''; - let line = `📋 실매 trade_history · 한도 ${tb > 0 ? tb.toLocaleString() : '—'}원`; + let line = `📋 실매 trade_history · 한도 ${tb > 0 ? fmtWon(tb) : '—'}원`; if (peak > 0 && peakAt) { - line += ` | 장중 누적 최고 +${peak.toLocaleString()}원 (${peakAt})`; + line += ` | 장중 누적 최고 +${fmtWon(peak)}원 (${peakAt})`; } - line += ` | 최종 누적 ${fmt(s.total_pnl)}원`; + line += ` | 최종 누적 ${fmtKrw(s.total_pnl)}`; actCtx.innerHTML = line; actCtx.style.display = 'block'; } @@ -1121,7 +1135,7 @@ function renderActual(d) { } if (s.peak_cum_pnl > 0 && s.total_pnl < s.peak_cum_pnl) { parts.push( - `💡 장중 누적이 +${fmt(s.peak_cum_pnl)}원까지 갔다가 이후 청산으로 ${fmt(s.total_pnl)}원 — ` + `💡 장중 누적이 +${fmtKrw(s.peak_cum_pnl)}까지 갔다가 이후 청산으로 ${fmtKrw(s.total_pnl)} — ` + `누적손익 열은 매도 완료 순 합계입니다.`, ); } @@ -1235,6 +1249,8 @@ function runBreakoutBacktest() { daily_trail_drop_pct: $('bo_daily_trail_drop')?.value || '0', daily_trail_arm_krw: $('bo_daily_trail_arm')?.value || '0', daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing', + eod_enabled: $('bo_eod_enabled')?.checked ? 1 : 0, + eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', }; const qs = new URLSearchParams(params).toString(); showSpinner(true); @@ -1271,6 +1287,9 @@ function saveBreakoutConfig() { shoulder_cut_pct: parseFloat($('bo_shoulder_scut').value), time_start_hm: parseInt($('bo_time_start').value, 10), time_end_hm: parseInt($('bo_time_end').value, 10), + eod_enabled: !!($('bo_eod_enabled')?.checked), + eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', + max_hold_bars: parseInt($('bo_max_hold')?.value || '0', 10), max_daily: parseInt($('bo_max_daily').value, 10), cooldown_min: parseFloat($('bo_cooldown').value), max_daily_chg: parseFloat($('bo_max_chg').value), @@ -1363,13 +1382,13 @@ function renderBreakoutBacktest(d) { setTxt('bo_winrate', (s.win_rate||0) + '%'); colorPnl($('bo_winrate'), (s.win_rate||0) - 50); - setTxt('bo_pnl', fmt(s.total_pnl) + '원'); + setTxt('bo_pnl', fmtKrw(s.total_pnl)); colorPnl($('bo_pnl'), s.total_pnl); setTxt('bo_pf', (s.profit_factor||0) >= 999 ? '∞' : String(s.profit_factor||0)); colorPnl($('bo_pf'), (s.profit_factor||0) - 1); - setTxt('bo_mdd', '-' + fmt(s.max_drawdown) + '원'); + setTxt('bo_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('bo_hold', (s.avg_hold_min||0) + '분'); const boSign = v => (v > 0 ? '+' : ''); @@ -1532,10 +1551,10 @@ function renderRangeBreakBacktest(d) { setTxt('rb_total', (s.total_trades || 0) + '건'); setTxt('rb_winrate', (s.win_rate || 0) + '%'); colorPnl($('rb_winrate'), (s.win_rate || 0) - 50); - setTxt('rb_pnl', fmt(s.total_pnl) + '원'); + setTxt('rb_pnl', fmtKrw(s.total_pnl)); colorPnl($('rb_pnl'), s.total_pnl); setTxt('rb_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); - setTxt('rb_mdd', '-' + fmt(s.max_drawdown) + '원'); + setTxt('rb_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('rb_hold', (s.avg_hold_min || 0) + '분'); try { if (d.equity && d.equity.length) { @@ -1639,6 +1658,8 @@ function runMomentumBacktest() { daily_trail_drop_pct: v('mom_daily_trail_drop') || '0', daily_trail_arm_krw: v('mom_daily_trail_arm') || '0', daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing', + eod_enabled: $('mom_eod_enabled')?.checked ? 1 : 0, + eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', }; const qs = new URLSearchParams(params).toString(); fetch('/api/backtest/momentum?' + qs) @@ -1706,6 +1727,8 @@ function saveMomentumConfig() { pg_filter: !!($('mom_pg_filter')?.checked), daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing', + eod_enabled: !!($('mom_eod_enabled')?.checked), + eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', }; if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.')) return; fetch('/api/backtest/momentum/save_config', { @@ -1770,13 +1793,13 @@ function renderMomentumBacktest(d) { setTxt('mom_winrate', (s.win_rate || 0) + '%'); colorPnl($('mom_winrate'), (s.win_rate || 0) - 50); - setTxt('mom_pnl', fmt(s.total_pnl) + '원'); + setTxt('mom_pnl', fmtKrw(s.total_pnl)); colorPnl($('mom_pnl'), s.total_pnl); setTxt('mom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('mom_pf'), (s.profit_factor || 0) - 1); - setTxt('mom_mdd', '-' + fmt(s.max_drawdown) + '원'); + setTxt('mom_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('mom_hold', (s.avg_hold_min || 0) + '분'); const momSign = v => (v > 0 ? '+' : ''); @@ -1832,7 +1855,7 @@ function renderMomentumBacktest(d) { if (peakCum > 0 && peakAt) { const peakLine = `💡 장중 누적 최고 +${peakCum.toLocaleString()}원 (${peakAt})` + - ` · 최종 ${fmt(s.total_pnl)}원`; + ` · 최종 ${fmtKrw(s.total_pnl)}`; if (momPb) momPb.innerHTML += `
${peakLine}
`; if (momCtx) momCtx.innerHTML += `
${peakLine}
`; } @@ -1973,6 +1996,10 @@ function fillTailFormFromApi(t) { set('tl_rsi', t.rsi); set('tl_ts', t.time_start); set('tl_te', t.time_end); + if (t.eod_enabled !== undefined && $('tl_eod_enabled')) { + $('tl_eod_enabled').checked = !!t.eod_enabled; + } + if (t.eod_hm != null) set('tl_eod_hm', t.eod_hm); set('tl_maxd', t.max_daily); set('tl_rsi_period', t.rsi_period); set('tl_tail_pct', t.tail_pct_min); @@ -2142,6 +2169,10 @@ function fillMomentumFormFromApi(m) { set('mom_vol_win', m.mom_vol_win); set('mom_time_end_buy', m.mom_time_end_hm); set('mom_time_start', m.mom_time_start_hm); + if (m.eod_enabled !== undefined && $('mom_eod_enabled')) { + $('mom_eod_enabled').checked = !!m.eod_enabled; + } + if (m.eod_hm != null) set('mom_eod_hm', m.eod_hm); set('mom_sl', m.sl_pct); set('mom_tp', m.tp_pct); set('mom_tp_max', m.tp_max_pct); @@ -2264,8 +2295,8 @@ function momApplySearchResult(rank, saveDb) { if (d.ui) fillMomentumFormFromApi(d.ui); const m = d.metrics || {}; const msg = saveDb - ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmt(m.total_pnl)}원 · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건` - : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmt(m.total_pnl)}원 · 승률 ${m.win_rate}% · PF ${m.pf}`; + ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건` + : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`; alert(msg); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); @@ -2318,7 +2349,7 @@ function momRenderParamSearch(top, meta) { ${rank} ${paramCells} - ${fmt(r.total_pnl)} + ${fmtWon(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold_min != null ? r.avg_hold_min : '-'} `; @@ -3261,6 +3294,8 @@ function runTailBacktest() { daily_trail_drop_pct: $('tl_daily_trail_drop')?.value || '0', daily_trail_arm_krw: $('tl_daily_trail_arm')?.value || '0', daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing', + eod_enabled: $('tl_eod_enabled')?.checked ? 1 : 0, + eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', }; const qs = new URLSearchParams(params).toString(); showSpinner(true); @@ -3297,13 +3332,13 @@ function renderTailBacktest(d) { $('tl_winrate').textContent = (s.win_rate||0) + '%'; colorPnl($('tl_winrate'), (s.win_rate||0) - 50); - $('tl_pnl').textContent = fmt(s.total_pnl) + '원'; + $('tl_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('tl_pnl'), s.total_pnl); $('tl_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0); colorPnl($('tl_pf'), (s.profit_factor||0) - 1); - $('tl_mdd').textContent = '-' + fmt(s.max_drawdown) + '원'; + $('tl_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('tl_hold').textContent = (s.avg_hold_min||0) + '분'; const tlSign = v => (v > 0 ? '+' : ''); @@ -3359,7 +3394,7 @@ function renderTailBacktest(d) { if (tlPeakCum > 0 && tlPeakAt) { const peakLine = `💡 장중 누적 최고 +${tlPeakCum.toLocaleString()}원 (${tlPeakAt})` + - ` · 최종 ${fmt(s.total_pnl)}원`; + ` · 최종 ${fmtKrw(s.total_pnl)}`; $('tl_params_bar').innerHTML += `
${peakLine}
`; if (tlCtx) tlCtx.innerHTML += `
${peakLine}
`; } @@ -3849,11 +3884,11 @@ function hdRenderResult(d, name) { $('hd_total').textContent = (s.total_trades||0) + '건'; $('hd_wr').textContent = (s.win_rate||0) + '%'; colorPnl($('hd_wr'), (s.win_rate||0) - 50); - $('hd_pnl').textContent = fmt(s.total_pnl) + '원'; + $('hd_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('hd_pnl'), s.total_pnl); $('hd_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0); colorPnl($('hd_pf'), (s.profit_factor||0) - 1); - $('hd_mdd').textContent = '-' + fmt(s.max_drawdown) + '원'; + $('hd_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('hd_hold').textContent = (s.avg_hold_days||0) + '일'; const sign = v => (v > 0 ? '+' : ''); @@ -3864,7 +3899,7 @@ function hdRenderResult(d, name) { colorPnl($('hd_bot_pct'), s.bot_pct||0); $('hd_bnh_pct').textContent = sign(s.bnh_pct) + s.bnh_pct + '%'; colorPnl($('hd_bnh_pct'), s.bnh_pct); - $('hd_bnh_pnl').textContent = sign(s.bnh_pnl) + fmt(s.bnh_pnl) + '원'; + $('hd_bnh_pnl').textContent = sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl); colorPnl($('hd_bnh_pnl'), s.bnh_pnl); $('hd_alpha').textContent = sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p'; colorPnl($('hd_alpha'), s.alpha_pct||0); @@ -3878,7 +3913,7 @@ function hdRenderResult(d, name) { alRow.style.display = ''; $('hd_bnh_aligned_pct').textContent = sign(s.bnh_aligned_pct) + s.bnh_aligned_pct + '%'; colorPnl($('hd_bnh_aligned_pct'), s.bnh_aligned_pct); - $('hd_bnh_aligned_pnl').textContent = sign(s.bnh_aligned_pnl) + fmt(s.bnh_aligned_pnl) + '원'; + $('hd_bnh_aligned_pnl').textContent = sign(s.bnh_aligned_pnl) + fmtKrw(s.bnh_aligned_pnl); colorPnl($('hd_bnh_aligned_pnl'), s.bnh_aligned_pnl); const aal = s.alpha_aligned_pct != null ? s.alpha_aligned_pct : 0; $('hd_alpha_aligned').textContent = sign(aal) + aal + '%p'; @@ -3969,7 +4004,7 @@ function hdRenderParamSearch(top, code, name, meta) { tbody.insertAdjacentHTML('beforeend', ` ${paramCells} - ${fmt(r.total_pnl)} + ${fmtWon(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold}일