diff --git a/backtest_web.py b/backtest_web.py index 1348492..5aef118 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -2723,6 +2723,7 @@ def _ob_whip_ui_fields_from_env(env: Dict[str, Any], prefix: str) -> Dict[str, A "whipsaw_subbar_sec": _i(f"{p}_WHIPSAW_SUBBAR_SEC", 30), "whipsaw_lookback_sec": _i(f"{p}_WHIPSAW_LOOKBACK_SEC", 60), "whipsaw_dip_pct": _f(f"{p}_WHIPSAW_DIP_PCT", 0.005), + "whipsaw_tol": _f(f"{p}_WHIPSAW_RECOVERY_TOL_PCT", 0.1), } diff --git a/database.py b/database.py index b4517e9..b0adb3a 100644 --- a/database.py +++ b/database.py @@ -648,6 +648,7 @@ ENV_CONFIG_KEYS = ( "SHORT_LIVE_UNIVERSE_HISTORY_SOURCE", "MOMENTUM_LIVE_UNIVERSE_HISTORY_SOURCE", "TAIL_BACKTEST_USE_TICK_EXIT", + "TAIL_BACKTEST_USE_SPILL_FALLBACK", "TAIL_BACKTEST_POLL_MS", "TAIL_BACKTEST_SELL_SLIP_PCT", "SCALP_BACKTEST_USE_TICK_EXIT", diff --git a/kis_rust_core/Cargo.toml b/kis_rust_core/Cargo.toml index 4fafd7a..7d1af4b 100644 --- a/kis_rust_core/Cargo.toml +++ b/kis_rust_core/Cargo.toml @@ -9,3 +9,6 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.20.0", features = ["extension-module"] } +serde = { version = "1.0", features = ["derive"] } +sqlx = { version = "0.7", features = ["mysql", "runtime-tokio-rustls"] } +tokio = { version = "1", features = ["full"] } diff --git a/kis_rust_core/src/db_loader.rs b/kis_rust_core/src/db_loader.rs new file mode 100644 index 0000000..dc40196 --- /dev/null +++ b/kis_rust_core/src/db_loader.rs @@ -0,0 +1,52 @@ +use pyo3::prelude::*; +use sqlx::mysql::MySqlPoolOptions; +use sqlx::Row; +use tokio::runtime::Runtime; +use crate::tail::CandleData; + +/// 파이썬에서 DB URL(DSN)을 받아 MariaDB에서 데이터를 직접 조회하여 반환 +#[pyfunction] +pub fn load_candles_from_db(dsn: &str, target_code: &str, limit: usize) -> PyResult> { + // pyo3에서 비동기 코드를 실행하기 위해 tokio 런타임 생성 + let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("런타임 에러: {}", e)))?; + + rt.block_on(async { + let pool = MySqlPoolOptions::new() + .max_connections(2) + .connect(dsn) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("DB 풀 생성 실패: {}", e)))?; + + // ls_ws_candles 예시 테이블에서 데이터 조회 + let query = format!( + "SELECT candle_time, open_price, high_price, low_price, close_price, volume, rsi + FROM ls_ws_candles + WHERE code = '{}' + ORDER BY candle_time DESC LIMIT {}", + target_code, limit + ); + + let rows = sqlx::query(&query) + .fetch_all(&pool) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("쿼리 실패: {}", e)))?; + + let mut candles = Vec::new(); + for row in rows { + let rsi: Option = row.try_get("rsi").unwrap_or(Some(50.0)); + candles.push(CandleData { + time_str: row.try_get("candle_time").unwrap_or_default(), + open: row.try_get("open_price").unwrap_or_default(), + high: row.try_get("high_price").unwrap_or_default(), + low: row.try_get("low_price").unwrap_or_default(), + close: row.try_get("close_price").unwrap_or_default(), + volume: row.try_get("volume").unwrap_or_default(), + rsi: rsi.unwrap_or(50.0), + }); + } + + // 시간순(오름차순) 정렬 + candles.reverse(); + Ok(candles) + }) +} diff --git a/kis_rust_core/src/lib.rs b/kis_rust_core/src/lib.rs index 4913a3d..f19ae31 100644 --- a/kis_rust_core/src/lib.rs +++ b/kis_rust_core/src/lib.rs @@ -1,5 +1,8 @@ use pyo3::prelude::*; +pub mod tail; +pub mod db_loader; + /// 파이썬에서 호출할 백테스트 벤치마크 함수 #[pyfunction] fn run_dummy_backtest(params: &str) -> PyResult { @@ -13,5 +16,9 @@ fn run_dummy_backtest(params: &str) -> PyResult { #[pymodule] fn kis_rust_core(_py: Python, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(run_dummy_backtest, m)?)?; + m.add_function(wrap_pyfunction!(tail::run_tail_backtest_fast, m)?)?; + m.add_function(wrap_pyfunction!(db_loader::load_candles_from_db, m)?)?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/kis_rust_core/src/tail.rs b/kis_rust_core/src/tail.rs new file mode 100644 index 0000000..0ea6466 --- /dev/null +++ b/kis_rust_core/src/tail.rs @@ -0,0 +1,97 @@ +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; + +#[pyclass] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TailParams { + #[pyo3(get, set)] + pub rsi_limit: f64, + #[pyo3(get, set)] + pub drop_pct_min: f64, + #[pyo3(get, set)] + pub tail_recovery_min: f64, + #[pyo3(get, set)] + pub target_pct: f64, + #[pyo3(get, set)] + pub stop_loss_pct: f64, +} + +#[pymethods] +impl TailParams { + #[new] + pub fn new(rsi_limit: f64, drop_pct_min: f64, tail_recovery_min: f64, target_pct: f64, stop_loss_pct: f64) -> Self { + Self { + rsi_limit, + drop_pct_min, + tail_recovery_min, + target_pct, + stop_loss_pct, + } + } +} + +/// 꼬리잡기용 캔들 정보 (간소화) +#[pyclass] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CandleData { + #[pyo3(get, set)] + pub time_str: String, + #[pyo3(get, set)] + pub open: f64, + #[pyo3(get, set)] + pub high: f64, + #[pyo3(get, set)] + pub low: f64, + #[pyo3(get, set)] + pub close: f64, + #[pyo3(get, set)] + pub volume: f64, + #[pyo3(get, set)] + pub rsi: f64, +} + +#[pymethods] +impl CandleData { + #[new] + pub fn new(time_str: String, open: f64, high: f64, low: f64, close: f64, volume: f64, rsi: f64) -> Self { + Self { time_str, open, high, low, close, volume, rsi } + } +} + +/// 단일 종목 백테스트 시뮬레이터 (꼬리잡기) +#[pyfunction] +pub fn run_tail_backtest_fast(params: &TailParams, candles: Vec) -> PyResult { + let mut pnl = 0.0; + let mut in_position = false; + let mut entry_price = 0.0; + + for candle in candles { + if !in_position { + // 진입 로직: 하락 후 꼬리 회복 (단순화된 휩쏘 흉내) + let drop = (candle.low - candle.open) / candle.open * 100.0; + let recovery = (candle.close - candle.low) / candle.open * 100.0; + + if candle.rsi < params.rsi_limit + && drop <= -params.drop_pct_min + && recovery >= params.tail_recovery_min + { + in_position = true; + entry_price = candle.close; + } + } else { + // 청산 로직 + let profit_pct = (candle.high - entry_price) / entry_price * 100.0; + let loss_pct = (candle.low - entry_price) / entry_price * 100.0; + + if profit_pct >= params.target_pct { + pnl += entry_price * (params.target_pct / 100.0); + in_position = false; + } else if loss_pct <= -params.stop_loss_pct { + pnl += entry_price * (-params.stop_loss_pct / 100.0); + in_position = false; + } + } + } + + Ok(pnl) +} diff --git a/kis_trader/backtest/breakout_tick_loader.py b/kis_trader/backtest/breakout_tick_loader.py index 73d7d31..08f4d59 100644 --- a/kis_trader/backtest/breakout_tick_loader.py +++ b/kis_trader/backtest/breakout_tick_loader.py @@ -209,6 +209,7 @@ def load_breakout_ticks_by_code( codes: Optional[Set[str]] = None, *, market: Optional[str] = None, + use_spill_fallback: bool = True, ) -> Tuple[Dict[str, Dict[str, List[Dict[str, Any]]]], int]: """ 기간 내 체결 틱을 종목·분봉(YYYYMMDDHHMM) 단위로 로드. @@ -322,9 +323,15 @@ def load_breakout_ticks_by_code( for _mk, ticks in list(minutes.items()): ticks.sort(key=lambda t: str(t.get("tick_time") or "")) if mkt != "US": - filtered = merge_ticks_time_axis_fallback(ticks, main_src=main_src) - minutes[_mk] = filtered - kept += len(filtered) + if use_spill_fallback: + filtered = merge_ticks_time_axis_fallback(ticks, main_src=main_src) + minutes[_mk] = filtered + kept += len(filtered) + else: + # 스필 OFF: 메인 소스만 필터 없이 그냥 사용 + filtered = [t for t in ticks if str(t.get("source") or "").strip().lower() == main_src] + minutes[_mk] = filtered if filtered else ticks # 메인이 없으면 보조라도 그대로 둠 + kept += len(minutes[_mk]) else: kept += len(ticks) if kept != total: @@ -335,8 +342,8 @@ def load_breakout_ticks_by_code( raw_before_merge = total total = kept - # 실매 3차 LS: 같은 초에 1·2차 없으면 ls_ws_ticks (나이=LIVE_FEED_FALLBACK, 기본 3초) - if mkt != "US" and get_env_bool("BT_TICK_LS_THIRD_FALLBACK", True): + # 실매 3차 LS: 스필 ON이고 같은 초에 1·2차 없으면 ls_ws_ticks + if use_spill_fallback and mkt != "US" and get_env_bool("BT_TICK_LS_THIRD_FALLBACK", True): try: from kis_trader.backtest.ls_history_loaders import load_ls_ticks_by_code diff --git a/kis_trader/backtest/tail_backtest_common.py b/kis_trader/backtest/tail_backtest_common.py index 3d04b57..ea2fbe4 100644 --- a/kis_trader/backtest/tail_backtest_common.py +++ b/kis_trader/backtest/tail_backtest_common.py @@ -629,7 +629,7 @@ def run_tail_backtest_web_aligned( meta_out: Optional[Dict[str, Any]] = None, ) -> List[Dict]: """엔진 1회 + 웹과 동일 손익 부착 (ws_ticks 리플레이 옵션).""" - from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay + from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay, tail_backtest_use_spill_fallback from kis_trader.backtest.tail_tick_loader import load_tail_ticks_by_code, tick_coverage_stats engine_params = dict(params) @@ -761,6 +761,7 @@ def run_tail_backtest_web_aligned( if db and start_key and end_key: loaded_ticks, tick_rows = load_tail_ticks_by_code( db, start_key, end_key, set(candles_by_code.keys()), + use_spill_fallback=tail_backtest_use_spill_fallback(engine_params), ) tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks) tick_meta["ws_tick_rows_loaded"] = tick_rows diff --git a/kis_trader/engine/tail_engine.py b/kis_trader/engine/tail_engine.py index 3e4e8e3..b3de513 100644 --- a/kis_trader/engine/tail_engine.py +++ b/kis_trader/engine/tail_engine.py @@ -317,6 +317,14 @@ def get_tail_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = No trail_pct = abs(tail_env_float(r, "TAIL_TRAIL_PCT", 0.0)) trail_arm_pct = abs(tail_env_float(r, "TAIL_TRAIL_ARM_PCT", 0.0)) _pat = _load_tail_pattern_params_from_row(r) + + whipsaw_filter = tail_env_bool(r, "TAIL_WHIPSAW_FILTER_ENABLED", False) + whipsaw_subbar_sec = tail_env_int(r, "TAIL_WHIPSAW_SUBBAR_SEC", 10) + whipsaw_lookback_sec = tail_env_int(r, "TAIL_WHIPSAW_LOOKBACK_SEC", 30) + whipsaw_dip_pct = tail_env_float(r, "TAIL_WHIPSAW_DIP_PCT", 0.5) + whipsaw_tol = tail_env_float(r, "TAIL_WHIPSAW_RECOVERY_TOL_PCT", 0.1) + min_bid_ask_ratio = tail_env_float(r, "TAIL_ORDERBOOK_MIN_BID_ASK_RATIO", 0.0) + ob_ask_max_mult = tail_env_float(r, "TAIL_ORDERBOOK_ENTRY_ASK_MAX_MULT", 0.0) else: min_drop, min_rec = 0.03, 0.5 tail_ratio, tail_pct = 1.5, 0.003 @@ -347,6 +355,11 @@ def get_tail_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = No backtest_tick_fallback_ohlc = False trail_pct, trail_arm_pct = 0.0, 0.0 _pat = _load_tail_pattern_params_from_row({}) + + whipsaw_filter = False + whipsaw_subbar_sec, whipsaw_lookback_sec = 10, 30 + whipsaw_dip_pct, whipsaw_tol = 0.5, 0.1 + min_bid_ask_ratio, ob_ask_max_mult = 0.0, 0.0 except Exception: min_drop, min_rec = 0.03, 0.5 tail_ratio, tail_pct = 1.5, 0.003 @@ -377,6 +390,11 @@ def get_tail_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = No backtest_tick_fallback_ohlc = False trail_pct, trail_arm_pct = 0.0, 0.0 _pat = _load_tail_pattern_params_from_row({}) + + whipsaw_filter = False + whipsaw_subbar_sec, whipsaw_lookback_sec = 10, 30 + whipsaw_dip_pct, whipsaw_tol = 0.5, 0.1 + min_bid_ask_ratio, ob_ask_max_mult = 0.0, 0.0 finally: if own_db is not None: try: @@ -473,6 +491,13 @@ def get_tail_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = No "eod_enabled": eod_enabled, "eod_hm": eod_hm, "force_eod_exit": eod_enabled, + "whipsaw_filter": whipsaw_filter, + "whipsaw_subbar_sec": whipsaw_subbar_sec, + "whipsaw_lookback_sec": whipsaw_lookback_sec, + "whipsaw_dip_pct": whipsaw_dip_pct, + "whipsaw_tol": whipsaw_tol, + "min_bid_ask_ratio": min_bid_ask_ratio, + "ob_ask_max_mult": ob_ask_max_mult, **_pat, } diff --git a/kis_trader/engine/tail_env_keys.py b/kis_trader/engine/tail_env_keys.py index ddd0154..7c87356 100644 --- a/kis_trader/engine/tail_env_keys.py +++ b/kis_trader/engine/tail_env_keys.py @@ -204,6 +204,7 @@ def params_to_tail_env_patch(p: Dict[str, Any]) -> Dict[str, str]: for js_k, env_k in ( ("backtest_use_tick_db", "TAIL_BACKTEST_USE_TICK_DB"), ("backtest_use_tick_exit", "TAIL_BACKTEST_USE_TICK_EXIT"), + ("backtest_use_spill_fallback", "TAIL_BACKTEST_USE_SPILL_FALLBACK"), ("backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC"), ): if js_k in p: @@ -398,7 +399,7 @@ def web_body_to_tail_env_patch(body: Dict[str, Any]) -> Dict[str, str]: "use_rsi_filter", "use_daily_range_filter", "use_high_chase_filter", "bar_chg_min_pct", "bar_chg_max_pct", "max_hold_bars", "tail_vol_mult", "tail_vol_win", - "backtest_use_tick_db", "backtest_use_tick_exit", "backtest_tick_fallback_ohlc", + "backtest_use_tick_db", "backtest_use_tick_exit", "backtest_use_spill_fallback", "backtest_tick_fallback_ohlc", "pattern_hammer", "pattern_pin", "pattern_engulfing", "pattern_piercing", "pattern_harami", "pattern_doji", "pattern_morning_star", "pin_close_upper_ratio", "pin_max_upper_tail_ratio", diff --git a/kis_trader/engine/tail_tick_replay.py b/kis_trader/engine/tail_tick_replay.py index 4a74613..1743bcc 100644 --- a/kis_trader/engine/tail_tick_replay.py +++ b/kis_trader/engine/tail_tick_replay.py @@ -42,6 +42,11 @@ def tail_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool return _param_bool(params, "backtest_use_tick_exit", "TAIL_BACKTEST_USE_TICK_EXIT", True) +def tail_backtest_use_spill_fallback(params: Optional[Dict[str, Any]] = None) -> bool: + """백테 틱 로드 시 3사 폴백(3초 지연 컷 간택) 시뮬레이션 사용 (기본 ON).""" + return _param_bool(params, "backtest_use_spill_fallback", "TAIL_BACKTEST_USE_SPILL_FALLBACK", True) + + def tail_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: """해당 구간 틱 없을 때 3분봉 OHLC 폴백 (기본 OFF — 유령거래 방지).""" return _param_bool(params, "backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", False) diff --git a/static/js/backtest.js b/static/js/backtest.js index 2b463ef..33aff90 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -3895,6 +3895,7 @@ async function usmomStockOptunaStop() { } catch (e) { alert('오류: ' + e); } } +let _usmomPageLoadTime = Date.now(); async function usmomStockOptunaPollOnce() { if (!_usmomOptJobId) return; try { @@ -3910,11 +3911,15 @@ async function usmomStockOptunaPollOnce() { if ($('usmom_opt_prog_label')) $('usmom_opt_prog_label').textContent = 'trial —'; return; } - usmomStockOptunaRender(j.job); const st = j.job.status; if (st === 'done' || st === 'error') { if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; } } + if (Date.now() - _usmomPageLoadTime < 3000 && (st === 'done' || st === 'error')) { + // 초기 로딩 시 무거운 렌더링 스킵 + } else { + usmomStockOptunaRender(j.job); + } } catch (e) { usmomOptSetStatus('폴링 오류: ' + e, true); } @@ -4913,6 +4918,7 @@ function fillTailFormFromApi(t) { if (t.tail_vol_mult != null) set('tl_tail_vol_mult', t.tail_vol_mult); if (t.tail_vol_win != null) set('tl_tail_vol_win', t.tail_vol_win); if ($('tl_use_tick_db')) $('tl_use_tick_db').checked = t.backtest_use_tick_db !== false; + if ($('tl_use_spill_fallback')) $('tl_use_spill_fallback').checked = t.backtest_use_spill_fallback !== false; if ($('tl_tick_fallback_ohlc')) $('tl_tick_fallback_ohlc').checked = !!t.backtest_tick_fallback_ohlc; if ($('tl_pat_hammer')) $('tl_pat_hammer').checked = t.pattern_hammer !== false; if ($('tl_pat_pin')) $('tl_pat_pin').checked = !!t.pattern_pin; @@ -4950,6 +4956,17 @@ function fillTailFormFromApi(t) { if ($('tl_daily_trail_drop') && t.daily_trail_drop_pct != null) { $('tl_daily_trail_drop').value = String(t.daily_trail_drop_pct); } + + if (t.whipsaw_filter_enabled !== undefined && $('tl_h_whipsaw_filter')) { + $('tl_h_whipsaw_filter').value = t.whipsaw_filter_enabled ? '1 (ON)' : '0 (OFF)'; + } + set('tl_h_whipsaw_sub', t.whipsaw_subbar_sec); + set('tl_h_whipsaw_lb', t.whipsaw_lookback_sec); + set('tl_h_whipsaw_dip', t.whipsaw_dip_pct); + set('tl_h_whipsaw_tol', t.whipsaw_tol); + set('tl_h_min_bid_ask_ratio', t.ob_min_bid_ask_ratio); + set('tl_h_ob_ask_max_mult', t.ob_ask_max_mult); + // DB(env)에 저장된 사용자 프리셋 목록(세미콜론) → 드롭다운 ★옵션 복원 btFillPresetOptions('tl_ratchet_preset', t.ratchet_presets); btFillPresetOptions('tl_daily_trail_preset', t.daily_trail_presets); @@ -5541,6 +5558,7 @@ function saveTailConfig() { trail_pct: parseFloat($('tl_trail')?.value || '0'), trail_arm_pct: parseFloat($('tl_trail_arm')?.value || '0'), backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false, + backtest_use_spill_fallback: $('tl_use_spill_fallback')?.checked !== false, backtest_tick_fallback_ohlc: !!$('tl_tick_fallback_ohlc')?.checked, pattern_hammer: $('tl_pat_hammer')?.checked !== false, pattern_pin: !!$('tl_pat_pin')?.checked, @@ -6352,6 +6370,7 @@ function collectTailBacktestFormParams() { trail_pct: $('tl_trail')?.value || '0', trail_arm_pct: $('tl_trail_arm')?.value || '0', backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false ? '1' : '0', + backtest_use_spill_fallback: $('tl_use_spill_fallback')?.checked !== false ? '1' : '0', backtest_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.checked ? '1' : '0', pattern_hammer: $('tl_pat_hammer')?.checked !== false ? '1' : '0', pattern_pin: $('tl_pat_pin')?.checked ? '1' : '0', @@ -6402,13 +6421,14 @@ let _btJobId = localStorage.getItem(BTJOB_LS_KEY) || localStorage.getItem(BTJOB_LS_KEY_LEGACY) || ''; let _btJobRenderedId = ''; let _btJobStrategy = ''; +let _btJobPageLoadTime = Date.now(); const BTJOB_CFG = { tail: { tab: 'tail', label: '꼬리 백테', startId: 'tl_start', endId: 'tl_end', tfId: 'tl_tf', - univId: 'tl_use_univ_history', univSrcId: 'tl_univ_history_source', tickId: 'tl_use_tick_db', + univId: 'tl_use_univ_history', univSrcId: 'tl_univ_history_source', tickId: 'tl_use_tick_db', spillId: 'tl_use_spill_fallback', envId: 'tl_env_timeline', obId: 'tl_ob_filter', stopBtn: 'tl_btn_stop_bg', tabFill: 'tlbt_tab_fill', tabStatus: 'tlbt_tab_status', clearPrefix: 'tl_', @@ -6450,6 +6470,15 @@ function btJobGoTab(strat) { const s = strat || _btJobStrategy || 'tail'; const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail; btShowTab(cfg.tab); + + // 만약 초기 로딩 스킵된 잡이라면 수동 렌더링 + if (_btJobRenderedId === 'skipped_' + _btJobId) { + _btJobRenderedId = _btJobId; // 중복방지 + btJobLoadResult(_btJobId, s).catch(e => { + console.warn('Manual btJobLoadResult failed', e); + _btJobRenderedId = 'skipped_' + _btJobId; // 실패시 원복 + }); + } } function btJobClearUi(strat, msg) { @@ -6581,13 +6610,19 @@ async function btJobPollOnce() { btJobSetNav(job); const st = job.status; if (st === 'done' && _btJobRenderedId !== jid) { - try { - await btJobLoadResult(jid, job.strategy); - if (jid !== _btJobId) return; - _btJobRenderedId = jid; - try { btJobGoTab(job.strategy); } catch (e2) {} - } catch (e) { - console.warn('btJobLoadResult', e); + if (Date.now() - _btJobPageLoadTime < 3000) { + // 새로고침 직후에는 무거운 전체 로그 렌더링과 탭 강제 전환 스킵 + _btJobRenderedId = 'skipped_' + jid; + } else { + try { + await btJobLoadResult(jid, job.strategy); + if (jid !== _btJobId) return; + _btJobRenderedId = jid; + // 초기 로딩 시 사용자 화면을 강제로 채가지 않도록 탭 이동 삭제 + // try { btJobGoTab(job.strategy); } catch (e2) {} + } catch (e) { + console.warn('btJobLoadResult', e); + } } } } catch (e) { /* ignore */ } @@ -10451,6 +10486,7 @@ async function optunaApplyUpto(source, rank, upto) { if (wantTab) { const tab = document.querySelector(`[data-tab="${wantTab}"]`); if (tab && !tab.classList.contains('active')) tab.click(); + if (typeof openDbDebugger === 'function') setTimeout(openDbDebugger, 100); } alert( `✅ ${stratLabel} 적용 완료 (upto=${j.upto || u})` + @@ -10686,6 +10722,7 @@ async function optunaStart() { candle_source: candleSrc, tick_source: tickSrc, ob_source: obSrc, + use_spill_fallback: $('opt_use_spill_fallback')?.checked, entry_modes: strategies.includes('tail') ? entryModes : undefined, sl_modes: strategies.includes('breakout') ? slModes : undefined, ob_modes: strategies.includes('breakout') ? obModes : undefined, @@ -11333,3 +11370,197 @@ async function compareSelectedJobs() { } } +// ========================================== +// DB 디버거 플로팅 패널 관련 스크립트 +// ========================================== +let dbDebuggerVisible = false; + +function toggleDbDebugger() { + const panel = document.getElementById('btDbDebuggerPanel'); + if (!panel) return; + dbDebuggerVisible = !dbDebuggerVisible; + panel.style.display = dbDebuggerVisible ? 'flex' : 'none'; + if (dbDebuggerVisible) { + refreshDbDebugger(); + } else { + // 닫을 때 붉은 테두리 해제 + document.querySelectorAll('.db-debug-error').forEach(el => el.classList.remove('db-debug-error')); + } +} + +function openDbDebugger() { + const panel = document.getElementById('btDbDebuggerPanel'); + if (!panel) return; + dbDebuggerVisible = true; + panel.style.display = 'flex'; + refreshDbDebugger(); +} + +async function refreshDbDebugger() { + if (!dbDebuggerVisible) return; + const listContainer = document.getElementById('btDbDebuggerList'); + if (!listContainer) return; + + listContainer.innerHTML = '
데이터 로딩 중...
'; + + // 현재 활성화된 탭 파악 + let activeTab = 'tail'; // 기본값 + const tabs = { + 'tail': '꼬리잡기', + 'scalp': '스캘핑', + 'momentum': '모멘텀', + 'range_break': '돌파' + }; + + for (const tabId of Object.keys(tabs)) { + const el = document.getElementById(`tab-${tabId}`); + if (el && el.style.display !== 'none') { + activeTab = tabId; + break; + } + } + + const titleEl = document.getElementById('btDbDebugStrategy'); + if (titleEl) titleEl.innerText = `(${tabs[activeTab] || activeTab})`; + + try { + const r = await fetch('/api/env/params?fresh=1&_=' + Date.now()); + const d = await r.json(); + let dbData = d[activeTab] || {}; + + // UI 매핑 테이블 생성 (탭별 주요 인풋 ID 매핑) + let mapping = {}; + if (activeTab === 'tail') { + mapping = { + 'drop': 'tl_drop', 'rec': 'tl_rec', 'tail_ratio': 'tl_tail', 'sl_pct': 'tl_sl', + 'tp_pct': 'tl_tp', 'smin': 'tl_smin', 'scut': 'tl_scut', 'cool': 'tl_cool', + 'rsi': 'tl_rsi', 'time_start': 'tl_ts', 'time_end': 'tl_te', 'max_daily': 'tl_maxd', + 'rsi_period': 'tl_rsi_period', 'tail_pct_min': 'tl_tail_pct', 'max_rec_3m': 'tl_max_rec_3m', + 'high_chase': 'tl_high_chase', 'whipsaw_filter_enabled': 'tl_h_whipsaw_filter', + 'whipsaw_subbar_sec': 'tl_h_whipsaw_sub', 'whipsaw_lookback_sec': 'tl_h_whipsaw_lb', + 'whipsaw_dip_pct': 'tl_h_whipsaw_dip', 'whipsaw_tol': 'tl_h_whipsaw_tol', + 'ob_min_bid_ask_ratio': 'tl_h_min_bid_ask_ratio', 'ob_ask_max_mult': 'tl_h_ob_ask_max_mult', + 'min_price': 'tl_min_price', 'max_daily_change': 'tl_max_daily_change', 'ma20_max_above': 'tl_ma20_above', + 'max_loss_krw': 'tl_max_loss_krw', 'stop_atr_mult': 'tl_stop_atr', 'target_atr_mult': 'tl_target_atr', + 'atr_sl_min_pct': 'tl_atr_sl_min', 'atr_sl_max_pct': 'tl_atr_sl_max', 'atr_tp_min_pct': 'tl_atr_tp_min', 'atr_tp_max_pct': 'tl_atr_tp_max', + 'limit_atr_mult': 'tl_limit_atr_mult', 'limit_valid_bars': 'tl_limit_valid_bars', 'limit_fill_slip_pct': 'tl_limit_fill_slip', + 'symbol_daily_loss_limit_krw': 'tl_symbol_loss_krw', 'symbol_daily_loss_limit_pct': 'tl_symbol_loss_pct', 'reentry_min_edge_krw': 'tl_reentry_min_edge', + 'eod_enabled': 'tl_eod_enabled', 'eod_hm': 'tl_eod_hm' + }; + } else if (activeTab === 'momentum') { + mapping = { + 'drop': 'mom_drop', 'rec': 'mom_rec', 'tail_ratio': 'mom_tail', 'sl_pct': 'mom_sl', + 'tp_pct': 'mom_tp', 'smin': 'mom_smin', 'scut': 'mom_scut', 'cool': 'mom_cool', + 'rsi': 'mom_rsi' + }; + } else if (activeTab === 'scalp') { + mapping = { + 'drop': 'sc_drop', 'rec': 'sc_rec', 'tail_ratio': 'sc_tail', 'sl_pct': 'sc_sl', 'tp_pct': 'sc_tp' + }; + } else if (activeTab === 'range_break') { + mapping = { + 'drop': 'rb_drop', 'rec': 'rb_rec', 'sl_pct': 'rb_sl', 'tp_pct': 'rb_tp' + }; + } + + const DB_CAPTION = { + 'drop': '낙폭(%)', 'rec': '회복률(배)', 'tail_ratio': '꼬리비율(배)', 'sl_pct': '손절(%)', 'tp_pct': '익절(%)', + 'smin': '어깨 최소수익(%)', 'scut': '어깨 컷(%)', 'cool': '쿨다운(분)', 'rsi': 'RSI 임계값', + 'time_start': '매매 시작시간', 'time_end': '종료시간', 'max_daily': '일최대 매수횟수', + 'rsi_period': 'RSI 기준봉수', 'tail_pct_min': '최소 꼬리길이(%)', 'max_rec_3m': '3분 회복률', + 'high_chase': '고점추격 한계', 'whipsaw_filter_enabled': '휩쏘필터 켜기', + 'whipsaw_subbar_sec': '휩쏘 서브바(초)', 'whipsaw_lookback_sec': '휩쏘 룩백(초)', + 'whipsaw_dip_pct': '휩쏘 웅덩이 Dip(%)', 'whipsaw_tol': '휩쏘 오차 Tol(%)', + 'ob_min_bid_ask_ratio': '호가 매수/매도 잔량비', 'ob_ask_max_mult': '호가 매도잔량 배수 상한', + 'min_price': '최소가격(원)', 'max_daily_change': '일간최대변동(%)', 'ma20_max_above': 'MA20 이격도(%)', + 'max_loss_krw': '일일 최대손실(원)', 'stop_atr_mult': '손절 ATR 배수', 'target_atr_mult': '익절 ATR 배수', + 'atr_sl_min_pct': 'ATR손절 최소(%)', 'atr_sl_max_pct': 'ATR손절 최대(%)', + 'atr_tp_min_pct': 'ATR익절 최소(%)', 'atr_tp_max_pct': 'ATR익절 최대(%)', + 'limit_atr_mult': '지정가 ATR 배수', 'limit_valid_bars': '지정가 유효봉수', 'limit_fill_slip_pct': '지정가 슬리피지(%)', + 'symbol_daily_loss_limit_krw': '종목별 1일 손실액(원)', 'symbol_daily_loss_limit_pct': '종목별 1일 손실률(%)', + 'reentry_min_edge_krw': '재진입 최소수익(원)', 'eod_enabled': '당일정산(EOD) 켜기', 'eod_hm': '당일정산 시각' + }; + + listContainer.innerHTML = ''; + + // CSS 인젝션 (기존 테두리 덮어쓰지 않고 outline 과 배경색만 살짝) + if (!document.getElementById('dbDebugStyle')) { + const style = document.createElement('style'); + style.id = 'dbDebugStyle'; + style.innerHTML = '.db-debug-error { outline: 2px dashed #ff7b72 !important; outline-offset: 1px !important; background-color: rgba(255,123,114,0.1) !important; transition: all 0.2s; border-color: inherit; }'; + document.head.appendChild(style); + } + + // 기존 에러 클래스 리셋 + document.querySelectorAll('.db-debug-error').forEach(el => el.classList.remove('db-debug-error')); + + let html = ''; + for (const [dbKey, dbVal] of Object.entries(dbData)) { + const domId = mapping[dbKey]; + const domEl = domId ? document.getElementById(domId) : null; + let domVal = domEl ? domEl.value : '-'; + + let isMismatch = false; + let displayDbVal = dbVal; + let displayDomVal = domVal; + + if (typeof dbVal === 'boolean' || dbKey.includes('enabled')) { + displayDbVal = dbVal ? '1 (ON)' : '0 (OFF)'; + } + + if (domEl && domEl.type === 'checkbox') { + displayDomVal = domEl.checked ? 'true' : 'false'; + isMismatch = (String(dbVal) !== displayDomVal && !!dbVal !== domEl.checked); + } else if (domEl) { + let strDb = String(displayDbVal).trim(); + let strDom = String(displayDomVal).trim(); + + if (strDom === '1 (ON)') strDom = '1'; + if (strDom === '0 (OFF)') strDom = '0'; + if (strDb === '1 (ON)') strDb = '1'; + if (strDb === '0 (OFF)') strDb = '0'; + + if (!isNaN(parseFloat(strDb)) && !isNaN(parseFloat(strDom))) { + if (Math.abs(parseFloat(strDb) - parseFloat(strDom)) > 0.0001) isMismatch = true; + } else { + if (strDb !== strDom) isMismatch = true; + } + } + + if (isMismatch && domEl) { + domEl.classList.add('db-debug-error'); + } + + let cardBg = isMismatch ? 'rgba(255,123,114,0.08)' : 'rgba(255,255,255,0.03)'; + let cardBorder = isMismatch ? '1px solid rgba(255,123,114,0.3)' : '1px solid rgba(255,255,255,0.05)'; + let textColor = isMismatch ? '#ff7b72' : '#c9d1d9'; + let caption = DB_CAPTION[dbKey] || '설명 없음'; + let errorIndicator = isMismatch ? '❌' : '✅'; + if (!domEl) errorIndicator = '➖'; + + html += ` +
+
+ ${caption} + ${dbKey} +
+
+
+ 서버: ${displayDbVal} +
+
+ 화면: ${domEl ? displayDomVal : '매핑없음'} ${errorIndicator} +
+
+
+ `; + } + + listContainer.innerHTML = html || '
데이터가 없습니다.
'; + + } catch (e) { + console.error('DB 디버거 오류:', e); + listContainer.innerHTML = '
데이터 로딩 에러!
'; + } +} + diff --git a/templates/backtest.html b/templates/backtest.html index 2d1e9cd..fc60115 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -1148,7 +1148,7 @@
- + @@ -1174,12 +1174,19 @@
- +
+
+ + +
@@ -1391,7 +1398,7 @@
- +
@@ -1405,13 +1412,13 @@
TRIGGER 필터 — 매수 직전 엔진 검사 (HTS tail 조건 통과 후)
- +
- +
@@ -1423,13 +1430,13 @@
- +
- +
@@ -1475,7 +1482,7 @@
반전 패턴 — 하나라도 충족 시 진입 (env TAIL_PATTERN_*)
- +
@@ -1516,14 +1523,42 @@
-