diff --git a/backtest_web.py b/backtest_web.py index 3ab831e..e946c00 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -3077,6 +3077,188 @@ def _backtest_filter_toggle(raw: Any) -> Optional[bool]: return str(raw).strip().lower() in ("1", "true", "y", "yes", "on") +def _args_get(args: Any, key: str, default: Any = None) -> Any: + """Flask request.args 또는 flat dict 에서 쿼리/폼 값 조회.""" + if args is None: + return default + if isinstance(args, dict): + return args.get(key, default) + try: + return args.args.get(key, default) + except Exception: + return default + + +def _apply_backtest_ob_whip_overrides_from_request( + req: Any, + params: Dict[str, Any], + *, + force_kiwoom_body_on_spread: bool = False, +) -> None: + """백테 쿼리/폼 → 엔진 params 1회 오버라이드 (호가·프로그램·휩쏘). DB(env)는 변경하지 않음. + + Optuna 「백테」탭 검증용 — 폼 hidden·체크박스 값을 그대로 재현. + """ + _ob_tg = _backtest_filter_toggle(_args_get(req, "ob_filter")) + if _ob_tg is not None: + params["_orderbook_filter_enabled"] = _ob_tg + _pg_tg = _backtest_filter_toggle(_args_get(req, "pg_filter")) + if _pg_tg is not None: + params["_program_filter_enabled"] = _pg_tg + _spread_req = _args_get(req, "max_spread_pct") + if _spread_req not in (None, ""): + params["_ob_max_spread_pct"] = float(_spread_req) + if force_kiwoom_body_on_spread: + params["backtest_use_kiwoom_body_snapshot"] = True + params["_backtest_use_kiwoom_body"] = True + _ratio_req = _args_get(req, "min_bid_ask_ratio") + if _ratio_req not in (None, ""): + params["_ob_min_bid_ask_ratio"] = float(_ratio_req) + _ask_req = _args_get(req, "ob_ask_max_mult") or _args_get(req, "ask_max_mult") + if _ask_req not in (None, ""): + params["_ob_ask_max_mult"] = float(_ask_req) + _whip_raw = _args_get(req, "whipsaw_filter") + if _whip_raw in (None, ""): + _whip_raw = _args_get(req, "whipsaw_enabled") + _whip_tg = _backtest_filter_toggle(_whip_raw) + if _whip_tg is not None: + params["whipsaw_enabled"] = _whip_tg + _wsub = _args_get(req, "whipsaw_subbar_sec") + if _wsub not in (None, ""): + params["whipsaw_subbar_sec"] = int(float(_wsub)) + _wlb = _args_get(req, "whipsaw_lookback_sec") + if _wlb not in (None, ""): + params["whipsaw_lookback_sec"] = int(float(_wlb)) + _wdip = _args_get(req, "whipsaw_dip_pct") + if _wdip not in (None, ""): + params["whipsaw_dip_pct"] = float(_wdip) + _wtol = _args_get(req, "whipsaw_recovery_tol_pct") + if _wtol not in (None, ""): + params["whipsaw_recovery_tol_pct"] = float(_wtol) + + +def _tail_form_dict_to_engine_overrides(form: Dict[str, Any]) -> Dict[str, Any]: + """꼬리 웹 폼(flat) → CLI params-json 엔진 키. 동기 /api/backtest/tail 과 동일 단위.""" + if not isinstance(form, dict) or not form: + return {} + _def = _get_tail_defaults_for_backtest() + out: Dict[str, Any] = {} + + def _has(k: str) -> bool: + return k in form and form.get(k) is not None and str(form.get(k)).strip() != "" + + def _ui_pct_to_frac(k: str, def_frac: float) -> float: + """웹 인풋은 항상 % 단위(0.55=0.55%) — 동기 /api/backtest/tail 과 동일하게 /100.""" + raw = float(_args_get(form, k, def_frac * 100.0)) + return raw / 100.0 + + def _ratio01(k: str, def_v: float) -> float: + raw = float(_args_get(form, k, def_v)) + return raw if 0 < raw <= 1 else raw / 100.0 + + if _has("min_drop_rate"): + out["min_drop_rate"] = _ui_pct_to_frac("min_drop_rate", float(_def.get("min_drop_rate", 0.03))) + if _has("min_recovery_ratio"): + out["min_recovery_ratio"] = _ui_pct_to_frac("min_recovery_ratio", float(_def.get("min_recovery_ratio", 0.5))) + if _has("tail_ratio_min"): + out["tail_ratio_min"] = float(form["tail_ratio_min"]) + if _has("tail_pct_min"): + out["tail_pct_min"] = _ui_pct_to_frac("tail_pct_min", float(_def.get("tail_pct_min", 0.003))) + if _has("max_rec_3m"): + out["max_rec_3m"] = _ratio01("max_rec_3m", float(_def.get("max_rec_3m", 0.8))) + if _has("sl_pct"): + out["sl_pct"] = _ui_pct_to_frac("sl_pct", float(_def.get("sl_pct", 0.03))) + if _has("tp_pct"): + out["tp_pct"] = _ui_pct_to_frac("tp_pct", float(_def.get("tp_pct", 0.05))) + if _has("shoulder_min_high"): + out["shoulder_min_high"] = _ui_pct_to_frac("shoulder_min_high", float(_def.get("shoulder_min_high", 0.003))) + if _has("shoulder_cut_pct"): + out["shoulder_cut_pct"] = _ui_pct_to_frac("shoulder_cut_pct", float(_def.get("shoulder_cut_pct", 0.002))) + if _has("trail_pct"): + out["trail_pct"] = abs(float(form["trail_pct"])) / 100.0 + if _has("trail_arm_pct"): + out["trail_arm_pct"] = abs(float(form["trail_arm_pct"])) / 100.0 + if _has("high_chase_thr"): + out["high_chase_thr"] = _ratio01("high_chase_thr", float(_def.get("high_chase_thr", 0.96))) + + for ik, dk, cast in ( + ("rsi_period", "rsi_period", int), + ("rsi_threshold", "rsi_threshold", float), + ("cooldown_min", "cooldown_min", int), + ("time_start", "time_start_hm", int), + ("time_end", "time_end_hm", int), + ("max_daily", "max_daily", int), + ("slot_money", "slot_money", float), + ("max_stocks", "max_stocks", int), + ("total_budget_krw", "total_budget_krw", float), + ("min_price", "min_price", float), + ("max_daily_change", "max_daily_change", float), + ("ma20_max_above", "ma20_max_above", float), + ("max_loss_krw", "max_loss_krw", int), + ("stop_atr_mult", "stop_atr_mult", float), + ("target_atr_mult", "target_atr_mult", float), + ("atr_sl_min_pct", "atr_sl_min_pct", float), + ("atr_sl_max_pct", "atr_sl_max_pct", float), + ("atr_tp_min_pct", "atr_tp_min_pct", float), + ("atr_tp_max_pct", "atr_tp_max_pct", float), + ("tail_vol_mult", "tail_vol_mult", float), + ("tail_vol_win", "tail_vol_win", int), + ("symbol_daily_loss_limit_krw", "symbol_daily_loss_limit_krw", float), + ("symbol_daily_loss_limit_pct", "symbol_daily_loss_limit_pct", float), + ("reentry_min_edge_krw", "reentry_min_edge_krw", float), + ("timeframe", "timeframe", int), + ): + if _has(ik): + out[dk] = cast(form[ik]) + + if _has("min_drop_pct_for_loss_cut"): + v = float(form["min_drop_pct_for_loss_cut"]) + out["min_drop_pct_for_loss_cut"] = v / 100.0 if v >= 1 else v + + if _has("ratchet_tiers"): + out["ratchet_tiers"] = str(form["ratchet_tiers"]).strip() + if _has("daily_trail_tiers"): + out["daily_trail_tiers"] = str(form["daily_trail_tiers"]).strip() + if _has("daily_trail_drop_pct"): + out["daily_trail_drop_pct"] = float(form["daily_trail_drop_pct"]) + if _has("daily_trail_arm_krw"): + out["daily_trail_arm_krw"] = float(form["daily_trail_arm_krw"]) + if _has("daily_profit_mode"): + out["daily_profit_mode"] = str(form["daily_profit_mode"]).strip().lower() + if _has("entry_mode"): + out["entry_mode"] = str(form["entry_mode"]).strip().lower() + if _has("limit_atr_mult"): + out["limit_atr_mult"] = float(form["limit_atr_mult"]) + if _has("limit_anchor"): + out["limit_anchor"] = str(form["limit_anchor"]).strip().lower() + if _has("limit_valid_bars"): + out["limit_valid_bars"] = int(float(form["limit_valid_bars"])) + if _has("limit_fill_slip_pct"): + out["limit_fill_slip_pct"] = float(form["limit_fill_slip_pct"]) + if _has("eod_hm"): + out["eod_hm"] = str(form["eod_hm"]).strip() + if _has("bar_chg_min_pct"): + out["bar_chg_min_pct"] = float(form["bar_chg_min_pct"]) + if _has("bar_chg_max_pct"): + out["bar_chg_max_pct"] = float(form["bar_chg_max_pct"]) + if _has("max_hold_bars"): + out["max_hold_bars"] = int(float(form["max_hold_bars"])) + + for bk in ( + "skip_hts_scan_dupes", "use_intraday_drop", "use_ma20_filter", "use_rsi_filter", + "use_daily_range_filter", "use_high_chase_filter", + "pattern_hammer", "pattern_pin", "pattern_engulfing", "pattern_piercing", + "pattern_harami", "pattern_doji", "pattern_morning_star", + "backtest_use_tick_db", "backtest_use_tick_exit", "backtest_tick_fallback_ohlc", + "eod_enabled", "daily_profit_enabled", "daily_risk_budget_enabled", + ): + if _has(bk): + out[bk] = str(form[bk]).strip().lower() in ("1", "true", "t", "y", "yes", "on") + + _apply_backtest_ob_whip_overrides_from_request(form, out) + return out + + def _backtest_env_timeline_from_request(req: Any = None) -> bool: """웹 백테 env 타임라인 — 기본 OFF. @@ -3521,19 +3703,10 @@ def api_backtest_scalping(): "e_min_chg_pct": e_min_chg_pct, "mom_time_end_hm": mom_time_end_hm, } - # 백테 전용 필터 토글 (폼 체크박스 → 이 1회 백테에만 적용. 비우면 DB=실매값 사용) - _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter")) - if _ob_tg is not None: - params["_orderbook_filter_enabled"] = _ob_tg - _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter")) - if _pg_tg is not None: - params["_program_filter_enabled"] = _pg_tg - # 호가 스프레드 상한(%) — kiwoom_0d 본체 재계산 (6/25~ 유효, 그 외 log_backfill 폴백) - _spread_req = request.args.get("max_spread_pct") - if _spread_req not in (None, ""): - params["_ob_max_spread_pct"] = float(_spread_req) - params["backtest_use_kiwoom_body_snapshot"] = True - params["_backtest_use_kiwoom_body"] = True + # 백테 전용 호가·휩쏘 오버라이드 (폼/Optuna → 이 1회만. 비우면 DB=실매값) + _apply_backtest_ob_whip_overrides_from_request( + request, params, force_kiwoom_body_on_spread=True, + ) if mode == "momentum": _mmax = request.args.get("mom_max_from_open_pct") _mmin = request.args.get("mom_min_from_open_pct") @@ -5973,16 +6146,8 @@ def api_backtest_tail(): params["scan_interval_min"] = _scan_iv params["timeframe"] = tail_tf params["_universe_history_source"] = _univ_hs - # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값) - _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter")) - if _ob_tg is not None: - params["_orderbook_filter_enabled"] = _ob_tg - _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter")) - if _pg_tg is not None: - params["_program_filter_enabled"] = _pg_tg - _spread_req = request.args.get("max_spread_pct") - if _spread_req not in (None, ""): - params["_ob_max_spread_pct"] = float(_spread_req) + # 백테 전용 호가·휩쏘 오버라이드 (폼/Optuna → 이 1회만. 비우면 DB=실매값) + _apply_backtest_ob_whip_overrides_from_request(request, params) # kiwoom_0d 본체는 ob_body=1 일 때만. spread 값만으로 본체 강제하면 # log_backfill 판정 재생(파람서치·CLI --orderbook-filter on 기본)과 어긋남. _ob_body = str(request.args.get("ob_body", "0")).strip().lower() in ( @@ -6471,18 +6636,10 @@ def api_backtest_breakout(): ui.update(_eod_params_from_request(request, _def, default_hm="15:15")) engine = _bo_ui_to_engine_params(ui) - # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값) - _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter")) - if _ob_tg is not None: - engine["_orderbook_filter_enabled"] = _ob_tg - _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter")) - if _pg_tg is not None: - engine["_program_filter_enabled"] = _pg_tg - _spread_req = request.args.get("max_spread_pct") - if _spread_req not in (None, ""): - engine["_ob_max_spread_pct"] = float(_spread_req) - engine["backtest_use_kiwoom_body_snapshot"] = True - engine["_backtest_use_kiwoom_body"] = True + # 백테 전용 호가·휩쏘 오버라이드 (폼/Optuna → 이 1회만. 비우면 DB=실매값) + _apply_backtest_ob_whip_overrides_from_request( + request, engine, force_kiwoom_body_on_spread=True, + ) db = _db() try: @@ -10057,6 +10214,18 @@ def api_bt_job_start(): env_tl = str(body.get("env_timeline") or "").strip().lower() in ( "1", "true", "t", "y", "yes", "on", ) + params_override: Dict[str, Any] = {} + raw_po = body.get("params_override") + form_params = body.get("form_params") + if isinstance(form_params, dict) and form_params: + if strategy == "tail": + params_override.update(_tail_form_dict_to_engine_overrides(form_params)) + # Optuna engine 스냅은 폼 변환 이후 덮어쓰지 않음(폼=사용자가 보는 값이 우선). + # 단, form_params 없을 때만 params_override 단독 사용. + if isinstance(raw_po, dict) and raw_po and not (isinstance(form_params, dict) and form_params): + params_override.update(raw_po) + if "ob_filter" in form_params: + ob = "on" if _backtest_filter_toggle(form_params.get("ob_filter")) else "off" try: job = bj.start_bt_job( strategy=strategy, @@ -10069,6 +10238,7 @@ def api_bt_job_start(): tick_exit=tick_exit, orderbook_filter=ob, env_timeline=env_tl, + params_override=params_override or None, ) return jsonify({"ok": True, "job": job}) except Exception as e: diff --git a/database.py b/database.py index f488bee..b4517e9 100644 --- a/database.py +++ b/database.py @@ -1439,6 +1439,9 @@ ENV_CONFIG_KEYS = ( "LS_WS_ORDERBOOK_SAVE", # 종목당 DB INSERT 최소 간격(ms) — 호가 폭주 시 DB 부하 완화 (RAM 캐시는 매 틱) "LS_WS_ORDERBOOK_SAVE_MS", + # ls_ws_ticks / ls_ws_orderbook 보존 일수 (기본 7) + "LS_WS_TICK_KEEP_DAYS", + "LS_WS_ORDERBOOK_KEEP_DAYS", # LS VI(UVI) 구독·적재 — 워치독 오탐 방지·백테 구간 마킹 (프로그램·상하한가 미포함) "LS_WS_UVI_ENABLED", "LS_WS_VI_SAVE", @@ -1743,6 +1746,10 @@ class TradeDB: """ allowed = { "target_candidates_history": "target_candidates_history", + "ws_ticks": "ws_ticks", + "ws_orderbook": "ws_orderbook", + "ws_ticks_us": "ws_ticks_us", + "ls_ws_orderbook": "ls_ws_orderbook", } tbl = allowed.get(str(table or "").strip()) name = str(idx_name or "").strip() @@ -2419,11 +2426,18 @@ class TradeDB: tot_volume DOUBLE NULL, chetime VARCHAR(16) NULL, tr_cd VARCHAR(8) NULL, + tk CHAR(14) NULL, + lag_sec DOUBLE NULL, INDEX idx_ls_tick_ts (ts), - INDEX idx_ls_tick_code_ts (code, ts) + INDEX idx_ls_tick_code_ts (code, ts), + INDEX idx_ls_tick_code_tk (code, tk) ) CHARACTER SET utf8mb4 """) - logger.info("📌 ls_ws_ticks 테이블 확인/생성") + # Migration: add tk, lag_sec if not exist + cols = [dict(r)["Field"] for r in self.conn.execute("SHOW COLUMNS FROM ls_ws_ticks").fetchall()] + if "tk" not in cols: + self.conn.execute("ALTER TABLE ls_ws_ticks ADD COLUMN tk CHAR(14) NULL, ADD COLUMN lag_sec DOUBLE NULL, ADD INDEX idx_ls_tick_code_tk (code, tk)") + logger.info("📌 ls_ws_ticks 테이블 확인/생성 및 마이그레이션 완료") except Exception as e: logger.warning(f"migrate ls_ws_ticks 실패: {e}") try: @@ -2462,12 +2476,19 @@ class TradeDB: levels_json MEDIUMTEXT, source VARCHAR(16) NOT NULL DEFAULT 'ls_uh1', recv_ts VARCHAR(30) NOT NULL, + tk CHAR(14) NULL, + lag_sec DOUBLE NULL, KEY idx_ls_ob_lookup (market, code, snap_time), KEY idx_ls_ob_recv (recv_ts), - KEY idx_ls_ob_code (code) + KEY idx_ls_ob_code (code), + KEY idx_ls_ob_code_snap (code, snap_time) ) CHARACTER SET utf8mb4 """) - logger.info("📌 ls_ws_orderbook 테이블 확인/생성") + # Migration: add tk, lag_sec if not exist + cols = [dict(r)["Field"] for r in self.conn.execute("SHOW COLUMNS FROM ls_ws_orderbook").fetchall()] + if "tk" not in cols: + self.conn.execute("ALTER TABLE ls_ws_orderbook ADD COLUMN tk CHAR(14) NULL, ADD COLUMN lag_sec DOUBLE NULL, ADD INDEX idx_ls_ob_code_snap (code, snap_time)") + logger.info("📌 ls_ws_orderbook 테이블 확인/생성 및 마이그레이션 완료") except Exception as e: logger.warning(f"migrate ls_ws_orderbook 실패: {e}") try: @@ -2564,6 +2585,21 @@ class TradeDB: "idx_tch_slot_sid", "(slot_key, strategy_id)", ) + self._migrate_feed_collect_stats_indexes() + + def _migrate_feed_collect_stats_indexes(self) -> None: + """수집통계·일자 범위 조회 — tick_time/snap_time 단독 인덱스 (타입 변경 없음).""" + specs = ( + ("ws_ticks", "idx_ws_ticks_tick_time", "(tick_time)"), + ("ws_ticks", "idx_ws_ticks_day_src", "(tick_time, source)"), + ("ws_ticks", "idx_ws_ticks_src_code_recv", "(source, code, recv_ts)"), + ("ws_orderbook", "idx_ws_ob_snap", "(snap_time)"), + ("ws_orderbook", "idx_ws_ob_snap_src", "(snap_time, source)"), + ("ws_ticks_us", "idx_ws_ticks_us_tick_time", "(tick_time)"), + ("ls_ws_orderbook", "idx_ls_ob_snap", "(snap_time)"), + ) + for tbl, name, cols in specs: + self._ensure_safe_index(tbl, name, cols) def _migrate_ws_candles_source_channel(self) -> None: """ws_candles.channel 추가 + UNIQUE(code,tf,time,source,channel) + rest 라벨 정규화.""" @@ -3945,13 +3981,15 @@ class TradeDB: tot_volume: Optional[float] = None, chetime: str = "", tr_cd: str = "", + tk: Optional[str] = None, + lag_sec: Optional[float] = None, ) -> bool: try: self.conn.execute( "INSERT INTO ls_ws_ticks " - "(ts, code, price, volume, tot_volume, chetime, tr_cd) " - "VALUES (%s,%s,%s,%s,%s,%s,%s)", - (ts, code, price, volume, tot_volume, chetime or None, tr_cd or None), + "(ts, code, price, volume, tot_volume, chetime, tr_cd, tk, lag_sec) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", + (ts, code, price, volume, tot_volume, chetime or None, tr_cd or None, tk, lag_sec), ) return True except Exception as e: @@ -4129,6 +4167,8 @@ class TradeDB: code: str, snap: Dict[str, Any], market: str = "KR", + tk: Optional[str] = None, + lag_sec: Optional[float] = None, ) -> bool: """LS UH1 호가 스냅샷 → ls_ws_orderbook (키움 ws_orderbook 스키마 대칭).""" try: @@ -4140,8 +4180,8 @@ class TradeDB: "INSERT INTO ls_ws_orderbook " "(market, code, snap_time, best_bid, best_ask, " "total_bid_qty, total_ask_qty, bid_qty_l3, ask_qty_l3, " - "levels_json, source, recv_ts) " - "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + "levels_json, source, recv_ts, tk, lag_sec) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( (market or "KR")[:8], code, @@ -4155,6 +4195,8 @@ class TradeDB: snap.get("levels_json"), str(snap.get("source") or "ls_uh1")[:16], recv_ts, + tk, + lag_sec, ), ) return True @@ -5142,17 +5184,94 @@ class TradeDB: logger.error("get_latest_confirmed_ws_candle 실패(%s): %s", code, e) return None - def cleanup_old_ws_candles(self, keep_days: int = 3): - """오래된 ws_candles 정리 (기본 3일 이상 지난 봉 삭제).""" + def cleanup_old_ws_candles(self, keep_days: int = 7) -> None: + """오래된 ws_candles 정리 (기본 7일 이상 지난 봉 삭제, 청크 방식).""" + if keep_days <= 0: + return cutoff = (datetime.datetime.now() - datetime.timedelta(days=keep_days)).strftime("%Y%m%d%H%M") + chunk = max(1000, int(os.environ.get("WS_CANDLE_CLEANUP_CHUNK", "5000") or 5000)) + max_loops = max(1, int(os.environ.get("WS_CANDLE_CLEANUP_MAX_LOOPS", "200") or 200)) + total = 0 try: - with self.conn: - self.conn.execute( - "DELETE FROM ws_candles WHERE candle_time < ?", (cutoff,) + import time as _time + for _ in range(max_loops): + cur = self.conn.execute( + "DELETE FROM ws_candles WHERE candle_time < %s LIMIT %s", + (cutoff, chunk), + ) + n = int(getattr(cur, "rowcount", 0) or 0) + total += n + if n < chunk: + break + _time.sleep(0.05) + if total: + logger.info( + "🧹 ws_candles 정리 %d행 (candle_time < %s, chunk=%d)", + total, cutoff, chunk, ) except Exception as e: logger.error("cleanup_old_ws_candles 실패: %s", e) + def cleanup_old_ls_ws_ticks(self, keep_days: int = 7) -> None: + """ls_ws_ticks 오래된 틱 정리 (ts 컬럼 기준, 청크 방식).""" + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + chunk = max(1000, int(os.environ.get("LS_TICK_CLEANUP_CHUNK", "5000") or 5000)) + max_loops = max(1, int(os.environ.get("LS_TICK_CLEANUP_MAX_LOOPS", "200") or 200)) + total = 0 + try: + import time as _time + for _ in range(max_loops): + cur = self.conn.execute( + "DELETE FROM ls_ws_ticks WHERE ts < %s LIMIT %s", + (cutoff, chunk), + ) + n = int(getattr(cur, "rowcount", 0) or 0) + total += n + if n < chunk: + break + _time.sleep(0.05) + if total: + logger.info( + "🧹 ls_ws_ticks 정리 %d행 (ts < %s, chunk=%d)", + total, cutoff, chunk, + ) + except Exception as e: + logger.error("cleanup_old_ls_ws_ticks 실패: %s", e) + + def cleanup_old_ls_ws_orderbook(self, keep_days: int = 7) -> None: + """ls_ws_orderbook 오래된 스냅샷 정리 (recv_ts 기준, 청크 방식).""" + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + chunk = max(1000, int(os.environ.get("LS_OB_CLEANUP_CHUNK", "5000") or 5000)) + max_loops = max(1, int(os.environ.get("LS_OB_CLEANUP_MAX_LOOPS", "200") or 200)) + total = 0 + try: + import time as _time + for _ in range(max_loops): + cur = self.conn.execute( + "DELETE FROM ls_ws_orderbook WHERE recv_ts < %s LIMIT %s", + (cutoff, chunk), + ) + n = int(getattr(cur, "rowcount", 0) or 0) + total += n + if n < chunk: + break + _time.sleep(0.05) + if total: + logger.info( + "🧹 ls_ws_orderbook 정리 %d행 (recv_ts < %s, chunk=%d)", + total, cutoff, chunk, + ) + except Exception as e: + logger.error("cleanup_old_ls_ws_orderbook 실패: %s", e) + # ================================================================== # ws_ticks — 실시간 체결 틱 (TickRecorder 배치 INSERT) # ================================================================== diff --git a/kis_trader/backtest/bt_web_jobs.py b/kis_trader/backtest/bt_web_jobs.py index 3647965..1a19a1b 100644 --- a/kis_trader/backtest/bt_web_jobs.py +++ b/kis_trader/backtest/bt_web_jobs.py @@ -259,6 +259,7 @@ def start_bt_job( tick_exit: Optional[bool] = None, orderbook_filter: str = "auto", params_json: Optional[str] = None, + params_override: Optional[Dict[str, Any]] = None, env_timeline: bool = False, ) -> Dict[str, Any]: _ensure_dirs() @@ -301,6 +302,14 @@ def start_bt_job( result_json = RESULTS_DIR / f"{job_id}.json" progress_file = _progress_path(job_id) + params_json_path = str(params_json or "").strip() or None + if params_override and isinstance(params_override, dict) and params_override: + pj_dir = ROOT / "logs" / "bt_job_params" + pj_dir.mkdir(parents=True, exist_ok=True) + pj = pj_dir / f"{job_id}_params.json" + pj.write_text(json.dumps(params_override, ensure_ascii=False), encoding="utf-8") + params_json_path = str(pj) + # tail 은 세밀 progress 있는 전용 CLI 우선 if strat == "tail" and TAIL_CLI.is_file(): cmd = [ @@ -318,8 +327,8 @@ def start_bt_job( cmd.extend(["--tick-db", "1" if tick_db else "0"]) if tick_exit is not None: cmd.extend(["--tick-exit", "1" if tick_exit else "0"]) - if params_json: - cmd.extend(["--params-json", str(params_json)]) + if params_json_path: + cmd.extend(["--params-json", str(params_json_path)]) if env_timeline: cmd.append("--env-timeline") if hist_src: @@ -342,8 +351,8 @@ def start_bt_job( cmd.extend(["--timeframe", str(int(timeframe))]) if tick_db is not None: cmd.extend(["--tick-db", "1" if tick_db else "0"]) - if params_json: - cmd.extend(["--params-json", str(params_json)]) + if params_json_path: + cmd.extend(["--params-json", str(params_json_path)]) if env_timeline: cmd.append("--env-timeline") if hist_src: diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index 148dbf6..80913e3 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -2449,10 +2449,18 @@ def _overlay_trial_ob_whip_on_ui(ui: Dict[str, Any], params: Dict[str, Any]) -> pg = p.get("pg_filter_enabled") if pg is not None: ui["pg_filter_enabled"] = bool(pg) + whip = p.get("whipsaw_enabled") + if whip is None: + whip = p.get("whipsaw_filter_enabled") + if whip is not None: + ui["whipsaw_filter_enabled"] = bool(whip) + ui["whipsaw_enabled"] = bool(whip) for k in ( "max_spread_pct", "min_bid_ask_ratio", + "ob_min_bid_ask_ratio", "ask_max_mult", + "ob_ask_max_mult", "whipsaw_enabled", "whipsaw_subbar_sec", "whipsaw_lookback_sec", @@ -2479,6 +2487,7 @@ def optuna_engine_params_to_web_ui(strategy: str, params: Dict[str, Any]) -> Dic ui = dict(p) if ui.get("ob_filter_enabled") is None and p.get("_orderbook_filter_enabled") is not None: ui["ob_filter_enabled"] = bool(p.get("_orderbook_filter_enabled")) + _overlay_trial_ob_whip_on_ui(ui, p) return ui if strat == "breakout": ui = dict(p) @@ -2488,8 +2497,11 @@ def optuna_engine_params_to_web_ui(strategy: str, params: Dict[str, Any]) -> Dic ui["pg_filter_enabled"] = bool(p.get("_program_filter_enabled")) if ui.get("shoulder_min_high_pct") is None and p.get("shoulder_min_high") is not None: ui["shoulder_min_high_pct"] = p.get("shoulder_min_high") + _overlay_trial_ob_whip_on_ui(ui, p) return ui - return dict(p) + ui = dict(p) + _overlay_trial_ob_whip_on_ui(ui, p) + return ui def get_candidate_detail( diff --git a/kis_trader/engine/tail_engine.py b/kis_trader/engine/tail_engine.py index 190b93b..3e4e8e3 100644 --- a/kis_trader/engine/tail_engine.py +++ b/kis_trader/engine/tail_engine.py @@ -1125,6 +1125,11 @@ def _tail_signal_with_whipsaw( ) if prog_rej: return (prog_rej, prog_msg, None) + + ob_snap = params.get("_backtest_orderbook_snapshot") + if ob_snap is not None: + sig["backtest_ob_source"] = str(getattr(ob_snap, "source", "") or "").strip() + return (None, None, sig) @@ -1761,6 +1766,7 @@ def _fill_portfolio_align_entry( "session_low": entry_price, "qty": qty, "entry_source": entry_src, + "ob_source": str(pe.get("backtest_ob_source") or ""), } return True, 0, 0 @@ -2003,6 +2009,7 @@ def run_tail_backtest_portfolio( "entry_bar_key": ent.get("candle_time"), "enroll_et": _enroll, "from_inclusion": True, + "backtest_ob_source": sig.get("backtest_ob_source"), })) if incl_cands: incl_cands.sort(key=lambda x: x[0]) @@ -2113,6 +2120,7 @@ def run_tail_backtest_portfolio( "session_low": fill, "qty": qty, "entry_source": str(fill_src or "ohlc_low"), + "ob_source": str(pl.get("backtest_ob_source") or ""), } ctx["pending_limit"] = None break @@ -2261,6 +2269,7 @@ def run_tail_backtest_portfolio( "qty": pos.get("qty", 1), "entry_source": str(pos.get("entry_source") or ""), "exit_source": str(exit_src or ""), + "ob_source": str(pos.get("ob_source") or ""), }) ctx["last_exit_dt"][day] = _t2dt(sell_time or t) ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 @@ -2366,6 +2375,7 @@ def run_tail_backtest_portfolio( "fill_slip": lp_cfg["fill_slip_pct"], "stop": stop_p, "target": target_p, + "backtest_ob_source": sig.get("backtest_ob_source"), })) continue @@ -2432,6 +2442,7 @@ def run_tail_backtest_portfolio( "target": target_p, "entry_bar_key": ent.get("candle_time"), "enroll_et": _enroll, + "backtest_ob_source": sig.get("backtest_ob_source"), })) if not candidates: @@ -2634,6 +2645,7 @@ def run_tail_backtest( "qty": position.get("qty", 1), "entry_source": str(position.get("entry_source") or ""), "exit_source": str(_exit_src or ""), + "ob_source": str(position.get("ob_source") or ""), }) last_exit_dt[day] = _t2dt(sell_time or c["candle_time"]) daily_cnt[day] = daily_cnt.get(day, 0) + 1 diff --git a/kis_trader/main.py b/kis_trader/main.py index a5a661c..3eb0981 100644 --- a/kis_trader/main.py +++ b/kis_trader/main.py @@ -1168,15 +1168,41 @@ class TradingOrchestrator: # 1) LS 전용 테이블 — 호가와 동일: 구독 중이면 적재 (영구만이 아님). # 봉·VI 는 아래 _ls_should_persist(영구) 유지. if get_env_bool("LS_WS_TICK_SAVE", False) and self._ls_is_subscribed(code): + tk_str = None + lag_sec = None + ts_dt = payload.get("ts") + che = str(payload.get("chetime") or "").strip() + if isinstance(ts_dt, datetime.datetime) and che and len(che) == 6: + tk_str = ts_dt.strftime("%Y%m%d") + che + try: + che_dt = datetime.datetime.strptime(tk_str, "%Y%m%d%H%M%S") + lag_sec = (ts_dt - che_dt).total_seconds() + except ValueError: + pass + self.db.insert_ls_ws_tick( code=code, - ts=payload.get("ts"), + ts=ts_dt, price=float(payload.get("price") or 0), volume=payload.get("volume"), tot_volume=payload.get("tot_volume"), - chetime=str(payload.get("chetime") or ""), + chetime=che, tr_cd=str(payload.get("tr_cd") or ""), + tk=tk_str, + lag_sec=lag_sec, ) + # 가끔 오래된 행 정리 (쓰로틀 — 매 틱마다 DELETE 금지) + try: + keep_lt = int(get_env_int("LS_WS_TICK_KEEP_DAYS", 7) or 7) + if keep_lt > 0 and hasattr(self.db, "cleanup_old_ls_ws_ticks"): + now_m = time.monotonic() + last = float(getattr(self, "_ls_tick_cleanup_mono", 0.0) or 0.0) + if now_m - last >= 3600.0: + self._ls_tick_cleanup_mono = now_m + self.db.cleanup_old_ls_ws_ticks(keep_days=keep_lt) + except Exception: + pass + # 1b) 호가 틱동기 — 체결 1건당 RAM 호가 1장 (스냅 없으면 생략=실매와 동일) try: self._maybe_save_ls_orderbook_on_tick(code, payload) @@ -1206,7 +1232,24 @@ class TradingOrchestrator: def _on_orderbook(code: str, snap: dict) -> None: if self._ls_should_persist_orderbook(code): - self.db.insert_ls_ws_orderbook(code=code, snap=snap, market="KR") + tk_str = None + lag_sec = None + snap_time = str(snap.get("snap_time") or "").strip() + if len(snap_time) >= 14: + tk_str = snap_time[:14] + try: + tk_dt = datetime.datetime.strptime(tk_str, "%Y%m%d%H%M%S") + recv_ts_raw = snap.get("recv_ts") + if isinstance(recv_ts_raw, datetime.datetime): + recv_dt = recv_ts_raw + elif isinstance(recv_ts_raw, float): + recv_dt = datetime.datetime.fromtimestamp(recv_ts_raw) + else: + recv_dt = datetime.datetime.now() + lag_sec = (recv_dt - tk_dt).total_seconds() + except Exception: + pass + self.db.insert_ls_ws_orderbook(code=code, snap=snap, market="KR", tk=tk_str, lag_sec=lag_sec) def _on_vi(code: str, payload: dict) -> None: if not self._ls_should_persist(code): diff --git a/kis_trader/web/feed_collect_stats.py b/kis_trader/web/feed_collect_stats.py index 3691f56..3fc0f47 100644 --- a/kis_trader/web/feed_collect_stats.py +++ b/kis_trader/web/feed_collect_stats.py @@ -8,9 +8,15 @@ """ from __future__ import annotations +import time as _time from datetime import datetime from typing import Any, Dict, List, Optional, Set +# 통계 결과 메모리 캐시 (key: day8, value: (ts, result)) +# 웹 탭 재조회 시 매번 100초대 쉷 나는 일일 직접 스캔을 피하기 위해 TTL 캠시 적용. +_STATS_CACHE: Dict[str, tuple] = {} # {day8: (expire_ts, result)} +_STATS_CACHE_TTL = float(300) # 5분 (ENV으로 오버라이드 가능) + def _ymd8(day: str) -> str: s = (day or "").strip().replace("-", "")[:8] @@ -23,6 +29,158 @@ def _like(day8: str) -> str: return f"{day8}%" +def _day_bounds(day8: str) -> tuple: + """일자 범위 tick_time/snap_time (VARCHAR14 숫자문자열 — LIKE 대신 range).""" + return f"{day8}000000", f"{day8}235959" + + +class _FeedStatsDaySlice: + """하루치 ws_ticks/ws_orderbook/ls_ws_ticks/ls_ws_orderbook TEMP. + + lag·pick 집계는 이 TEMP만 스캔 → LS 풀스캔 제거. + ls_ws_ticks TEMP는 생성 시 lag_sec(TIMESTAMPDIFF)까지 미리 계산해 저장. + """ + + TICK_TMP = "tmp_fs_ticks" + OB_TMP = "tmp_fs_ob" + LS_TICK_TMP = "tmp_fs_ls_ticks" + LS_OB_TMP = "tmp_fs_ls_ob" + + def __init__(self, db, day8: str) -> None: + self.db = db + self.day8 = day8 + self.t0, self.t1 = _day_bounds(day8) + self.like = _like(day8) + self.ready = False + self.ls_ready = False # LS TEMP 별도 플래그 (LS WS 꺼진 날도 KIS/키움 TEMP는 동작) + self.tick_n = 0 + self.ob_n = 0 + self.ls_tick_n = 0 + self.ls_ob_n = 0 + # ls_ws_ticks 당일 ts 범위 (DATETIME 형) + from datetime import datetime as _dt + self._ls_d0 = _dt.strptime(day8, "%Y%m%d") + self._ls_d1 = self._ls_d0.replace(hour=23, minute=59, second=59) + + def ensure(self) -> bool: + """KIS/키움 TEMP + LS TEMP 모두 생성. 실패해도 KIS/키움 TEMP는 유지.""" + self._ensure_kis_kiwoom() + self._ensure_ls() + return self.ready + + def _ensure_kis_kiwoom(self) -> bool: + if self.ready: + return True + conn = self.db.conn + try: + conn.execute(f"DROP TEMPORARY TABLE IF EXISTS {self.TICK_TMP}") + conn.execute(f"DROP TEMPORARY TABLE IF EXISTS {self.OB_TMP}") + conn.execute( + f"CREATE TEMPORARY TABLE {self.TICK_TMP} (" + "id BIGINT, code VARCHAR(32), tick_time VARCHAR(14), " + "tick_time_raw VARCHAR(64), source VARCHAR(16), recv_ts VARCHAR(30), " + "KEY idx_src (source), KEY idx_recv (recv_ts)" + ") AS " + "SELECT id, code, tick_time, tick_time_raw, source, recv_ts " + "FROM ws_ticks WHERE tick_time >= %s AND tick_time <= %s", + (self.t0, self.t1), + ) + conn.execute( + f"CREATE TEMPORARY TABLE {self.OB_TMP} (" + "id BIGINT, code VARCHAR(32), snap_time VARCHAR(14), " + "source VARCHAR(16), recv_ts VARCHAR(30), " + "reject_code VARCHAR(64), strategy VARCHAR(64), " + "KEY idx_src (source)" + ") AS " + "SELECT id, code, snap_time, source, recv_ts, reject_code, strategy " + "FROM ws_orderbook WHERE snap_time >= %s AND snap_time <= %s", + (self.t0, self.t1), + ) + r = conn.execute(f"SELECT COUNT(*) AS n FROM {self.TICK_TMP}").fetchone() + self.tick_n = int((r.get("n") if isinstance(r, dict) else r[0]) or 0) + r = conn.execute(f"SELECT COUNT(*) AS n FROM {self.OB_TMP}").fetchone() + self.ob_n = int((r.get("n") if isinstance(r, dict) else r[0]) or 0) + self.ready = True + return True + except Exception: + self.ready = False + return False + + def _ensure_ls(self) -> bool: + """ls_ws_ticks/ls_ws_orderbook 당일치 TEMP 생성 (lag_sec 미리 계산 포함).""" + if self.ls_ready: + return True + conn = self.db.conn + d0, d1 = self._ls_d0, self._ls_d1 + try: + conn.execute(f"DROP TEMPORARY TABLE IF EXISTS {self.LS_TICK_TMP}") + # ls_ws_ticks: tk, lag_sec 가 이미 계산되어 있다면 바로 사용 (폴백 지원) + conn.execute( + f"CREATE TEMPORARY TABLE {self.LS_TICK_TMP} (" + "code VARCHAR(20), tk CHAR(14), lag_sec DOUBLE, " + "KEY idx_code_tk (code, tk)" + ") AS " + "SELECT code, " + " IFNULL(tk, CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), RIGHT(IFNULL(chetime,'000000'),6))) AS tk, " + " IFNULL(lag_sec, TIMESTAMPDIFF(SECOND, " + " STR_TO_DATE(" + " CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), RIGHT(IFNULL(chetime,'000000'),6))," + " '%%Y%%m%%d%%H%%i%%s'), " + " ts)) AS lag_sec " + "FROM ls_ws_ticks WHERE ts >= %s AND ts <= %s", + (d0, d1), + ) + r = conn.execute(f"SELECT COUNT(*) AS n FROM {self.LS_TICK_TMP}").fetchone() + self.ls_tick_n = int((r.get("n") if isinstance(r, dict) else r[0]) or 0) + except Exception: + self.ls_tick_n = 0 + + try: + conn.execute(f"DROP TEMPORARY TABLE IF EXISTS {self.LS_OB_TMP}") + # ls_ws_orderbook: tk, lag_sec 활용 + conn.execute( + f"CREATE TEMPORARY TABLE {self.LS_OB_TMP} (" + "code VARCHAR(32), snap_time CHAR(14), lag_sec DOUBLE, " + "KEY idx_code_snap (code, snap_time)" + ") AS " + "SELECT code, IFNULL(tk, LEFT(snap_time,14)) AS snap_time, " + " IFNULL(lag_sec, TIMESTAMPDIFF(SECOND, " + " STR_TO_DATE(LEFT(snap_time,14), '%%Y%%m%%d%%H%%i%%s'), " + " recv_ts)) AS lag_sec " + "FROM ls_ws_orderbook WHERE snap_time >= %s AND snap_time <= %s", + (self.t0, self.t1), + ) + r = conn.execute(f"SELECT COUNT(*) AS n FROM {self.LS_OB_TMP}").fetchone() + self.ls_ob_n = int((r.get("n") if isinstance(r, dict) else r[0]) or 0) + except Exception: + self.ls_ob_n = 0 + + self.ls_ready = True + return True + + def tick_from(self) -> str: + return self.TICK_TMP if self.ready else "ws_ticks" + + def ob_from(self) -> str: + return self.OB_TMP if self.ready else "ws_orderbook" + + def ls_tick_from(self) -> str: + return self.LS_TICK_TMP if self.ls_ready else None + + def ls_ob_from(self) -> str: + return self.LS_OB_TMP if self.ls_ready else None + + def tick_filter_sql(self) -> tuple: + if self.ready: + return "", () + return " WHERE tick_time >= %s AND tick_time <= %s", (self.t0, self.t1) + + def ob_filter_sql(self) -> tuple: + if self.ready: + return "", () + return " WHERE snap_time >= %s AND snap_time <= %s", (self.t0, self.t1) + + def _row_nc(r: Any) -> Dict[str, Any]: if r is None: return {"n": 0, "codes": 0} @@ -104,11 +262,17 @@ def _pick_winner(flags: Dict[str, bool], chain: List[str]) -> Optional[str]: def _count_tick_picks( - db, day8: str, age: float, chain: List[str], + db, + day8: str, + age: float, + chain: List[str], + day_slice: Optional[_FeedStatsDaySlice] = None, ) -> Dict[str, int]: """종목×초(YYYYMMDDHHMMSS)마다 usable 벤더 중 체인 1순위 간택 횟수.""" wins = {"kis": 0, "kiwoom": 0, "ls": 0} - like = _like(day8) + ds = day_slice or _FeedStatsDaySlice(db, day8) + tick_tbl = ds.tick_from() + tick_where, tick_params = ds.tick_filter_sql() exch_dt = _sql_ws_tick_exchange_dt() slots: Dict[tuple, Dict[str, bool]] = {} try: @@ -118,10 +282,10 @@ def _count_tick_picks( "FROM (" " SELECT code, tick_time, source, " f" TIMESTAMPDIFF(SECOND, {exch_dt}, recv_ts) AS lag_sec " - " FROM ws_ticks WHERE tick_time LIKE %s" + f" FROM {tick_tbl}{tick_where}" ") x GROUP BY code, LEFT(tick_time, 14), source" ) - for r in db.conn.execute(sql, (age, like)).fetchall() or []: + for r in db.conn.execute(sql, (age, *tick_params)).fetchall() or []: d = dict(r) if not isinstance(r, dict) else r src = str(d.get("source") or "").strip().lower() if src not in wins: @@ -135,34 +299,50 @@ def _count_tick_picks( except Exception: pass try: - d0 = datetime.strptime(day8, "%Y%m%d") - d1 = d0.replace(hour=23, minute=59, second=59) - ls_sql = ( - "SELECT code, DATE_FORMAT(ts, '%%Y%%m%%d%%H%%i%%s') AS tk, " - "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " - "FROM (" - " SELECT code, ts, " - " TIMESTAMPDIFF(SECOND, " - " STR_TO_DATE(" - " CASE WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=14 " - " THEN LEFT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 14) " - " WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=6 " - " THEN CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), " - " RIGHT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 6)) " - " ELSE DATE_FORMAT(ts,'%%Y%%m%%d%%H%%i%%s') END, " - " '%%Y%%m%%d%%H%%i%%s'), " - " ts) AS lag_sec " - " FROM ls_ws_ticks WHERE ts>=%s AND ts<=%s" - ") t GROUP BY code, DATE_FORMAT(ts, '%%Y%%m%%d%%H%%i%%s')" - ) - for r in db.conn.execute(ls_sql, (age, d0, d1)).fetchall() or []: - d = dict(r) if not isinstance(r, dict) else r - if int(d.get("ok") or 0) <= 0: - continue - key = (str(d.get("code") or ""), str(d.get("tk") or "")) - if not key[0] or len(key[1]) < 14: - continue - slots.setdefault(key, {})["ls"] = True + ls_tbl = ds.ls_tick_from() + if ls_tbl: + # TEMP: tk 콜럼 이미 계산(YYYYMMDDHHMMSS), lag_sec 이미 계산 + ls_sql = ( + "SELECT code, tk, " + "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " + f"FROM {ls_tbl} " + "GROUP BY code, tk" + ) + for r in db.conn.execute(ls_sql, (age,)).fetchall() or []: + d = dict(r) if not isinstance(r, dict) else r + if int(d.get("ok") or 0) <= 0: + continue + key = (str(d.get("code") or ""), str(d.get("tk") or "")) + if not key[0] or len(key[1]) < 14: + continue + slots.setdefault(key, {})["ls"] = True + else: + # TEMP 없으면 본 테이블 폴백 (REGEXP 제거한 단순 CONCAT 버전) + d0 = datetime.strptime(day8, "%Y%m%d") + d1 = d0.replace(hour=23, minute=59, second=59) + ls_sql = ( + "SELECT code, " + " CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), RIGHT(IFNULL(chetime,'000000'),6)) AS tk, " + "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " + "FROM (" + " SELECT code, " + " IFNULL(tk, CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), RIGHT(IFNULL(chetime,'000000'),6))) AS tk, " + " IFNULL(lag_sec, TIMESTAMPDIFF(SECOND, " + " STR_TO_DATE(" + " CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), RIGHT(IFNULL(chetime,'000000'),6))," + " '%%Y%%m%%d%%H%%i%%s'), " + " ts)) AS lag_sec " + " FROM ls_ws_ticks WHERE ts>=%s AND ts<=%s" + ") t GROUP BY code, tk" + ) + for r in db.conn.execute(ls_sql, (age, d0, d1)).fetchall() or []: + d = dict(r) if not isinstance(r, dict) else r + if int(d.get("ok") or 0) <= 0: + continue + key = (str(d.get("code") or ""), str(d.get("tk") or "")) + if not key[0] or len(key[1]) < 14: + continue + slots.setdefault(key, {})["ls"] = True except Exception: pass for flags in slots.values(): @@ -173,13 +353,26 @@ def _count_tick_picks( def _count_ob_picks( - db, day8: str, age: float, chain: List[str], + db, + day8: str, + age: float, + chain: List[str], + day_slice: Optional[_FeedStatsDaySlice] = None, ) -> Dict[str, int]: """종목×초마다 호가 usable 벤더 체인 간택.""" wins = {"kis": 0, "kiwoom": 0, "ls": 0} - like = _like(day8) + ds = day_slice or _FeedStatsDaySlice(db, day8) + ob_tbl = ds.ob_from() + ob_where, ob_params = ds.ob_filter_sql() + t0, t1 = ds.t0, ds.t1 slots: Dict[tuple, Dict[str, bool]] = {} try: + if ds.ready: + ob_src_where = f" FROM {ob_tbl} WHERE source<>%s" + ob_src_params: tuple = ("filter_eval",) + else: + ob_src_where = f" FROM {ob_tbl}{ob_where} AND source<>%s" + ob_src_params = (*ob_params, "filter_eval") sql = ( "SELECT code, tk, source, " "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " @@ -195,11 +388,13 @@ def _count_ob_picks( " ELSE NULL END, " " '%%Y%%m%%d%%H%%i%%s'), " " recv_ts) AS lag_sec " - " FROM ws_orderbook WHERE snap_time LIKE %s AND source<>%s" + f"{ob_src_where}" ") x WHERE tk IS NOT NULL " "GROUP BY code, tk, source" ) - for r in db.conn.execute(sql, (age, day8, day8, like, "filter_eval")).fetchall() or []: + for r in db.conn.execute( + sql, (age, day8, day8, *ob_src_params) + ).fetchall() or []: d = dict(r) if not isinstance(r, dict) else r vend = _ob_src_to_vendor(str(d.get("source") or "")) if not vend or int(d.get("ok") or 0) <= 0: @@ -211,25 +406,43 @@ def _count_ob_picks( except Exception: pass try: - ls_sql = ( - "SELECT code, LEFT(snap_time,14) AS tk, " - "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " - "FROM (" - " SELECT code, snap_time, " - " TIMESTAMPDIFF(SECOND, " - " STR_TO_DATE(LEFT(snap_time,14), '%%Y%%m%%d%%H%%i%%s'), " - " recv_ts) AS lag_sec " - " FROM ls_ws_orderbook WHERE snap_time LIKE %s" - ") t GROUP BY code, LEFT(snap_time,14)" - ) - for r in db.conn.execute(ls_sql, (age, like)).fetchall() or []: - d = dict(r) if not isinstance(r, dict) else r - if int(d.get("ok") or 0) <= 0: - continue - key = (str(d.get("code") or ""), str(d.get("tk") or "")) - if not key[0] or len(key[1]) < 14: - continue - slots.setdefault(key, {})["ls"] = True + ls_ob_tbl = ds.ls_ob_from() + if ls_ob_tbl: + # TEMP 사용: lag_sec 이미 계산됨 + ls_sql = ( + "SELECT code, snap_time AS tk, " + "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " + f"FROM {ls_ob_tbl} " + "GROUP BY code, snap_time" + ) + for r in db.conn.execute(ls_sql, (age,)).fetchall() or []: + d = dict(r) if not isinstance(r, dict) else r + if int(d.get("ok") or 0) <= 0: + continue + key = (str(d.get("code") or ""), str(d.get("tk") or "")) + if not key[0] or len(key[1]) < 14: + continue + slots.setdefault(key, {})["ls"] = True + else: + ls_sql = ( + "SELECT code, LEFT(snap_time,14) AS tk, " + "MAX(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END) AS ok " + "FROM (" + " SELECT code, IFNULL(tk, LEFT(snap_time,14)) AS tk, " + " IFNULL(lag_sec, TIMESTAMPDIFF(SECOND, " + " STR_TO_DATE(LEFT(snap_time,14), '%%Y%%m%%d%%H%%i%%s'), " + " recv_ts)) AS lag_sec " + " FROM ls_ws_orderbook WHERE snap_time >= %s AND snap_time <= %s" + ") t GROUP BY code, tk" + ) + for r in db.conn.execute(ls_sql, (age, t0, t1)).fetchall() or []: + d = dict(r) if not isinstance(r, dict) else r + if int(d.get("ok") or 0) <= 0: + continue + key = (str(d.get("code") or ""), str(d.get("tk") or "")) + if not key[0] or len(key[1]) < 14: + continue + slots.setdefault(key, {})["ls"] = True except Exception: pass for flags in slots.values(): @@ -311,7 +524,12 @@ def _sql_ws_tick_exchange_dt() -> str: ) -def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: +def _feed_age_cut_stats( + db, + day8: str, + age_sec: float, + day_slice: Optional[_FeedStatsDaySlice] = None, +) -> Dict[str, Any]: """recv_ts vs 체결/스냅 시각 지연 → 3초(폴백나이) 합격·탈락·미상. 옵투나 ``merge_ticks_time_axis_fallback`` / 호가 lag 컷과 동일 축. @@ -319,7 +537,12 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: empty_minutes = 그 분 틱이 전부 fail 인 (code,분) 수 (비어 재현되는 분). """ age = max(0.0, float(age_sec or 3.0)) - like = _like(day8) + ds = day_slice or _FeedStatsDaySlice(db, day8) + tick_tbl = ds.tick_from() + ob_tbl = ds.ob_from() + tick_where, tick_params = ds.tick_filter_sql() + ob_where, ob_params = ds.ob_filter_sql() + t0, t1 = ds.t0, ds.t1 rows_out: List[Dict[str, Any]] = [] exch_dt = _sql_ws_tick_exchange_dt() @@ -334,10 +557,10 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: "FROM (" " SELECT source, " f" TIMESTAMPDIFF(SECOND, {exch_dt}, recv_ts) AS lag_sec " - " FROM ws_ticks WHERE tick_time LIKE %s" + f" FROM {tick_tbl}{tick_where}" ") t GROUP BY source" ) - for r in db.conn.execute(tick_sql, (age, age, like)).fetchall() or []: + for r in db.conn.execute(tick_sql, (age, age, *tick_params)).fetchall() or []: d = dict(r) if not isinstance(r, dict) else r rows_out.append( _age_cut_row( @@ -361,43 +584,57 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: " FROM (" " SELECT code, tick_time, " f" TIMESTAMPDIFF(SECOND, {exch_dt}, recv_ts) AS lag_sec " - " FROM ws_ticks WHERE tick_time LIKE %s" + f" FROM {tick_tbl}{tick_where}" " ) x " " GROUP BY code, LEFT(tick_time,12) " " HAVING SUM(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END)=0 " " AND COUNT(*)>0" ") z" ) - r = db.conn.execute(empty_sql, (like, age)).fetchone() + r = db.conn.execute(empty_sql, (*tick_params, age)).fetchone() empty_min = int((r["n"] if isinstance(r, dict) else r[0]) or 0) except Exception: empty_min = 0 # ── ls_ws_ticks ── try: - d0 = datetime.strptime(day8, "%Y%m%d") - d1 = d0.replace(hour=23, minute=59, second=59) - ls_sql = ( - "SELECT 'ls' AS src, " - "COUNT(*) AS total, " - "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, " - "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, " - "SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n " - "FROM (" - " SELECT TIMESTAMPDIFF(SECOND, " - " STR_TO_DATE(" - " CASE WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=14 " - " THEN LEFT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 14) " - " WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=6 " - " THEN CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), " - " RIGHT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 6)) " - " ELSE DATE_FORMAT(ts,'%%Y%%m%%d%%H%%i%%s') END, " - " '%%Y%%m%%d%%H%%i%%s'), " - " ts) AS lag_sec " - " FROM ls_ws_ticks WHERE ts>=%s AND ts<=%s" - ") t" - ) - r = db.conn.execute(ls_sql, (age, age, d0, d1)).fetchone() + ls_tbl = ds.ls_tick_from() + if ls_tbl: + # TEMP 사용: lag_sec 이미 계산됨 → REGEXP 연산 없음 + ls_sql = ( + "SELECT 'ls' AS src, " + "COUNT(*) AS total, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, " + "SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n " + f"FROM {ls_tbl}" + ) + r = db.conn.execute(ls_sql, (age, age)).fetchone() + else: + # TEMP 없으면 본 테이블 폴백 + d0 = datetime.strptime(day8, "%Y%m%d") + d1 = d0.replace(hour=23, minute=59, second=59) + ls_sql = ( + "SELECT 'ls' AS src, " + "COUNT(*) AS total, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, " + "SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n " + "FROM (" + " SELECT IFNULL(lag_sec, TIMESTAMPDIFF(SECOND, " + " STR_TO_DATE(" + " CASE WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=14 " + " THEN LEFT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 14) " + " WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=6 " + " THEN CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), " + " RIGHT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 6)) " + " ELSE DATE_FORMAT(ts,'%%Y%%m%%d%%H%%i%%s') END, " + " '%%Y%%m%%d%%H%%i%%s'), " + " ts)) AS lag_sec " + " FROM ls_ws_ticks WHERE ts>=%s AND ts<=%s" + ") t" + ) + r = db.conn.execute(ls_sql, (age, age, d0, d1)).fetchone() if r: d = dict(r) if not isinstance(r, dict) else r if int(d.get("total") or 0) > 0: @@ -428,6 +665,12 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: # ── ws_orderbook 스트림 ── try: + if ds.ready: + ob_stream_from = f" FROM {ob_tbl} WHERE source<>%s" + ob_stream_params: tuple = ("filter_eval",) + else: + ob_stream_from = f" FROM {ob_tbl}{ob_where} AND source<>%s" + ob_stream_params = (*ob_params, "filter_eval") ob_sql = ( "SELECT source AS src, " "COUNT(*) AS total, " @@ -444,10 +687,12 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: " '%%Y%%m%%d%%H%%i%%s'), " " recv_ts" " ) AS lag_sec " - " FROM ws_orderbook WHERE snap_time LIKE %s AND source<>%s" + f"{ob_stream_from}" ") t GROUP BY source" ) - for r in db.conn.execute(ob_sql, (age, age, day8, like, "filter_eval")).fetchall() or []: + for r in db.conn.execute( + ob_sql, (age, age, day8, *ob_stream_params) + ).fetchall() or []: d = dict(r) if not isinstance(r, dict) else r rows_out.append( _age_cut_row( @@ -464,20 +709,33 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: # LS 호가 try: - ls_ob_sql = ( - "SELECT 'ls_uh1' AS src, " - "COUNT(*) AS total, " - "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, " - "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, " - "SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n " - "FROM (" - " SELECT TIMESTAMPDIFF(SECOND, " - " STR_TO_DATE(LEFT(snap_time,14), '%%Y%%m%%d%%H%%i%%s'), " - " recv_ts) AS lag_sec " - " FROM ls_ws_orderbook WHERE snap_time LIKE %s" - ") t" - ) - r = db.conn.execute(ls_ob_sql, (age, age, like)).fetchone() + ls_ob_tbl = ds.ls_ob_from() + if ls_ob_tbl: + # TEMP 사용: lag_sec 이미 계산됨 + ls_ob_sql = ( + "SELECT 'ls_uh1' AS src, " + "COUNT(*) AS total, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, " + "SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n " + f"FROM {ls_ob_tbl}" + ) + r = db.conn.execute(ls_ob_sql, (age, age)).fetchone() + else: + ls_ob_sql = ( + "SELECT 'ls_uh1' AS src, " + "COUNT(*) AS total, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, " + "SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, " + "SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n " + "FROM (" + " SELECT IFNULL(lag_sec, TIMESTAMPDIFF(SECOND, " + " STR_TO_DATE(LEFT(snap_time,14), '%%Y%%m%%d%%H%%i%%s'), " + " recv_ts)) AS lag_sec " + " FROM ls_ws_orderbook WHERE snap_time >= %s AND snap_time <= %s" + ") t" + ) + r = db.conn.execute(ls_ob_sql, (age, age, t0, t1)).fetchone() if r: d = dict(r) if not isinstance(r, dict) else r rows_out.append( @@ -532,8 +790,8 @@ def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]: pass tick_chain = _vendor_read_chain(tick_primary) ob_chain = _vendor_read_chain(ob_primary) - tick_wins = _count_tick_picks(db, day8, age, tick_chain) - ob_wins = _count_ob_picks(db, day8, age, ob_chain) + tick_wins = _count_tick_picks(db, day8, age, tick_chain, day_slice=ds) + ob_wins = _count_ob_picks(db, day8, age, ob_chain, day_slice=ds) _annotate_share_and_pick( rows_out, tick_wins=tick_wins, @@ -599,17 +857,35 @@ def _gap_row_from_sql(d: Dict[str, Any], *, soft: int, hard: int) -> Dict[str, A } -def _feed_disconnect_stats(db, day8: str) -> Dict[str, Any]: +def _feed_disconnect_stats( + db, + day8: str, + day_slice: Optional[_FeedStatsDaySlice] = None, +) -> Dict[str, Any]: """종목별 recv_ts 간격으로 끊김(연결 공백) 집계 — usable(체결시각 lag)과 다른 축. 장중(09:00~15:30) · 연속 틱 LAG. cap 초과 공백은 미구독/장외로 제외. """ soft, hard, cap = _disconnect_thresholds() - like = _like(day8) + ds = day_slice or _FeedStatsDaySlice(db, day8) + tick_tbl = ds.tick_from() + tick_where, tick_params = ds.tick_filter_sql() rows: List[Dict[str, Any]] = [] by_vendor: Dict[str, Dict[str, Any]] = {} try: + if ds.ready: + tick_lag_where = ( + f" FROM {tick_tbl} " + "WHERE TIME(recv_ts) BETWEEN '09:00:00' AND '15:30:00'" + ) + tick_lag_params: tuple = () + else: + tick_lag_where = ( + f" FROM {tick_tbl}{tick_where} " + "AND TIME(recv_ts) BETWEEN '09:00:00' AND '15:30:00'" + ) + tick_lag_params = tick_params sql = ( "SELECT source AS vendor, " "COUNT(*) AS pair_n, " @@ -622,14 +898,14 @@ def _feed_disconnect_stats(db, day8: str) -> Dict[str, Any]: " TIMESTAMPDIFF(SECOND, " " LAG(recv_ts) OVER (PARTITION BY source, code ORDER BY recv_ts, id), " " recv_ts) AS gap_sec " - " FROM ws_ticks " - " WHERE tick_time LIKE %s " - " AND TIME(recv_ts) BETWEEN '09:00:00' AND '15:30:00'" + f"{tick_lag_where}" ") t " "WHERE gap_sec IS NOT NULL AND gap_sec > 0 AND gap_sec < %s " "GROUP BY source" ) - for r in db.conn.execute(sql, (soft, hard, soft, like, cap)).fetchall() or []: + for r in db.conn.execute( + sql, (soft, hard, soft, *tick_lag_params, cap) + ).fetchall() or []: d = dict(r) if not isinstance(r, dict) else r row = _gap_row_from_sql(d, soft=soft, hard=hard) v = row["vendor"] @@ -891,57 +1167,124 @@ def build_feed_collect_stats( day: Optional[str] = None, *, heavy: bool = False, + invalidate_cache: bool = False, ) -> Dict[str, Any]: """한 거래일(KST YYYYMMDD) 수집 요약 + 증권사 대비. 기본(heavy=False): GROUP BY COUNT + 폴백나이(STR_TO_DATE 전수, 보통 십수 초). heavy=True: 추가로 DISTINCT 종목교집합·filter_eval pass/reject. - 최근 N일 일괄 GROUP BY 는 제거(틱·호가 테이블 폭주). + 종료: 결과는 5분 TTL 메모리 캐시에 저장 (웹탭 재조회 시 즉시 반환). + invalidate_cache=True 이면 캐시 무시 및 강제 재계산. """ + global _STATS_CACHE, _STATS_CACHE_TTL day8 = _ymd8(day or "") + + # 캐시 히트 확인 + if not invalidate_cache: + cached = _STATS_CACHE.get(day8) + if cached: + expire_ts, result = cached + if _time.monotonic() < expire_ts: + return result + + result = _build_feed_collect_stats_inner(db, day8, heavy=heavy) + + # 캐시 저장 (과거일의 경우 1시간, 당일은 5분) + today8 = datetime.now().strftime("%Y%m%d") + ttl = _STATS_CACHE_TTL if day8 >= today8 else 3600.0 + _STATS_CACHE[day8] = (_time.monotonic() + ttl, result) + # 낡은 캐시 항목 정리 + _STATS_CACHE = { + k: v for k, v in _STATS_CACHE.items() + if _time.monotonic() < v[0] + } + return result + + +def invalidate_feed_collect_stats_cache(day8: Optional[str] = None) -> None: + """캐시 무효화. day8 지정 시 해당 일자만, None이면 전체.""" + global _STATS_CACHE + if day8: + _STATS_CACHE.pop(day8, None) + else: + _STATS_CACHE.clear() + + +def _build_feed_collect_stats_inner( + db, + day8: str, + *, + heavy: bool = False, +) -> Dict[str, Any]: + """build_feed_collect_stats 실제 연산 바디 (캐시 래퍼 제외).""" + + t0, t1 = _day_bounds(day8) like = _like(day8) notes: List[str] = [] heavy = bool(heavy) + day_slice = _FeedStatsDaySlice(db, day8) + if not day_slice.ensure(): + notes.append( + "TEMP 일별 슬라이스 생성 실패 — tick_time range 직접 스캔으로 폴백." + ) + elif day_slice.tick_n > 0 or day_slice.ob_n > 0: + notes.append( + f"일별 TEMP 슬라이스: tick={day_slice.tick_n:,} ob={day_slice.ob_n:,} " + "(lag·끊김·pick은 TEMP만 스캔)." + ) + + tick_tbl = day_slice.tick_from() + ob_tbl = day_slice.ob_from() + tick_where, tick_params = day_slice.tick_filter_sql() + ob_where, ob_params = day_slice.ob_filter_sql() + ticks_by_src = _safe_group( db, "SELECT source AS k, COUNT(*) AS n, COUNT(DISTINCT code) AS c " - "FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source ORDER BY n DESC", - (like,), + f"FROM {tick_tbl}{tick_where} GROUP BY source ORDER BY n DESC", + tick_params, ) ob_by_src = _safe_group( db, "SELECT source AS k, COUNT(*) AS n, COUNT(DISTINCT code) AS c " - "FROM ws_orderbook WHERE snap_time LIKE %s GROUP BY source ORDER BY n DESC", - (like,), + f"FROM {ob_tbl}{ob_where} GROUP BY source ORDER BY n DESC", + ob_params, ) + if day_slice.ready: + fe_where = f" FROM {ob_tbl} WHERE source=%s" + fe_params: tuple = ("filter_eval",) + else: + fe_where = f" FROM {ob_tbl}{ob_where} AND source=%s" + fe_params = (*ob_params, "filter_eval") fe_by_strat = _safe_group( db, "SELECT COALESCE(strategy,'') AS k, COUNT(*) AS n, COUNT(DISTINCT code) AS c " - "FROM ws_orderbook WHERE source=%s AND snap_time LIKE %s " + f"{fe_where} " "GROUP BY strategy ORDER BY n DESC", - ("filter_eval", like), + fe_params, ) fe_reject = {"n": 0, "codes": 0} fe_pass = {"n": 0, "codes": 0} if heavy: fe_reject = _safe_nc( db, - "SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ws_orderbook " - "WHERE source=%s AND snap_time LIKE %s AND reject_code IS NOT NULL AND reject_code<>''", - ("filter_eval", like), + "SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c " + f"{fe_where} AND reject_code IS NOT NULL AND reject_code<>''", + fe_params, ) fe_pass = _safe_nc( db, - "SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ws_orderbook " - "WHERE source=%s AND snap_time LIKE %s AND (reject_code IS NULL OR reject_code='')", - ("filter_eval", like), + "SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c " + f"{fe_where} AND (reject_code IS NULL OR reject_code='')", + fe_params, ) ls_ob = _safe_nc( db, - "SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ls_ws_orderbook WHERE snap_time LIKE %s", - (like,), + "SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ls_ws_orderbook " + "WHERE snap_time >= %s AND snap_time <= %s", + (t0, t1), ) try: d0 = datetime.strptime(day8, "%Y%m%d") @@ -999,26 +1342,48 @@ def build_feed_collect_stats( ls_tick_codes: Optional[Set[str]] = None ls_ob_codes: Optional[Set[str]] = None if heavy: - kis_tick_codes = _distinct_codes( - db, - "SELECT DISTINCT code FROM ws_ticks WHERE tick_time LIKE %s AND source=%s", - (like, "kis"), - ) - kis_ob_codes = _distinct_codes( - db, - "SELECT DISTINCT code FROM ws_orderbook WHERE snap_time LIKE %s AND source=%s", - (like, "kis_h0stasp0"), - ) - kw_tick_codes = _distinct_codes( - db, - "SELECT DISTINCT code FROM ws_ticks WHERE tick_time LIKE %s AND source=%s", - (like, "kiwoom"), - ) - kw_ob_codes = _distinct_codes( - db, - "SELECT DISTINCT code FROM ws_orderbook WHERE snap_time LIKE %s AND source=%s", - (like, "kiwoom_0d"), - ) + if day_slice.ready: + kis_tick_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {tick_tbl} WHERE source=%s", + ("kis",), + ) + kis_ob_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {ob_tbl} WHERE source=%s", + ("kis_h0stasp0",), + ) + kw_tick_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {tick_tbl} WHERE source=%s", + ("kiwoom",), + ) + kw_ob_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {ob_tbl} WHERE source=%s", + ("kiwoom_0d",), + ) + else: + kis_tick_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {tick_tbl}{tick_where} AND source=%s", + (*tick_params, "kis"), + ) + kis_ob_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {ob_tbl}{ob_where} AND source=%s", + (*ob_params, "kis_h0stasp0"), + ) + kw_tick_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {tick_tbl}{tick_where} AND source=%s", + (*tick_params, "kiwoom"), + ) + kw_ob_codes = _distinct_codes( + db, + f"SELECT DISTINCT code FROM {ob_tbl}{ob_where} AND source=%s", + (*ob_params, "kiwoom_0d"), + ) try: d0 = datetime.strptime(day8, "%Y%m%d") d1 = d0.replace(hour=23, minute=59, second=59) @@ -1031,8 +1396,8 @@ def build_feed_collect_stats( ls_tick_codes = set() ls_ob_codes = _distinct_codes( db, - "SELECT DISTINCT code FROM ls_ws_orderbook WHERE snap_time LIKE %s", - (like,), + "SELECT DISTINCT code FROM ls_ws_orderbook WHERE snap_time >= %s AND snap_time <= %s", + (t0, t1), ) coverage = [ @@ -1173,8 +1538,8 @@ def build_feed_collect_stats( notes.append("LS_WS_TICK_SAVE=true → 구독 종목 US3를 ls_ws_ticks 에 적재 (영구만이 아님).") # 폴백나이: 기본 조회에도 포함 (실측 ~10초대, 요약테이블/크론 불필요) - age_cut = _feed_age_cut_stats(db, day8, fb_age) - disconnect = _feed_disconnect_stats(db, day8) + age_cut = _feed_age_cut_stats(db, day8, fb_age, day_slice=day_slice) + disconnect = _feed_disconnect_stats(db, day8, day_slice=day_slice) return { "day": f"{day8[:4]}-{day8[4:6]}-{day8[6:8]}", diff --git a/kis_trader/ws/kis_ws.py b/kis_trader/ws/kis_ws.py index 67b9833..3762672 100644 --- a/kis_trader/ws/kis_ws.py +++ b/kis_trader/ws/kis_ws.py @@ -1508,6 +1508,7 @@ class CandleAggregator: batch: list = [] last_flush = time.time() last_stale_check = 0.0 + last_cleanup_ts = 0.0 stale_check_interval = float( get_env_int("WS_CANDLE_STALE_CHECK_INTERVAL_SEC", 2) ) @@ -1551,6 +1552,16 @@ class CandleAggregator: except Exception as e: logger.debug("flush_stale_current_candles 실패(무시): %s", e) + # ws_candles 구데이터 1시간 주기 정리 + if now - last_cleanup_ts >= 3600.0: + last_cleanup_ts = now + try: + keep = get_env_int("SCALP_CANDLE_KEEP_DAYS", 7) or 7 + if keep > 0 and self.db is not None and hasattr(self.db, "cleanup_old_ws_candles"): + self.db.cleanup_old_ws_candles(keep_days=keep) + except Exception as e: + logger.debug("ws_candles 정리 실패(무시): %s", e) + # 루프 종료 시 남은 배치 처리 if batch: self._flush_batch(batch) diff --git a/kis_trader/ws/ls_ws.py b/kis_trader/ws/ls_ws.py index 8ba40fd..8a0c6f4 100644 --- a/kis_trader/ws/ls_ws.py +++ b/kis_trader/ws/ls_ws.py @@ -496,12 +496,6 @@ class LSWebSocketPriceCache: if code.isdigit() and len(code) == 6: need_reg = False with self._sub_lock: - if max_n > 0 and code not in self._subscribed and len(self._subscribed) >= max_n: - logger.warning( - "⚠️ LS WS 구독 한도 초과 (%d/%d) — %s 등록 거절", - len(self._subscribed), max_n, code, - ) - return False ow = self._sub_owners.setdefault(code, set()) if own in ow and code in self._subscribed: return True @@ -515,16 +509,6 @@ class LSWebSocketPriceCache: else: need_reg = False with self._sub_lock: - if ( - max_n > 0 - and code not in self._us_subscribed - and (len(self._subscribed) + len(self._us_subscribed)) >= max_n - ): - logger.warning( - "⚠️ LS WS 구독 한도 초과 (%d/%d) — %s 등록 거절", - len(self._subscribed) + len(self._us_subscribed), max_n, code, - ) - return False ow = self._us_sub_owners.setdefault(code, set()) if own in ow and code in self._us_subscribed: return True diff --git a/static/js/backtest.js b/static/js/backtest.js index c404557..55b316e 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -188,6 +188,7 @@ function normalizeVirtualTrade(t, meta) { rsi: t.rsi_entry, entrySource: t.entry_source_label || t.entry_source || '', exitSource: t.exit_source_label || t.exit_source || '', + obSource: t.ob_source || '', debugTick: t.debug_tick || '', parityException: !!(t.parity_exception) || _detectParityExceptionCauses(t).length > 0, parityCause: t.parity_cause || _detectParityExceptionCauses(t).join('+'), @@ -551,8 +552,8 @@ function renderVirtualTrades(tbodyId, trades, opts) { const reasonHtml = `${r.reason || '-'}`; const debugHtml = r.debugTick ? `${r.debugTick}` - : (r.entrySource || r.exitSource - ? `${r.entrySource || '-'}→${r.exitSource || '-'}` + : (r.entrySource || r.exitSource || r.obSource + ? `틱:${r.entrySource || '?'} / 호가:${r.obSource || '?'} → ${r.exitSource || '?'}` : '-'); const rowFx = getDisplayFxRate(r.fxRate || defaultFx); const cumPnlHtml = Number.isFinite(r.cumPnl) @@ -2679,7 +2680,7 @@ function runBreakoutBacktest() { skip_hts_scan_dupes: $('bo_skip_hts_dupes')?.checked ? 1 : 0, env_timeline: envTimelineParam('bo_env_timeline'), }; - const qs = new URLSearchParams(params).toString(); + const qs = new URLSearchParams(mergeOptunaObWhipQuery(params)).toString(); showSpinner(true); fetch('/api/backtest/breakout?' + qs) .then(async r => { @@ -3148,7 +3149,7 @@ function runMomentumBacktest() { backtest_skip_pre_subscribe: $('mom_skip_pre_sub')?.checked ? 1 : 0, env_timeline: envTimelineParam('mom_env_timeline'), }; - const qs = new URLSearchParams(params).toString(); + const qs = new URLSearchParams(mergeOptunaObWhipQuery(params)).toString(); fetch('/api/backtest/momentum?' + qs) .then(async r => { const d = await r.json().catch(() => ({})); @@ -4606,10 +4607,143 @@ function fillObWhipReadonly(prefix, s) { if (whipOn) { bits.push(`sub=${s.whipsaw_subbar_sec ?? '?'} lb=${s.whipsaw_lookback_sec ?? '?'} dip=${s.whipsaw_dip_pct ?? '?'}`); } - note.textContent = 'DB: ' + bits.join(' · '); + note.textContent = (window._optunaBacktestObWhipOverrides && Object.keys(window._optunaBacktestObWhipOverrides).length) + ? ('Optuna trial: ' + bits.join(' · ')) + : ('DB: ' + bits.join(' · ')); } } +/** Optuna trial params → 백테 API 쿼리 (호가·휩쏘 1회 오버라이드, DB 미변경) */ +function optunaObWhipOverridesFromParams(p) { + if (!p || typeof p !== 'object') return {}; + const obOn = p.ob_filter_enabled != null ? p.ob_filter_enabled : p._orderbook_filter_enabled; + const pgOn = p.pg_filter_enabled != null ? p.pg_filter_enabled : p._program_filter_enabled; + const whipOn = p.whipsaw_filter_enabled != null ? p.whipsaw_filter_enabled : p.whipsaw_enabled; + const spread = p.max_spread_pct != null ? p.max_spread_pct : p._ob_max_spread_pct; + const ratio = p.ob_min_bid_ask_ratio != null ? p.ob_min_bid_ask_ratio + : (p.min_bid_ask_ratio != null ? p.min_bid_ask_ratio : p._ob_min_bid_ask_ratio); + const ask = p.ob_ask_max_mult != null ? p.ob_ask_max_mult + : (p.ask_max_mult != null ? p.ask_max_mult : p._ob_ask_max_mult); + const out = {}; + if (obOn != null) out.ob_filter = obOn ? 1 : 0; + if (pgOn != null) out.pg_filter = pgOn ? 1 : 0; + if (spread != null && spread !== '') out.max_spread_pct = spread; + if (ratio != null && ratio !== '') out.min_bid_ask_ratio = ratio; + if (ask != null && ask !== '') out.ob_ask_max_mult = ask; + if (whipOn != null) out.whipsaw_filter = whipOn ? 1 : 0; + if (p.whipsaw_subbar_sec != null) out.whipsaw_subbar_sec = p.whipsaw_subbar_sec; + if (p.whipsaw_lookback_sec != null) out.whipsaw_lookback_sec = p.whipsaw_lookback_sec; + if (p.whipsaw_dip_pct != null) out.whipsaw_dip_pct = p.whipsaw_dip_pct; + if (p.whipsaw_recovery_tol_pct != null) out.whipsaw_recovery_tol_pct = p.whipsaw_recovery_tol_pct; + return out; +} + +function mergeOptunaObWhipQuery(params) { + const ov = optunaObWhipOverridesFromHidden('tl'); + if (!ov || !Object.keys(ov).length) { + const mem = window._optunaBacktestObWhipOverrides; + if (!mem || typeof mem !== 'object' || !Object.keys(mem).length) return params; + return Object.assign({}, params, mem); + } + return Object.assign({}, params, ov); +} + +function optunaObWhipOverridesFromHidden(prefix) { + const px = prefix || 'tl'; + const g = (id) => { const el = $(id); return el ? String(el.value || '').trim() : ''; }; + const out = {}; + const wf = g(`${px}_h_whipsaw_filter`); + if (wf !== '') out.whipsaw_filter = wf; + const sub = g(`${px}_h_whipsaw_sub`); if (sub !== '') out.whipsaw_subbar_sec = sub; + const lb = g(`${px}_h_whipsaw_lb`); if (lb !== '') out.whipsaw_lookback_sec = lb; + const dip = g(`${px}_h_whipsaw_dip`); if (dip !== '') out.whipsaw_dip_pct = dip; + const tol = g(`${px}_h_whipsaw_tol`); if (tol !== '') out.whipsaw_recovery_tol_pct = tol; + const ratio = g(`${px}_h_min_bid_ask_ratio`); if (ratio !== '') out.min_bid_ask_ratio = ratio; + const ask = g(`${px}_h_ob_ask_max_mult`); if (ask !== '') out.ob_ask_max_mult = ask; + return out; +} + +function applyOptunaObWhipToEditableForm(p, prefix) { + if (!p || typeof p !== 'object') return; + const obOn = p.ob_filter_enabled != null ? p.ob_filter_enabled : p._orderbook_filter_enabled; + const pgOn = p.pg_filter_enabled != null ? p.pg_filter_enabled : p._program_filter_enabled; + const spread = p.max_spread_pct != null ? p.max_spread_pct : p._ob_max_spread_pct; + const map = { + tl: { ob: 'tl_ob_filter', pg: 'tl_pg_filter', spread: 'tl_max_spread_pct' }, + bo: { ob: 'bo_ob_filter', pg: 'bo_pg_filter', spread: 'bo_max_spread_pct' }, + mom: { ob: 'mom_ob_filter', pg: 'mom_pg_filter', spread: 'mom_max_spread_pct' }, + }; + const ids = map[prefix]; + if (!ids) return; + if (ids.ob && $(ids.ob) && obOn != null) $(ids.ob).checked = !!obOn; + if (ids.pg && $(ids.pg) && pgOn != null) $(ids.pg).checked = !!pgOn; + if (ids.spread && $(ids.spread) && spread != null && spread !== '') $(ids.spread).value = spread; + applyOptunaObWhipToHiddenInputs(p, prefix); +} + +/** Optuna trial 호가/휩쏘 → hidden input (쿠키/세션 아님 · 폼 DOM에 저장) */ +function applyOptunaObWhipToHiddenInputs(p, prefix) { + if (!p || typeof p !== 'object') return; + const px = prefix || 'tl'; + const hid = { + tl: { + whip: 'tl_h_whipsaw_filter', sub: 'tl_h_whipsaw_sub', lb: 'tl_h_whipsaw_lb', + dip: 'tl_h_whipsaw_dip', tol: 'tl_h_whipsaw_tol', + ratio: 'tl_h_min_bid_ask_ratio', ask: 'tl_h_ob_ask_max_mult', banner: 'tl_optuna_ob_whip_banner', + }, + }[px]; + if (!hid) return; + const set = (id, v) => { if ($(id) && v !== undefined && v !== null && v !== '') $(id).value = v; }; + const whipOn = p.whipsaw_filter_enabled != null ? p.whipsaw_filter_enabled : p.whipsaw_enabled; + if (whipOn != null) set(hid.whip, whipOn ? '1' : '0'); + set(hid.sub, p.whipsaw_subbar_sec); + set(hid.lb, p.whipsaw_lookback_sec); + set(hid.dip, p.whipsaw_dip_pct); + set(hid.tol, p.whipsaw_recovery_tol_pct); + const ratio = p.ob_min_bid_ask_ratio != null ? p.ob_min_bid_ask_ratio + : (p.min_bid_ask_ratio != null ? p.min_bid_ask_ratio : p._ob_min_bid_ask_ratio); + const ask = p.ob_ask_max_mult != null ? p.ob_ask_max_mult + : (p.ask_max_mult != null ? p.ask_max_mult : p._ob_ask_max_mult); + set(hid.ratio, ratio); + set(hid.ask, ask); + const banner = $(hid.banner); + if (banner) { + const sum = optunaObWhipOverrideSummary(optunaObWhipOverridesFromParams(p)); + if (sum) { + banner.classList.remove('d-none'); + banner.innerHTML = `📌 Optuna 호가/휩쏘(폼 hidden): ${sum} · `; + } else { + banner.classList.add('d-none'); + banner.textContent = ''; + } + } +} + +function clearOptunaObWhipHidden(prefix) { + const px = prefix || 'tl'; + ['_h_whipsaw_filter', '_h_whipsaw_sub', '_h_whipsaw_lb', '_h_whipsaw_dip', '_h_whipsaw_tol', + '_h_min_bid_ask_ratio', '_h_ob_ask_max_mult'].forEach(s => { + const el = $(px + s); + if (el) el.value = ''; + }); + const banner = $('tl_optuna_ob_whip_banner'); + if (banner) { banner.classList.add('d-none'); banner.textContent = ''; } + window._optunaBacktestObWhipOverrides = null; +} + +function optunaObWhipOverrideSummary(ov) { + if (!ov || typeof ov !== 'object' || !Object.keys(ov).length) return ''; + const bits = []; + if (ov.ob_filter != null) bits.push(ov.ob_filter ? '호가ON' : '호가OFF'); + if (ov.pg_filter != null) bits.push(ov.pg_filter ? 'PGON' : 'PGOFF'); + if (ov.whipsaw_filter != null) bits.push(ov.whipsaw_filter ? '휩쏘ON' : '휩쏘OFF'); + if (ov.max_spread_pct != null) bits.push(`spr≤${ov.max_spread_pct}`); + if (ov.whipsaw_filter && ov.whipsaw_subbar_sec != null) { + bits.push(`sub=${ov.whipsaw_subbar_sec} lb=${ov.whipsaw_lookback_sec ?? '?'} dip=${ov.whipsaw_dip_pct ?? '?'}`); + } + return bits.join(' · '); +} + function fillBreakoutFormFromApi(b) { if (!b || typeof b !== 'object') return; const setM = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; @@ -5475,7 +5609,7 @@ function runBacktest() { params.candle_source = $('bt_candle_source')?.value || ''; params.tick_source = $('bt_tick_source')?.value || ''; params.ob_source = $('bt_ob_source')?.value || ''; - const qs = new URLSearchParams(params).toString(); + const qs = new URLSearchParams(mergeOptunaObWhipQuery(params)).toString(); showSpinner(true); fetch('/api/backtest/scalping?' + qs) .then(r => r.json()) @@ -6120,7 +6254,8 @@ function formatLimitEntryLabel(p) { return ' · 진입 A align(다음봉 시가)'; } -function runTailBacktest() { +function collectTailBacktestFormParams() { + const h = (id) => { const el = $(id); return el ? String(el.value || '').trim() : ''; }; const params = { start: $('tl_start').value, end: $('tl_end').value, @@ -6204,7 +6339,18 @@ function runTailBacktest() { eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', env_timeline: envTimelineParam('tl_env_timeline'), }; - const qs = new URLSearchParams(params).toString(); + const wh = optunaObWhipOverridesFromHidden('tl'); + Object.assign(params, wh); + const ratio = h('tl_h_min_bid_ask_ratio'); + const ask = h('tl_h_ob_ask_max_mult'); + if (ratio) params.min_bid_ask_ratio = ratio; + if (ask) params.ob_ask_max_mult = ask; + return params; +} + +function runTailBacktest() { + const params = collectTailBacktestFormParams(); + const qs = new URLSearchParams(mergeOptunaObWhipQuery(params)).toString(); showSpinner(true); fetch('/api/backtest/tail?' + qs) .then(r => r.json()) @@ -6429,7 +6575,6 @@ function btJobCollectBody(strategy) { end: $(cfg.endId)?.value, universe: univOn ? cfg.universeOn : cfg.universeOff, universe_history_source: cfg.univSrcId ? univHistorySourceParam(cfg.univSrcId) : 'kiwoom', - // 폼에 호가체크 없으면 DB 따름(auto). scalp/breakout obId=null 이 off 강제되던 버그 수정 orderbook_filter: cfg.obId ? ($(cfg.obId)?.checked ? 'on' : 'off') : 'auto', @@ -6437,15 +6582,25 @@ function btJobCollectBody(strategy) { }; if (cfg.tfId && $(cfg.tfId)) body.timeframe = $(cfg.tfId).value || 3; if (cfg.tickId) body.backtest_use_tick_db = $(cfg.tickId)?.checked !== false ? 1 : 0; + if (strategy === 'tail') { + body.form_params = collectTailBacktestFormParams(); + } return body; } async function btJobStart(strategy) { const body = btJobCollectBody(strategy); if (!body.start || !body.end) { alert('시작·종료일을 입력하세요'); return; } + const hasForm = !!(body.form_params && Object.keys(body.form_params).length); + const whipHint = strategy === 'tail' && $('tl_h_whipsaw_filter')?.value !== '' + ? `\n휩쏘 hidden=${$('tl_h_whipsaw_filter').value === '1' ? 'ON' : 'OFF'}` + : ''; if (!confirm( `${BTJOB_CFG[strategy].label} 백그라운드 시작?\n${body.start}~${body.end}\n` + - `파라미터=현재 DB (+기간/유니버스)\n다른 전략 백테와 동시 실행 불가(전역 1잡).\n탭 이동 OK.` + (hasForm + ? `파라미터=현재 폼(+Optuna hidden 호가/휩쏘)${whipHint}` + : `파라미터=현재 DB (+기간/유니버스)`) + + `\n다른 전략 백테와 동시 실행 불가(전역 1잡).\n탭 이동 OK.` )) return; try { const r = await fetch('/api/backtest/job/start', { @@ -9876,6 +10031,8 @@ async function optunaBacktestFromCandidate(source, rank) { const cache = window._optunaCandidateCache; const strat = String(cache.strategy || curJob?.strategy || '').toLowerCase(); const p = cache.params || {}; + window._optunaBacktestObWhipOverrides = optunaObWhipOverridesFromParams(p); + applyOptunaObWhipToHiddenInputs(p, strat === 'tail' ? 'tl' : (strat === 'scalp' ? 'bt' : 'tl')); const job = curJob || {}; const trialNo = (j.metrics && j.metrics.optuna_trial_number != null) ? j.metrics.optuna_trial_number @@ -9916,7 +10073,18 @@ async function optunaBacktestFromCandidate(source, rank) { }; if (strat === 'momentum' || strat === 'us_momentum') { + applyOptunaObWhipToEditableForm(p, 'mom'); + if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('mom', p); await optunaFillFormFromCandidate(src, rk); + const ovSum = optunaObWhipOverrideSummary(window._optunaBacktestObWhipOverrides); + if (ovSum) { + alert( + (strat === 'us_momentum' ? '해외 모멘텀' : '모멘텀') + + ' 폼에 반영했습니다.\n' + + `trial #${trialNo} · 호가/휩쏘는 백테 실행 시 Optuna 값으로 1회 적용됩니다.\n${ovSum}\n` + + '(DB 저장 없음 — [백테 실행]으로 검증하세요)' + ); + } return; } @@ -9968,7 +10136,9 @@ async function optunaBacktestFromCandidate(source, rank) { `${tabLabel[strat] || strat} 백테 탭에 반영했습니다.\n` + `trial #${trialNo} · ${src} #${rk}\n` + `기간 ${cache.start || ''}~${cache.end || ''}\n` + - '호가/휩쏘 등 후처리 파라미터는 DB 기반으로 동작합니다.\n정확한 수익금 비교를 원하시면 Optuna 리스트에서 [적용(DB저장)]을 먼저 누른 후 백테스트를 실행하세요.' + (optunaObWhipOverrideSummary(window._optunaBacktestObWhipOverrides) + ? `호가/휩쏘 Optuna 1회 적용: ${optunaObWhipOverrideSummary(window._optunaBacktestObWhipOverrides)}\n[백테 실행] 시 위 값이 API로 전달됩니다 (DB 미변경).` + : '호가/휩쏘는 폼·DB 기본값으로 동작합니다.') ); } catch (e) { alert('백테 폼 반영 오류: ' + e); @@ -9985,12 +10155,14 @@ function fillBreakoutFormFromOptunaParams(p) { b.pg_filter_enabled = !!b._program_filter_enabled; } fillBreakoutFormFromApi(b); + applyOptunaObWhipToEditableForm(b, 'bo'); if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bo', b); } function fillTailFormFromOptunaParams(p) { if (!p || typeof p !== 'object') return; fillTailFormFromApi(p); + applyOptunaObWhipToEditableForm(p, 'tl'); if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('tl', p); } diff --git a/templates/backtest.html b/templates/backtest.html index 31a6f3d..862a0d8 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -1509,6 +1509,15 @@ +