From 93c6f711fa36f48972d51241fde791fbe8e5c107 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 22:12:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=8A=AC?= =?UTF-8?q?=EB=A6=AC=ED=94=BC=EC=A7=80=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81(UI/=EB=B0=B1=EC=97=94=EB=93=9C)=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=B0=8F=20Rust=20=EC=97=94=EC=A7=84=20=ED=8F=AC=ED=8C=85?= =?UTF-8?q?=20Phase=201=20=EC=85=8B=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backtest_web.py | 4 +++ docs/like_mcp.md/slippage_improvement.md | 23 +++++++++++++++ kis_rust_core/.gitignore | 2 ++ kis_rust_core/Cargo.toml | 11 ++++++++ kis_rust_core/src/lib.rs | 17 +++++++++++ kis_trader/backtest/optuna_web_jobs.py | 6 +++- kis_trader/web/feed_collect_stats.py | 20 +++++++++++++ static/js/backtest.js | 36 +++++++++++++++++++++++- templates/backtest.html | 7 +++++ 9 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 docs/like_mcp.md/slippage_improvement.md create mode 100644 kis_rust_core/.gitignore create mode 100644 kis_rust_core/Cargo.toml create mode 100644 kis_rust_core/src/lib.rs diff --git a/backtest_web.py b/backtest_web.py index e946c00..1348492 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -849,6 +849,9 @@ def api_actual(): # 해외(US_*)는 달러 소수 유지 — 정수원 반올림 금지 _is_us_strat = str(strategy or "").upper().startswith("US_") _im = row.get("is_mock") + target_px = float(row.get("target_price") or 0) + slippage_pct = ((buy_px - target_px) / target_px * 100.0) if target_px > 0 and buy_px > 0 else 0.0 + open_trades.append({ "code": row.get("code"), "name": row.get("name"), @@ -859,6 +862,7 @@ def api_actual(): "realized_pnl": None, "unrealized_pnl": (round(unrealized, 4) if _is_us_strat else round(unrealized)), "profit_rate": round(profit_rate, 2), + "slippage_pct": round(slippage_pct, 3) if slippage_pct != 0 else None, "hold_minutes": hold_min, "buy_date": bd_str, "sell_date": None, diff --git a/docs/like_mcp.md/slippage_improvement.md b/docs/like_mcp.md/slippage_improvement.md new file mode 100644 index 0000000..4d1b4fd --- /dev/null +++ b/docs/like_mcp.md/slippage_improvement.md @@ -0,0 +1,23 @@ +# 실매매 슬리피지(Slippage) 정확도 개선 요건 + +현재 백테스트 웹 모니터링에서 제공하는 슬리피지(Slippage) 수치는 `target_price`(수익 실현 목표가)를 기준으로 계산되어 실제 호가 괴리율을 대변하지 못하는 문제가 있습니다. +이를 해결하고 정확한 슬리피지를 추적하기 위해 다음 개선 작업이 필요합니다. + +## 1. DB 스키마 개선 +- **`active_trades` 테이블**: 매수 주문 생성 당시의 "주문 의도 가격"을 저장할 `order_intent_price` (또는 `entry_intent_price`) 컬럼을 FLOAT 형태로 추가. +- **`trade_history` 테이블**: 위와 동일하게 `order_intent_price` 컬럼을 추가. + +## 2. 실매매 봇 엔진 수정 (Order Flow) +- 주문 모듈(예: `kis_order.py` 또는 `order_worker.py`)에서 시장가(IOC/FOK) 매수 주문을 KIS API로 쏘는 바로 그 시점의 **1호가 매도 잔량 가격(또는 최우선 매수 기준가)**을 포착. +- 해당 가격을 DB `active_trades` INSERT/UPDATE 시 `order_intent_price`에 기록. +- (기존 `target_price`는 순수한 '익절 목표가' 용도로만 유지) + +## 3. 청산 모듈 (Trade History 이관) +- `database.py`의 `close_trade` 함수에서 `active_trades`의 데이터를 `trade_history`로 옮겨 담을 때, `order_intent_price` 값도 함께 복사(INSERT)되도록 쿼리 수정. + +## 4. 백테스트 웹 API 및 프론트엔드 연동 +- `backtest_web.py`의 `api_actual` 및 `feed_collect_stats.py`에서 슬리피지 계산 공식을 변경: + - **이전**: `(buy_price - target_price) / target_price * 100` + - **변경**: `(buy_price - order_intent_price) / order_intent_price * 100` +- 변경된 공식으로 계산된 슬리피지는 "주문을 넣으려던 호가 대비 실제 체결가가 얼마나 밀렸는지(혹은 유리하게 잡혔는지)"를 정확히 대변하게 됩니다. +- 프론트엔드의 툴팁과 안내 문구를 "주문 당시 호가 대비 체결 밀림 현상"으로 갱신. diff --git a/kis_rust_core/.gitignore b/kis_rust_core/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/kis_rust_core/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/kis_rust_core/Cargo.toml b/kis_rust_core/Cargo.toml new file mode 100644 index 0000000..4fafd7a --- /dev/null +++ b/kis_rust_core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "kis_rust_core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "kis_rust_core" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.20.0", features = ["extension-module"] } diff --git a/kis_rust_core/src/lib.rs b/kis_rust_core/src/lib.rs new file mode 100644 index 0000000..4913a3d --- /dev/null +++ b/kis_rust_core/src/lib.rs @@ -0,0 +1,17 @@ +use pyo3::prelude::*; + +/// 파이썬에서 호출할 백테스트 벤치마크 함수 +#[pyfunction] +fn run_dummy_backtest(params: &str) -> PyResult { + // 실제로는 파라미터를 파싱하고 루프를 돌아야 하지만, 지금은 연결 테스트용 + println!("[Rust] 파이썬으로부터 받은 파라미터: {}", params); + let dummy_pnl = 150000.0; // 15만원 + Ok(dummy_pnl) +} + +/// 모듈 초기화 +#[pymodule] +fn kis_rust_core(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(run_dummy_backtest, m)?)?; + Ok(()) +} diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index 80913e3..d13ff0b 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -392,8 +392,12 @@ def filter_redundant_refine1_jobs(jobs: List[Dict[str, Any]]) -> List[Dict[str, def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]: _ensure_dirs() + paths = list(JOBS_DIR.glob("*.json")) + paths.sort(key=lambda x: x.stat().st_mtime, reverse=True) + out: List[Dict[str, Any]] = [] - for p in JOBS_DIR.glob("*.json"): + # 파일이 많을 때 전체 파싱을 막기 위해 최근 100개(limit 30 기준 넉넉히)만 메모리에 올림 + for p in paths[:100]: try: out.append(json.loads(p.read_text(encoding="utf-8"))) except Exception: diff --git a/kis_trader/web/feed_collect_stats.py b/kis_trader/web/feed_collect_stats.py index 3fc0f47..17b4d6e 100644 --- a/kis_trader/web/feed_collect_stats.py +++ b/kis_trader/web/feed_collect_stats.py @@ -1426,9 +1426,29 @@ def _build_feed_collect_stats_inner( max_age_sec=max_age, ), ] + try: + slip_rows = db.conn.execute( + "SELECT avg_buy_price, target_price FROM active_trades WHERE buy_date >= %s", + (f"{day8[:4]}-{day8[4:6]}-{day8[6:8]} 00:00:00",) + ).fetchall() + slips = [] + for r in slip_rows: + r = dict(r) + bp = float(r.get("avg_buy_price") or 0) + tp = float(r.get("target_price") or 0) + if tp > 0 and bp > 0: + slips.append((bp - tp) / tp * 100.0) + avg_slippage = round(sum(slips) / len(slips), 3) if slips else None + slippage_count = len(slips) + except Exception as e: + avg_slippage = None + slippage_count = 0 + vendor_matrix = { "day8": day8, "day": f"{day8[:4]}-{day8[4:6]}-{day8[6:8]}", + "avg_slippage_pct": avg_slippage, + "slippage_count": slippage_count, "vendors": [ _pack_vendor( "kis", diff --git a/static/js/backtest.js b/static/js/backtest.js index 55b316e..2b463ef 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -206,6 +206,7 @@ function normalizeVirtualTrade(t, meta) { : (Number(t.is_mock) === 1 ? '모의' : '실전') ), isMock: t.is_mock, + slippagePct: t.slippage_pct, }; } @@ -550,11 +551,16 @@ function renderVirtualTrades(tbodyId, trades, opts) { : (r.code || '-'); const linksHtml = _tradeExtLinksHtml(r); const reasonHtml = `${r.reason || '-'}`; - const debugHtml = r.debugTick + let debugHtml = r.debugTick ? `${r.debugTick}` : (r.entrySource || r.exitSource || r.obSource ? `틱:${r.entrySource || '?'} / 호가:${r.obSource || '?'} → ${r.exitSource || '?'}` : '-'); + if (r.slippagePct != null) { + const slipSign = r.slippagePct > 0 ? '+' : ''; + const slipColor = r.slippagePct > 0 ? 'var(--pnl-neg)' : 'var(--pnl-pos)'; + debugHtml += `
슬리피지: ${slipSign}${r.slippagePct.toFixed(3)}%`; + } const rowFx = getDisplayFxRate(r.fxRate || defaultFx); const cumPnlHtml = Number.isFinite(r.cumPnl) ? `${fmtPnlCell(r.cumPnl, rowFx)}` @@ -911,6 +917,21 @@ async function fsLoad(heavy) { return '틱동기 부분'; } const vm = j.vendor_matrix || {}; + + // ⚡ 전역 시스템 모니터링 배지 업데이트 (Slippage) + const sysPerfBadge = $('nav_sys_perf'); + if (sysPerfBadge) { + if (vm.avg_slippage_pct != null) { + const sign = vm.avg_slippage_pct > 0 ? '+' : ''; + sysPerfBadge.textContent = `⚡ Slippage: ${sign}${vm.avg_slippage_pct.toFixed(3)}% (${vm.slippage_count}건)`; + // 양수(손실)면 bg-danger, 음수(이득)면 bg-success + sysPerfBadge.className = `badge ms-2 ${vm.avg_slippage_pct > 0 ? 'bg-danger' : 'bg-success'}`; + } else { + sysPerfBadge.textContent = '⚡ Slippage: -'; + sysPerfBadge.className = 'badge bg-secondary ms-2'; + } + } + const vTb = $('fs_vendor_tbody'); if (vTb) { const vendors = vm.vendors || []; @@ -2481,6 +2502,19 @@ function renderActual(d) { $('a_hold').textContent = s.avg_hold_min + '분'; + // 슬리피지 집계 (open_trades 중 값이 있는 것만 평균 계산) + const trades = d.trades || []; + const slips = trades.filter(t => t.slippage_pct != null).map(t => t.slippage_pct); + if (slips.length > 0) { + const avgSlip = slips.reduce((sum, val) => sum + val, 0) / slips.length; + const sign = avgSlip > 0 ? '+' : ''; + $('a_slippage').textContent = `${sign}${avgSlip.toFixed(3)}%`; + // 양수면 손실이므로 빨간색, 음수면 이득이므로 초록색 + colorPnl($('a_slippage'), -avgSlip); + } else { + $('a_slippage').textContent = '-'; + } + // 운용한도 대비 수익률 — API summary에 없으면 params.total_budget_krw 로 계산 const aSign = (v) => (v > 0 ? '+' : ''); let botPct = s.bot_pct; diff --git a/templates/backtest.html b/templates/backtest.html index 862a0d8..2d1e9cd 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -45,6 +45,10 @@
+ + + ⚡ Slippage: - +
평균 보유(분)
-
+
+
평균 슬리피지
-
+
총 수익률(운용한도 대비)
-