From fc27e726f96b0a49d0548f75c6e1b59379380db8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 17 Jul 2026 01:09:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EA=B7=9C=EC=B9=99=20=EB=B0=8F=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9=EC=9D=84=20=ED=86=B5=ED=95=9C=20?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=EB=94=A9=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함. --- .cursor/rules/agent-shell-python-safety.mdc | 45 + .cursor/rules/backtest-web-restart.mdc | 46 + .cursor/rules/db-adhoc-query-safety.mdc | 50 + .cursor/rules/hts-condition-grids.mdc | 65 + .cursor/rules/live-backtest-optuna-parity.mdc | 61 + .../rules/optuna-backtest-token-savings.mdc | 41 + .cursorrules | 50 + __pycache__/database.cpython-312.pyc | Bin 160218 -> 169125 bytes backtest_web.py | 648 +- database.py | 7468 +++++++++-------- deploy/kis_error_watch_mm.service | 15 + docs/BREAKOUT_LIVE_BT_QA.md | 122 + kis_approval_manager.py | 277 + kis_trader/backtest/backtest_env_timeline.py | 158 + .../backtest/backtest_portfolio_common.py | 220 +- .../backtest/breakout_backtest_common.py | 270 +- .../backtest/breakout_portfolio_backtest.py | 97 +- kis_trader/backtest/breakout_tick_loader.py | 285 +- kis_trader/backtest/dbband_backtest_common.py | 3 +- kis_trader/backtest/env_timeline.py | 106 + .../backtest/momentum_backtest_common.py | 213 +- .../backtest/momentum_portfolio_backtest.py | 38 + .../backtest/momentum_random_benchmark.py | 12 +- kis_trader/backtest/momentum_rr_crossval.py | 22 +- kis_trader/backtest/momentum_rr_quick_eval.py | 7 +- kis_trader/backtest/momentum_tick_loader.py | 9 +- kis_trader/backtest/optuna_breakout.py | 184 +- kis_trader/backtest/optuna_common.py | 67 +- kis_trader/backtest/optuna_mode_combo.py | 235 + kis_trader/backtest/optuna_momentum.py | 215 +- kis_trader/backtest/optuna_scalping.py | 520 ++ kis_trader/backtest/optuna_search_space.py | 11 + .../backtest/param_search_apply_snapshot.py | 96 +- kis_trader/backtest/param_search_breakout.py | 449 +- kis_trader/backtest/param_search_dates.py | 94 + kis_trader/backtest/param_search_momentum.py | 680 +- kis_trader/backtest/param_search_optuna.py | 338 +- .../backtest/param_search_range_break.py | 6 +- kis_trader/backtest/param_search_scalping.py | 830 +- kis_trader/backtest/param_search_updow.py | 8 +- kis_trader/backtest/param_search_updow_us.py | 13 +- .../backtest/param_search_updown_box.py | 8 +- .../backtest/range_break_backtest_common.py | 49 +- .../range_break_portfolio_backtest.py | 116 +- .../backtest/scalping_backtest_common.py | 58 + .../backtest/scalping_portfolio_backtest.py | 153 +- kis_trader/backtest/tail_backtest_common.py | 391 +- kis_trader/backtest/tail_mfe_analysis.py | 3 +- kis_trader/backtest/tail_param_search.py | 952 ++- kis_trader/backtest/tail_tick_loader.py | 8 +- kis_trader/backtest/universe_timeline.py | 25 + kis_trader/database/db_manager.py | 148 +- kis_trader/engine/candle_rollup.py | 158 + kis_trader/engine/daily_profit_halt.py | 10 +- kis_trader/engine/dbband_engine.py | 119 +- kis_trader/engine/momentum_engine.py | 488 +- kis_trader/engine/momentum_env_keys.py | 33 +- kis_trader/engine/momentum_hts_logic.py | 456 + kis_trader/engine/momentum_tick_replay.py | 28 +- kis_trader/engine/orderbook_env.py | 187 + kis_trader/engine/orderbook_filter.py | 252 +- .../engine/post_sell_candle_backfill.py | 417 + kis_trader/engine/program_filter.py | 101 +- kis_trader/engine/range_break_engine.py | 39 +- kis_trader/engine/scalping_engine.py | 267 +- kis_trader/engine/strategy_eod.py | 17 +- kis_trader/engine/tail_engine.py | 1064 ++- kis_trader/engine/tail_env_keys.py | 19 +- kis_trader/engine/tail_tick_replay.py | 60 +- kis_trader/engine/tick_exit_common.py | 373 + kis_trader/engine/trigger_eval_collect.py | 76 + kis_trader/execution/kis_client.py | 22 +- kis_trader/execution/order_manager.py | 209 +- kis_trader/execution/orphan_reconcile.py | 169 +- kis_trader/main.py | 60 +- .../network/kiwoom_condition_manager.py | 215 +- kis_trader/network/ws_manager.py | 348 +- kis_trader/scripts/test_kis_ws_diagnostic.py | 403 + kis_trader/scripts/verify_three_paths.py | 42 +- kis_trader/strategies/base.py | 58 +- kis_trader/strategies/breakout.py | 254 +- kis_trader/strategies/dbband_strategy.py | 2 +- kis_trader/strategies/momentum.py | 59 +- kis_trader/strategies/scalping.py | 40 +- kis_trader/strategies/tail_catch.py | 21 +- kis_trader/utils/env.py | 26 +- kis_trader/utils/kr_trading_day.py | 232 + kis_trader/utils/non_stock.py | 142 + kis_trader/utils/stock_name.py | 97 + kis_trader/utils/strategy_ids.py | 3 +- kis_trader/utils/trade_time.py | 32 + kis_trader/web/live_config_schema.py | 283 +- kis_trader/ws/kis_ws.py | 723 +- kis_trader/ws/kis_ws_overseas.py | 35 +- kis_trader/ws/trigger_eval_recorder.py | 3 + logs/market_open_sim_smoke.stdout | 71 + logs/market_open_sim_smoke_run.out | 91 + ...omentum_mode_combo_backfill_latest.logpath | 1 + logs/momentum_ratchet_ab_latest.logpath | 1 + logs/optuna_breakout_fine_latest.jsonpath | 1 + logs/optuna_breakout_latest.jsonpath | 1 + logs/optuna_breakout_wide_latest.jsonpath | 1 + logs/optuna_latest.jsonpath | 1 + logs/optuna_momentum_fine_latest.jsonpath | 1 + logs/optuna_momentum_latest.jsonpath | 1 + ...optuna_momentum_ratchet_ab_latest.jsonpath | 1 + ...search_optuna_breakout_fine_latest.logpath | 1 + ...m_search_optuna_breakout_fine_latest.study | 1 + ...search_optuna_breakout_wide_latest.logpath | 1 + ...m_search_optuna_breakout_wide_latest.study | 1 + ...search_optuna_momentum_fine_latest.logpath | 1 + ...m_search_optuna_momentum_fine_latest.study | 1 + ...search_optuna_momentum_wide_latest.logpath | 1 + ...m_search_optuna_momentum_wide_latest.study | 1 + ...am_search_optuna_scalp_fine_latest.logpath | 1 + ...aram_search_optuna_scalp_fine_latest.study | 1 + ...am_search_optuna_scalp_wide_latest.logpath | 1 + ...aram_search_optuna_scalp_wide_latest.study | 1 + ...m_search_optuna_tail_coarse_latest.logpath | 1 + ...ram_search_optuna_tail_fine_latest.logpath | 1 + ...param_search_optuna_tail_fine_latest.study | 1 + ...ram_search_optuna_tail_full_latest.logpath | 1 + ...am_search_optuna_tail_wide2_latest.logpath | 1 + ...aram_search_optuna_tail_wide2_latest.study | 1 + ...ram_search_optuna_tail_wide_latest.logpath | 1 + ...param_search_optuna_tail_wide_latest.study | 1 + logs/tail_live_review_20260709.txt | 43 + scripts/_diag_mom_2min_gap.py | 124 + scripts/_diag_mom_tick_today.py | 99 + scripts/_run_breakout_optuna_715.sh | 26 + .../_run_breakout_optuna_fine_wideTune_715.sh | 28 + scripts/_run_breakout_optuna_wide_715.sh | 28 + scripts/_run_error_watch_mm.sh | 13 + scripts/_run_momentum_optuna_715.sh | 36 + .../_run_momentum_optuna_fine_wideTune_715.sh | 32 + scripts/_run_momentum_optuna_wide_715.sh | 34 + scripts/_run_scalp_optuna_715.sh | 31 + scripts/_run_tail_optuna_fine_715.sh | 25 + scripts/_run_tail_optuna_wide2_715.sh | 25 + scripts/_run_tail_optuna_wide_715.sh | 25 + scripts/append_tail_optuna_compare.py | 183 + scripts/backfill_trade_candles.py | 70 + scripts/kis_error_watch_mm.py | 387 + scripts/market_open_sim_smoke.py | 362 + scripts/momentum_ratchet_ab_715.py | 191 + scripts/smoke_candle_upsert_rollup.py | 113 + scripts/tail_live_bt_forensics.py | 158 + scripts/tail_symbol_gate_verify_20260709.py | 126 + static/css/backtest.css | 88 +- static/js/backtest.js | 715 +- templates/backtest.html | 388 +- 151 files changed, 20718 insertions(+), 6450 deletions(-) create mode 100644 .cursor/rules/agent-shell-python-safety.mdc create mode 100644 .cursor/rules/backtest-web-restart.mdc create mode 100644 .cursor/rules/db-adhoc-query-safety.mdc create mode 100644 .cursor/rules/hts-condition-grids.mdc create mode 100644 .cursor/rules/live-backtest-optuna-parity.mdc create mode 100644 .cursor/rules/optuna-backtest-token-savings.mdc create mode 100644 deploy/kis_error_watch_mm.service create mode 100644 docs/BREAKOUT_LIVE_BT_QA.md create mode 100644 kis_approval_manager.py create mode 100644 kis_trader/backtest/backtest_env_timeline.py create mode 100644 kis_trader/backtest/env_timeline.py create mode 100644 kis_trader/backtest/optuna_mode_combo.py create mode 100644 kis_trader/backtest/optuna_scalping.py create mode 100644 kis_trader/backtest/param_search_dates.py create mode 100644 kis_trader/engine/candle_rollup.py create mode 100644 kis_trader/engine/momentum_hts_logic.py create mode 100644 kis_trader/engine/orderbook_env.py create mode 100644 kis_trader/engine/post_sell_candle_backfill.py create mode 100644 kis_trader/engine/tick_exit_common.py create mode 100644 kis_trader/engine/trigger_eval_collect.py create mode 100644 kis_trader/scripts/test_kis_ws_diagnostic.py create mode 100644 kis_trader/utils/kr_trading_day.py create mode 100644 kis_trader/utils/non_stock.py create mode 100644 kis_trader/utils/stock_name.py create mode 100644 kis_trader/utils/trade_time.py create mode 100644 logs/market_open_sim_smoke.stdout create mode 100644 logs/market_open_sim_smoke_run.out create mode 100644 logs/momentum_mode_combo_backfill_latest.logpath create mode 100644 logs/momentum_ratchet_ab_latest.logpath create mode 100644 logs/optuna_breakout_fine_latest.jsonpath create mode 100644 logs/optuna_breakout_latest.jsonpath create mode 100644 logs/optuna_breakout_wide_latest.jsonpath create mode 100644 logs/optuna_latest.jsonpath create mode 100644 logs/optuna_momentum_fine_latest.jsonpath create mode 100644 logs/optuna_momentum_latest.jsonpath create mode 100644 logs/optuna_momentum_ratchet_ab_latest.jsonpath create mode 100644 logs/param_search_optuna_breakout_fine_latest.logpath create mode 100644 logs/param_search_optuna_breakout_fine_latest.study create mode 100644 logs/param_search_optuna_breakout_wide_latest.logpath create mode 100644 logs/param_search_optuna_breakout_wide_latest.study create mode 100644 logs/param_search_optuna_momentum_fine_latest.logpath create mode 100644 logs/param_search_optuna_momentum_fine_latest.study create mode 100644 logs/param_search_optuna_momentum_wide_latest.logpath create mode 100644 logs/param_search_optuna_momentum_wide_latest.study create mode 100644 logs/param_search_optuna_scalp_fine_latest.logpath create mode 100644 logs/param_search_optuna_scalp_fine_latest.study create mode 100644 logs/param_search_optuna_scalp_wide_latest.logpath create mode 100644 logs/param_search_optuna_scalp_wide_latest.study create mode 100644 logs/param_search_optuna_tail_coarse_latest.logpath create mode 100644 logs/param_search_optuna_tail_fine_latest.logpath create mode 100644 logs/param_search_optuna_tail_fine_latest.study create mode 100644 logs/param_search_optuna_tail_full_latest.logpath create mode 100644 logs/param_search_optuna_tail_wide2_latest.logpath create mode 100644 logs/param_search_optuna_tail_wide2_latest.study create mode 100644 logs/param_search_optuna_tail_wide_latest.logpath create mode 100644 logs/param_search_optuna_tail_wide_latest.study create mode 100644 logs/tail_live_review_20260709.txt create mode 100644 scripts/_diag_mom_2min_gap.py create mode 100644 scripts/_diag_mom_tick_today.py create mode 100755 scripts/_run_breakout_optuna_715.sh create mode 100755 scripts/_run_breakout_optuna_fine_wideTune_715.sh create mode 100755 scripts/_run_breakout_optuna_wide_715.sh create mode 100755 scripts/_run_error_watch_mm.sh create mode 100755 scripts/_run_momentum_optuna_715.sh create mode 100644 scripts/_run_momentum_optuna_fine_wideTune_715.sh create mode 100755 scripts/_run_momentum_optuna_wide_715.sh create mode 100755 scripts/_run_scalp_optuna_715.sh create mode 100755 scripts/_run_tail_optuna_fine_715.sh create mode 100755 scripts/_run_tail_optuna_wide2_715.sh create mode 100755 scripts/_run_tail_optuna_wide_715.sh create mode 100644 scripts/append_tail_optuna_compare.py create mode 100644 scripts/backfill_trade_candles.py create mode 100755 scripts/kis_error_watch_mm.py create mode 100644 scripts/market_open_sim_smoke.py create mode 100644 scripts/momentum_ratchet_ab_715.py create mode 100644 scripts/smoke_candle_upsert_rollup.py create mode 100644 scripts/tail_live_bt_forensics.py create mode 100644 scripts/tail_symbol_gate_verify_20260709.py diff --git a/.cursor/rules/agent-shell-python-safety.mdc b/.cursor/rules/agent-shell-python-safety.mdc new file mode 100644 index 0000000..32cdae7 --- /dev/null +++ b/.cursor/rules/agent-shell-python-safety.mdc @@ -0,0 +1,45 @@ +--- +description: 에이전트 셸·Python 원라이너 — 문법 오류 코드 금지로 토큰/재실행 낭비 방지 +alwaysApply: true +--- + +# 에이전트 셸 / Python 스니펫 안전 (토큰 절약) + +대화에서 실제로 낭비된 패턴: **실행 전에 문법이 틀린 Python을 heredoc으로 돌림** +(예: `from X import Y if False else None` → `SyntaxError` → 재작성으로 턴·토큰 낭비). + +## 1. 실행 전 문법 유효성 (필수) + +- `python - <<'PY'` / `-c` / 임시 `.py` 를 **돌리기 전에** 문법이 맞는지 스스로 확인. +- 확신이 없으면 파일로 쓴 뒤 `python -m py_compile path.py` **1회**만 하고 실행. +- `SyntaxError` / `IndentationError` 나면 **원인 고친 뒤 1회만** 재실행. 같은 스니펫을 살짝만 바꿔 반복 금지. + +## 2. 금지 패턴 (자주 나는 가짜 문법) + +- `from module import name if cond else None` — **문법 불가**. + → `import module as m` 후 `getattr(m, "name", None)` 또는 `try/except ImportError`. +- `from X import a, b if c` / 조건부 import를 `from` 한 줄에 섞기. +- 존재하지 않는 심볼을 **추측 import** (`get_scalp_grids` 등). + → 먼저 `hasattr` / `dir` / Grep으로 실명 확인 후 import. +- heredoc 안에서 이전에 실패한 코드를 **거의 그대로** 다시 붙여 넣기. + +## 3. 그리드·헬퍼 조회 스니펫 최소형 + +축 개수 세기 등은 아래처럼 **검증된 형태만** 사용 (조건부 from 금지): + +```python +from kis_trader.backtest.param_search_breakout import _breakout_grids +import kis_trader.backtest.param_search_scalping as sc + +bg = _breakout_grids() +print({m: len(a) for m, a in bg.items()}) +fn = getattr(sc, "_scalp_grids", None) +print({m: len(a) for m, a in fn().items()} if callable(fn) else "no _scalp_grids") +``` + +시그니처를 모르면 `inspect.signature(fn)` **먼저** — `_tail_grids(mode)` 처럼 인자가 필요한데 `()`로 호출하지 말 것. + +## 4. 토큰 절약 원칙 + +- “한 번에 여러 전략 import + 조건부 트릭”보다 **짧은 확정 코드 1회**. +- 실패 로그를 사용자에게 길게 반복 붙여 넣지 말 것. 고치고 결과만 보고. diff --git a/.cursor/rules/backtest-web-restart.mdc b/.cursor/rules/backtest-web-restart.mdc new file mode 100644 index 0000000..949393b --- /dev/null +++ b/.cursor/rules/backtest-web-restart.mdc @@ -0,0 +1,46 @@ +--- +description: 백테 웹 수정 후 systemctl 재시작 + 브라우저로 열어 클릭 검증까지 완료 +alwaysApply: true +--- + +# 백테 웹 재시작 + 브라우저 검증 (필수) + +`backtest_web.py` · `templates/backtest.html` · `static/js/backtest.js` · `kis_trader/web/**` 등 **웹 UI/API에 반영되는 코드**를 수정한 뒤에는: + +1. 사용자에게 “재시작/새로고침하세요”만 말하지 말고 **직접 재시작** +2. **페이지를 띄워 관련 UI를 눌러보는 검증까지** 끝낸 뒤에야 “완료”로 보고 + +`curl 200` / `systemctl active` 만으로는 검증 완료가 **아님**. 콘솔 `ReferenceError`·버튼 미동작은 브라우저 클릭 없이는 놓친다. + +## 1) 재시작 + +```bash +sudo systemctl restart kis_backtest_web.service +sleep 2 +systemctl is-active kis_backtest_web.service +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/ +``` + +- 유닛: `kis_backtest_web.service` (포트 **5050**) +- 실패 시: `journalctl -u kis_backtest_web.service -n 40 --no-pager` + +## 2) 브라우저 검증 (수정 범위만큼) + +브라우저 도구로 `http://127.0.0.1:5050/` (또는 LAN `http://192.168.0.149:5050/`) 을 연다. + +- 강력 새로고침에 해당하는 방식으로 최신 JS/HTML 로드 +- **수정한 탭·버튼·날짜 인풋·정렬·거래내역**을 실제로 클릭/입력 +- DevTools 콘솔에 `Uncaught` / `ReferenceError` / 빨간 네트워크 실패가 **없어야** 함 +- 스냅샷으로 화면 상태 확인 후, 잔여 오류가 있으면 고치고 재검증 + +최소 산출물(보고에 포함): + +- 재시작 결과 (`active` + HTTP 코드) +- 연 URL + 누른 탭/버튼 +- 콘솔 오류 유무 (없으면 “콘솔 오류 없음”) + +## 언제 + +- Python API/서버 변경 → 재시작 **필수** + 브라우저 검증 +- `static/` · `templates/` 만 변경 → 재시작(또는 캐시 무효) + **브라우저 검증 필수** +- 실매 봇(`kis_trader_main` 등)은 웹과 무관하면 재시작하지 말 것 diff --git a/.cursor/rules/db-adhoc-query-safety.mdc b/.cursor/rules/db-adhoc-query-safety.mdc new file mode 100644 index 0000000..ed9e86b --- /dev/null +++ b/.cursor/rules/db-adhoc-query-safety.mdc @@ -0,0 +1,50 @@ +--- +description: TradeDB/MariaDB 임시 조회 시 스키마 확인·PyMySQL % 이스케이프 필수 (실패 재시도 금지) +alwaysApply: true +--- + +# DB 임시 조회(adhoc) 안전 규칙 + +TradeDB/`database.py`로 SQL을 날릴 때 **추측 쿼리 금지**. 실패하면 같은 가정을 반복하지 말 것(토큰 낭비). + +## 1. 쿼리 전에 스키마 확인 (필수) + +모르는 테이블/컬럼이면 **먼저** `SHOW COLUMNS FROM ` 또는 `DESCRIBE
`. + +대표 함정: + +| 테이블 | 주의 | +|--------|------| +| `target_candidates` | **전략 컬럼 없음** (code/name/score/price/scan_time/updated_at + market/sector/theme). `strategy_id` SELECT 금지 | +| `target_candidates_history` | 컬럼은 `SHOW COLUMNS`로 확인 후 사용. CREATE 기본 DDL과 실제 DB가 다를 수 있음 | +| 전략별 후보 | history의 `strategy_id` 또는 별도 테이블/코드 경로를 문서·스키마로 확인 | + +## 2. PyMySQL `%` 포맷 충돌 (필수) + +`TradeDB.conn.execute()`는 pymysql이라 SQL 문자열의 `%`가 포맷으로 해석된다. + +- ❌ `LIKE '20260712%'` / `LIKE '%BREAK%'` (단독 문자열에 `%`) +- ✅ 바인딩: `LIKE %s` + params `('20260712%',)` +- ✅ 또는 `%%` 이스케이프: `LIKE '20260712%%'` + +`not enough arguments for format string` = 이 문제. 스키마 문제가 아님. + +## 3. 실패 시 재시도 규칙 + +1. 에러 읽기 → 원인 분류(포맷 vs 컬럼없음 vs 테이블없음) +2. **스키마/바인딩 고친 뒤 1회만** 재실행 +3. 같은 실패를 다른 날짜·다른 strategy 문자열로 반복 금지 +4. 불확실하면 `database.py`의 CREATE/migrate/`get_*` 헬퍼를 읽고 그걸 쓰거나, 헬퍼에 없는 조회면 스키마 확인 후 작성 + +## 4. 최소 템플릿 + +```python +from database import TradeDB +db = TradeDB() +cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM target_candidates_history").fetchall()] +# cols 확인 후 SELECT. LIKE는 반드시 %s 바인딩 +rows = db.conn.execute( + "SELECT slot_key, COUNT(*) n FROM target_candidates_history WHERE slot_key LIKE %s GROUP BY slot_key", + ("20260712%",), +).fetchall() +``` diff --git a/.cursor/rules/hts-condition-grids.mdc b/.cursor/rules/hts-condition-grids.mdc new file mode 100644 index 0000000..af1b1a1 --- /dev/null +++ b/.cursor/rules/hts-condition-grids.mdc @@ -0,0 +1,65 @@ +--- +description: HTS=SCAN 유니버스만 — TRIGGER/그리드는 HTS와 동일 조건 중복 필터 금지 +alwaysApply: true +--- + +# HTS 조건식 vs 코드·그리드 (정정) + +**HTS에 그리드/TRIGGER를 “맞출” 필요 없다.** +조건검색이 이미 후보를 걸렀으므로, 코드·Optuna에서 **같은 축을 또 거르지 않는다.** + +## 역할 분리 + +| 단계 | 역할 | +|------|------| +| **HTS (SCAN)** | 유니버스·후보 풀. 일봉/조건식 숫자로 1차 선별 | +| **코드 TRIGGER** | HTS와 **다른** 타점·청산·호가·리스크 (중복 재필터 금지) | +| **Optuna 그리드** | TRIGGER/청산/포트 축 탐색. **HTS 밴드 재현이 목적 아님** | + +## 하지 말 것 + +- HTS 등락 3~10% / vol 150% / min_price 2000 등을 **그리드에 넣어야 한다**고 강제하지 말 것 +- “HTS와 안 맞는다”며 fast 그리드를 HTS 숫자로 채우지 말 것 +- SCAN에서 이미 통과한 종목에 TRIGGER에서 **동일 의미의 일봉 조건**을 다시 적용하지 말 것 (의도적 이중필터가 아니면) + +## 해도 되는 것 + +- HTS 원본은 **문서·운영 참고**로만 유지 (아래 표) +- TRIGGER는 분봉·호가·ATR·어깨·트레일·RSI 등 **타점/청산** 축 +- 실매 DB ↔ 웹백테 ↔ Optuna **동일 엔진** 정합(항목 15)은 그대로. 그건 HTS 복제가 아님 + +## `*_SKIP_HTS_SCAN_DUPES` — 기본 false 유지 + +| 키 | 운영 기본 | +|----|-----------| +| `TAIL_SKIP_HTS_SCAN_DUPES` | **false** | +| `MOMENTUM_SKIP_HTS_SCAN_DUPES` | **false** | +| `BREAKOUT_SKIP_HTS_SCAN_DUPES` | **false** / `0` | +| `SCALP_SKIP_HTS_SCAN_DUPES` | **false** / `0` | + +- **false**: TRIGGER에서 HTS와 겹칠 수 있는 축도 검사 (현재 운영값). +- **true**: HTS SCAN 중복 필터 스킵. +- 사용자가 **명시적으로** true/변경을 말하지 않으면 **false 유지**. 임의로 true로 바꾸거나 “철학상 true가 맞다”며 DB/기본값을 고치지 말 것. +- Optuna 그리드에 `[True, False]` 스윕이 있어도, **실매·웹 기본값/DB 적용**은 사용자 지시 없이 false 유지. + +## 주말 종목수 + +주말 HTS 후보 증가 ≠ 시장 호전 (이탈 없음). 그리드 이슈와 무관. + +--- + +## 참고: 현재 HTS 원본 (SCAN 전용, 그리드 강제 아님) + +### 돌파 +- 종가 1000~200000 · 5봉평균 대비 ≥150% · 전일고가 상향돌파 · 등락 3~10% + +### 꼬리 — (A∨G)∧B∧D∧E∧F +- A [일] 시가→종가 -10~-0.5% · G [1분] 직전종가→종가 -10~-0.5% +- B 체결강도 85~400% · D 3봉전 대비 150~2000% · E 종가 1000~200000 +- F [일] 저가→종가 1~8% + +### 모멘텀 +- 전시가 상향돌파 · 종가 1000~200000 · ≥105% · 거래량증감 상위 350 + +### 스캘핑 +- 시가→저가 -8~-1.5% · 저가→종가 2~12% · 5분평균 대비 1분 ≥1.5배 · 1봉 연속증가 diff --git a/.cursor/rules/live-backtest-optuna-parity.mdc b/.cursor/rules/live-backtest-optuna-parity.mdc new file mode 100644 index 0000000..396178c --- /dev/null +++ b/.cursor/rules/live-backtest-optuna-parity.mdc @@ -0,0 +1,61 @@ +--- +description: 실매↔웹백테↔파람 정합 — 파람서치는 Optuna 기본, 수정 후 교차검증 체크리스트 +alwaysApply: true +--- + +# 실매 · 웹백테 · 파람서치 정합 (Optuna 기본) + +`.cursorrules` 5·7번을 **실행 절차**로 구체화한다. +전략 로직/파라미터/공통 엔진을 고치면 “코드만 맞춤”으로 끝내지 말고 **실매 기준 → 웹백테 → Optuna** 순으로 검증한다. + +## 0. 파람서치 기본 = Optuna + +- 사용자가 Grid CLI를 **명시**하지 않으면 파람서치는 **`param_search_optuna.py`** 를 쓴다. + - 이유: trial 진행률·study 재개·TPE 탐색이 Grid보다 섬세하고 운영에 맞음. +- Grid (`tail_param_search.py` 등)는 Optuna가 없는 전략·사용자가 Grid를 지정한 경우·그리드 공간 점검용만. +- Optuna 대상: `tail` | `momentum` | `breakout` | `scalp` (`--strategy`). + +## 1. 수정 시 맞출 대상 (한 축이라도 빠지면 미완료) + +| 축 | 무엇 | +|----|------| +| 실매 | `*_engine` / Strategy + DB env (`get_*_defaults_from_db`) | +| 웹백테 | `backtest_web.py` API + 해당 탭 인풋 = 실매 키·기본값 | +| 파람 | Optuna 그리드 ⊃ **실매 TRIGGER/청산 핵심값** (HTS SCAN 밴드 재현 목적 아님 — `hts-condition-grids.mdc`) | +| 공통 | 가능하면 엔진/헬퍼 1경로 공유 (백테·Optuna가 실매와 다른 분기 금지) | + +## 2. 검증 체크리스트 (보고 전) + +1. **거래일**: start/end = 최근 장운영일 (`kr_trading_day`). 주말·휴장 날짜로 돌리지 말 것. +2. **실매 DB 스냅샷**: 해당 전략 핵심 파라미터 확인. +3. **그리드 ⊃ 실매값**: Optuna mode 축에 실매값이 없으면 그리드 먼저 고침 (또는 사용자 보고 후 중단). +4. **웹백테 1회**: 동일 start/end·동일 파라미터로 탭 실행 → 거래수·PnL·승률 기록. (웹 수정 시 항목 14 브라우저 검증 포함) +5. **Optuna (기본)**: + ```bash + python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy --mode \ + --start YYYY-MM-DD --end YYYY-MM-DD \ + --trials N --min_trades 1 --orderbook-filter off --no-progress \ + --study-name <전략>__ + # --apply-best 없음 (기본) + ``` + - nohup + 로그 경로 안내. `for+sleep` 폴링 금지. + - categorical 그리드 변경 시 **새 study-name** 필수. +6. **교차 비교**: 동일 기간 **현재 DB 백테 PnL** vs **Optuna best 백테 PnL** (헬퍼 재사용: `scripts/append_tail_optuna_compare.py` 등). + - 수치·건수가 어긋나면 “정합 OK” 금지 → 원인 분류(엔진/유니버스/틱재생/포트폴리오). +7. **DB 적용**: 사용자 `--apply-best`/적용 지시 **없으면** 저장 금지. 1일 best는 과적합 경고. + +## 3. 완료 보고 최소 산출물 + +- 전략·기간(거래일)·mode·study-name·로그/JSON 경로 +- 현재 DB 백테 요약 (trades / WR / PnL) +- Optuna best 요약 + Δ(현재 대비) +- 웹백테를 돌렸으면 그 수치 (또는 “웹 미해당”) +- 적용 여부: **미적용** / 사용자 지시로 적용 + +## 4. 하지 말 것 + +- Grid를 기본 경로로 돌리기 (사용자 미지정 시) +- 실매만 고치고 웹·Optuna 그리드/기본값 방치 +- Optuna best를 검증 없이 DB 반영 +- 주말 날짜·구 study·실매값 빠진 그리드로 trial 낭비 diff --git a/.cursor/rules/optuna-backtest-token-savings.mdc b/.cursor/rules/optuna-backtest-token-savings.mdc new file mode 100644 index 0000000..754af8c --- /dev/null +++ b/.cursor/rules/optuna-backtest-token-savings.mdc @@ -0,0 +1,41 @@ +--- +description: Optuna/백테/장일 확인·그리드-실매정합·긴잡폴링 금지로 토큰·재실행 낭비 방지 +alwaysApply: true +--- + +# 파람서치·백테 운영 (토큰/재실행 절약) + +**파람서치 기본 경로 = Optuna** (`param_search_optuna.py`). 정합·검증 절차는 `.cursor/rules/live-backtest-optuna-parity.mdc`. + +이 대화에서 실제로 낭비된 패턴을 반복하지 말 것. + +## 1. 장일(거래일) 먼저 + +- `오늘`/`end=오늘`이 **주말·공휴일**이면 봉/틱 없음 → **최근 거래일**로 바꿔 한 번만 실행. +- 달력 확인 없이 Optuna/백테를 돌리지 말 것. (예: 7/11 일요일이라 7/10으로 재실행한 낭비) + +## 2. Optuna study / 그리드 변경 + +- categorical 그리드(축·값)를 바꾼 뒤 **같은 `--study-name` 재사용 금지**. +- `CategoricalDistribution does not support dynamic value space` = study 충돌 → 즉시 **새 study-name**으로 1회만 재시작. 폴링하며 같은 실패를 반복하지 말 것. +- 그리드 변경 후 기본 명령에 `--study-name <전략>__wide_` 포함. + +## 3. 그리드 ⊃ 실매 DB 기본값 (실행 전) + +- Optuna/`--mode` 돌리기 전에 **현재 DB 실매 TRIGGER·청산 핵심 파라미터**가 해당 mode 그리드에 있는지 검사. +- HTS SCAN 조건을 그리드에 넣으라고 강제하지 말 것 (이미 조건식이 거름 — `hts-condition-grids.mdc`). +- 실매값이 그리드에 없으면 trial 전에 고치거나 보고. **빈 격자 trial 금지**. + +## 4. DB 미적용 기본 + +- 사용자가 `--apply-best`/DB반영을 명시하지 않으면 **적용하지 말 것**. +- 1일 Optuna best는 과적합 가능 → 적용 권고 전에 비교표만. + +## 5. 긴 잡: 폴링 루프 금지 + +- Optuna/백테는 `nohup`+로그 경로만 안내. 에이전트 턴에서 `for + sleep`으로 수십 회 폴링하지 말 것. +- 진행 확인은 로그 `tail` 1회 또는 완료 알림 후. 비교는 `scripts/append_tail_optuna_compare.py` 등 **기존 헬퍼 재사용** (인라인 비교 스크립트 재작성 금지). + +## 6. 주말 HTS 종목수 + +- 주말 후보 증가 = 시장 호전으로 단정하지 말 것. 장외 이탈 없음/일봉 sticky가 기본 가설. DB 조회는 `db-adhoc-query-safety` 준수. diff --git a/.cursorrules b/.cursorrules index 417dd58..6673b5c 100644 --- a/.cursorrules +++ b/.cursorrules @@ -31,6 +31,56 @@ - 8. 공통으로 사용할 수 있는 코드는 공통으로 코드를 작성하고 함수로 만들어서 사용하세요. - 9. 코드 수정 시 개발의 기본 none null 로 인한 오류가 나지 않는지 확인하세요. - 10. 테스트 코드를 돌리거나 할 때 백그라운드로 돌리고 tail -f 로 볼 수 있는 log파일 경로를 알려주세요. +- 11. **인프라·공유경로·재시작 안정성 검증 (전 전략 공통, 필수)** + 갭보정·WS/REST·워커·봉 롤업·구독·유니버스·env 기본값·공유 헬퍼 등 **실매에 영향 있는 인프라/공통 코드**를 수정한 뒤에는, + “유닛이 돌아간다”만으로 끝내지 말고 **재시작·다전략 공존** 관점에서 검증한 뒤 보고하라. + 하나라도 미확인이면 “안전/문제없다”고 단정하지 말고, 고치거나 잔여 위험을 명시하라. + (꼬리·모멘텀·돌파·스캘핑·레인지 등 **어느 전략을 고쳐도** 동일. 특정 수치·특정 사건 전용 체크가 아님.) + - **교차 부작용**: A전략용 한도·가드·ok마킹·재시도가 B전략 웜업/매수를 깨지 않는가? 공유 큐·공유 캐시·owner(후보) 분기를 확인했는가? + - **웜업/매매 0건**: 봉·틱·롤업·min 봉수 조건이 스킵·조기완료·영구 미충족으로 트리거가 막히지 않는가? + - **API 폭주**: 재시도·재큐·bulk refill·폴백(키움/한투)이 무한·과도 반복하지 않는가? 실패 상한 후 재큐가 멈추는가? sleep·워커·초당 한도를 넘지 않는가? + - **장외/장중·폴백 기본값**: 장외 no-op/안전 동작, 위험 폴백 기본 OFF 여부. + - **락/예외**: 공유 lock 재진입·데드락, 예외 삼킴으로 인한 조용한 실패가 없는가? + - **최소 산출물**: 관련 스모크(가능하면 전략 2개 이상 경로) + 재시작 후 정상/이상 로그 시그니처 + log 경로. +- 12. **DB 임시 조회(adhoc) — 스키마·PyMySQL 안전 (토큰 낭비 금지)** + - TradeDB로 SQL 날리기 전 **반드시** `SHOW COLUMNS FROM
`로 실제 컬럼 확인. 추측 SELECT 금지. + - `target_candidates`에는 **`strategy_id` 없음** (code/name/score/price/scan_time/updated_at ± market/sector/theme). + - pymysql: SQL 문자열의 `%`는 포맷으로 해석됨. `LIKE '20260712%'` 금지 → `LIKE %s` + `('20260712%',)` 또는 `%%`. + - `not enough arguments for format string` = `%` 충돌이지 스키마 오류가 아님. + - 실패 시 원인 고친 뒤 **1회만** 재실행. 같은 가정으로 날짜/strategy만 바꿔 반복 금지. 상세: `.cursor/rules/db-adhoc-query-safety.mdc` +- 13. **파람서치·백테 토큰/재실행 절약** (상세: `.cursor/rules/optuna-backtest-token-savings.mdc`) + - 주말/공휴일이면 **최근 거래일**로만 실행 (오늘=일요일이라 재실행한 낭비 금지). + - 그리드(categorical) 변경 후 **반드시 새 `--study-name`**. 동일 study 재사용 → dynamic value space 에러 → 통째 재실행 낭비. + - Optuna 전: **실매 DB 핵심값이 해당 mode 그리드에 포함**되는지 확인. 없으면 trial 금지(실매 근방 미탐색). + - `--apply-best` 미명시 시 DB 미적용. 긴 잡은 nohup+로그 경로만, `for+sleep` 폴링 금지. 비교 헬퍼 재사용. +- 14. **백테 웹 재시작 + 브라우저 검증** (상세: `.cursor/rules/backtest-web-restart.mdc`) + - 웹 UI/API 수정 후 `sudo systemctl restart kis_backtest_web.service` 실행 → `active` + curl 확인. + - **그걸로 끝내지 말 것.** 브라우저로 페이지를 띄워 수정한 탭·버튼을 눌러보고, 콘솔 `Uncaught`/`ReferenceError` 없음을 확인한 뒤에야 완료 보고. + - `curl 200`만 = 검증 미완료. 실매 봇은 웹과 무관하면 재시작하지 말 것. +- 15. **실매↔웹백테↔파람 정합 — 파람은 Optuna 기본** (상세: `.cursor/rules/live-backtest-optuna-parity.mdc`) + - 전략/파라미터 수정 시 실매 엔진·DB → 웹백테 인풋/API → Optuna 그리드(실매값 포함)를 **한 세트로** 맞춘다. + - 파람서치 기본 = `param_search_optuna.py` (진행률·TPE). Grid는 사용자 명시·미지원 전략·그리드 점검용만. + - 검증: 거래일 보정 → 웹백테 1회 → Optuna(no `--apply-best`, 새 study-name) → 현재 DB vs best 비교표. 어긋나면 정합 OK 금지. + - 완료 보고: 기간·로그/JSON·현재/best PnL·Δ·적용여부(기본 미적용). +- 16. **HTS = SCAN 참고 — TRIGGER/그리드 HTS 숫자 강제 맞춤 금지** (상세: `.cursor/rules/hts-condition-grids.mdc`) + - HTS는 후보 유니버스 참고. 그리드를 HTS 밴드에 맞추라고 강제하지 말 것. + - `*_SKIP_HTS_SCAN_DUPES` 는 **사용자가 언급하기 전까지 false 유지**. 임의로 true로 바꾸지 말 것. + - Optuna는 타점/청산/리스크 축. 실매↔웹↔Optuna 엔진 정합(15)은 별개. +- 17. **에이전트 셸·Python 스니펫 — 문법 오류 코드 실행 금지** (상세: `.cursor/rules/agent-shell-python-safety.mdc`) + - `from X import Y if cond else None` 등 **가짜 문법** heredoc 금지. SyntaxError로 턴·토큰 낭비. + - 실행 전 문법 확인(또는 `py_compile` 1회). 실패 시 고치고 **1회만** 재실행. + - 심볼명은 Grep/`hasattr`로 확인 후 import. 시그니처 모르면 `inspect.signature` 먼저. +- 18. ** 백테 파람서치 정합성 검증** + - 백테 파람서치 정합성 검증 시 백테 파라미터서치의 결과 값이 동일해야 하고 검증을 꼭 거쳐야 한다. + - optuna 파라미터서치 결과 값이 각 백테 탭 결과와 동일해야 하고 검증을 꼭 거쳐야 한다. + - 백테 웹페이지 탭을 직접 들어가 결과를 확인하고 검증을 꼭 거쳐야 한다. + - optuna `SKIP_HTS_SCAN_DUPES` 는 false 유지하고 수정하지 말 것. + - optuna 각 전략의 hts_skip은 false 유지하고 수정하지 말 것. +- 19. **땜빵용 코딩은 지양한다** + - 근본원인을 고쳐야돼 항상 근본원인을 먼저찾고 초등학생도 이해하기 쉽게 설명 후 설계를 하고 보고한다. +- 20. 수정 사항이 실매에 영향이 가는지 백테 파라미터에만만 영향이 가는지 명확히 분류 후 보고하고 수정한다. +- 21. 백테 웹페이지 탭 ui 수정시 다른 전략 탭도 모두 수정되어야 한다. + - 가상거래내역, 실거래내역 ui 는 모두 동일해야한다. # [CRITICAL SYSTEM DIRECTIVES: 절대 엄수 사항 - 위반 시 작동 중지] ## 1. 🚨 하드코딩 절대 금지 (NO HARDCODING) diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index 851237b8fff87571288c1082a649d7aad97360b7..627eca6fdf634b0165b11576264128c581fa1ff7 100644 GIT binary patch delta 17675 zcmbU}34D~r^*gh7vYUgPn*%l>0har|6*kExS(0pEcLM~GHRjtuBqaFla>d}Hq6VQ7 zSYc402CFrAwAf&mR;^bnRIP#+@PUfee_IOJcq3N(e{a6M0_d;5rtI7K=Dm6I=FPii zzO~ZF`bYLBB)w)dCMe)<-M<&!^zutjB~8hvYE^GV{#}G*1sgo+!813idX1>Y$1a@|&YwP&u?X|3_NY*uG!rKiU3@VMMor#rZ`ctoC&69B!Zrmos88KRF1 z?2h2J;(Y-#?{U@FlmJZ`aI5!J*ScJuYI}{{?WuAukbatCZmC-`aUM>a&Ea;|a{+8+ z6*fLt7BGTx+UC{Soi>l7*5R;KNNH|s@1!Af$qfTJg9@j;%u{Z4xjkWVxF9&ePvKF- zL6?n>%!j%_QF=EHy#Ut`g$)7WoHo15 zRcGVl^TSib2w-j?1f?MRY@1`gr^e>2u$6gAt)-PVkK0|%FRbN_=OX!|s+c<9ljqJf)RZ&|5VuBW`&7jFPE8#^JUtbb}c>Y%ZxF zh6+@A+)it$t;AYd%+N{;?N|@9JRH(gy@z{#$+LG`Fpamd4D;{6N z*XFcU*cSGe*Iwb6Sqt_nqcZx9sqxIQyWKXYloQ|0J+W>sSiIX_ULSmSQhC5!>zoV9 z0488crS@u@XD+a`)s{uIS@5G2)i!Gx8jh4*QCSO)GPe%w!2?zpWhNDu)y=K8ms;J> z?<})9J>~Z5YL1_81Mjp2!dq5wIUTa#BvFeVWCw?~mRajPm0%fmM}-S)%UX|q<<*13aEO-nPBl}Ll)I?{~`!PJYQJypf|0aN73gC^>> z;V!`(o*GwdXbk1D0m@ZvpXmI7icC0 z26(J)X>f!nIC4gwnulRnLHe~Qa0U;TSBNLa>S%X=(eijKKo$weB0utI(iF%|SbIsP z95A9RA`6!X#}?I}b)nPh04wGK;{+)xglR%n;8bhV4I3epyKgXcMPM z#3>5H&%k|3mfVgFjW7-miXr;(?ETlYdJ-61Q(FVMudW6{(R`Z+LUx72QwO_IUh4#B zt$@K!sW3_uje1BD-rw@-+FGZSePj({Diq=X1ZfD;0Z69ku5;@` zX0p+^q^u|k^B52Gyl(5Yl^4)}X)dM+Zm(f0L$g6DDd!556Jitum5c%2T zu-4du>+KI_q(l4;uLwhUNnJUlieT=X;ekOhjLaBPY`NW86Ak?ZKSmHsEQK)=$lefp z@2{qACa`hW^`B1uHK`A!)l99Cvx>3 zrGSdn9Zomly8CJ<*&g!xClWr?HIkbICjP6TQCX$1UdVFct#XiQd=e;dC>ERM7wzalFu?%L|gD%qzm5e>`4>%qM!eq`+0b9@U|ss zQ_`ZGP+=}N_(ymc)*?JYmDJUHtTnZD4!2Ygp$;Yw&FvlOYE;h8gEN*qj+atg=Wy~V zFSjoQ-4BdmZ41F@$}np|RT`#{vSN7jIy}KFfO^W+`@{T=Br7u-c2GF^&{$eo5xi~T zZ_|cEaZwfY7S?a0ft)BJl3c*EWbc#;uE0eCe@PwGM-Y3Y>?jFlP0Si$HgQ1^F_^I+ z#9<+x8yx~EsP~2II;dK_R;f?~=Pg;K7O#O;??+1<$=YyE3Er^6q>84CA0J^Rf8m^d9CEN&@SwbVnB!!u(1Oa+y^Ik8<41tN7pwB8S4L_@dW$;|W zr;$aWLNoM~3O518a`?1xN?ELc0#>&I&}G6eg;uV;5uo2T(4I|L$)A(Fxm;S6a5K=A z>lOGv;ZXrJB#1!agKxigfUpWatKqW-K5Lr6ghsdxgs@)~b30(>2>XRQyje|D*a#e|V6*Rp-fH14 zZ-JL99r$O#0`G?28lh7NqzP%lCgGkvD$a5W$h#RnTi_#WBRs6hApC5jW|2eKhwKHT zpa&-R!W6YI#a3W87rwjTdmemmgKy^*Q*MV|*X6yzS+}SI?tWbF1Fm)bdUin1{L4oQ zJAvPVD|#99F6=jY7mTj&*KHDvf9l>Wls2qSD4sjl^*-s@pdaU?hFyq5Idb6gtg z-2=hL&WtPEikz(Yd<_6gqBs!S&p@xF65H1JfYJA7=0OCj{b&TeLDSpu(QOHqM-iRm zZS{-mJWa6PRifAD5K%Hqig=ojZEaZL6?=k1&fiZS?S0|=J2cRP{hAu9qt03_et-|! zy4K~bZEIS^5AjKVb( z-PFI^!Vjj-OvL1 z1e`=xa}|s4;e&S7N&#%fCq9S6%5g%RN=otg{B5FFfG-j7!6Bbj(~X*5|7Quh>-Y?) zL=n|0_8~wmUQKo4XNce$`y5+bS*W|1#<-%ye`4RG!IR%E^h6gYsbTi!m6CcDXh{^g z}m?6AE(+p8C%a-IpN$evNp~4n47GxtLA` zd*%+3x1)}Xpb6TiZzo1V&V;%Mfq$o1fs%+>e2`B33U$M6;u~z;!^Z6+Y3w;AF&H=} zZs}jE*b8@%EOuT=CeV@EP=SiHQDC%9L!Qh>Mq&~u)1sCY%g3dnb|d3NEyyud%O2E{ z2Wg6$&DN2DiNC~gXoF%;s98s{^<%geaG4Gf3RT5f0k;A@L_7uHEZZNe5=L(%%e0fUo{r>@)h?{0%K0 zpSQ8CRq%=2jK0GW+_SiEax*%Jecb!FX>rYQmy?4Px3cUwWn|#|nF!(*jXbij4{cCV zg9`~>*-t)a%hE~7B<^FzWz9Z+*uj>rlRfZzlvUf<*5dQ{mvI*r%YcV38vxt)A7Ww` z)5#E8p$(Zc$Vs&}4`<0&@608mXu6t>$t8IcP)Fq!Fd7sCQ%N|)<73QKRB?;wVV|YY zEVd(;j3d45a4xx&#Dk=!VMW_2h_s)F-pnI9s=>W#|25_-wsHV5upb7J&^SJwY$XYC zJYya(3T5Qp&$r_&``aLLJvkgIGn3Ows#3GUB2qgjCaoIjH3<0TUP8IQWnD!iM>`&S zshT}iMC!omwZ%lOCZ|Hi;bbwv^)DYm?#{W~?xSjWiy20eqMWJ7gvWwu*qV-D27<34 zG~}`$`iPFL7)i#EZ$f)VlFv1CjGAqmKziw5H7lP;me9#+_REQ+h?>;w^@${x4pg(V z6Ui`KO4=k+JTWSOq(>_hye<9)QTD;8EIgQT6O)aXwM-(}qh%w*E#rokChM~rgp(2* zSNYqTnmib;!?BJ1W)e9`^L3%0O$Mh`)dCrN@e`66%A7`iO|=eO6UhGh3^JP*s#(%Z zGCVLDJJAI>uo&W^#5_b@O-v^uxUAfKxWHV#@P6aOl17#<7Iwu2*sYiSZe?qWxDcMH zSkK0MNb=cxGszU%pbI5mL%vp4B1$wNeR9L!=(VUNgUpA!l21xl z)7%@`}&)7V7VcHhIK=3M? zU?XMM?18qVYHVA%PUL}sAalNI+#m2PUZ=D{ucU6|4#Fqkn|G2Ov=J*|p|5RZf=-Jb zBj%_>V;$r+Wd7dI@f z&X}ij|6Z?VAFUv!qPvjEtq5X*#7hi_jk4{$as%WllRos_4dgk^LQWkMqdm%oD;mX? z70nm*6s!lTgr6QG&&pDk5sO^C@*v7R_9E)zEgp>0q6LPi#Yr%tu-Q+N4A!-bOr{I< zp(D%4J1Wg>d`aw~6<2awiGFyI6>NsQeCrKFtJ#dSTXwdR>;b%TQMRq}%YH*Pw?pc764Dh< z!trPr7Ny7;?#2!gfTTr77k$m(bRXSLMi}DJEc*`9ly}(_JdLYG@HaucggVR7$Ya>= z?;zFWgOGM3xnDD#_eJLw_u_=F;##_~wGRLkGn@#;5FbMD2!dZS=^ipFCpIhJaGX_q z6!G$$2EGE^9CRXHIxX1@p)5Ng#5R*H8trYcTyXEu@a^Q+#DwaV)UZs?gtOyuw^0-e z6;HFzgJfaGVMG(bhbl6g#X(4Mgn8~G<7i_7+kPJehP~|d`^fmxm{{Ct&~l(WUfI&@ z7cpN-nx-~!MdaY{CW^{m}D87KW znBm1v5kJXDd=Z8zq1Ix)-DIeSukjvs%1jGKh0A`d4x}VZkRDkZeyGzN^bVWjF`W|v0(fknw`cvqgy=1hKTw=<7WN>24XL#5hBzwv$NO>56JO*df3C)m~ zS3)2LPpQeFxvXm+nMEE79oUo77}8|4nl0Fi6PC?}cn+14;<{~)GVubs z(5DDau|GURb_b$jil%UlQX|_SkD*IB(|+s?p+r?EXB@5m<=U;O9Py; z+SW7+aOCCn(7%vx9|Eq7*I+g&f!{@W*kdn~j67}zKVY{SZX-Yg@1j)iAvnQKftcid z_QT7hn9fTK75|ZJLstwPCgX14{7@77b#WHT;~SS=icT8cU5h?WC_a8W67djs23uSh zehBzM#oDIO3Ofqks$lO<8W|05}I*>#G+Ys1K=;*x#j%cgXa$2 zcb;{f-?+yt-{J~z-)NqBjhSm`fcgC1L*@%RH=TQQi}_sd!GHF4vl|bS;e$qtmtZ^E z$C(#Y+MKq_h8Fvn`D2pJ&KxGX*-;-7zk?AGOXEJvQORheKjkCO&m>ukvORFcEI_5% z+SI&MzG(Jv3ER!?$Sw4bP<)XHG3z3%cY~r℞YwsXUpScK@UkX_=jK`lyC=A0gQT z7amQiKarZ*rTapyOi%hoqe#x$v~2TDeOiSv^)mx|^9Xrr(mi8JCMsT}cFB1Z= zF}&Yg*E)dkT2ArB5rEv9Xb177u(9V!W^neiI@lRM4R#&M!-o{GfT0S-VqmadiDt*1 zDj?~kBlzN>jJysdY$2#iu}@~6u5 zU@B^XMolpVOtB#Wis+TE5VcL?PmfT61hc|+Nx3P3ErNwAz^IHJ>abmcg@RpX__HFT zI&_Z{_SEl44z6OjpjO{=H?F3SPx^Q0~V$k;%c2l|)gm*m5@F!{Fud zSQTP%R0MBHSXBq3vFt&oVQc?NlB|E-aG&hg=JT8P{PVzV!|LI0R87sOvNCgJ<@B1G z=`NQAT>jvN9lOlupWbtRb2mCac>cBvJNA!muVneX#FY8tK5sI}IT&+hdl~!3dtj`N zH;FbN-}XmhOR-PXqT8eYh&pV=5=Xk2gdLY^M}J!G0Qzc1_k7& z6a#q8d5G*A6lV$scWO>1r=Lj6I-WM>SlXDk)5aY&j5}=_e%v(nm}%_WrtzOEh`x$! zvUiqtrJpbi*jlrxW>?YioGHh0rW{fpa=$R??Hv2NhB_p1^2wisn2$%tJGdu5i z*7##tj6eU?g&k z5lF=A1n|c|4#eO%B6>9|!GsA=zovxyVA80Ck|QqSPJ=-S{xq&X038XjL=hKp6NJQY#7z+R zoA>pJ9f=VB%^g}UNFYv)i4iqKz9E6lP${;oQ7Pi$pEiQ!@h&AYe>C3R8;xhiRapN2 z^XVu4d0?aY-0cU=;aXt0d5k#{_b5U}+(1K3>b8mA=eLTkMDiwJtjzJQx z%Xx9k?;h|tASoLJK@O6!sJJWuq;l;Xt(YZ^4~~Rh0XXn_S-$8)4%D|!qTY3Iiw7?W z{GJ=}eoNBBOW|-+-NbLacx}y(FL-E?Xk&v9q8D5m#s2v9ku84?D^zh*LrB0EQ+GW~4XxEKz8wErroJWjf0^xF{v{kZl2OL@17ZZ2JgCY_t0+Z10znPWpvt4m~wZO z?3%YLbC<500GD~)H+K*1HuV(rl=R$uAhoBWXW9Y#(adW*6HjEBb`|a#*R{H9*{%j) zJq~EKyVmuPp2D7aJva9Z?J*s&98I6usXv{O9jxnApU@e%>Nn}TM!cgNcseam^tr;I zAKF>kN3}*%|LB@I`GD_$w@*Q4E+POk7LhX<=5Am%@(Zmhd%!n3MOyB*VZq^jdPQnR zUlIh|?d`k7&lO7J&@Sca;S&$Io-KN3_^dA1uDrK0hP>c9lKSHOE-IyEoJcn@`!@TY z#_q!M-Amt1AM%+N#`L9uAYbM{@a^($UVl`VckxT3B0V1>u+elfB^x5J(R9f-0Cw`F zl(A*W^pDBerSEZ{eN=sB_vVLbvj!Mv9{RUcdYaldPEe!0mjk8jxn zACp1!69}+pNhUk?F&Rp4F@!FCOaxkojQG6}+~u#sb>mr7erL#6%TKwS4L^6|an+)g zJ+BBC&Ao}AdyeBk`Do30|3((lZ3briJ2^m(v#piX6gvKQxR-$IsZ0MLxrUgJC#o60 z?>^463=gFV2x9k~9J& zfvaF14@PR(^XK7})o%?f^#a+gAB{L74%4uwFAz8Vy&;tNDftrK@bBy+%kWL-O?_ls z?qwSt6YonkOZ|$NQpV$ACm@)J;A{3_A6(wQXb4UDjM$Ykc_-@9(cA@|-mV-ydUV|G zL`TDod2G+Zz3ka9$*h#vl>D0DO}1|x9ES#eMGUD^a7G@n_^}dh%V&H=7ST5hA@&vN zrcMiolfEM;D=@@4QG1NeO6A;TPF6HSAU^$z{)7B-hZt8Q8xsk{^r* zfIz-}pCmpW(q1Ax@Y{yafq#=bRSy2UhE!So{4#nL3OpNu6+sDtQUn4Ta2d922&Uqe zmSYRqixt>@eTPton$1h0O&KwRi+fot z|E}c26l%~eKyFQ8m(``xDb!^QSySnYM6(kdt=+`trqK+RpGI}Emt?VP(r5*}(HOcb zjWX3N{z8jZIpsP4u29!D@+2m|E@%!L{?}2POo@@{OC7Wu=3i(|gP>7ch@HaiN1@b%Y=%uHmfdGfU z)QasuFAlm)`Qckf52^vTQlia%iLQiSaxiNST}<1Jp$Bv5XT;b56wRGb=z;nEitWv# zMFwsPJ{;-Sg#MC8-QnBcSJ}`()I1DFgsY=QXtt;G8YZd+Gp}nv8KJg8V0pv&6}KBz zQ-^>rjxRX2fQU(+GgH%W?g9K2U|0kg#3gc|L~gXPux;X7q1kkwF|@LP5~YU!%HR}R zQ$%l%{qRAIGY?b~){Jpi>Rf9)1=VZVsA5`1j~GL~V(KM?v6DmSV7getei%aM(Njh? zZz!FkS^`qAZGVPDFmnjqQv|1TMSHHd)d#=+^Q`gV4+fTs4J+gusp(MQiNIjhFj_;t zU@s4&Rdk6alwzSTQw@LF^ls?n2)YpiHNHYA;&-n~ITG+2v1fq2504p8T*)uxo@Nh> zq86Hv6#COBdPFmlYbtI_xcYcj!p(tqUU2M;m?!()M7qSvjWV&RxdmS2d%SP~&W%&N z3DtWFb;KpR9UjllMs3}Jt^Z(aBedFQ4`Vk>qLbMfC( &m9}aGV69Ydf+c{LVuV< z(}}(XC|6lv4Exm-I*@%knI=t*S@};IxVQqw##=X=HHD6*ts1->zn%_B3cWFf{#!fb zrxz~Xg6=AoOyq%bEB2rtUTvvj7h-X#;x+^uHOw=cTERv4&8FFewm<;0(sBR$ zB8p1r$nYXE*|HK^N?l2zCrjusRm5;lmD5AowKzH0;sX_QJ`E(X+A3;iGb*WzwrfIN zmGlXcDBC&OT^SqL0PjVGIkYP}@VhzGNw@NWdvMH!Dq5#n9#xins_Abe5naK|cjNS( z2sRhV^ z1pU{$CVssr&2pr{CAYtp5IY88iyfFmQ`x$D`hbAz^;uh>MBkEpk?M3i&1n_}tRh%5Sg&P=gMSkr6I5&2#iF?Sl|1U>}tfh$IAbt1S2!F>QE)s1a!E#fYGegHuag2xc}aLDhm z^)iCj5h(eA`)zFg78ejga0mfE@bJR`e|g8>V)0WCKO6Ad%rrEId;|pu1|t}PU>E|d z(Zw+c#vz!DUzf}bPk zMz9^hZUm1a*v}R<(E{xJ0vU zMsMtF?3&QIY}3Fm)IiraeW*I~z=S>pd>xwjg7O8|3%cK3`vpGsIf+S~f5Md4r^N4} z!}&L7u*vj=8v6Ps7pt>-)*e#sU;l-oSRE=_PAioOc;u95n@{|Tt!{-E&-?&32{-3k zcA=H#mmI`3zJXN6@D-7-ahVs+k82UJ4naEtJe!Ff03@UQA9GcBT=4%s;0DjEZlgQN wo$TW_I+eW0@>bG!^ox?x=!WEP+Vj}AE9w3u?{EV5vIsBsc?;Qro9XcX2SI}b8vpNTdN|43`6dO1hSr)utX&wn+Pay zfg=w=ut-Hw!Lhw+OD(@*l`7AQJjJnAMXS)qB8ZAgAMg9l9WnuJfBlvG=0Ep-=bUf< z&Ne)8Fx2;HNcbBzTWApeTRCm|Ld!3YgpW*OOAX%qq_qGE>O4;(#BA-`%X_xZa^`JK z`Co&&GcsLgQ@GiSEs#T@?M&e!qnfD|4B`#3{wwR z6yx_+^$(SHZ$(vKP+zKP_HLi~v{`+5-ZS10oi0OXX!+!v!WoXDlH5FBk6X`0j#;@`{ql)63OOP3>x%GtnE|Toe(K zQdCluS6oq9q(-iY_vSBOYfimx2nyog5qwYZ1Hlb~e-Qj1!A*mDb@dR)Rr{_P1wyS^ zlM0Wh%h!~{PW6<0zP@HC{Fj=xHU)O6Q`aU%J*o_H#V9sc&pM-GUu~L|r0iCguH6oM z)bMo^hCQZ?c10-TWsk5rvoZmD*pafcK{M5^?x6}$& z8n87-d0bg2%}qzM7NJ>_lt%d;?n>|rZ^C|)LxSi}`Gtb&D$UrV1;1NeJ(V{6aQwLO z<3V~me)_pOBb3ERSc0FW_*sVjPEhykq;qx#*Y+v7^$VVvE7Vp%Wk7=K}z|LMsCHC6+fb?NUaQv9)Nmo!{Jo{5>mD! zrCQmsI%rnSvJm?L@9P^>BN)^6gEI-L6V7T|)vsNp+ZcsnHSQ%-fk>Lap2pxY^j~p7JUOr+~J1=d9NHy~E z)SlE@%uA8yLFQu_nLJ)yc)5~&X7V1pY==8#*OTmM_2!pZXkXTq=&%tu+-Kx1OWZiH z>`E!bsr#>_vv*A1x38=WwfCdua96X3FLTtP>FwO*F6N|`J(5?;vCYmV7w@m$b7Lp; z^G5&UH0$h7baPQoab->+pGhy4%H~DQElZmDEP4x>-@eSz${T83d^W!GIRrl^m`lxJ zP7ilDJq{_AJBU7y=!Q1WGVY|;`2@8@vrH{1%q=L+=L)`6v{Ol+-ChrOk1C=r($ka3 z}+jyH7k55y%-vrJw8)iV+%@)rp|8aH*Nd-(7&^u-gD?ZgPvskD0KFoybn<; zh}uR#F5uEhCUTG47eZf-#m+|bC${-uF(()EQhKqpw z@QY)+xCfw+eQVNA0E7n5mNLkVoE*y+5s)8$%6Ytz8l=yf=t;VZych79bQO0It)IvU zg&IefeLfQ^(9r5LwWFgrm&uYu9i=13k-pRDRl0`!Y7f>OavxF4sJ$4Ud}hp&_C{ny zlxp$VSV-7mg`qH2%do;o2Gv@f4MwoeJ1qOBMs?-D9pMj{Vvo>65pdJL!7TdrWb$(p znURnd+=6x-G;@=f8wqxcRJ1HeL~esb@l+(Fv-`~2dy!xRn63S-2W$qY5{sjN$H;NP zypE4yY zHcE~(Y_j=jb5M|5h>l(`Q1p!jPx=OI^ac4s-LA$)y|R2(`Ha(*yQP_@&|pea-e3D& zEX0NklkOq=i#fw6omVJSR7AWA;k*pE9I$|B{e`I?>;jwE*AE^t>tPew{o(aaQr6x( zP$GOS^_Ro5>4_ZS3&yl+^)%o-Azk7jn$6SVaW*>KuG*Gn#m!}!Ev6B&_+>b0^}JLJOvTcC*{rpv!uuwRAfAsHMP)h+EqfSWD%zt^!r9#$ zlP}!=%!R#=CSTaF|Ki3clLv8f)D$~r6b2DNQ;#qI;_jJgDG?6$2 zz7LnPsoB}o(vDMdlQv)|1XJD&9R?p}NeO!5gmz;SD2XRYmYf!37!)xk6Mhb(wVj#p z7X#}wiX}gTl8FHYS5W_z1Tv`Y^hB}K_3fHm(I`19K0{6S8HG6;DxqAPlMN=5nX<__ zLd|-zml!&Rg=yk_QQFr%{a0gF$3teIBI#tV^yh zgwj%RHF}&}uT@(K3v@YXCM820mnnLcql}~@CUZz@^>QVqT@(K}B1vwC^O|OFC!4}Fd zzTK#;nF}io(XyMJNK}k`Fu@QlVLsdqFjG|4!j?W=p_lIU8S7ixxzF6x(A@5E;XK`% z&0+^hr6}ipMx~+F!=>P9#7qV~LJ#_7qsUcYLhpc3T^{v2WfVIVm<5BiYYOZQ9xT-i zbnK=b^}b5*6@v3oCicyR7+EV~^?VpC4zI##_m%lDLu7iGMQdq>OVC5s^fekK)8}iW zXl{kHLHZnH?UqL}g`_!wvR-QxS<9hMDwuHmcf=&1Sa%Ds;iggCY=siGF<6_?21m`^ z_99aH*ECwPkWMEPbf-%Lhu|yj5G412#&?YAk|=&(3%$g>OQGkOfUdF#-NaTJaYxSk zC!Vg22fyDatl5wxK3@vi?2TY8X&Ib0ns3nfYVqxTU(Sq@RmZ4}2^AuiKQKCmG8 zLfkk7cpQsd%X}8xB+`SvnK87qd-VEY?nR6( zTX12~P0}uQ!eWCy0nSr%_Vn6{3x%8mH?4w&hRE9!QBS=X(Pte5rv^MG-5NEebvp$4XUrySS`EyjdS3 zBDO&xlxlOf!A|pNN$Zl!V@Y5hiB6!WLuAq{_4)sk|Sx z>oTp<0@{G`Q~LJGy9;-l8$z@TyI`Z)G6=23c|q%Z9G-(X@-gb{Zg31ge;3Wcq=}tj zp5*U`n)pf742qjPl$^}_Qjf_<>lh-e2Ov(I+K*ZEZix6|Kh7<&BIOCVt23aVTrXR3 z4DP5~8yh^FmQtU&u7x+_<4*^v+}?Eoa3?%ZU{8F@1aY9iP}u$y2X0qp>>6lfbsCl$Go zvfKC=VjRfIz&Ye!GU8)#m;rf+*Pn)gS<>Qwj{>C~uy_(#>_1aHV-(4UAl4%7!@{0$ z2#VRqA=<--U>lexkVX?V`?HW?fOb)H1o~TB$pX2xk@I+r*nI?o2S~MZsQV-WeIP#h z@QSFe&e4kVdz(0M1ZG3Dmij!zgTB8l(DJ-6z}#C-KGDQEp!%)qT0Rym<^tkcEq?J5 zG{Pt?@@2TfAVRRCke?ew!(zy#2Di(jGm!TH(&9Tdfil|u87+|p$}RB-vFRxEfh=+8 zC=3Qgd+#Xxw<%0E$u$LcQb+y(vvR(s>p~KBo0=%uQtxy-Jsz&J3%E$G3{t@&QZP;& zI|FfXoRU^%E5A-;vwmXr#fo{SF&6$sTI@a!1CruN8fi)s=3)olA@~?yqz52L{Pj2_ z!7_2iMFi6PDdO5Yken#Py9}B5On9Avw$C7jr3B?7^EC7`lSey}#s8gxWOh7M>o^Tt z4A3FM&Op{Y$%!0+0Kq-@V>UOiwvcTn$N&i#cSuDyvfGS|x!4KfHA(-v~i!tlOnvdWn z`}aH64A3-#x|gZTxkN%ETDk_m8Fi->C&K~ zk}ZL>g)#&)NFLr~p5K~wdioDt0k1C;(dS?Qgp1MV@NhRq+k6g^nD+P$c-WwC!LJ*( zk7BE#cKcCg@04<+7Sl;sVVBsSntr601I0;80AEfXA&=@u9lb({Mh4yHWm%e`c$wJb z#4Ae7QnaC$Kam@7gwNz!>Txt<;f5FC#&8F)ELLa5n>F@ZLyBD1Xocr8STbD=`eG*u zi^KsFv-iHk+v`XXk5TNgu{euNM~6v2sVA7(K(@)M<(iqos^m04rH>_WjdDj;q&!c{ zn94{>X3_-|61zo@#zQ3HRx~hQ$P@m06q{pMFVQoEO=iEais}${n3b8t$17Q^mJ-VP z!63@8;`RuXi$&mAwAgZU6{o1O%Y-Jw;?vnAoDpnk} zF^i>)bW!~lp0;7PSFGAa8w-cSE94Ps1HKhmMX(C? zrd6wqWY_4PY+6rNPd5NB^kiB8{05*!-0aEXBOfK{y9xFXvx_pd7pZ zBItk&PmTGy6&H#1qBIlbIuqEZwsh1@-@az^G_5p|J;`JtI&rgq>jW90rS)Yqjm7JU zVGlh_)%eU0l3`B~JWX(j;28piYT;RWdXB(Loe$F!iQ`8I(!|OBtOl&VOdG%qa;fWh zPu=ovtoTg^s}{QkvX6QM>P5Qrx>0Nx#QLQLL`%p13c1lG0G=7Twy<=7!yAc2ad{9{ z(y%Zs*3PCIZadfPI0U`J4|cy9JxHgFByo5M8*a`ILq9cJfz(i@Cz5yVvW}G4EXOTeIGQ`}mEI#T48BMYA zu?CNiwbK3PhOul8+Zd*uAIq+Q?G5bF@IaKzSG)LPJR1-uWBVbE{HsZenSiCQ?}ze4 zTMkQ>BXpPWlP0kzhsCiSM(w2>RtE7aNY}Rr{!AdXmX?^r*H1&gEdNnR=8-%Y`^8{i zDfLB?T0tr7PblGg0uy8VWY(W~!?c$tGccG>5bG*&Yzn(Sa1F@2MV;F3*qX~_R4z<^ zFo`AkESLGhw4?dV1?HaEy(3AiD8RK~bTU@&A5CIv0V`z}!^ExvHqH1tDkWN{vgaX6 zJUWe~FzoQkG**NOW1G&Vv)4`PbG>3UKAkJbf{ddP9hPH zf6dO;TNx<~)okuqnTOW8hDJORI$XFNl=;X{V|SU0`Y%%#@b&ye^8ZD8x=Bx$Ok#I6 z%QM?hD`)1dd*BGoTElt(d)FlT%w)qy1@iS@rYe7r1a{BU^)p!pJ7c2VU~lo(OqS0^ z*|gYM?1m-nUt7t)Bvw0&V)T4AW)#`Szat9O*-zQUzo%BRi~m4y387;wRp6Lly#k#P zUDk=HTJ|LwXHr8DzRRY4tFS@FTUngiz+SYRC4De4KWkuB<}Egi z{x)$epG_947Bc!Fm^KG?ZG0Xp22t9`vTqIE*vOvm8l2a}%FO$^4gR=^RT@7-*6Q0| z_0~$7**IfomrLcG7MRJflu-r0NAc+jyZ#R1ers30v+jMa0BZr)~#UmUGe>91uJAX zZDRClRvOBQf@UBv5kw&P zjPqMs8hI4GM-%iXNF{L7kZgJyPmoVwp#M+9i|H|o*xp4joIsw7 zVFQiWM6j7)E5UYxodmlG_7Xfn@Fc+t1TPUBCHOtTaZ^x15Pyvx|3dIF!KVbD6PzQ+ zBxU|VPi9KTV1iJB8$_d|K68b&e-sFw|Qg>?-J9PZkB7oO|BLT>sAO5SqGv+K7=^VuAe zONyryC@MveO|&VNq;F#JBTe#c`D#49~^esH;JQ O*T0 TradeDB: return TradeDB() +def _default_trading_day_ymd() -> str: + """주말·휴장이면 이전 장운영일 — 웹/API 날짜 인풋·기본 end/day.""" + try: + from kis_trader.utils.kr_trading_day import default_kr_trading_end + return default_kr_trading_end() + except Exception: + return datetime.now().strftime("%Y-%m-%d") + + def _get_fee_defaults() -> dict: """ DB 병합 스냅샷(env_config 공통)에서 수수료/세금 기본값 로드. @@ -256,7 +265,7 @@ def _enrich_trades_with_names(db, trades: list) -> None: ph = ", ".join(["%s"] * len(missing)) rows = db.conn.execute( "SELECT code, name FROM target_candidates_history " - "WHERE code IN (" + ph + ") AND name IS NOT NULL AND name != '' " + "WHERE code IN (" + ph + ") AND name IS NOT NULL AND name != '' AND name != code " "ORDER BY COALESCE(event_time, scan_time) DESC, id DESC", missing, ).fetchall() @@ -270,9 +279,34 @@ def _enrich_trades_with_names(db, trades: list) -> None: except Exception as exc: logger.debug("target_candidates_history 이름 조회 실패: %s", exc) + # 3) 그래도 없으면 실계좌 잔고(보유중인 경우만 이름 확인 가능)에서 보완 후 stock_meta 캐싱. + # - 키움 조건검색 실시간 응답엔 종목명이 없어(코드=이름 임시값) 여기서 채워야 + # 다음부터는 캐시 hit. inquire-price 는 hts_kor_isnm 미제공이라 잔고 API(prdt_name) 사용. + # - TTL 캐시된 잔고 조회라 REST 폭주 없음(force=False). + still_missing = [c for c in codes if c not in code_to_name] + if still_missing: + try: + order_mgr, _ = _portfolio_infra() + holdings = order_mgr.get_broker_holdings(force=False) or {} + except Exception as exc: + holdings = {} + logger.debug("종목명 잔고 조회용 client 준비 실패: %s", exc) + for c in still_missing: + n = str((holdings.get(c) or {}).get("name") or "").strip() + if n and n != c: + code_to_name[c] = n + db.upsert_stock_meta(c, name=n) + for t in trades: c = str(t.get("code") or "").strip() - t["name"] = code_to_name.get(c, c) + if c in code_to_name: + t["name"] = code_to_name[c] + else: + try: + from kis_trader.utils.stock_name import resolve_stock_display_name + t["name"] = resolve_stock_display_name(db, c, fallback=c) + except Exception: + t["name"] = c def _norm_ts(t: Dict[str, Any], keys) -> str: @@ -312,9 +346,54 @@ def _int_display_price(v: Any) -> int: return 0 +def _format_trade_time_display(raw: Any) -> str: + """웹 거래표 — YYYYMMDDHHMM(봉)/ISO 모두 초(SS) 포함 표시.""" + if raw is None: + return "" + s = str(raw).strip() + if not s: + return "" + if len(s) >= 19 and s[4] == "-" and ":" in s: + return s[:19] + if len(s) >= 16 and s[4] == "-" and s.count(":") == 1: + return s[:16] + ":00" + d = s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "") + if len(d) >= 14: + return ( + f"{d[:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:{d[12:14]}" + ) + if len(d) >= 12: + return f"{d[:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:00" + if len(d) >= 8: + return f"{d[:4]}-{d[4:6]}-{d[6:8]}" + return s + + +def _enrich_trade_times_for_web(trade: Dict[str, Any]) -> None: + """entry/exit·buy/sell 시각 필드를 웹 표시용(초 포함)으로 통일.""" + for key in ( + "entry_time", "exit_time", "buy_time", "sell_time", "buy_date", "sell_date", + ): + if trade.get(key) is not None and str(trade.get(key)).strip(): + trade[key] = _format_trade_time_display(trade[key]) + if not trade.get("buy_time") and trade.get("entry_time"): + trade["buy_time"] = trade["entry_time"] + if not trade.get("sell_time") and trade.get("exit_time"): + trade["sell_time"] = trade["exit_time"] + if not trade.get("buy_date") and trade.get("buy_time"): + trade["buy_date"] = trade["buy_time"] + if not trade.get("sell_date") and trade.get("sell_time"): + trade["sell_date"] = trade["sell_time"] + if not trade.get("entry_time") and trade.get("buy_time"): + trade["entry_time"] = trade["buy_time"] + if not trade.get("exit_time") and trade.get("sell_time"): + trade["exit_time"] = trade["sell_time"] + + def _trade_with_int_prices(trade: Dict) -> Dict: - """웹 거래표 매수가·매도가 — 소수점 제거.""" + """웹 거래표 매수가·매도가 — 소수점 제거 + 시각 초 표시.""" out = dict(trade) + _enrich_trade_times_for_web(out) for k in ( "buy_price", "sell_price", "entry_price", "exit_price", "entry", "exit", "avg_price", @@ -354,23 +433,40 @@ def _enrich_momentum_trades_debug( trades: List[Dict], *, total_budget_krw: float, + with_tick_debug: bool = True, ) -> None: - """매도 시각 순 누적손익·틱/OHLC 디버그 라벨 (모멘텀 웹 거래표).""" + """매도 시각 순 누적손익·누적% (선택: 틱/OHLC 디버그 라벨). 전 전략 거래표 공용.""" if not trades: return ordered = sorted(trades, key=_trade_exit_sort_key) cum = 0.0 tb = float(total_budget_krw or 0) for t in ordered: - pnl = float(t.get("pnl") or 0) + pnl = float(t.get("pnl") or t.get("realized_pnl") or 0) cum += pnl t["cum_pnl"] = int(round(cum)) t["cum_return_pct"] = round(cum / tb * 100.0, 2) if tb > 0 else 0.0 - el = _momentum_source_label("entry", str(t.get("entry_source") or "")) - xl = _momentum_source_label("exit", str(t.get("exit_source") or "")) - t["entry_source_label"] = el - t["exit_source_label"] = xl - t["debug_tick"] = f"{el}→{xl}" + if with_tick_debug: + el = _momentum_source_label("entry", str(t.get("entry_source") or "")) + xl = _momentum_source_label("exit", str(t.get("exit_source") or "")) + t["entry_source_label"] = el + t["exit_source_label"] = xl + t["debug_tick"] = f"{el}→{xl}" + + +def _peak_cum_from_trades(trades: List[Dict]) -> Tuple[float, str]: + """매도시각 순 장중 누적 최고액·도달 시각.""" + peak = 0.0 + peak_at = "" + cum = 0.0 + for t in sorted(trades or [], key=_trade_exit_sort_key): + cum += float(t.get("pnl") or t.get("realized_pnl") or 0) + if cum > peak: + peak = cum + peak_at = str( + t.get("exit_time") or t.get("sell_time") or t.get("sell_date") or "" + ) + return peak, peak_at[:19] if peak_at else "" def _resolve_backtest_universe( @@ -655,23 +751,32 @@ def api_actual(): top_list = [{"code": c, "name": code_name[c], "pnl": round(v)} for c, v in top_codes] # 누적손익·누적% (청산 완료, 매도시각 순 — 백테 거래표와 동일) - latest_env = db.get_latest_env() - env_row = dict(latest_env["snapshot"]) if latest_env else {} + # 한도: get_latest_env 스냅샷이 비면 SCALP 600만으로 떨어질 수 있어 merged 우선 + try: + from kis_trader.utils.env import get_merged_env_dict, invalidate_merged_env_cache + invalidate_merged_env_cache() + env_row = dict(get_merged_env_dict() or {}) + except Exception: + latest_env = db.get_latest_env() + env_row = dict(latest_env["snapshot"]) if latest_env else {} + if not env_row: + latest_env = db.get_latest_env() + env_row = dict(latest_env["snapshot"]) if latest_env else {} total_budget_krw = 0.0 try: + from kis_trader.backtest.backtest_portfolio_common import resolve_portfolio_params from kis_trader.backtest.momentum_backtest_common import ( resolve_momentum_portfolio_params, ) - from kis_trader.backtest.scalping_backtest_common import ( - resolve_scalp_portfolio_params, - ) strat_u = (strategy or "SCALP").upper() if strat_u == "MOMENTUM": port = resolve_momentum_portfolio_params(env_row, {}) else: - port = resolve_scalp_portfolio_params( - env_row, None, strategy=strat_u if strat_u != "ALL" else "SCALP", - ) + # SHORT/TAIL → 꼬리 한도(120만). SCALP 키로 폴백되면 600만 오표시. + port_strat = _portfolio_strategy_key(strat_u) + if port_strat not in ("TAIL", "SHORT", "SCALP", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND"): + port_strat = "SCALP" + port = resolve_portfolio_params(env_row, strategy=port_strat) total_budget_krw = float(port.get("total_budget_krw") or 0) except Exception: total_budget_krw = 0.0 @@ -691,6 +796,28 @@ def api_actual(): peak_cum = cum_trace peak_cum_at = str(t.get("sell_date") or "") + _enrich_trades_with_names(db, trades) + + tick_live: Dict[str, Any] = {} + try: + from kis_trader.backtest.breakout_tick_loader import build_tick_coverage_meta_for_day + cov_codes = { + str(t.get("code") or "").strip() + for t in closed_trades + if str(t.get("code") or "").strip() + } + if cov_codes and start and end: + # 돌파=1분 · 꼬리=3분 · 그 외 1분 (실매 기록 기준 커버 참고) + tf_map = {"SHORT": 3, "TAIL": 3} + tf = int(tf_map.get((strategy or "").upper(), 1)) + tick_live = build_tick_coverage_meta_for_day( + db, cov_codes, start, end, + timeframe=tf, trades=closed_trades, + ) or {} + except Exception as _cov_ex: + logger.warning("api_actual tick coverage: %s", _cov_ex) + tick_live = {} + return jsonify({ "summary": { "total_trades": total, @@ -713,6 +840,7 @@ def api_actual(): "closed_count": len(closed_trades), "open_count": len(open_trades), }, + "tick_live": tick_live, "equity": equity, "daily": daily_list, "reasons": reasons, @@ -727,8 +855,9 @@ def api_actual(): # API: 실거래 당일 운영 대시보드 (전략별 + 합계) # ──────────────────────────────────────────────────────────────────────────── -# ※ SCALP·DBBAND·RANGE_BREAK 는 숨김 전략(strategy_ids.HIDDEN_STRATEGY_IDS) → 대시보드 집계 제외. +# ※ RANGE_BREAK·DBBAND 는 숨김 전략 → 대시보드 집계 제외. SCALP 포함. _ACTUAL_DASHBOARD_STRATEGIES: Tuple[str, ...] = ( + "SCALP", "SHORT", "MOMENTUM", "UPDOW", @@ -775,7 +904,7 @@ def _is_forced_ghost_trade(row: Dict[str, Any]) -> bool: def _day_bounds_kst(day_iso: str) -> Tuple[str, str]: d = (day_iso or "").strip()[:10] if len(d) != 10: - d = datetime.now().strftime("%Y-%m-%d") + d = _default_trading_day_ymd() return f"{d} 00:00:00", f"{d} 23:59:59" @@ -1044,9 +1173,15 @@ def _build_actual_dashboard(db: TradeDB, day_iso: str) -> Dict[str, Any]: @app.route("/api/actual/dashboard", methods=["GET"]) def api_actual_dashboard(): + from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day day = (request.args.get("date") or "").strip()[:10] if not day: - day = datetime.now().strftime("%Y-%m-%d") + day = _default_trading_day_ymd() + else: + try: + day = clamp_to_prev_kr_trading_day(day) + except ValueError: + day = _default_trading_day_ymd() db = _db() try: payload = _build_actual_dashboard(db, day) @@ -1063,7 +1198,8 @@ def api_actual_dashboard(): # ──────────────────────────────────────────────────────────────────────────── def _t2dt(t: str) -> datetime: - return datetime.strptime(t, "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(t) def _backtest_period_days(start: str, end: str, fallback: int = 1) -> int: @@ -1096,6 +1232,7 @@ from kis_trader.strategies.breakout import ( check_buy_signal_breakout_live as _bo_check_buy, check_sell_signal_breakout_live as _bo_check_sell, normalize_breakout_max_loss_krw, + resolve_breakout_skip_hts_scan_dupes, run_breakout_backtest as _bo_run_backtest, ) from kis_trader.strategies.base import is_strategy_eod_bar @@ -1104,7 +1241,7 @@ from kis_trader.strategies.base import is_strategy_eod_bar def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: """config_momentum + env_config 병합에서 모멘텀 웹·백테 초기값. - - MOMENTUM_* 우선 → SCALP_MOM_* / SCALP_* / ``get_scalping_defaults_from_db`` 폴백. + - MOMENTUM_* 전용. - ``insert_env_snapshot`` / param_search_apply → config_momentum 저장. """ env: Dict[str, Any] = _strategy_env("MOMENTUM") @@ -1167,11 +1304,11 @@ def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: ) high_chase = pickf( - ("HIGH_CHASE_THR", "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "HIGH_PRICE_CHASE_THRESHOLD"), + ("MOMENTUM_HIGH_CHASE_THR", "HIGH_CHASE_THR", "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "HIGH_PRICE_CHASE_THRESHOLD"), float(_d.get("high_chase_thr", 0.96)), ) max_daily_chg = pickf( - ("MAX_DAILY_CHG", "SCALP_MAX_DAILY_CHANGE_PCT", "MAX_DAILY_CHANGE_PCT"), + ("MOMENTUM_MAX_DAILY_CHG", "MAX_DAILY_CHG", "SCALP_MAX_DAILY_CHANGE_PCT", "MAX_DAILY_CHANGE_PCT"), float(_d.get("max_daily_chg", 20.0)), ) min_price = pickf( @@ -1197,7 +1334,9 @@ def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: except (ValueError, TypeError): pass - _udf = env.get("SCALP_USE_DEFENSE_FILTERS") + _udf = env.get("MOMENTUM_USE_DEFENSE_FILTERS") + if _udf in (None, "", "None"): + _udf = env.get("SCALP_USE_DEFENSE_FILTERS") if _udf not in (None, "", "None"): use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on") else: @@ -1291,11 +1430,11 @@ def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: _pg_strat_set = _pg_strat_raw not in (None, "", "None") return { - "mom_rsi_min": pickf(("MOMENTUM_RSI_MIN", "SCALP_MOM_RSI_MIN"), 50.0), - "mom_rsi_max": pickf(("MOMENTUM_RSI_MAX", "SCALP_MOM_RSI_MAX"), 80.0), - "mom_vol_mult": pickf(("MOMENTUM_VOL_MULT", "SCALP_MOM_VOL_MULT"), 1.5), - "mom_vol_win": picki(("MOMENTUM_VOL_WIN", "SCALP_MOM_VOL_WIN"), 5), - "mom_time_end_hm": picki(("MOMENTUM_TIME_END_HM", "SCALP_MOM_TIME_END_HM"), 1430), + "mom_rsi_min": pickf(("MOMENTUM_RSI_MIN",), 50.0), + "mom_rsi_max": pickf(("MOMENTUM_RSI_MAX",), 80.0), + "mom_vol_mult": pickf(("MOMENTUM_VOL_MULT",), 1.05), + "mom_vol_win": picki(("MOMENTUM_VOL_WIN",), 5), + "mom_time_end_hm": picki(("MOMENTUM_TIME_END_HM",), 1530), "mom_time_start_hm": picki( ("MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START"), 900, ), @@ -1313,9 +1452,13 @@ def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: "trail_trigger": pick_trail_ui("SCALP_ATR_UP_MULT", float(_d.get("trail_trigger", 0.007))), "trail_stop": pick_trail_ui("SCALP_ATR_DOWN_MULT", float(_d.get("trail_stop", 0.004))), "cooldown_min": ( - max(0, int(float(env.get("SCALP_COOLDOWN_SEC"))) // 60) - if env.get("SCALP_COOLDOWN_SEC") not in (None, "", "None") - else float(_d.get("cooldown_min", 10)) + max(0, int(float(env.get("MOMENTUM_COOLDOWN_SEC"))) // 60) + if env.get("MOMENTUM_COOLDOWN_SEC") not in (None, "", "None") + else ( + max(0, int(float(env.get("SCALP_COOLDOWN_SEC"))) // 60) + if env.get("SCALP_COOLDOWN_SEC") not in (None, "", "None") + else float(_d.get("cooldown_min", 10)) + ) ), "max_daily": picki(("MOMENTUM_MAX_DAILY", "SCALP_MAX_DAILY"), 5), "slot_money": slot_money, @@ -1355,14 +1498,14 @@ def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: "pg_strategy_explicit": _pg_strat_set, # 호가 스프레드 상한(%) — 0.45 = 0.45%. kiwoom_0d 본체 재계산용 (6/25~ 유효) "max_spread_pct": pickf( - ("MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"), + ("MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, ), "eod_enabled": pick_bool(("MOMENTUM_EOD_ENABLED",), True), "eod_hm": ( - str(env.get("MOMENTUM_EOD_HM") or "15:25").strip() + str(env.get("MOMENTUM_EOD_HM") or "15:20").strip() if env.get("MOMENTUM_EOD_HM") not in (None, "", "None") - else "15:25" + else "15:20" ), **_momentum_exit_ui_from_engine(), } @@ -1456,6 +1599,7 @@ def _momentum_engine_dict_to_ui( "use_defense_filters": d.get("use_defense_filters"), "use_high_chase_filter": d.get("use_high_chase_filter"), "use_daily_range_filter": d.get("use_daily_range_filter"), + "backtest_skip_pre_subscribe": bool(d.get("backtest_skip_pre_subscribe", False)), "use_ema_filter": d.get("use_ema_filter"), "use_rsi_max_filter": d.get("use_rsi_max_filter"), "pattern_breakout": d.get("pattern_breakout"), @@ -1600,6 +1744,15 @@ def _scalp_ui_defaults_from_db() -> Dict[str, Any]: "min_margin": float(env.get("SCALP_MIN_PROFIT_PCT") or _d.get("min_margin", 0.2)), "use_defense_filters": use_def, "use_macd_cross": use_macd, + "rsi_period": int(float(env.get("SCALP_RSI_PERIOD") or _d.get("rsi_period", 3))), + "vol_mult": float(_d.get("vol_mult", 0)), + "time_start_hm": int(float(env.get("SCALP_TIME_START") or env.get("TIME_START") or _d.get("time_start_hm", 900))), + "time_end_hm": int(float(env.get("SCALP_TIME_END") or env.get("TIME_END") or _d.get("time_end_hm", 1530))), + "max_daily": int(float(env.get("SCALP_MAX_DAILY") or _d.get("max_daily", 3))), + "max_stocks": int(float(env.get("SCALP_MAX_STOCKS") or env.get("MAX_STOCKS") or _d.get("max_stocks", 3))), + "total_budget_krw": float(env.get("SCALP_TOTAL_BUDGET_KRW") or _d.get("total_budget_krw", 0) or 0), + "skip_hts_scan_dupes": se.resolve_scalp_skip_hts_scan_dupes(env), + "require_reversal_candle": bool(_d.get("require_reversal_candle", True)), } @@ -1750,6 +1903,7 @@ def _bo_defaults_from_db() -> Dict[str, Any]: ), "ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))), "ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))), + "skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env), "ob_filter_enabled": _strategy_trigger_filter_enabled( env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED", ), @@ -1757,7 +1911,7 @@ def _bo_defaults_from_db() -> Dict[str, Any]: env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED", ), "max_spread_pct": pick( - ("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"), 0.45, float, + ("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, float, ), } @@ -1938,12 +2092,38 @@ def _backtest_filter_toggle(raw: Any) -> Optional[bool]: return str(raw).strip().lower() in ("1", "true", "y", "yes", "on") +def _backtest_env_timeline_from_request(req: Any = None) -> bool: + """웹 백테 env 타임라인 — 기본 OFF. + + ``env_timeline=1`` (또는 true/on) 일 때만 ON. + ON: 봉 시각별 과거 env_config 스냅샷으로 파람 덮어씀(실매 재현). + OFF: 폼/요청 파람을 구간 전체 고정(파람 평가·Optuna 정합). + """ + if req is None: + req = request + raw = None + try: + raw = req.args.get("env_timeline") + except Exception: + raw = None + if raw is None or str(raw).strip() == "": + try: + body = req.get_json(silent=True) or {} + if isinstance(body, dict): + raw = body.get("env_timeline") + except Exception: + raw = None + if raw is None or str(raw).strip() == "": + return False + return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") + + def _eod_params_from_request( req: Any, defaults: Dict[str, Any], *, default_enabled: bool = True, - default_hm: str = "15:25", + default_hm: str = "15:20", ) -> Dict[str, Any]: """웹 백테 쿼리 → 실매와 동일 ``eod_enabled`` / ``eod_hm`` (비우면 DB 기본값).""" raw_en = req.args.get("eod_enabled") @@ -1959,39 +2139,92 @@ def _eod_params_from_request( return {"eod_enabled": eod_enabled, "eod_hm": eod_hm} -def _daily_trail_params_from_request(req: Any) -> Dict[str, Any]: - """백테 탭 '당일 누적손익 트레일 익절' 입력 → 시뮬 파라미터(전용 daily_trail_* 키). +def _daily_trail_params_from_request( + req: Any, + *, + prefix: str = "SHORT", + default_enabled: Optional[bool] = None, +) -> Dict[str, Any]: + """백테 탭 '당일 누적손익 트레일 익절' → 시뮬 파라미터. - drop<=0 이면 빈 dict 반환 → 게이트 OFF(기존 백테 동작 불변). - arm_krw<=0 이면 실매 _trail_reached 가 비활성이라 트레일이 발동하지 않는다 - (작은 수익에 조기 종료 방지 — 실매와 동일 안전장치). UI 힌트로 안내. - 꼬리 개별포지션 'trail_arm_pct' 와 키가 겹치지 않도록 daily_trail_* 전용 키 사용. + - ``daily_profit_enabled`` = 마스터 스위치 (실매 ``{prefix}_DAILY_PROFIT_TARGET_ENABLED``). + - 다단/단일 숫자는 규칙일 뿐 — 스위치 OFF면 게이트 OFF (숫자만으로 자동 ON 하지 않음). + - 쿼리에 enabled 없으면 ``default_enabled``(보통 DB) 사용. """ + def _truthy(raw: Any, default: bool = False) -> bool: + if raw is None or raw == "": + return default + return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") + + raw_en = req.args.get("daily_profit_enabled") + if raw_en is None and default_enabled is None: + # DB 실매값 추종 + from kis_trader.utils.env import get_env_from_db + sid = str(prefix or "SHORT").strip().upper() + if sid == "TAIL": + sid = "SHORT" + raw_db = get_env_from_db(f"{sid}_DAILY_PROFIT_TARGET_ENABLED", "false") + enabled = _truthy(raw_db, False) + elif raw_en is None: + enabled = bool(default_enabled) + else: + enabled = _truthy(raw_en, False) + mode = str(req.args.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" - # 다단 tier 우선 — 값이 있으면 단일 drop 무시. 'off'/빈값이면 단일 drop 경로. tiers = str(req.args.get("daily_trail_tiers") or "").strip() - if tiers and tiers.lower() != "off": - return { - "_backtest_daily_profit_trail": True, - "daily_profit_mode": mode, - "daily_trail_tiers": tiers, - } try: drop = float(req.args.get("daily_trail_drop_pct") or 0) except (TypeError, ValueError): drop = 0.0 - if drop <= 0: - return {} try: arm_krw = float(req.args.get("daily_trail_arm_krw") or 0) except (TypeError, ValueError): arm_krw = 0.0 - return { - "_backtest_daily_profit_trail": True, + + out: Dict[str, Any] = { + "daily_profit_enabled": enabled, + "_daily_profit_strategy_id": str(prefix or "SHORT").upper(), + "_backtest_daily_profit_trail": bool(enabled), "daily_profit_mode": mode, - "daily_trail_drop_pct": drop, - "daily_trail_arm_krw": arm_krw, } + if not enabled: + return out + if tiers and tiers.lower() != "off": + out["daily_trail_tiers"] = tiers + elif drop > 0: + out["daily_trail_drop_pct"] = drop + out["daily_trail_arm_krw"] = arm_krw + return out + + +def _daily_trail_save_patch(body: Dict[str, Any], prefix: str) -> Dict[str, str]: + """ + 당일 누적손익 다단 트레일 — 봇저장 패치. + ``daily_profit_enabled`` → ``{prefix}_DAILY_PROFIT_TARGET_ENABLED`` (마스터). + 다단 문자열은 규칙만 저장 — 값 있다고 ENABLED를 강제 true 하지 않음. + """ + out: Dict[str, str] = {} + if "daily_profit_enabled" in body: + en = body.get("daily_profit_enabled") + on = ( + en is True + or str(en).strip().lower() in ("1", "true", "t", "y", "yes", "on") + ) + out[f"{prefix}_DAILY_PROFIT_TARGET_ENABLED"] = "true" if on else "false" + if "daily_trail_tiers" in body: + tiers = str(body.get("daily_trail_tiers") or "").strip() + if tiers and tiers.lower() != "off": + out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = tiers + out[f"{prefix}_DAILY_PROFIT_MODE"] = ( + str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" + ) + else: + out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = "" + elif "daily_profit_mode" in body: + out[f"{prefix}_DAILY_PROFIT_MODE"] = ( + str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" + ) + return out def _strategy_trigger_filter_enabled( @@ -2001,11 +2234,13 @@ def _strategy_trigger_filter_enabled( kind: str, global_key: str, ) -> bool: - """전략별 TRIGGER 필터 ON/OFF — 전략키 우선, 없으면 공통키, 둘 다 없으면 True.""" + """전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌.""" sk = f"{prefix}_{kind}_FILTER_ENABLED" raw = env.get(sk) if raw not in (None, "", "None"): return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") + if kind == "ORDERBOOK": + return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기 raw_g = env.get(global_key) if raw_g not in (None, "", "None"): return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on") @@ -2103,17 +2338,17 @@ def api_backtest_scalping(): _force_eod_raw = request.args.get("force_eod_exit") if _force_eod_raw in (None, ""): _mom_eod_src = _mom_def if _mom_def is not None else _def - eod_patch = _eod_params_from_request(request, _mom_eod_src, default_hm="15:25") + eod_patch = _eod_params_from_request(request, _mom_eod_src, default_hm="15:20") else: # 레거시 force_eod_exit 쿼리 (하위호환) eod_patch = { "eod_enabled": str(_force_eod_raw).strip().lower() in ("1", "true", "y", "yes", "on"), "eod_hm": str( - (_mom_def or _def).get("eod_hm") or "15:25" - ).strip() or "15:25", + (_mom_def or _def).get("eod_hm") or "15:20" + ).strip() or "15:20", } - # ── 모드 분기: reversal(기존, V자 반전) vs momentum(추격형, SCALP_MODE=momentum 백테스트) ── + # ── 모드 분기: reversal vs momentum (모멘텀 탭은 /api/backtest/momentum) ── # 실매매 봇이 ``check_buy_signal_momentum_live`` 를 사용 중이면 백테스트도 # ``mode=momentum`` 으로 호출해야 동일한 규칙으로 비교할 수 있다. # (mode / _mom_def 는 상단에서 이미 확정) @@ -2251,6 +2486,15 @@ def api_backtest_scalping(): "stoch_k_period": int(_def.get("stoch_k_period", 5)), "stoch_d_period": int(_def.get("stoch_d_period", 3)), "stoch_slow": int(_def.get("stoch_slow", 3)), + "skip_hts_scan_dupes": _tail_bool_arg( + request, "skip_hts_scan_dupes", _def.get( + "skip_hts_scan_dupes", + te.resolve_tail_skip_hts_scan_dupes() if _TAIL_ENGINE_AVAILABLE else False, + ), + ), + "require_reversal_candle": _tail_bool_arg( + request, "require_reversal_candle", _def.get("require_reversal_candle", True), + ), # scan_interval_min — 유니버스 해석 후 덮어씀 (이력=1분, 시뮬=5분) "scan_interval_min": 5, # 모멘텀 진입 전용 (mode=momentum 에서만 사용) @@ -2282,6 +2526,11 @@ def api_backtest_scalping(): params["mom_min_from_open_pct"] = float(_mmin) if _mmin not in (None, "") else float( _mom_def.get("mom_min_from_open_pct", -999.0), ) + params["backtest_skip_pre_subscribe"] = _tail_bool_arg( + request, + "backtest_skip_pre_subscribe", + (_mom_def or {}).get("backtest_skip_pre_subscribe", False), + ) _mom_eng = me.get_momentum_defaults_from_db() _tr_req = request.args.get("trail_pct") params["trail_pct"] = ( @@ -2444,6 +2693,7 @@ def api_backtest_scalping(): "db": db, "start_key": start_key, "end_key": end_key, + "backtest_env_timeline": _backtest_env_timeline_from_request(request), } all_virtual_trades = sbc.run_scalping_backtest_web_aligned( codes_candles, @@ -2458,10 +2708,11 @@ def api_backtest_scalping(): meta_out=bt_meta, ) - # 당일 누적손익 트레일 익절 시뮬 (백테 탭 입력 → drop>0 일 때만 ON). + # 당일 누적손익 트레일 익절 시뮬 — 마스터 스위치(daily_profit_enabled) 기준. # trades 에 pnl·buy_time·sell_time 부착 완료 후 신규진입 차단. (모멘텀 포함) - _trail_p = _daily_trail_params_from_request(request) - if _trail_p: + _trail_pfx = "MOMENTUM" if str(mode or "").strip().lower() == "momentum" else "SCALP" + _trail_p = _daily_trail_params_from_request(request, prefix=_trail_pfx) + if _trail_p.get("_backtest_daily_profit_trail"): from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim all_virtual_trades = apply_daily_profit_halt_sim( all_virtual_trades, _trail_p, budget_krw=float(total_budget_v or 0), @@ -2524,9 +2775,12 @@ def api_backtest_scalping(): daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)} for d, v in sorted(daily.items())] - # 가상거래에도 종목명 표시 (실거래와 동일) - if mode == "momentum": - _enrich_momentum_trades_debug(all_virtual_trades, total_budget_krw=total_budget_v) + # 가상거래에도 종목명·누적손익 표시 (실거래와 동일) + _enrich_momentum_trades_debug( + all_virtual_trades, + total_budget_krw=total_budget_v, + with_tick_debug=(mode == "momentum"), + ) trades_out = _trades_recent_first(all_virtual_trades, 200) _enrich_trades_with_names(db, trades_out) @@ -2583,6 +2837,9 @@ def api_backtest_scalping(): "trail_arm_pct": (params.get("trail_arm_pct", 0) * 100) if mode == "momentum" else None, "max_hold_bars": params.get("max_hold_bars") if mode == "momentum" else None, "ratchet_tiers": params.get("ratchet_tiers") if mode == "momentum" else None, + "backtest_skip_pre_subscribe": ( + params.get("backtest_skip_pre_subscribe") if mode == "momentum" else None + ), "exit_priority": ( "ratchet/shoulder→trail→sl→time→loss_cap→tp_max→eod" if mode == "momentum" else None @@ -2603,8 +2860,8 @@ def api_backtest_scalping(): "daily_avg_pct": daily_avg_pct, "backtest_days": period_days, "budget_warning": portfolio.get("budget_warning"), - "peak_cum_pnl": round(peak_cum) if mode == "momentum" else None, - "peak_cum_at": peak_cum_at if mode == "momentum" else None, + "peak_cum_pnl": round(peak_cum), + "peak_cum_at": (peak_cum_at[:19] if peak_cum_at else ""), "tick_backtest": bt_meta.get("tick_backtest") if mode == "momentum" else None, "skip_stats": bt_meta.get("skip_stats") if mode == "momentum" else None, }, @@ -2705,7 +2962,11 @@ def _tail_engine_dict_to_ui( "rsi_period": int(d.get("rsi_period") or 14), "time_start": int(d.get("time_start_hm") or 930), "time_end": int(d.get("time_end_hm") or 1500), - "max_daily": int(d.get("max_daily") or 3), + "max_daily": int(d.get("max_daily") or 20), + "symbol_daily_loss_limit_krw": float(d.get("symbol_daily_loss_limit_krw") or 30000), + "symbol_daily_loss_limit_pct": float(d.get("symbol_daily_loss_limit_pct") or 1.5), + "reentry_min_edge_krw": float(d.get("reentry_min_edge_krw") or 0), + "reentry_require_nonneg": bool(d.get("reentry_require_nonneg", False)), "max_rec_3m": _tail_ratio_to_ui_pct(d.get("max_rec_3m"), 90.0), "high_chase": _tail_ratio_to_ui_pct(d.get("high_chase_thr"), 96.0), "min_price": d.get("min_price"), @@ -2724,7 +2985,11 @@ def _tail_engine_dict_to_ui( "total_budget_krw": int(float( snap.get("TAIL_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0 ) or int(float(slot_raw)) * int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3)), - "skip_hts_scan_dupes": d.get("skip_hts_scan_dupes", True), + "skip_hts_scan_dupes": d.get( + "skip_hts_scan_dupes", + te.resolve_tail_skip_hts_scan_dupes(snap) if _TAIL_ENGINE_AVAILABLE else False, + ), + "cand_limit": int(d.get("cand_limit") or 0), "use_intraday_drop": d.get("use_intraday_drop", False), "use_ma20_filter": d.get("use_ma20_filter", False), "use_rsi_filter": d.get("use_rsi_filter", True), @@ -2738,8 +3003,9 @@ def _tail_engine_dict_to_ui( "max_hold_bars": int(d.get("max_hold_bars") or 0), "trail_pct": _tail_frac_to_ui_pct(d.get("trail_pct")) or 0.0, "trail_arm_pct": _tail_frac_to_ui_pct(d.get("trail_arm_pct")) or 0.0, - "backtest_use_tick_db": d.get("backtest_use_tick_db", False), - "backtest_tick_fallback_ohlc": d.get("backtest_tick_fallback_ohlc", True), + "backtest_use_tick_db": d.get("backtest_use_tick_db", True), + "backtest_use_tick_exit": d.get("backtest_use_tick_exit", True), + "backtest_tick_fallback_ohlc": d.get("backtest_tick_fallback_ohlc", False), "pattern_hammer": d.get("pattern_hammer", True), "pattern_pin": d.get("pattern_pin", False), "pattern_engulfing": d.get("pattern_engulfing", False), @@ -2755,11 +3021,14 @@ def _tail_engine_dict_to_ui( ), "max_spread_pct": float( snap.get("TAIL_ORDERBOOK_MAX_SPREAD_PCT") - or snap.get("ORDERBOOK_MAX_SPREAD_PCT") or d.get("max_spread_pct") or 0.45 ), # 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값 + 사용자 저장 프리셋 목록(세미콜론 구분) + "daily_profit_enabled": ( + str(snap.get("SHORT_DAILY_PROFIT_TARGET_ENABLED") or "false").strip().lower() + in ("1", "true", "t", "y", "yes", "on") + ), "daily_trail_tiers": str(snap.get("SHORT_DAILY_PROFIT_TRAIL_TIERS") or "").strip(), "daily_profit_mode": str(snap.get("SHORT_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing", "ratchet_presets": str(snap.get("BT_RATCHET_PRESETS") or "").strip(), @@ -2770,7 +3039,7 @@ def _tail_engine_dict_to_ui( if snap.get("TAIL_EOD_ENABLED") not in (None, "", "None") else True ), - "eod_hm": str(snap.get("TAIL_EOD_HM") or "15:25").strip() or "15:25", + "eod_hm": str(snap.get("TAIL_EOD_HM") or "15:20").strip() or "15:20", } @@ -2783,30 +3052,6 @@ def _tail_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str return _tail_engine_dict_to_ui(d, snap) -def _daily_trail_save_patch(body: Dict[str, Any], prefix: str) -> Dict[str, str]: - """ - 당일 누적손익 다단 트레일 익절(레칫식) 저장 패치 — 전 전략 공통. - body 의 daily_trail_tiers/daily_profit_mode → {prefix}_DAILY_PROFIT_* (config_{strategy}). - - tier 값이 있으면 trailing 모드 + 일일익절 활성화를 함께 저장해야 실매가 작동한다 - (없으면 _guard_active=False 라 무동작). 'off'/빈값이면 tier 만 비우고 모드는 안 건드림. - prefix 예: 'SHORT'(꼬리)·'MOMENTUM'·'BREAKOUT'. - """ - out: Dict[str, str] = {} - if "daily_trail_tiers" not in body: - return out - tiers = str(body.get("daily_trail_tiers") or "").strip() - if tiers and tiers.lower() != "off": - out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = tiers - out[f"{prefix}_DAILY_PROFIT_MODE"] = ( - str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" - ) - out[f"{prefix}_DAILY_PROFIT_TARGET_ENABLED"] = "true" - else: - out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = "" - return out - - def _accumulate_preset( snap: Dict[str, Any], patch: Dict[str, str], key: str, value: Any, max_keep: int = 20 ) -> None: @@ -3434,6 +3679,10 @@ def _momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if "eod_enabled" in body: patch["MOMENTUM_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) + if "backtest_skip_pre_subscribe" in body: + patch["MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE"] = _env_bool_10( + body.get("backtest_skip_pre_subscribe"), + ) x = gv("eod_hm") if x is not None: eod_s = str(x).strip() @@ -3515,6 +3764,9 @@ def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: x = gv("rsi_overbought") if x is not None: patch["SCALP_RSI_OVERBOUGHT"] = str(float(x)) + x = gv("rsi_period") + if x is not None: + patch["SCALP_RSI_PERIOD"] = str(int(float(x))) x = gv("sl_pct") if x is not None: @@ -3531,6 +3783,11 @@ def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if x is not None: patch["SCALP_MIN_DROP_RATE"] = str(abs(float(x)) / 100.0) + x = gv("vol_mult") + if x is not None: + # 거래량 배수 — 0=OFF. 공용 VOL_MULTIPLIER (실매 reload_config / 엔진 동일 키) + patch["VOL_MULTIPLIER"] = str(float(x)) + x = gv("shoulder_min_high") if x is not None: sr = str(abs(float(x)) / 100.0) @@ -3591,6 +3848,34 @@ def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if "use_macd_cross" in body: patch["SCALP_USE_MACD_CROSS"] = _env_bool_10(body.get("use_macd_cross")) + x = gv("time_start_hm") + if x is not None: + patch["SCALP_TIME_START"] = str(int(float(x))) + x = gv("time_end_hm") + if x is not None: + te = str(int(float(x))) + patch["SCALP_TIME_END"] = te + patch["TIME_END"] = te + x = gv("max_daily") + if x is not None: + patch["SCALP_MAX_DAILY"] = str(int(float(x))) + x = gv("slot_money") + if x is not None: + sm = str(int(float(x))) + patch["SLOT_MONEY_DEFAULT"] = sm + patch["SCALP_MAX_BUY_AMOUNT"] = sm + patch["MAX_BUY_AMOUNT_PER_STOCK"] = sm + x = gv("max_stocks") + if x is not None: + patch["SCALP_MAX_STOCKS"] = str(int(float(x))) + x = gv("total_budget_krw") + if x is not None: + patch["SCALP_TOTAL_BUDGET_KRW"] = str(int(float(x))) + if "skip_hts_scan_dupes" in body: + patch["SCALP_SKIP_HTS_SCAN_DUPES"] = _env_bool_10(body.get("skip_hts_scan_dupes")) + if "require_reversal_candle" in body: + patch["SCALP_REQUIRE_REVERSAL_CANDLE"] = _env_bool_10(body.get("require_reversal_candle")) + return patch @@ -3659,6 +3944,10 @@ def _breakout_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if x is not None: patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) + # 래칫 — 빈 문자열도 OFF 로 명시 저장 + if "ratchet_tiers" in body: + patch["BREAKOUT_RATCHET_TIERS"] = str(body.get("ratchet_tiers") or "").strip() + if "eod_enabled" in body: patch["BREAKOUT_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) x = gv("eod_hm") @@ -3738,6 +4027,9 @@ def _breakout_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if x is not None: patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x))) + if "skip_hts_scan_dupes" in body: + patch["BREAKOUT_SKIP_HTS_SCAN_DUPES"] = _env_bool_10(body.get("skip_hts_scan_dupes")) + # 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼) patch.update(_daily_trail_save_patch(body, "BREAKOUT")) @@ -3978,8 +4270,17 @@ def api_backtest_tail(): cooldown_min = int( request.args.get("cooldown_min", _def.get("cooldown_min", 15))) time_start_hm = int( request.args.get("time_start", _def.get("time_start_hm", 930))) time_end_hm = int( request.args.get("time_end", _def.get("time_end_hm", 1500))) - max_daily = int( request.args.get("max_daily", _def.get("max_daily", 3))) - eod_patch = _eod_params_from_request(request, _def, default_hm="15:25") + max_daily = int( request.args.get("max_daily", _def.get("max_daily", 20))) + symbol_daily_loss_limit_krw = float( + request.args.get("symbol_daily_loss_limit_krw", _def.get("symbol_daily_loss_limit_krw", 30000)) + ) + symbol_daily_loss_limit_pct = float( + request.args.get("symbol_daily_loss_limit_pct", _def.get("symbol_daily_loss_limit_pct", 1.5)) + ) + reentry_min_edge_krw = float( + request.args.get("reentry_min_edge_krw", _def.get("reentry_min_edge_krw", 0)) + ) + eod_patch = _eod_params_from_request(request, _def, default_hm="15:20") _legacy_force_eod = request.args.get("force_eod_exit") if _legacy_force_eod not in (None, ""): eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in ( @@ -4035,7 +4336,10 @@ def api_backtest_tail(): bt_meta: Dict[str, Any] = {} tail_trigger_flags: Dict[str, Any] = { "skip_hts_scan_dupes": _tail_bool_arg( - request, "skip_hts_scan_dupes", _def.get("skip_hts_scan_dupes", True), + request, "skip_hts_scan_dupes", _def.get( + "skip_hts_scan_dupes", + te.resolve_tail_skip_hts_scan_dupes() if _TAIL_ENGINE_AVAILABLE else False, + ), ), "use_intraday_drop": _tail_bool_arg( request, "use_intraday_drop", _def.get("use_intraday_drop", False), @@ -4096,6 +4400,9 @@ def api_backtest_tail(): "rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase_thr, "time_start_hm": time_start_hm, "time_end_hm": time_end_hm, "cooldown_min": cooldown_min, "max_daily": max_daily, + "symbol_daily_loss_limit_krw": symbol_daily_loss_limit_krw, + "symbol_daily_loss_limit_pct": symbol_daily_loss_limit_pct, + "reentry_min_edge_krw": reentry_min_edge_krw, "min_price": min_price, "max_daily_change": max_daily_change, "ma20_max_above": ma20_max_above, "stop_atr_mult": stop_atr_mult, "target_atr_mult": target_atr_mult, "max_loss_krw": max_loss_krw, "atr_sl_min_pct": float(request.args.get("atr_sl_min_pct", _def.get("atr_sl_min_pct", 0.5))), @@ -4112,6 +4419,10 @@ def api_backtest_tail(): **eod_patch, "max_stocks": max_stocks, "total_budget_krw": total_budget_krw, + # 실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 검사(0=무제한). + "cand_limit": int(float( + request.args.get("cand_limit", _def.get("cand_limit", 0)) or 0 + )), "portfolio_mode": True, "ratchet_tiers": str( request.args.get("ratchet_tiers", _def.get("ratchet_tiers", "")) or "" @@ -4120,16 +4431,19 @@ def api_backtest_tail(): request.args.get("max_hold_bars", _def.get("max_hold_bars", 0)) or 0 )), "backtest_use_tick_db": _tail_bool_arg( - request, "backtest_use_tick_db", _def.get("backtest_use_tick_db", False), + request, "backtest_use_tick_db", _def.get("backtest_use_tick_db", True), + ), + "backtest_use_tick_exit": _tail_bool_arg( + request, "backtest_use_tick_exit", _def.get("backtest_use_tick_exit", True), ), "backtest_tick_fallback_ohlc": _tail_bool_arg( - request, "backtest_tick_fallback_ohlc", _def.get("backtest_tick_fallback_ohlc", True), + request, "backtest_tick_fallback_ohlc", _def.get("backtest_tick_fallback_ohlc", False), ), "trail_pct": trail_pct, "trail_arm_pct": trail_arm_pct, - # 당일 누적손익 트레일 익절(daily_trail_*) — drop>0 일 때만 게이트 ON. + # 당일 누적손익 트레일 익절 — 마스터 스위치(daily_profit_enabled) 기준. # apply_daily_profit_halt_sim 이 engine_params 로 읽어 신규진입 차단. - **_daily_trail_params_from_request(request), + **_daily_trail_params_from_request(request, prefix="SHORT"), **tail_trigger_flags, } start_ymd = start_key[:8] @@ -4167,6 +4481,7 @@ def api_backtest_tail(): "start_key": start_key, "end_key": end_key, "timeframe": tail_tf, + "backtest_env_timeline": _backtest_env_timeline_from_request(request), } all_trades = tbc.run_tail_backtest_web_aligned( candles_by_code, params, universe_by_slot, @@ -4427,10 +4742,13 @@ def api_backtest_tail(): "매수시간·기간·파라미터가 파라서치와 다른지 확인하세요." ) - tail_trades_out = _trades_recent_first(all_trades, 200) - _enrich_trades_with_names(db, tail_trades_out) - # 모멘텀과 동일하게 매도시각 순 누적손익·누적수익률·체결(틱/OHLC) 디버그 라벨 부착 - _enrich_momentum_trades_debug(tail_trades_out, total_budget_krw=total_budget_krw) + tail_trades_all = list(all_trades) + _enrich_trades_with_names(db, tail_trades_all) + # 전체 거래 기준 누적손익 부착 후 최신순 슬라이스 (실매·모멘텀과 동일) + _enrich_momentum_trades_debug( + tail_trades_all, total_budget_krw=total_budget_krw, with_tick_debug=True, + ) + tail_trades_out = _trades_recent_first(tail_trades_all, 200) return jsonify({ "params": { @@ -4478,7 +4796,10 @@ def api_backtest_tail(): "limit_anchor": _lp_tail["anchor"], "limit_valid_bars": _lp_tail["valid_bars"], "limit_fill_slip_pct": _lp_tail["fill_slip_pct"], + "cand_limit": params.get("cand_limit", 0) if use_engine else 0, **tail_trigger_flags, + "skip_hts_scan_dupes_effective": bt_meta.get("skip_hts_scan_dupes_effective"), + "skip_hts_scan_dupes_requested": bt_meta.get("skip_hts_scan_dupes_requested"), }, "summary": { "total_trades": total, @@ -4569,6 +4890,12 @@ def api_backtest_breakout(): "intrabar_slippage_pct": _arg( "intrabar_slippage_pct", _def.get("intrabar_slippage_pct", 0.0), float, ), + "skip_hts_scan_dupes": _tail_bool_arg( + request, "skip_hts_scan_dupes", _def.get( + "skip_hts_scan_dupes", + resolve_breakout_skip_hts_scan_dupes(), + ), + ), } _uef = request.args.get("use_ema_filter") if _uef not in (None, ""): @@ -4662,6 +4989,7 @@ def api_backtest_breakout(): "db": db, "start_key": start_key, "end_key": end_key, + "backtest_env_timeline": _backtest_env_timeline_from_request(request), } all_trades = bbc.run_breakout_backtest_web_aligned( codes_candles, @@ -4675,9 +5003,9 @@ def api_backtest_breakout(): meta_out=bt_meta, ) - # 당일 누적손익 트레일 익절 시뮬 (백테 탭 입력 → drop>0 일 때만 ON). - _trail_p = _daily_trail_params_from_request(request) - if _trail_p: + # 당일 누적손익 트레일 익절 시뮬 — 마스터 스위치(daily_profit_enabled) 기준. + _trail_p = _daily_trail_params_from_request(request, prefix="BREAKOUT") + if _trail_p.get("_backtest_daily_profit_trail"): from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim all_trades = apply_daily_profit_halt_sim( all_trades, _trail_p, budget_krw=float(total_budget_v or 0), @@ -4701,22 +5029,24 @@ def api_backtest_breakout(): losses = [t for t in all_trades if t.get("pnl", 0) < 0] peak, mdd, cum = 0.0, 0.0, 0.0 + peak_cum_at = "" equity: List[Dict[str, Any]] = [] daily_map: Dict[str, int] = {} - for t in sorted(all_trades, key=lambda x: x.get("sell_time", "")): - cum += t.get("pnl", 0) + for t in sorted(all_trades, key=_trade_exit_sort_key): + cum += float(t.get("pnl") or 0) if cum > peak: peak = cum + peak_cum_at = str(t.get("sell_time") or t.get("exit_time") or "") dd = peak - cum if dd > mdd: mdd = dd - day = str(t.get("sell_time", ""))[:8] + day = str(t.get("sell_time", "") or t.get("exit_time", ""))[:8] if day: equity.append({ "date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum), }) - daily_map[day] = daily_map.get(day, 0) + t.get("pnl", 0) + daily_map[day] = daily_map.get(day, 0) + int(t.get("pnl") or 0) daily_list = [ {"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)} @@ -4728,6 +5058,9 @@ def api_backtest_breakout(): rk = str(t.get("sell_reason") or "unknown") reasons[rk] = reasons.get(rk, 0) + 1 + _enrich_momentum_trades_debug( + all_trades, total_budget_krw=total_budget_v, with_tick_debug=True, + ) trades_out = _trades_recent_first(all_trades, 200) _enrich_trades_with_names(db, trades_out) @@ -4766,6 +5099,8 @@ def api_backtest_breakout(): "budget_warning": portfolio.get("budget_warning"), "backtest_buy_source": bt_meta.get("backtest_buy_source"), "tick_backtest": bt_meta.get("tick_backtest"), + "peak_cum_pnl": round(peak), + "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", }, "equity": equity, "daily": daily_list, @@ -4897,7 +5232,11 @@ def api_backtest_range_break(): engine["total_budget_krw"] = total_budget_v engine["portfolio_mode"] = True - bt_meta: Dict[str, Any] = {"db": db, "start_key": start_key, "end_key": end_key} + _env_tl = _backtest_env_timeline_from_request(request) + bt_meta: Dict[str, Any] = { + "db": db, "start_key": start_key, "end_key": end_key, + "backtest_env_timeline": _env_tl, + } all_trades = rbc.run_range_break_backtest_web_aligned( codes_candles, engine, @@ -4926,22 +5265,24 @@ def api_backtest_range_break(): daily_avg_pct = float(stats["daily_avg_pct"]) peak, mdd, cum = 0.0, 0.0, 0.0 + peak_cum_at = "" equity: List[Dict[str, Any]] = [] daily_map: Dict[str, int] = {} - for t in sorted(all_trades, key=lambda x: x.get("sell_time", "")): - cum += t.get("pnl", 0) + for t in sorted(all_trades, key=_trade_exit_sort_key): + cum += float(t.get("pnl") or 0) if cum > peak: peak = cum + peak_cum_at = str(t.get("sell_time") or t.get("exit_time") or "") dd = peak - cum if dd > mdd: mdd = dd - day = str(t.get("sell_time", ""))[:8] + day = str(t.get("sell_time", "") or t.get("exit_time", ""))[:8] if day: equity.append({ "date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum), }) - daily_map[day] = daily_map.get(day, 0) + t.get("pnl", 0) + daily_map[day] = daily_map.get(day, 0) + int(t.get("pnl") or 0) daily_list = [ {"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)} @@ -4953,6 +5294,9 @@ def api_backtest_range_break(): rk = str(t.get("sell_reason") or "unknown") reasons[rk] = reasons.get(rk, 0) + 1 + _enrich_momentum_trades_debug( + all_trades, total_budget_krw=total_budget_v, with_tick_debug=False, + ) trades_out = _trades_recent_first(all_trades, 200) _enrich_trades_with_names(db, trades_out) @@ -4989,6 +5333,8 @@ def api_backtest_range_break(): "max_stocks": max_stocks_v, "budget_warning": portfolio.get("budget_warning"), "backtest_buy_source": bt_meta.get("backtest_buy_source"), + "peak_cum_pnl": round(peak), + "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", }, "equity": equity, "daily": daily_list, @@ -5076,7 +5422,7 @@ def api_holding_fetch_candles(): body = request.get_json(force=True) or {} code = body.get("code", "") start_date = body.get("start", "2023-01-01") - end_date = body.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = body.get("end", _default_trading_day_ymd()) if not code: return jsonify({"error": "code 필수"}), 400 @@ -5099,7 +5445,7 @@ def api_holding_backtest(): """홀딩 전략 백테스트 (종목별 파라미터 사용 or 요청 파라미터 오버라이드)""" code = request.args.get("code", "") start_date = request.args.get("start", "2023-01-01") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) if not code: return jsonify({"error": "code 필수"}), 400 @@ -5144,7 +5490,7 @@ def api_holding_v1_backtest(): """홀딩 V1 (RSI 분할매수) 백테스트""" code = request.args.get("code", "") start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) if not code: return jsonify({"error": "code 필수"}), 400 @@ -5176,7 +5522,7 @@ def api_holding_v1_param_search(): """홀딩 V1 (RSI 분할매수) 파라미터 Grid Search""" code = request.args.get("code", "") start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) min_trades = max(0, min(50, int(request.args.get("min_trades", 1)))) if not code: return jsonify({"error": "code 필수"}), 400 @@ -5208,7 +5554,7 @@ def api_holding_param_search(): """홀딩 전략 파라미터 Grid Search (단일 종목)""" code = request.args.get("code", "") start_date = request.args.get("start", "2023-01-01") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) min_trades = max(0, min(50, int(request.args.get("min_trades", 1)))) if not code: @@ -5256,7 +5602,7 @@ def api_holding_min_candles_fetch(): body = request.get_json(force=True, silent=True) or {} code = body.get("code", "") start = body.get("start", "") - end = body.get("end", datetime.now().strftime("%Y-%m-%d")) + end = body.get("end", _default_trading_day_ymd()) tf = int(body.get("tf", 60)) if not code or not start: @@ -5318,7 +5664,7 @@ def api_holding_min_candles_fetch_kiwoom(): symbol = str(body.get("symbol", code)).strip().upper() or code market_type, exchange, symbol = uhc.resolve_market_meta(code, market_type, exchange, symbol) start = body.get("start", "") - end = body.get("end", datetime.now().strftime("%Y-%m-%d")) + end = body.get("end", _default_trading_day_ymd()) try: tf = int(body.get("tf", 60)) except (TypeError, ValueError): @@ -5459,7 +5805,7 @@ def api_holding_min_backtest(): """60분봉 기반 백테스트 (run_backtest 재사용, candle_date=candle_dt 로 호환)""" code = request.args.get("code", "") start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) tf = int(request.args.get("tf", 60)) if not code: @@ -5653,7 +5999,7 @@ def api_updown_box_backtest(): code = request.args.get("code", "") start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) if not code: return jsonify({"error": "code 필수"}), 400 @@ -5719,7 +6065,7 @@ def api_updown_box_param_search(): from kis_trader.utils.env import get_env_int start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) min_trades = max(0, min(100, int(request.args.get("min_trades", 3)))) mode = str(request.args.get("mode", "fast") or "fast").strip().lower() rank_by = str(request.args.get("rank_by", "alpha") or "alpha").strip().lower() @@ -6144,7 +6490,7 @@ def api_dbband_backtest(): return jsonify({"error": "dbband_engine 미설치"}), 503 code = request.args.get("code", "").strip() start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) if not code: return jsonify({"error": "code 필수 (QQQM·069500 등 종목별 파라미터)"}), 400 @@ -6192,6 +6538,17 @@ def api_dbband_backtest(): if len(candles) < min_need: return jsonify({"error": f"봉 부족: {len(candles)} < {min_need} (추세MA+워밍업)"}), 400 + sk = start_date.replace("-", "") + "0000" + ek = end_date.replace("-", "") + "2359" + _env_tl = _backtest_env_timeline_from_request(request) + cfg["backtest_env_timeline"] = _env_tl + from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params + attach_backtest_env_timeline_to_params( + cfg, + {"db": db, "start_key": sk, "end_key": ek, "backtest_env_timeline": _env_tl}, + "DBBAND", + ) + trades = bbe.run_dbband_backtest_single(candles, cfg) snap = db.get_merged_env_snapshot() portfolio = dbbc.resolve_dbband_portfolio_params(snap, cfg) @@ -6250,7 +6607,7 @@ def api_dbband_param_search(): if not code: return jsonify({"error": "code 필수"}), 400 start_date = request.args.get("start", "") - end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) + end_date = request.args.get("end", _default_trading_day_ymd()) db = _dbband_db() try: from kis_trader.backtest.dbband_param_search import run_search_for_code @@ -6754,6 +7111,7 @@ def api_env_params(): # 모멘텀·스캘핑·돌파 — config_* + env_config 병합 (파라서치 JSON 덮어쓰기 없음) from kis_trader.engine.momentum_engine import get_momentum_defaults_from_db as _mom_def_db + from kis_trader.utils.kr_trading_day import trading_dates_payload _mom_ui = _momentum_ui_defaults_from_db(_mom_def_db()) _scalp_ui = _scalp_ui_defaults_from_db() _bo_ui = _bo_defaults_from_db() @@ -6762,11 +7120,19 @@ def api_env_params(): _dt_presets = str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip() for _ui, _pfx in ((_mom_ui, "MOMENTUM"), (_bo_ui, "BREAKOUT")): if isinstance(_ui, dict): + _ui["daily_profit_enabled"] = ( + str(snap.get(f"{_pfx}_DAILY_PROFIT_TARGET_ENABLED") or "false").strip().lower() + in ("1", "true", "t", "y", "yes", "on") + ) _ui["daily_trail_tiers"] = str(snap.get(f"{_pfx}_DAILY_PROFIT_TRAIL_TIERS") or "").strip() _ui["daily_profit_mode"] = ( str(snap.get(f"{_pfx}_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing" ) _ui["daily_trail_presets"] = _dt_presets + try: + _lb = int(float(str(snap.get("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS") or "7"))) + except (ValueError, TypeError): + _lb = 7 return jsonify({ "scalp": _scalp_ui, "tail": _tail_ui_defaults_from_db(snap), @@ -6774,6 +7140,8 @@ def api_env_params(): "breakout": _bo_ui, "range_break": _rb_ui, "dbband": _dbband_ui_defaults_from_db(snap) if _DBBAND_ENGINE_AVAILABLE else {}, + # 주말·휴장 → 이전 장운영일 (웹 날짜 인풋 기본값) + "dates": trading_dates_payload(max(1, _lb)), }) finally: db.close() @@ -6792,7 +7160,13 @@ def api_live_config_get(): day = (request.args.get("date") or "").strip()[:10] if not day: - day = datetime.now().strftime("%Y-%m-%d") + day = _default_trading_day_ymd() + else: + try: + from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day + day = clamp_to_prev_kr_trading_day(day) + except ValueError: + day = _default_trading_day_ymd() db = _db() try: snap = db.get_merged_env_snapshot() or {} diff --git a/database.py b/database.py index 992f842..8c854b5 100644 --- a/database.py +++ b/database.py @@ -1,3615 +1,3853 @@ -""" -트레이딩 봇 데이터베이스 관리 모듈 -- MariaDB 기반 (pymysql) — SQLite 에서 이전 -- 활성 트레이딩 관리 (active_trades) -- 매매 히스토리 관리 (trade_history) - -MariaDB 접속 정보 우선순위: - 1) 환경변수 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME - 2) 아래 DEFAULT 상수 (기본값) -""" -import json -import os -import datetime -import logging -import threading -from typing import Any, Dict, List, Optional, Tuple - -try: - import pymysql - import pymysql.cursors - _PYMYSQL_AVAILABLE = True -except ImportError: - _PYMYSQL_AVAILABLE = False - -logger = logging.getLogger("TradeDB") - -# ── MariaDB 기본 접속 정보 (환경변수로 재정의 가능) ───────────────── -_DB_HOST = os.environ.get("DB_HOST", "192.168.0.141") -_DB_PORT = int(os.environ.get("DB_PORT", "3306")) -_DB_USER = os.environ.get("DB_USER", "jae") -_DB_PASS = os.environ.get("DB_PASS", "1234") -_DB_NAME = os.environ.get("DB_NAME", "kis_quant_db") - - -# ══════════════════════════════════════════════════════════════════════ -# SQLite 호환 래퍼 — 기존 conn.execute() / with conn: 패턴 유지 -# ══════════════════════════════════════════════════════════════════════ -class _MariaDBConn: - """ - pymysql 연결을 sqlite3 인터페이스처럼 감싸는 래퍼. - - conn.execute(sql, params) → DictCursor 반환 (row['col'] 접근) - - with conn: ... → 자동커밋(autocommit=True) 이므로 no-op - - conn.commit() → 명시적 커밋 (autocommit=True라 호환) - - ? 플레이스홀더 → %s 자동 변환 - - SQLite-only DDL 키워드 자동 변환 - """ - - # SQL 텍스트 자동 변환 규칙 (SQLite → MySQL) - _REPLACE_PAIRS = [ - # DDL 키워드 - ("INTEGER PRIMARY KEY AUTOINCREMENT", "INT NOT NULL AUTO_INCREMENT PRIMARY KEY"), - ("INTEGER PRIMARY KEY", "INT NOT NULL PRIMARY KEY"), - ("AUTOINCREMENT", "AUTO_INCREMENT"), - # DML — SQLite 전용 구문 - ("INSERT OR REPLACE INTO", "REPLACE INTO"), - ("last_insert_rowid()", "LAST_INSERT_ID()"), - # ON CONFLICT 처리: 단순 패턴 제거 후 ON DUPLICATE KEY UPDATE 로 수동 변환 - # (복잡한 케이스는 각 메서드에서 직접 처리) - ] - - def __init__(self): - self._lock = threading.Lock() - self._conn = None - self._connect() - - def _connect(self): - """pymysql 연결 (재연결 포함).""" - if not _PYMYSQL_AVAILABLE: - raise ImportError( - "pymysql 미설치. 설치: pip install pymysql\n" - "또는: pip install PyMySQL" - ) - self._conn = pymysql.connect( - host=_DB_HOST, port=_DB_PORT, - user=_DB_USER, password=_DB_PASS, - database=_DB_NAME, - charset="utf8mb4", - autocommit=True, - cursorclass=pymysql.cursors.DictCursor, - connect_timeout=10, - read_timeout=30, - write_timeout=30, - ) - logger.debug("✅ MariaDB 연결 완료 (%s:%s/%s)", _DB_HOST, _DB_PORT, _DB_NAME) - - def _ensure_connected(self): - """연결 끊김 시 자동 재접속.""" - try: - self._conn.ping(reconnect=True) - except Exception: - try: - self._connect() - except Exception as e: - logger.error("❌ MariaDB 재접속 실패: %s", e) - raise - - @staticmethod - def _translate_sql(sql: str) -> str: - """SQLite 전용 SQL 구문을 MySQL 호환으로 변환.""" - for old, new in _MariaDBConn._REPLACE_PAIRS: - sql = sql.replace(old, new) - # ? → %s (파라미터 플레이스홀더) - # 단, 이미 %s 가 있는 경우 중복 변환 방지 - if "?" in sql: - sql = sql.replace("?", "%s") - return sql - - def execute(self, sql: str, params=None): - """ - SQL 실행. sqlite3.Connection.execute() 와 동일한 인터페이스. - 반환값: DictCursor (fetchone/fetchall/lastrowid 사용 가능) - """ - sql = self._translate_sql(sql) - with self._lock: - self._ensure_connected() - cur = self._conn.cursor() - cur.execute(sql, params or ()) - return cur - - def __enter__(self): - """with conn: 패턴 호환 (autocommit=True 이므로 실질적으로 no-op).""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - def commit(self): - """명시적 커밋 (autocommit=True 환경에서 호환성 유지용).""" - try: - with self._lock: - self._conn.commit() - except Exception: - pass - - @property - def row_factory(self): - return None # DictCursor 가 이미 dict 반환, 호환용 stub - - @row_factory.setter - def row_factory(self, _): - pass # sqlite3 호환용 stub (무시) - - def close(self): - try: - self._conn.close() - except Exception: - pass - - # ── information_schema 기반 컬럼 목록 조회 (PRAGMA 대체) ────── - def get_columns(self, table_name: str) -> list: - """ - PRAGMA table_info() 대체. - 반환: 컬럼명 문자열 리스트 - """ - try: - cur = self.execute( - "SELECT COLUMN_NAME FROM information_schema.COLUMNS " - "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s " - "ORDER BY ORDINAL_POSITION", - (_DB_NAME, table_name), - ) - return [row["COLUMN_NAME"] for row in cur.fetchall()] - except Exception as e: - logger.debug("get_columns(%s) 실패: %s", table_name, e) - return [] - -# ML 학습용 진입 시점 피처 컬럼 (active_trades / trade_history 공통) -ML_ENTRY_FEATURE_COLUMNS = ( - "rsi", "volume_ratio", "tail_length_pct", "ma5_gap_pct", "ma20_gap_pct", - "foreign_net_buy", "institution_net_buy", "market_hour", -) - -# env_config 테이블 컬럼 (키 하나당 컬럼 하나, 추가/삭제 시 여기와 CREATE TABLE만 수정) -ENV_CONFIG_KEYS = ( - "STOP_LOSS_PCT", "SHOULDER_CUT_PCT", "SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_NET_PCT", "STOP_ATR_MULTIPLIER_TAIL", "TARGET_ATR_MULTIPLIER_TAIL", - "TAIL_ATR_SL_MIN_PCT", "TAIL_ATR_SL_MAX_PCT", "TAIL_ATR_TP_MIN_PCT", "TAIL_ATR_TP_MAX_PCT", - "MAX_POSITION_PCT", "USE_SLOT_CAP", "SLOT_CAP_PCT", "MAX_STOCKS", - "USE_KELLY", "RISK_PCT_PER_TRADE", "MIN_POSITION_AMOUNT", - "USE_RISK_CHECK", "DAILY_STOP_LOSS_PCT", "CONSECUTIVE_LOSS_LIMIT", - # 일일 익절 목표 (마스터·총합) — env_config. 전략별은 {SID}_DAILY_PROFIT_* → config_* - "DAILY_PROFIT_TARGET_ENABLED", - "DAILY_PROFIT_TARGET_KRW", - "DAILY_PROFIT_TARGET_PCT", - "DAILY_PROFIT_TARGET_BUDGET_KRW", - "DAILY_PROFIT_HALT_NEW_BUYS", - "DAILY_PROFIT_NOTIFY_MM", - # 일일 누적손익 트레일 익절 (고점 대비 되돌림 시 신규매수 중단) — 마스터·총합 - "DAILY_PROFIT_MODE", - "DAILY_PROFIT_TRAIL_TIERS", - "DAILY_PROFIT_TRAIL_DROP_PCT", - "DAILY_PROFIT_TRAIL_ARM_KRW", - "DAILY_PROFIT_TRAIL_ARM_PCT", - "USE_BAN_SYSTEM", "BAN_HOURS", "USE_STOCK_FILTER", "RSI_OVERHEAT_THRESHOLD", - "MIN_RECOVERY_RATIO", "MAX_RECOVERY_RATIO", - "USE_TWAP", "TWAP_MIN_SPLIT", "TWAP_MAX_SPLIT", "TWAP_MIN_DELAY", "TWAP_MAX_DELAY", - "USE_ML_SIGNAL", "ML_MIN_PROBABILITY", "USE_NEWS_ANALYSIS", "NEWS_ANALYSIS_HOUR", "NEWS_MAX_COUNT", - "USE_QUICK_PROFIT_PROTECTION", "HIGH_PRICE_CHASE_THRESHOLD", "MAX_DAILY_CHANGE_PCT", - "MA20_MAX_ABOVE_PCT", "VOLUME_AVG_MULTIPLIER", "CANDLE_OPEN_PRICE_BUFFER", - "MIN_CANDLE_LEN_TAIL", "MIN_PRICE_TAIL", "TAIL_SCORE_BASE", "TAIL_SCORE_RATIO_MULT", - "TAIL_RATIO_MIN", "TAIL_PCT_MIN", # 꼬리 비율/하락률 최소 (AI·봇 공통 기준) - "TAIL_SKIP_HTS_SCAN_DUPES", "TAIL_USE_INTRADAY_DROP", "TAIL_USE_MA20_FILTER", - "TAIL_USE_RSI_FILTER", "TAIL_USE_DAILY_RANGE_FILTER", "TAIL_USE_HIGH_CHASE_FILTER", - "TAIL_BAR_CHG_MIN_PCT", "TAIL_BAR_CHG_MAX_PCT", "TAIL_RSI_THRESHOLD", - "TAIL_PATTERN_HAMMER", "TAIL_PATTERN_PIN", "TAIL_PATTERN_ENGULFING", - "TAIL_PATTERN_PIERCING", "TAIL_PATTERN_HARAMI", "TAIL_PATTERN_DOJI", - "TAIL_PATTERN_MORNING_STAR", - "TAIL_PIN_CLOSE_UPPER_RATIO", "TAIL_PIN_MAX_UPPER_TAIL_RATIO", - "TAIL_ENGULF_MIN_BODY_RATIO", "TAIL_PIERCING_PENETRATE_RATIO", - "TAIL_HARAMI_MAX_BODY_RATIO", "TAIL_DOJI_BODY_MAX_RATIO", - "TAIL_MORNING_STAR_BODY_MAX_RATIO", - # 꼬리잡기 TAIL_* 통일 키 (레거시 MIN_DROP_RATE·STOP_LOSS_PCT 등 대체) - "TAIL_MIN_DROP_RATE", "TAIL_MIN_RECOVERY_RATIO", "TAIL_MAX_RECOVERY_3M", - "TAIL_STOP_LOSS_PCT", "TAIL_TAKE_PROFIT_PCT", - "TAIL_SHOULDER_MIN_HIGH_PCT", "TAIL_SHOULDER_CUT_PCT", - "TAIL_RATCHET_TIERS", "TAIL_MAX_HOLD_BARS", - "TAIL_TRAIL_PCT", "TAIL_TRAIL_ARM_PCT", - "TAIL_BACKTEST_USE_TICK_DB", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", - "TAIL_STOP_ATR_MULT", "TAIL_TARGET_ATR_MULT", - "TAIL_MIN_PRICE", "TAIL_MAX_DAILY_CHG", "TAIL_MA20_MAX_ABOVE_PCT", - "TAIL_MAX_LOSS_KRW", "TAIL_MIN_DROP_FOR_LOSS_CUT", - "TAIL_RSI_PERIOD", "TAIL_COOLDOWN_SEC", "TAIL_MAX_DAILY", - "TAIL_HIGH_CHASE_THR", "TAIL_TIME_START", "TAIL_TIME_END", - "TAIL_MAX_STOCKS", "TAIL_TOTAL_BUDGET_KRW", "TAIL_SLOT_MONEY", - "TAIL_ENTRY_MODE", "TAIL_LIMIT_ATR_MULT", "TAIL_LIMIT_ANCHOR", - "TAIL_LIMIT_VALID_BARS", "TAIL_LIMIT_FILL_SLIP_PCT", - "TAIL_MIN_INVEST_RATIO_OF_SLOT", - "TAIL_PARAM_SEARCH_ENTRY_MODE", - "TAIL_GRID_FAST_MAX_DAILY_CHG", "TAIL_GRID_COARSE_MAX_DAILY_CHG", - "TAIL_GRID_FAST_LIMIT_ATR_MULT", "TAIL_GRID_COARSE_LIMIT_ATR_MULT", - # 더블 볼린저 DBBAND_* (config_dbband) - "DBBAND_BB_PERIOD", "DBBAND_BB_INNER_STD", "DBBAND_BB_OUTER_STD", - "DBBAND_TREND_MA_PERIOD", "DBBAND_USE_TREND_FILTER", "DBBAND_SIDE_MODE", - "DBBAND_ENTRY_VALID_BARS", "DBBAND_ENTRY_MODE", - "DBBAND_STOP_MODE", "DBBAND_STOP_BUFFER_PCT", "DBBAND_STOP_LOSS_PCT", - "DBBAND_TP_MODE", "DBBAND_TAKE_PROFIT_PCT", "DBBAND_RR_RATIO", - "DBBAND_EXIT_MODE", - "DBBAND_SHOULDER_MIN_HIGH_PCT", "DBBAND_SHOULDER_CUT_PCT", - "DBBAND_TRAIL_PCT", "DBBAND_TRAIL_ARM_PCT", - "DBBAND_TIME_START", "DBBAND_TIME_END", "DBBAND_COOLDOWN_SEC", - "DBBAND_MAX_DAILY", "DBBAND_MIN_PRICE", - "DBBAND_SLOT_MONEY", "DBBAND_MAX_STOCKS", "DBBAND_TOTAL_BUDGET_KRW", - "DBBAND_MAX_BUY_AMOUNT", "DBBAND_MAX_HOLD_BARS", "DBBAND_TIMEFRAME", - "DBBAND_MIN_INVEST_RATIO_OF_SLOT", "DBBAND_FORCE_EOD_EXIT", - "DBBAND_LIVE_MIN_CANDLES", "DBBAND_LIVE_SIGNAL_LOOKBACK_BARS", - "DBBAND_UNIVERSE_SOURCE", "DBBAND_CANDLE_FETCH_N", - "STRATEGY_DBBAND_ENABLED", "KIS_DBBAND_MM_CHANNEL", - "RANK_DBBAND_SORT", "RANK_DBBAND_LIMIT", - "CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ", - "DBBAND_GRID_BB_PERIOD0", "DBBAND_GRID_BB_PERIOD1", - "DBBAND_GRID_INNER_STD0", "DBBAND_GRID_INNER_STD1", - "DBBAND_GRID_OUTER_STD0", "DBBAND_GRID_OUTER_STD1", - "DBBAND_GRID_TREND_MA0", "DBBAND_GRID_TREND_MA1", - "DBBAND_GRID_SL0", "DBBAND_GRID_SL1", - "DBBAND_GRID_TP0", "DBBAND_GRID_TP1", - "DBBAND_GRID_RR0", "DBBAND_GRID_RR1", - "INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "SIZE_CLASS_LARGE_MIN", "SIZE_CLASS_MID_MIN", - # 단타 스캔 후보 점수 (전부 env/DB) - "SCAN_INVESTOR_NET_STRONG", "SCAN_INVESTOR_SCORE_STRONG", "SCAN_INVESTOR_SCORE_WEAK", - "SCAN_INVESTOR_BONUS_STRONG", "SCAN_INVESTOR_BONUS_WEAK", - "SCAN_VOLUME_BONUS_MIN", "SCAN_VOLUME_BONUS_POINT", - "SCAN_EXEC_STRENGTH_HIGH", "SCAN_EXEC_STRENGTH_MID", "SCAN_EXEC_BONUS_HIGH", "SCAN_EXEC_BONUS_MID", - "SCAN_SCORE_DROP_WEIGHT", "SCAN_SCORE_RECOVERY_WEIGHT", - "MIN_SCORE_DISPLAY", "MM_TOP_N", - "SLOT_MONEY_DEFAULT", "SLOT_BASE_AMOUNT_CAP", - "SIZE_CLASS_SMALL_RATIO", "SIZE_CLASS_MID_RATIO", - # 개미털기/유니버스 필터 추가 키 - "SCAN_MIN_PREV_DAY_PCT", "UPDATE_UNIVERSE_MIN_CANDIDATES", - "UPDATE_UNIVERSE_MIN_SCORE", "UPDATE_UNIVERSE_FALLBACK_TOP_N", "UPDATE_UNIVERSE_TOP_LOG", "UPDATE_UNIVERSE_TOP_N", - "SCAN_INTERVAL_SEC", # 유니버스 스캔 주기(초), kiwoom_universe_scanner 전용 - "USE_RANDOM_SPLIT", "FORCE_MARKET_OPEN", "FORCE_BUY_TEST", "TOTAL_DEPOSIT", - # POP/LOCK·금액 손절 관련 추가 키 - "ROUND_TRIP_COST_PCT", "POP_NET_PCT", "LOCK_NET_PCT", "MAX_LOSS_PER_TRADE_KRW", - # 금액손실컷 발동 최소 하락률(%): 이 하락률 미만이면 슬리피지/흔들림으로 간주하고 금액손실컷 미발동 (꼬리잡기) - "MIN_DROP_PCT_FOR_LOSS_CUT", - # 한투 API 관련 키 추가 (실전/모의 계좌 분리) - "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", - "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK", - "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", # 실전 계좌 (KIS_MOCK=false 시 사용) - "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", # 모의 계좌 (KIS_MOCK=true 시 사용) - "KIS_MOCK", - # 단타 봇 전용 키 - "TAKE_PROFIT_PCT", "MIN_DROP_RATE", "MIN_RECOVERY_RATIO_SHORT", - # 단타 매도 로직 (env/DB에서 수치 로드) - "SCALP_ATR_UP_MULT", "SCALP_ATR_DOWN_MULT", "SCALP_ATR_DROP_MULT", - "QUICK_PROFIT_PROTECT_HOURS", "QUICK_PROFIT_MAX_RATIO", "QUICK_PROFIT_CURRENT_MIN", - "MIN_HOLD_EARLY_TAKE_PCT", "MIN_HOLD_HIGH_PCT", "MIN_HOLD_DROP_FROM_HIGH", - "POST_HOLD_TAKE_PCT", "POST_HOLD_DROP_FROM_HIGH", - # 늘림목 봇 전용 키 - "MAX_PER", "MAX_PEG", "MIN_GROWTH_PCT", "DCA_INTERVALS", "DCA_AMOUNTS", - # Mattermost 및 AI 리포트 관련 키 - "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY", - "AI_JOURNAL_LINES", "ANTHROPIC_API_KEY", "CLAUDE_MODEL_ID", "CLAUDE_MAX_TOKENS", - "MM_BUTLER_CHANNEL", - # true=체결 알림을 전략 채널 + MATTERMOST_CHANNEL(통합) 양쪽 발송 - "MM_DUAL_CHANNEL_ENABLED", - # OpenRouter API 연동용 키 - "OPENROUTER_API_KEY", "OPENROUTER_MODEL_ID", - # 봇별 Mattermost 채널 — 전략별 config_* (KIS_LONG 은 홀딩봇 전용 → env_config) - "KIS_LONG_MM_CHANNEL", - # 롱 위시리스트/뉴스 리포트 전용 키 - "LONG_DAILY_LOOKBACK_DAYS", "LONG_MA_SHORT_DAYS", "LONG_MA_LONG_DAYS", - "LONG_REPORT_AM_HOUR", "LONG_REPORT_AM_MIN", - "LONG_REPORT_PM_HOUR", "LONG_REPORT_PM_MIN", - "LONG_NEWS_ENABLED", "LONG_NEWS_INTERVAL_MIN", - "LONG_NEWS_ACTIVE_START_HOUR", "LONG_NEWS_ACTIVE_END_HOUR", - "LONG_ANALYSIS_DELAY_MIN_SEC", "LONG_ANALYSIS_DELAY_MAX_SEC", - # RSI 기간: 스윙=14(기본), 단타=5, 스캘핑=3 권장 (DB에서 실시간 변경 가능) - "RSI_PERIOD", - # KIS WebSocket 관련 키 - "KIS_WS_URL_REAL", "KIS_WS_URL_MOCK", "KIS_WS_MOCK_ENABLED", - # 재진입 쿨다운: 매도 후 같은 종목 재매수를 N초 동안 차단 (반복매매 루프 방지) - "REENTRY_COOLDOWN_SEC", - # 꼬리잡기/단타 매수 허용 시간대 (HHMM 정수, 930=09:30, 1500=15:00) — 백테스트·실매 공통 - "TIME_START", "TIME_END", - # 매도 실패 백오프: 영업일 아님·장외 시간 오류 시 N초 동안 재시도 금지 (API 낭비·차단 방지) - "SELL_FAILURE_BACKOFF_SEC", - # 실잔고 inquire-balance 캐시 TTL(초) — 매도 루프 내 N종목 1회 조회 공유 - "BROKER_HOLDINGS_CACHE_TTL_SEC", - # 유령잔고(DB·메모리만 존재) 정리 후 동일 (전략,종목) 재시도 쿨다운(초) - "GHOST_POSITION_COOLDOWN_SEC", - # ── 스캘핑봇(kis_scalping_ver1) 전용 키 ────────────────────────────── - # RSI 과매도 임계값: 이 값 이하면 "과매도 → 되돌림 가능" 후보로 판단 - "SCALP_RSI_OVERSOLD", - # RSI 과매수 임계값: 이 값 이상이면 신규 진입 금지 (고점 추격 방지) - "SCALP_RSI_OVERBOUGHT", - # 스캘핑봇이 사용할 봉 단위 (분): 1 또는 3 권장 - "SCALP_CANDLE_TIMEFRAME", - # 장 시작 후 몇 분 뒤부터 매매 허용 (장 시작 직후 변동성 회피) - "SCALP_MARKET_OPEN_WAIT_MIN", - # WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 - "SCALP_GAP_FILL_LIMIT", - # 확정 봉 기준 최소 거래량: 이 값 미만인 봉은 유동성 부족으로 무시 - "SCALP_MIN_VOLUME", - # 스캘핑·꼬리 MM alias → config_scalp / config_short - "KIS_SCALP_MM_CHANNEL", - "KIS_SHORT_MM_CHANNEL", - # 시스템 알림(시작·종료·장시작·장마감) 단일 MM alias (기본 default=키스명령봇) - "KIS_SYSTEM_MM_CHANNEL", - # 시작·종료 알림 추가 발송 MM alias (기본 stock — 재시작/종료 짝 확인용) - "KIS_LIFECYCLE_MM_CHANNEL", - # 미등록 보유분 일괄 시장가매도 시 종목 간 간격(초) — 429 방지 - "BULK_SELL_INTERVAL_SEC", - # 수동매수 보호 종목코드(쉼표구분) — 일괄매도에서 무조건 제외 - "MANUAL_HOLD_CODES", - # 장마감 후 봇 고아(active_trades 미기록) 자동 복구 ON/OFF - "ORPHAN_RECONCILE_ENABLED", - # ws_candles 자동 정리 보존 일수 - "SCALP_CANDLE_KEEP_DAYS", - # ws_ticks 실시간 체결 틱 (RAM 링버퍼 + 배치 INSERT, C안) - "WS_TICK_SAVE_ENABLED", - "WS_TICK_BUFFER_MAX_PER_CODE", - "WS_TICK_DB_BATCH_SIZE", - "WS_TICK_DB_FLUSH_SEC", - "WS_TICK_WRITE_QUEUE_MAX", - "WS_TICK_KEEP_DAYS", - "WS_TICK_RECORD_SCOPE", - "WS_TICK_DEFAULT_MARKET", - "WS_TICK_DEFAULT_EXCHANGE", - "WS_TICK_DEFAULT_CURRENCY", - # ws_orderbook / ws_program — TRIGGER 호가·프로그램 스냅샷 (백테 재현) - "WS_ORDERBOOK_SAVE_ENABLED", - "WS_PROGRAM_SAVE_ENABLED", - "WS_TRIGGER_SNAPSHOT_DB_INTERVAL_SEC", - "WS_TRIGGER_SNAPSHOT_DB_BATCH_SIZE", - "WS_TRIGGER_SNAPSHOT_DB_FLUSH_SEC", - "WS_TRIGGER_SNAPSHOT_WRITE_QUEUE_MAX", - "WS_ORDERBOOK_KEEP_DAYS", - "WS_PROGRAM_KEEP_DAYS", - # TRIGGER 필터 판정 시점 스냅샷 (실매 RAM → DB, 백테 재생) - "WS_TRIGGER_EVAL_SAVE_ENABLED", - "WS_TRIGGER_EVAL_DB_BATCH_SIZE", - "WS_TRIGGER_EVAL_DB_FLUSH_SEC", - "WS_TRIGGER_EVAL_WRITE_QUEUE_MAX", - "BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", - "BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", - "MOMENTUM_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", - "BREAKOUT_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", - "TAIL_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", - "BACKTEST_USE_TRIGGER_SNAPSHOT_DB", - "MOMENTUM_BACKTEST_USE_TRIGGER_SNAPSHOT_DB", - "MOMENTUM_BACKTEST_USE_TICK_EXIT", - "MOMENTUM_BACKTEST_USE_TICK_ENTRY", - "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", - "MOMENTUM_BACKTEST_POLL_MS", - "MOMENTUM_BACKTEST_SELL_SLIP_PCT", - "MOMENTUM_BACKTEST_BUY_SLIP_PCT", - "MOMENTUM_BACKTEST_UNIVERSE_STRICT", - "MOMENTUM_BACKTEST_UNIVERSE_STRICT_LAG_MIN", - "MOMENTUM_BACKTEST_LIVE_SCAN_QUEUE", - "MOMENTUM_BACKTEST_SCAN_SEC", - "MOMENTUM_BACKTEST_UNIVERSE_SCAN_AT", - "MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", - "MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", - "BREAKOUT_BACKTEST_USE_TRIGGER_SNAPSHOT_DB", - "TAIL_BACKTEST_USE_TRIGGER_SNAPSHOT_DB", - # 휩쏘(TRIGGER) 필터 — 공통 + 전략별 - "WHIPSAW_FILTER_ENABLED", - "WHIPSAW_BACKTEST_OHLC_FALLBACK", - "WHIPSAW_MODE", - "WHIPSAW_SUBBAR_SEC", - "WHIPSAW_LOOKBACK_SEC", - "WHIPSAW_DIP_PCT", - "WHIPSAW_RECOVERY_TOL_PCT", - "WHIPSAW_MIN_SUBBARS", - "WHIPSAW_TICK_BUFFER_LIMIT", - "MOMENTUM_WHIPSAW_FILTER_ENABLED", - "MOMENTUM_WHIPSAW_MODE", - "MOMENTUM_WHIPSAW_SUBBAR_SEC", - "MOMENTUM_WHIPSAW_LOOKBACK_SEC", - "MOMENTUM_WHIPSAW_DIP_PCT", - "MOMENTUM_WHIPSAW_RECOVERY_TOL_PCT", - "BREAKOUT_WHIPSAW_FILTER_ENABLED", - "BREAKOUT_WHIPSAW_MODE", - "BREAKOUT_WHIPSAW_SUBBAR_SEC", - "BREAKOUT_WHIPSAW_LOOKBACK_SEC", - "BREAKOUT_WHIPSAW_DIP_PCT", - "BREAKOUT_WHIPSAW_RECOVERY_TOL_PCT", - "TAIL_WHIPSAW_FILTER_ENABLED", - "TAIL_WHIPSAW_MODE", - "TAIL_WHIPSAW_SUBBAR_SEC", - "TAIL_WHIPSAW_LOOKBACK_SEC", - "TAIL_WHIPSAW_DIP_PCT", - "TAIL_WHIPSAW_RECOVERY_TOL_PCT", - # 스캘핑 전용 손절 % (꼬리잡기 STOP_LOSS_PCT와 분리, 기본 1.5%) - # 1분봉 초단타: 손절이 넓으면(-4%) 자금이 묶여 회전율 0 → 타이트하게 - "SCALP_STOP_LOSS_PCT", - # 스캘핑 전용 익절 % (꼬리잡기 TAKE_PROFIT_PCT와 분리, 기본 1.5%) - # 1분봉에서 +5% 익절은 도달 불가 → 박리다매 전략으로 1.5%씩 수익 적립 - "SCALP_TAKE_PROFIT_PCT", - # 스캘핑 익절 상한·어깨컷 (꼬리와 동일 1순위 청산) - "SCALP_TP_MAX_PCT", - "SCALP_SHOULDER_MIN_HIGH_PCT", "SCALP_SHOULDER_CUT_PCT", - # 스캘핑 낙폭 필터 % (꼬리잡기 MIN_DROP_RATE와 분리, 기본 1.5%) - # 3% 기준은 1분봉 소형주에서 너무 엄격 → 1.5%로 완화해 타점 빈도 증가 - "SCALP_MIN_DROP_RATE", - # 봉부족 감지 시 재갭보정 최소 간격(초): 같은 종목 중복 REST 호출 방지 (기본 30초) - "SCALP_GAP_RETRY_SEC", - # 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (기본 600=10분) - # 백테스트와 동일 파라미터로 맞추려면 DB에 값 저장 후 봇/백테스트 모두 이 값 사용 - "SCALP_REQUIRE_REVERSAL_CANDLE", # true=직전 음봉+현재 양봉 필수, false=RSI 과매도만으로 진입 허용 - "SCALP_COOLDOWN_SEC", - # 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이 이상 올라야 트레일링 활성화 - # 0.5%면 수수료(~0.21%) 뺀 나머지만 이익 → 1.5 이상 권장 - "SCALP_TRAIL_TRIGGER_PCT", - # 트레일링/본절사수 후 최소 순이익(%): 수수료+세금 위에 이 값만큼 추가 마진 확보 - # 0이면 본절(수수료 이후 0원), 0.2면 최소 +0.2% 순이익 보장 - "SCALP_MIN_PROFIT_PCT", - # 스캘핑 전용 방어로직 (꼬리잡기와 값 분리: HIGH_PRICE_CHASE_THRESHOLD / MIN_PRICE_TAIL 등과 별도) - "SCALP_HIGH_PRICE_CHASE_THRESHOLD", # 고점추격 방지: 당일고가 대비 비율 이하면 진입 허용 (0.96 등) - "SCALP_MAX_DAILY_CHANGE_PCT", # 급등주 필터: 당일 등락률 이 값 초과 시 진입 금지 (%) - "SCALP_MIN_PRICE", # 최소 가격(원): 이 값 미만 종목 진입 금지 - "SCALP_MAX_LOSS_PER_TRADE_KRW", # 1회 최대 손실(원): 종목당 손실 상한 - "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", # 금액손실컷 발동 최소 하락률(%): 이 미만은 흔들림으로 미발동 - "SCALP_USE_DEFENSE_FILTERS", # true/false: 스캘핑 진입 방어 필터(낙폭/고점추격/급등/최소가격) ON/OFF - # HTS C(MACD+Stoch 골든크로스) — TRIGGER 전용. true면 RSI reversal 대신 MACD 진입. - "SCALP_USE_MACD_CROSS", - "SCALP_MACD_FAST", "SCALP_MACD_SLOW", "SCALP_MACD_SIGNAL", - "SCALP_STOCH_K_PERIOD", "SCALP_STOCH_D_PERIOD", "SCALP_STOCH_SLOW", - # ── 단타봇(kis_short_ver2) 전용 키 ─────────────────────────────────── - # 켈리 공식 사용 여부 (true=켈리 적용, false=고정 비중) - "USE_KELLY_FORMULA", - # 켈리 공식 적용 배수 (0.25 = Full Kelly의 25%, 과도한 베팅 방지) - "KELLY_MULTIPLIER", - # 시장가 IOC 주문 사용 여부 (실전: true=IOC, false=일반 시장가) - "USE_MARKET_IOC", - # 체결 확인 엄격 모드: true=모의도 fill 없으면 가정 체결 금지 (실전 훈련) - "STRICT_FILL_VERIFY", - # 주문 직후 체결 조회 대기(초) — 시장가 - "ORDER_FILL_WAIT_SEC", - # 지정가 주문 직후 체결 조회 대기(초) - "LIMIT_ORDER_FILL_WAIT_SEC", - # heartbeat 미체결 재조회 간격(초) - "PENDING_FILL_POLL_INTERVAL_SEC", - # 미체결 재조회 시 get_execution 1회 대기(초) - "PENDING_FILL_POLL_SEC", - # (레거시) 매수 미체결 최대 대기 — PENDING_BUY_MAX_AGE_SEC 미설정 시 fallback - "PENDING_FILL_MAX_AGE_SEC", - # 매수 미체결 최대 대기(초) — 초과 시 취소·다음 신호 대기 - "PENDING_BUY_MAX_AGE_SEC", - # 익절·지정가 매도 미체결 최대 대기(초) - "PENDING_SELL_MAX_AGE_SEC", - # 손절·장마감·긴급 매도 미체결 최대 대기(초) — 짧게, 만료 시 시장가 재주문 - "PENDING_SELL_STOP_MAX_AGE_SEC", - # 손절 등 긴급 매도 만료 취소 후 즉시 시장가 재주문 - "SELL_PENDING_REORDER_ON_EXPIRE", - # 동일 전략·종목 미체결 매수 있으면 재주문 스킵 (체결 API 지연 시 주문 폭주 방지) - "BUY_DEDUP_PENDING", - # 매수 부분체결 후 잔량 자동 취소 (지정가·비-IOC 시장가) - "AUTO_CANCEL_PARTIAL_BUY_REMAINDER", - # heartbeat poll_pending_fills: 당일 체결 1 REST 일괄 조회 (실매 전용) - "PENDING_POLL_BATCH_FETCH", - # 잔고 연속조회 최대 페이지 (1p=실전50/모의20종목) — 보유 많을 때 누락 방지 - "BALANCE_MAX_PAGES", - # WebSocket 실시간 가격 캐시 유효기간(초): 이 시간 이상 지나면 REST 재조회 - "KIS_PRICE_CACHE_TTL_SEC", - # WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 (단타봇) - "SHORT_GAP_FILL_LIMIT", - # 매수 직후 최소 보유 시간(초): 이 기간 내 매도 신호 무시 (API 잔고 반영 지연 대응) - "MIN_HOLD_AFTER_BUY_SEC", - # 최소 보유 시간(시간): 이 기간 이전에는 손절 외 매도 금지 (꼬리잡기 전략 충분히 대기) - "MIN_HOLD_HOURS", - # 3개월 최대 회복 비율: 전고점 대비 이 비율 이상 회복한 종목은 추격 매수 제외 - "MAX_RECOVERY_RATIO_3M", - # 유니버스 상위 N개 후보 경량 체크 (매수 후보 1차 필터) - "CANDIDATE_LIST_TOP_N_LIGHT", - # 매수 신호 체크 시 사용할 유니버스 최대 종목 수 (과부하 방지) - "SCAN_UNIVERSE_MAX_CODES", - # 꼬리 캔들 패턴 인식 시 과거 몇 봉까지 확인할지 (lookback) - "TAIL_CANDLE_LOOKBACK", - # ── 레거시 단일 계좌 키 (KIS_APP_KEY_REAL/MOCK 이전 버전 호환용) ────── - "KIS_APP_KEY", "KIS_APP_SECRET", "KIS_ACCOUNT_NO", "KIS_ACCOUNT_CODE", - # ── mm_butler 전용 키 ──────────────────────────────────────────────── - # Gemini AI 모델 ID (gemini-2.5-flash, gemini-1.5-pro 등) - "GEMINI_MODEL_ID", - # MM 원격 명령 폴링 주기(초): 너무 짧으면 API 과부하 - "MM_BUTLER_POLL_SEC", - # AI 소스 텍스트 최대 길이(문자): 초과 시 잘라서 전송 (토큰 비용 관리) - "AI_SOURCE_MAX_CHARS", - # ── 수수료·거래세 (전략 공통) ────────────────────────────────────────── - # FEE_RATE_PCT : 위탁수수료율 (매수/매도 각각, 기본 0.015%) - # SELL_TAX_RATE_PCT: 증권거래세율 (매도 시만 부과, 기본 0.18% ← 2025 코스피/코스닥 공통) - "FEE_RATE_PCT", "SELL_TAX_RATE_PCT", - # ── 키움증권 REST API 키 (60분봉 과거 데이터 수집 전용) ──────────────── - # 실전/모의 분리 (KIS_MOCK 값에 따라 자동 선택) - # KIWOOM_APP_KEY_REAL : 키움증권 실전 앱키 - # KIWOOM_APP_SECRET_REAL: 키움증권 실전 시크릿 - # KIWOOM_APP_KEY_MOCK : 키움증권 모의 앱키 (mockapi.kiwoom.com) - # KIWOOM_APP_SECRET_MOCK: 키움증권 모의 시크릿 - # KIWOOM_APP_KEY / KIWOOM_APP_SECRET: 레거시 (단일 키 호환용) - "KIWOOM_APP_KEY_REAL", "KIWOOM_APP_SECRET_REAL", - "KIWOOM_APP_KEY_MOCK", "KIWOOM_APP_SECRET_MOCK", - "KIWOOM_APP_KEY", "KIWOOM_APP_SECRET", - # ── WebSocket 영구 구독 종목 (시장 방향 필터용) ───────────────────── - # KOSPI/KOSDAQ 지수 ETF는 매매 후보와 무관하게 항상 구독 유지. - # 60분봉 RSI 로 상승장/하락장 체크 → 스캘핑/꼬리잡기 진입 방향 결정. - # 기본값: KODEX200(069500), KODEX KOSDAQ150(229200) - # 콤마 구분 코드: "069500,229200" - "PERMANENT_WS_CODES", - # ── 시장 방향 필터 (상승장에서만 롱 진입) ────────────────────────── - # USE_MARKET_REGIME_FILTER: true=활성, false=비활성 (기본 false) - # MARKET_REGIME_MIN_RSI : ETF 60분봉 RSI 이 값 이상이어야 롱 진입 허용 (기본 48) - "USE_MARKET_REGIME_FILTER", "MARKET_REGIME_MIN_RSI", - # ── 테마 과열 필터 (테마 전체가 과열이면 신규 진입 억제) ─────────── - # USE_THEME_HEAT_FILTER : true=활성 (기본 false) - # THEME_HEAT_RSI_MAX : 테마 평균 RSI 이 값 초과면 진입 차단 (기본 72) - "USE_THEME_HEAT_FILTER", "THEME_HEAT_RSI_MAX", - # ── kis_trader 통합봇 전용 키 (전략 스위치 / 유니버스 / 랭킹) ──────── - # 전략 on/off → config_{전략} (classify_config_key) - "STRATEGY_SCALP_ENABLED", "STRATEGY_SHORT_ENABLED", "STRATEGY_BREAKOUT_ENABLED", - "STRATEGY_RANGE_BREAK_ENABLED", - "STRATEGY_UPDOW_ENABLED", - # 중복매매 정책: allow(기본)=전략별 ODNO·active_trades 분리 / block=동일종목 1전략만 - "STRATEGY_SAME_CODE_POLICY", - # 가용 예수금 캐시 (kv_store account.*) — 부족 시에만 매수 qty 축소 - "ORDER_CASH_PCT", "ORDER_CASH_FEE_BUFFER", "ORDER_CASH_DIVIDE_BY_MAX_STOCKS", - "ACCOUNT_CASH_PERSIST_SEC", - # 라이브 진입 타이밍 = 백테 (신호봉 확정 → 다음 봉 시가) - "SCALP_LIVE_BACKTEST_ALIGN", "SCALP_LIVE_SIGNAL_LOOKBACK_BARS", - "BACKTEST_EXIT_CHECKS_PER_BAR", - "SHORT_LIVE_BACKTEST_ALIGN", "SHORT_LIVE_SIGNAL_LOOKBACK_BARS", - "MOMENTUM_LIVE_BACKTEST_ALIGN", "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", - "BREAKOUT_LIVE_BACKTEST_ALIGN", "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", - "RANGE_BREAK_LIVE_BACKTEST_ALIGN", "RANGE_BREAK_LIVE_SIGNAL_LOOKBACK_BARS", - "BREAKOUT_ENTRY_MODE", - "BREAKOUT_INTRABAR_SLIPPAGE_PCT", - # 유니버스 소스: ranking(기본) | condition - "SCALP_UNIVERSE_SOURCE", "SHORT_UNIVERSE_SOURCE", "BREAKOUT_UNIVERSE_SOURCE", - "RANGE_BREAK_UNIVERSE_SOURCE", - # 랭킹 정렬/크기 — sort: volume|trading_value|strength|fluct_up|fluct_down - "RANK_SCALP_SORT", "RANK_SCALP_LIMIT", - "RANK_SHORT_SORT", "RANK_SHORT_LIMIT", - "RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT", - "RANK_RANGE_BREAK_SORT", "RANK_RANGE_BREAK_LIMIT", - # 랭킹 폴링 주기(초) — 기본 10초 (모의 TPS 5건/s 대비 안전, 시간당 720회) - "RANKING_POLL_INTERVAL_SEC", - # 조건검색 파라미터 - "KIS_HTS_ID", "CONDITION_POLL_INTERVAL_SEC", - # 조건검색 EXIT 유예 시간 — 한 번 빠진 종목을 N초간 universe 에 keep - # (단발성 EXIT/RE-ENTER 회전으로 WS 구독·캔들 데이터가 휘발되는 사이클 차단) - # 0 = 비활성(기존 동작), 권장 60 - "CONDITION_EXIT_GRACE_SEC", - "CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ", - "CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ", - "CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ", - "CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ", - # 키움 조건식 seq (선택). 이름은 CONDITION_{SID}_NAME 공통 — UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용. - "CONDITION_SHORT_KIWOOM_SEQ", - "CONDITION_BREAKOUT_KIWOOM_SEQ", - "CONDITION_MOMENTUM_KIWOOM_SEQ", - # (레거시) KIWOOM 전용 이름 — CONDITION_{SID}_NAME 과 다를 때만 설정 - "CONDITION_SHORT_KIWOOM_NAME", - "CONDITION_BREAKOUT_KIWOOM_NAME", - "CONDITION_MOMENTUM_KIWOOM_NAME", - # 유니버스 히스토리(백테스트용) — 기본 true, 배치 INSERT 로 DB 부담 최소화 - "UNIVERSE_HISTORY_SAVE", - # WS 갭보정 파라미터 (KIS_FALLBACK 은 기본 false — 모의서버 500 폭탄 회피) - "WS_GAP_FILL_OFF_HOURS", "WS_GAP_FILL_LIMIT", "WS_TIMEFRAMES", - "WS_GAP_FILL_KIS_FALLBACK", - "WS_GAP_FILL_MAX_RETRIES", "WS_GAP_FILL_RETRY_DELAY_SEC", - "WS_GAP_BULK_REFILL_DEBOUNCE_SEC", - "WS_GAP_FILL_PRIORITY_TFS", - "WS_GAP_FILL_TF_SLEEP_MIN_SEC", "WS_GAP_FILL_TF_SLEEP_MAX_SEC", - "WS_GAP_FILL_CODE_SLEEP_MIN_SEC", "WS_GAP_FILL_CODE_SLEEP_MAX_SEC", - "WS_GAP_FILL_PHASE_PAUSE_SEC", - "WS_GAP_FILL_WORKERS", - "KIWOOM_CNSRREQ_GAP_MIN_SEC", "KIWOOM_CNSRREQ_GAP_MAX_SEC", - "KIWOOM_CNSRREQ_MAX_RETRIES", "KIWOOM_CNSRREQ_RETRY_SEC", - # 종목당 최대 매수금액 하드캡 (손절%가 작으면 포지션 사이즈 폭주 방지) - "MAX_BUY_AMOUNT_PER_STOCK", - "SCALP_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT", - # KIS REST 안정화 파라미터 - "KIS_MIN_INTERVAL_SEC", "KIS_REST_MAX_RETRIES", - "KIS_REST_BACKOFF_CAP_SEC", "KIS_REST_TIMEOUT_SEC", - # 도메인별 REST 최소 호출 간격 (한투 유량: 실전 18건/초, 모의 1건/초) - "KIS_MIN_INTERVAL_SEC_MOCK", "KIS_MIN_INTERVAL_SEC_REAL", - # 스캔 루프 회전율 sleep (REST 유량과 별개 · WS 따라가기 속도 · 비우면 전략별 기본값) - "SCAN_REJECT_SLEEP_MIN", "SCAN_REJECT_SLEEP_MAX", - "SCAN_BUY_OK_SLEEP_MIN", "SCAN_BUY_OK_SLEEP_MAX", - "SCAN_BUY_FAIL_SLEEP_MIN", "SCAN_BUY_FAIL_SLEEP_MAX", - "STRATEGY_LOOP_SLEEP_MIN", "STRATEGY_LOOP_SLEEP_MAX", - # ── 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 차단) ── - # 거래소 공식 서킷브레이커는 -8% 이지만, 봇 보호용으로 더 빨리(-2%) 반응. - # PANIC 모드 시 매수만 차단, 매도는 평소처럼 동작 (포지션 정리 가능). - "MARKET_GUARD_ENABLED", # 활성화 토글 (true/false). 백테스트 못하므로 운영 후 켜기 - "MARKET_GUARD_5MIN_DROP_PCT", # 5분 내 -N% 하락 시 PANIC 진입 (기본 2.0) - "MARKET_GUARD_DAILY_DROP_PCT", # 일중 누적 -N% 하락 시 PANIC 진입 (기본 3.0) - "MARKET_GUARD_RECOVERY_PCT", # PANIC 중 5분 +N% 반등 시 자동 해제 (기본 1.0) - "MARKET_GUARD_INDEX_CODE", # 감시 지수 ("0001"=KOSPI, "1001"=KOSDAQ, "both"=둘 다) - "MARKET_GUARD_POLL_SEC", # 지수 폴링 주기 초 (기본 30) - "MARKET_GUARD_INDEX_CANDLE_KEEP_MIN", # 재시작 시 ws_candles MG* 1분봉 복원 개수 (기본 20) - "MARKET_GUARD_PERSIST_STATE", # kv_store PANIC 영속 (기본 true) - # ── 전략별 후보 하드캡 (WS 구독 41 한도 보호 + 매수 체크 회전율 보장) ─ - # cond/ranking 매니저가 폭주해도 본 전략은 상위 N개만 처리. 0=무제한. - # 권장: 세 전략 합산 ≤ 30 (영구구독 + 보유 여유 11종목 확보). - "SCALP_CAND_LIMIT", "SHORT_CAND_LIMIT", "BREAKOUT_CAND_LIMIT", - "RANGE_BREAK_CAND_LIMIT", - # ── 전략별 매매 시간대 (HHMM 정수, 930=09:30, 1500=15:00) ────────── - # 꼬리(SHORT): config_short 의 TAIL_TIME_START/TAIL_TIME_END (SHORT_TIME_* 레거시 제거) - # 그 외 미설정 시 글로벌 TIME_START/TIME_END → 기본 900~1530. - "SCALP_TIME_START", "SCALP_TIME_END", - "BREAKOUT_TIME_START", "BREAKOUT_TIME_END", - # ── BREAKOUT(돌파) — HTS SCAN(널넬) + 봇 TRIGGER(엄격) ───────────────── - "BREAKOUT_LOOKBACK_MIN", "BREAKOUT_VOL_WIN", "BREAKOUT_VOL_MULT", - "BREAKOUT_PREV_CHG_MIN", "BREAKOUT_PREV_CHG_MAX", - "BREAKOUT_STOP_LOSS_PCT", "BREAKOUT_TAKE_PROFIT_PCT", "BREAKOUT_TRAIL_PCT", - "BREAKOUT_TRAIL_ARM_PCT", - "BREAKOUT_SHOULDER_MIN_HIGH_PCT", "BREAKOUT_SHOULDER_CUT_PCT", - "BREAKOUT_RATCHET_TIERS", "BREAKOUT_MAX_HOLD_BARS", - "BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", - "MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", - "TAIL_EOD_ENABLED", "TAIL_EOD_HM", - # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%) - "BREAKOUT_SL_MODE", "BREAKOUT_ATR_PERIOD", "BREAKOUT_ATR_SL_MULT", - "BREAKOUT_ATR_SL_MIN_PCT", "BREAKOUT_ATR_SL_MAX_PCT", - "BREAKOUT_GOLDEN_END_HM", - "BREAKOUT_GRID_COARSE_SHOULDER_SMIN", "BREAKOUT_GRID_COARSE_SHOULDER_CUT", - "BREAKOUT_GRID_FINE_SHOULDER_SMIN", "BREAKOUT_GRID_FINE_SHOULDER_CUT", - "BREAKOUT_GRID_FULL_SHOULDER_SMIN", "BREAKOUT_GRID_FULL_SHOULDER_CUT", - "BREAKOUT_MAX_DAILY_CHG", "BREAKOUT_MIN_PRICE", - "BREAKOUT_MIN_BAR_TRADE_VALUE_KRW", - "BREAKOUT_MIN_TURNOVER_1M_PCT", "BREAKOUT_SHARE_DENOM", "STOCK_SHARE_DENOM", - "BREAKOUT_USE_EMA_FILTER", "BREAKOUT_EMA_FAST_PERIOD", "BREAKOUT_EMA_SLOW_PERIOD", - "BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "BREAKOUT_SLOT_MONEY", - "BREAKOUT_MAX_DAILY", "BREAKOUT_COOLDOWN_SEC", - # ── RANGE_BREAK(박스권 돌파) — HTS momentum SCAN + 봇 TRIGGER ───────────── - "RANGE_BREAK_BOX_LOOKBACK_MIN", "RANGE_BREAK_BOX_MAX_WIDTH_PCT", - "RANGE_BREAK_BOX_MIN_WIDTH_PCT", "RANGE_BREAK_SETUP_VOL_MAX_MULT", - "RANGE_BREAK_SETUP_BEAR_BARS_MIN", "RANGE_BREAK_VOL_MULT", "RANGE_BREAK_VOL_WIN", - "RANGE_BREAK_VOL_BASELINE_WIN", "RANGE_BREAK_BREAK_MARGIN_PCT", "RANGE_BREAK_BODY_MIN_PCT", - "RANGE_BREAK_TIME_START", "RANGE_BREAK_TIME_END_HM", - "RANGE_BREAK_STOP_LOSS_PCT", "RANGE_BREAK_TAKE_PROFIT_PCT", "RANGE_BREAK_TRAIL_PCT", - "RANGE_BREAK_TRAIL_ARM_PCT", "RANGE_BREAK_SHOULDER_MIN_HIGH_PCT", "RANGE_BREAK_SHOULDER_CUT_PCT", - "RANGE_BREAK_MAX_HOLD_BARS", "RANGE_BREAK_MAX_DAILY", "RANGE_BREAK_COOLDOWN_SEC", - "RANGE_BREAK_MAX_DAILY_CHG", "RANGE_BREAK_MIN_PRICE", "RANGE_BREAK_HIGH_CHASE_THR", - "RANGE_BREAK_USE_HIGH_CHASE_FILTER", "RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", - "RANGE_BREAK_SLOT_MONEY", - "RANGE_BREAK_MAX_BUY_AMOUNT", "RANGE_BREAK_MIN_INVEST_RATIO_OF_SLOT", - "RANGE_BREAK_FORCE_EOD_EXIT", - # ETN/ETF/스팩/우선주 자동 제외 토글 (기본 true) — 후보 매니저 결과를 한 번 더 거른다. - "EXCLUDE_NON_STOCK", - # ── SCALP 진입 모드 토글 (reversal | momentum) ─────────────────── - # reversal : 기존 RSI 과매도 V자 반전(원본 로직). - # momentum : 키움 'scalp' 조건검색(갭상승+신고가 돌파+거래량 폭증)으로 - # 걸러진 종목군을 따라붙기. 봇은 모멘텀 살아있음/끝물 아님/ - # 양봉 마무리/거래량 평균 초과만 검증. - "SCALP_MODE", - # SCALP momentum 모드 전용 파라미터 - # mom_rsi_min ≤ RSI3 ≤ mom_rsi_max 만 진입 (모멘텀 살아있고 과열은 아님). - "SCALP_MOM_RSI_MIN", "SCALP_MOM_RSI_MAX", - # 직전 N봉 평균 거래량 × 배수 이상이어야 진입. - "SCALP_MOM_VOL_MULT", "SCALP_MOM_VOL_WIN", - # momentum 모드 진입 마감 시각(HHMM, 기본 1430). 종가 후폭풍 회피용. - "SCALP_MOM_TIME_END_HM", - # SCALP 일일 진입 횟수 (momentum은 회전 빠르므로 5 정도 권장) - "SCALP_MAX_DAILY", - # ── MOMENTUM 전략 (SCALP 와 분리된 독립 전략) ───────────────────── - # 키움 'scalp' 조건검색(갭상승+신고가) 후보를 따라붙기. SCALP=reversal 고정과 분리. - # 신규 키가 비어있으면 자동으로 기존 SCALP_MOM_*/SCALP_* 키로 fallback (운영 호환). - "STRATEGY_MOMENTUM_ENABLED", - # MM 체결 알림 alias (mm_config.json channels 키) → config_momentum / config_breakout / config_updow - "KIS_MOMENTUM_MM_CHANNEL", - "KIS_BREAKOUT_MM_CHANNEL", - "KIS_RANGE_BREAK_MM_CHANNEL", - "KIS_UPDOW_MM_CHANNEL", - "MOMENTUM_UNIVERSE_SOURCE", - "RANK_MOMENTUM_SORT", "RANK_MOMENTUM_LIMIT", - "CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ", - "MOMENTUM_CAND_LIMIT", - "MOMENTUM_TIME_START", "MOMENTUM_TIME_END", - "MOMENTUM_MAX_STOCKS", - "MOMENTUM_MAX_BUY_AMOUNT", - "MOMENTUM_MAX_DAILY", - "MOMENTUM_RSI_MIN", "MOMENTUM_RSI_MAX", - "MOMENTUM_VOL_MULT", "MOMENTUM_VOL_WIN", - "MOMENTUM_TIME_END_HM", - # 시가 대비 등락(%) 컷 — 파라서치 coarse 그리드 mom_max_from_open_pct 와 동일 의미 - "MOMENTUM_MAX_FROM_OPEN_PCT", "MOMENTUM_MIN_FROM_OPEN_PCT", - "MOMENTUM_SKIP_HTS_SCAN_DUPES", - "MOMENTUM_STOP_LOSS_PCT", "MOMENTUM_TAKE_PROFIT_PCT", - "MOMENTUM_TP_MAX_PCT", - "MOMENTUM_SHOULDER_MIN_HIGH_PCT", "MOMENTUM_SHOULDER_CUT_PCT", - "MOMENTUM_MIN_DROP_RATE", - "MOMENTUM_SLOT_MONEY", - "MOMENTUM_MIN_PRICE", - "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", - "MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT", - "MOMENTUM_MIN_PROFIT_PCT", - "MOMENTUM_RATCHET_TIERS", - "MOMENTUM_TRAIL_PCT", - "MOMENTUM_TRAIL_ARM_PCT", - "MOMENTUM_MAX_HOLD_BARS", - "MOMENTUM_USE_DEFENSE_FILTERS", - "MOMENTUM_USE_HIGH_CHASE_FILTER", - "MOMENTUM_USE_DAILY_RANGE_FILTER", - "MOMENTUM_USE_EMA_FILTER", - "MOMENTUM_EMA_FAST_PERIOD", - "MOMENTUM_EMA_SLOW_PERIOD", - "MOMENTUM_USE_RSI_MAX_FILTER", - "MOMENTUM_PATTERN_BREAKOUT", - "MOMENTUM_PATTERN_PULLBACK", - "MOMENTUM_CHASE_LOOKBACK_MIN", - "MOMENTUM_PULLBACK_LOOKBACK_MIN", - "MOMENTUM_PULLBACK_MIN_PCT", - "MOMENTUM_PULLBACK_MAX_PCT", - "MOMENTUM_SETUP_VOL_MAX_MULT", - "MOMENTUM_SETUP_BEAR_BARS_MIN", - "MOMENTUM_HIGH_CHASE_THR", - "MOMENTUM_RSI_PERIOD", - "MOMENTUM_COOLDOWN_SEC", - "MOMENTUM_MIN_HOLD_SEC", - "MOMENTUM_FORCE_EOD_EXIT", - # ── UPDOW 전략 (직전봉 몸통 하락 → 다음봉 시가, kis_trader 독립 쓰레드) ─ - "UPDOW_UNIVERSE_SOURCE", - "RANK_UPDOW_SORT", "RANK_UPDOW_LIMIT", - "CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ", - "UPDOW_CAND_LIMIT", - "UPDOW_TIME_START", "UPDOW_TIME_END", - "UPDOW_REENTRY_COOLDOWN_SEC", - "UPDOW_MIN_CANDLE_LEN", "MIN_CANDLE_LEN_UPDOW", - "UPDOW_CANDLE_FETCH_N", - "UPDOW_LIVE_MIN_CANDLES", - "UPDOW_MAX_LOSS_PER_TRADE_KRW", - "UPDOW_MAX_BUY_AMOUNT", - # ── 전략별 동시 보유 종목 한도 (기존 글로벌 MAX_STOCKS 분리) ───────── - # 미설정(0/빈값) 시 글로벌 MAX_STOCKS 로 폴백 → 구버전 호환. - # 권장: 합계 ≤ MAX_STOCKS (계좌 슬롯 분산), 예: 3+2+2=7. - "SCALP_MAX_STOCKS", "SHORT_MAX_STOCKS", "BREAKOUT_MAX_STOCKS", - "RANGE_BREAK_MAX_STOCKS", "UPDOW_MAX_STOCKS", - # 전략별 시각순 포트폴리오 총 운용한도 (동시 보유 매입금 합 상한) - "SHORT_TOTAL_BUDGET_KRW", - "SCALP_TOTAL_BUDGET_KRW", - "MOMENTUM_TOTAL_BUDGET_KRW", - "BREAKOUT_TOTAL_BUDGET_KRW", - "RANGE_BREAK_TOTAL_BUDGET_KRW", - "UPDOW_TOTAL_BUDGET_KRW", - # 일일 익절 목표 (서브·전략별) — split_env_keys → config_{strategy} - "SCALP_DAILY_PROFIT_TARGET_ENABLED", - "SCALP_DAILY_PROFIT_TARGET_KRW", - "SCALP_DAILY_PROFIT_TARGET_PCT", - "SCALP_DAILY_PROFIT_HALT_NEW_BUYS", - "SCALP_DAILY_PROFIT_MODE", "SCALP_DAILY_PROFIT_TRAIL_TIERS", - "SCALP_DAILY_PROFIT_TRAIL_DROP_PCT", "SCALP_DAILY_PROFIT_TRAIL_ARM_KRW", "SCALP_DAILY_PROFIT_TRAIL_ARM_PCT", - "SHORT_DAILY_PROFIT_TARGET_ENABLED", - "SHORT_DAILY_PROFIT_TARGET_KRW", - "SHORT_DAILY_PROFIT_TARGET_PCT", - "SHORT_DAILY_PROFIT_HALT_NEW_BUYS", - "SHORT_DAILY_PROFIT_MODE", "SHORT_DAILY_PROFIT_TRAIL_TIERS", - "SHORT_DAILY_PROFIT_TRAIL_DROP_PCT", "SHORT_DAILY_PROFIT_TRAIL_ARM_KRW", "SHORT_DAILY_PROFIT_TRAIL_ARM_PCT", - "MOMENTUM_DAILY_PROFIT_TARGET_ENABLED", - "MOMENTUM_DAILY_PROFIT_TARGET_KRW", - "MOMENTUM_DAILY_PROFIT_TARGET_PCT", - "MOMENTUM_DAILY_PROFIT_HALT_NEW_BUYS", - "MOMENTUM_DAILY_PROFIT_MODE", "MOMENTUM_DAILY_PROFIT_TRAIL_TIERS", - "MOMENTUM_DAILY_PROFIT_TRAIL_DROP_PCT", "MOMENTUM_DAILY_PROFIT_TRAIL_ARM_KRW", "MOMENTUM_DAILY_PROFIT_TRAIL_ARM_PCT", - "BREAKOUT_DAILY_PROFIT_TARGET_ENABLED", - "BREAKOUT_DAILY_PROFIT_TARGET_KRW", - "BREAKOUT_DAILY_PROFIT_TARGET_PCT", - "BREAKOUT_DAILY_PROFIT_HALT_NEW_BUYS", - "BREAKOUT_DAILY_PROFIT_MODE", "BREAKOUT_DAILY_PROFIT_TRAIL_TIERS", - "BREAKOUT_DAILY_PROFIT_TRAIL_DROP_PCT", "BREAKOUT_DAILY_PROFIT_TRAIL_ARM_KRW", "BREAKOUT_DAILY_PROFIT_TRAIL_ARM_PCT", - "RANGE_BREAK_DAILY_PROFIT_TARGET_ENABLED", - "RANGE_BREAK_DAILY_PROFIT_TARGET_KRW", - "RANGE_BREAK_DAILY_PROFIT_TARGET_PCT", - "RANGE_BREAK_DAILY_PROFIT_HALT_NEW_BUYS", - "RANGE_BREAK_DAILY_PROFIT_MODE", "RANGE_BREAK_DAILY_PROFIT_TRAIL_TIERS", - "RANGE_BREAK_DAILY_PROFIT_TRAIL_DROP_PCT", "RANGE_BREAK_DAILY_PROFIT_TRAIL_ARM_KRW", "RANGE_BREAK_DAILY_PROFIT_TRAIL_ARM_PCT", - "UPDOW_DAILY_PROFIT_TARGET_ENABLED", - "UPDOW_DAILY_PROFIT_TARGET_KRW", - "UPDOW_DAILY_PROFIT_TARGET_PCT", - "UPDOW_DAILY_PROFIT_HALT_NEW_BUYS", - "UPDOW_DAILY_PROFIT_MODE", "UPDOW_DAILY_PROFIT_TRAIL_TIERS", - "UPDOW_DAILY_PROFIT_TRAIL_DROP_PCT", "UPDOW_DAILY_PROFIT_TRAIL_ARM_KRW", "UPDOW_DAILY_PROFIT_TRAIL_ARM_PCT", - "DBBAND_DAILY_PROFIT_TARGET_ENABLED", - "DBBAND_DAILY_PROFIT_TARGET_KRW", - "DBBAND_DAILY_PROFIT_TARGET_PCT", - "DBBAND_DAILY_PROFIT_HALT_NEW_BUYS", - "DBBAND_DAILY_PROFIT_MODE", "DBBAND_DAILY_PROFIT_TRAIL_TIERS", - "DBBAND_DAILY_PROFIT_TRAIL_DROP_PCT", "DBBAND_DAILY_PROFIT_TRAIL_ARM_KRW", "DBBAND_DAILY_PROFIT_TRAIL_ARM_PCT", - # ── 시세 WS 공급자 토글 (키움 시세 마이그레이션) ───────────────── - # 운영(매매 의사결정)에는 항상 KIS WS 만 사용. 키움 WS 는 검증 모드에서만 - # 백그라운드 동시 구독 → ws_price_validation 테이블에 가격 비교 기록. - # kis_only : 현행 (기본). 키움 WS 미기동. - # kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음) - # kiwoom_only : 시세를 키움으로 전환 (검증 통과 후에만 사용) - "WS_PROVIDER", - # true 시: KIS WS 는 PERMANENT_WS_CODES ∪ 보유 종목만 구독, 후보 종목은 키움 WS. - # 키움 WS 기동 필요(WS_PROVIDER=kis_with_validation 권장 또는 동시 true). - "WS_SUBSCRIBE_KIS_MINIMAL", - # 검증 비교 주기(초) — 너무 짧으면 부하, 너무 길면 표본 부족. 기본 5. - "WS_VALIDATION_INTERVAL_SEC", - # 차이 경고 임계(%). |diff| 가 이 값 이상이면 WARN 로그. 기본 0.10. - "WS_VALIDATION_DIFF_WARN_PCT", - # 키움 WS 시세는 KIS_MOCK 와 무관하게 항상 실키/실전 도메인 사용. - # 모의 도메인은 장외 데이터 제공 X / 실시간 표본도 빈약 → 시세 비교/마이그레이션 가치 없음. - # true (기본) = 실키 강제, false = KIS_MOCK 따라 자동 (디버그용). - "KIWOOM_WS_FORCE_REAL", - # 키움 WS: 그룹당 최대 구독 수·REG 전송 레이트(초기 기동 시 TRNM 건수 초과 완화) - "KIWOOM_WS_MAX_SUBSCRIPTIONS", - "KIWOOM_WS_REG_CHUNK_SIZE", - "KIWOOM_WS_REG_GAP_SEC", - "KIWOOM_WS_REG_DEBOUNCE_SEC", - # ── Updow(직전 분봉 음봉·몸통 하락 → 다음 봉 시가 매수) — 백테·웹·CLI 공통 ── - "UPDOW_BODY_DROP_MIN_PCT", - "UPDOW_TP_PCT", - "UPDOW_STOP_LOSS_PCT", - "UPDOW_ATR_USE_DYNAMIC", - "UPDOW_ATR_PERIOD", - "UPDOW_ATR_SL_MULT", - "UPDOW_ATR_TP_MULT", - "UPDOW_ATR_SL_MIN_PCT", - "UPDOW_ATR_SL_MAX_PCT", - "UPDOW_ATR_TP_MIN_PCT", - "UPDOW_ATR_TP_MAX_PCT", - "UPDOW_ATR_MULT_FLOOR", - "UPDOW_ATR_PCT_FLOOR", - "UPDOW_EXIT_PCT_FLOOR", - "UPDOW_SHOULDER_MIN_HIGH_PCT", - "UPDOW_SHOULDER_CUT_PCT", - "UPDOW_MAX_HOLD_BARS", - "UPDOW_EXIT_ON_GREEN", - "UPDOW_SLOT_MONEY", - "UPDOW_TF_MIN", - "UPDOW_GRID_BODY0", - "UPDOW_GRID_BODY1", - "UPDOW_GRID_BODY2", - "UPDOW_GRID_BODY3", - "UPDOW_GRID_TP0", - "UPDOW_GRID_TP1", - "UPDOW_GRID_TP2", - "UPDOW_GRID_TP3", - "UPDOW_GRID_TP4", - "UPDOW_GRID_SL0", - "UPDOW_GRID_SL1", - "UPDOW_GRID_SL2", - "UPDOW_GRID_SL3", - "UPDOW_GRID_HOLD0", - "UPDOW_GRID_HOLD1", - "UPDOW_GRID_HOLD2", - "UPDOW_GRID_HOLD3", - "UPDOW_GRID_HOLD4", - "UPDOW_GRID_SHOULDER_SMIN0", - "UPDOW_GRID_SHOULDER_SMIN1", - "UPDOW_GRID_SHOULDER_SMIN2", - "UPDOW_GRID_SHOULDER_CUT0", - "UPDOW_GRID_SHOULDER_CUT1", - "UPDOW_GRID_SHOULDER_CUT2", - "UPDOW_GRID_REGIME0", - "UPDOW_GRID_REGIME1", - "UPDOW_REGIME_MA_BARS", - "UPDOW_REGIME_MA_EASE_PCT", - "UPDOW_REGIME_MA_EASE_CAP", - "UPDOW_GRID_REGIME_EASE0", - "UPDOW_GRID_REGIME_EASE1", - "UPDOW_KOSPI_1MIN_PROXY_CODE", - "UPDOW_REGIME_PREFER_WS_CANDLES", - "UPDOW_REGIME_WS_CANDLE_MIN", - # ── 익절 호가 (OrderManager) — config_short 에 저장 ── - "SELL_USE_ORDERBOOK_ON_PROFIT", - "SELL_ORDERBOOK_BID_LEVELS", - "SELL_ORDERBOOK_DEPTH_MULT", - # ── 키움 WS 호가(0D) + TRIGGER 호가 필터 ── - "KIWOOM_WS_ORDERBOOK_ENABLED", - "KIWOOM_WS_PROGRAM_ENABLED", - "ORDERBOOK_FILTER_ENABLED", - "ORDERBOOK_MAX_SPREAD_PCT", - "ORDERBOOK_ENTRY_BID_LEVELS", - "ORDERBOOK_ENTRY_BID_DEPTH_MULT", - "ORDERBOOK_ENTRY_ASK_MAX_MULT", - "ORDERBOOK_MIN_BID_ASK_RATIO", - "ORDERBOOK_BREAKOUT_ASK_WALL_MAX_QTY", - "MOMENTUM_ORDERBOOK_FILTER_ENABLED", - "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT", - "MOMENTUM_ORDERBOOK_ENTRY_BID_LEVELS", - "MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT", - "MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT", - "MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO", - "BREAKOUT_ORDERBOOK_FILTER_ENABLED", - "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT", - "BREAKOUT_ORDERBOOK_ENTRY_BID_LEVELS", - "BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT", - "BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT", - "BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO", - "BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY", - "TAIL_ORDERBOOK_FILTER_ENABLED", - "TAIL_ORDERBOOK_MAX_SPREAD_PCT", - "TAIL_ORDERBOOK_ENTRY_BID_LEVELS", - "TAIL_ORDERBOOK_ENTRY_BID_DEPTH_MULT", - "TAIL_ORDERBOOK_ENTRY_ASK_MAX_MULT", - "TAIL_ORDERBOOK_MIN_BID_ASK_RATIO", - # ── 키움 WS 프로그램매매(0w) + TRIGGER 프로그램 필터 ── - "PROGRAM_FILTER_ENABLED", - "PROGRAM_MIN_NET_BUY_QTY", - "PROGRAM_MIN_NET_BUY_AMT", - "PROGRAM_MAX_SELL_BUY_QTY_RATIO", - "PROGRAM_MIN_NET_DELTA_QTY", - "PROGRAM_SNAPSHOT_MAX_AGE_SEC", - "MOMENTUM_PROGRAM_FILTER_ENABLED", - "MOMENTUM_PROGRAM_MIN_NET_BUY_QTY", - "MOMENTUM_PROGRAM_MIN_NET_BUY_AMT", - "MOMENTUM_PROGRAM_MAX_SELL_BUY_QTY_RATIO", - "MOMENTUM_PROGRAM_MIN_NET_DELTA_QTY", - "MOMENTUM_PROGRAM_SNAPSHOT_MAX_AGE_SEC", - "BREAKOUT_PROGRAM_FILTER_ENABLED", - "BREAKOUT_PROGRAM_MIN_NET_BUY_QTY", - "BREAKOUT_PROGRAM_MIN_NET_BUY_AMT", - "BREAKOUT_PROGRAM_MAX_SELL_BUY_QTY_RATIO", - "BREAKOUT_PROGRAM_MIN_NET_DELTA_QTY", - "BREAKOUT_PROGRAM_SNAPSHOT_MAX_AGE_SEC", - "TAIL_PROGRAM_FILTER_ENABLED", - "TAIL_PROGRAM_MIN_NET_BUY_QTY", - "TAIL_PROGRAM_MIN_NET_BUY_AMT", - "TAIL_PROGRAM_MAX_SELL_BUY_QTY_RATIO", - "TAIL_PROGRAM_MIN_NET_DELTA_QTY", - "TAIL_PROGRAM_SNAPSHOT_MAX_AGE_SEC", -) - -# ── 전략별 config_* 테이블 분류 (config_schema.py) ───────────────────── -from config_schema import ( # noqa: E402 - CONFIG_TABLE_NAMES, - STRATEGY_ID_TO_TABLE, - _EXPLICIT_KEY_TABLE, - classify_config_key, - split_env_keys, -) - -_CONFIG_SPLIT = split_env_keys(ENV_CONFIG_KEYS) -ENV_GLOBAL_KEYS = _CONFIG_SPLIT["env_config"] -CONFIG_SCALP_KEYS = _CONFIG_SPLIT["config_scalp"] -CONFIG_SHORT_KEYS = _CONFIG_SPLIT["config_short"] -CONFIG_MOMENTUM_KEYS = _CONFIG_SPLIT["config_momentum"] -CONFIG_BREAKOUT_KEYS = _CONFIG_SPLIT["config_breakout"] -CONFIG_RANGE_BREAK_KEYS = _CONFIG_SPLIT["config_range_break"] -CONFIG_UPDOW_KEYS = _CONFIG_SPLIT["config_updow"] -CONFIG_DBBAND_KEYS = _CONFIG_SPLIT["config_dbband"] -CONFIG_TABLE_KEYS: Dict[str, Tuple[str, ...]] = { - "env_config": ENV_GLOBAL_KEYS, - "config_scalp": CONFIG_SCALP_KEYS, - "config_short": CONFIG_SHORT_KEYS, - "config_momentum": CONFIG_MOMENTUM_KEYS, - "config_breakout": CONFIG_BREAKOUT_KEYS, - "config_range_break": CONFIG_RANGE_BREAK_KEYS, - "config_updow": CONFIG_UPDOW_KEYS, - "config_dbband": CONFIG_DBBAND_KEYS, -} - - -class TradeDB: - """ - 트레이딩 봇용 MariaDB 데이터베이스 관리 클래스. - 기존 SQLite 인터페이스와 100% 호환 (db_path 인수는 무시됨). - - ※ 호출자가 매번 ``TradeDB()`` → ``db.close()`` 패턴으로 쓰므로, - DDL(`CREATE TABLE IF NOT EXISTS`) 과 INFO 로그는 **프로세스당 1회만** 실행한다. - (내부 클래스 변수 ``_tables_created`` 가드) - """ - _tables_created = False # 프로세스 내 테이블 생성 1회만 - _tables_lock = threading.RLock() # 마이그레이션 중 get_env→TradeDB 재진입 허용 - - def __init__(self, db_path="quant_bot.db"): - """ - Args: - db_path: 하위 호환용 (무시됨). MariaDB 접속 정보는 환경변수/모듈 상수 사용. - """ - self.db_path = db_path # 호환용 보존 - self.conn = _MariaDBConn() - # 마이그레이션(ws_candles RSI/EMA 컬럼 등)이 get_env_from_db → TradeDB() 재호출 시 - # 동일 인스턴스를 재사용 — 미등록이면 _tables_lock 데드락으로 기동 무음 hang. - try: - from kis_trader.utils.env import set_db - set_db(self) - except Exception: - pass - # 테이블 생성은 첫 인스턴스에서만 — 매 호출마다 21개 DDL 폭탄 방지 - with TradeDB._tables_lock: - if not TradeDB._tables_created: - self._create_tables() - logger.info( - "✅ TradeDB 초기화 완료: MariaDB %s:%s/%s", - _DB_HOST, _DB_PORT, _DB_NAME, - ) - TradeDB._tables_created = True - else: - # 이후 인스턴스는 조용히 — 같은 정보 매초 찍어 로그 오염 방지 - logger.debug( - "TradeDB 인스턴스 재생성 (테이블 스킵): %s:%s/%s", - _DB_HOST, _DB_PORT, _DB_NAME, - ) - - def _create_tables(self): - """DB 테이블 생성 (없을 경우)""" - with self.conn: - # 1. 활성 트레이딩 테이블 (현재 보유 중이거나 매수 중인 종목) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS active_trades ( - code VARCHAR(20) NOT NULL, -- 종목코드 - name VARCHAR(100) NOT NULL, -- 종목명 - strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL', -- 매매 전략 (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등) - PRIMARY KEY (code, strategy), -- 복합 PK: 같은 종목을 서로 다른 봇이 독립 보유 가능 - -- [가격 정보] - avg_buy_price DOUBLE NOT NULL, -- 평단가 - current_price DOUBLE, -- 현재가 (업데이트용) - stop_price DOUBLE, -- 손절가 - target_price REAL, -- 목표가 - max_price REAL, -- 최고가 (트레일링 스탑용) - atr_entry REAL, -- 진입 시 ATR 변동성 - - -- [수량 및 진행 상태 (분할매수용)] - target_qty INTEGER NOT NULL, -- 목표 매수 수량 - current_qty INTEGER NOT NULL,-- 현재 체결 수량 - total_invested REAL, -- 총 투입 금액 (수수료 제외) - - -- [상태 관리] - status TEXT NOT NULL, -- BUYING(매수중), HOLDING(보유중), SELLING(매도중) - buy_date TEXT NOT NULL, -- 첫 매수 시작 시간 - updated_at TEXT NOT NULL, -- 마지막 업데이트 시간 - size_class TEXT -- 대/중/소형 (매수 시점) - ) - """) - - # 2. 매매 기록 테이블 (손익 분석 & 켈리 공식용) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS trade_history ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL, - strategy VARCHAR(50), - buy_price DOUBLE NOT NULL, - sell_price DOUBLE NOT NULL, - qty INT NOT NULL, - profit_rate DOUBLE NOT NULL, - realized_pnl DOUBLE NOT NULL, - hold_minutes INT, - buy_date VARCHAR(30), - sell_date VARCHAR(30) NOT NULL, - sell_reason VARCHAR(200), - env_snapshot TEXT, - size_class VARCHAR(20) - ) CHARACTER SET utf8mb4 - """) - - # 3. 일일 손익 요약 테이블 (대시보드용) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS daily_summary ( - date VARCHAR(10) NOT NULL PRIMARY KEY, - start_asset DOUBLE, - end_asset DOUBLE, - total_trades INT, - win_trades INT, - total_pnl DOUBLE, - win_rate DOUBLE - ) CHARACTER SET utf8mb4 - """) - - # 4. 주문·체결 보강 테이블 (kt00007 / ka10076) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS order_execution_history ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - source VARCHAR(20) NOT NULL, - ord_no VARCHAR(30), - stk_cd VARCHAR(20), - stk_nm VARCHAR(100), - trde_tp VARCHAR(20), - ord_qty VARCHAR(20), - ord_uv VARCHAR(20), - cntr_qty VARCHAR(20), - cntr_uv VARCHAR(20), - ord_tm VARCHAR(20), - cnfm_tm VARCHAR(20), - sell_tp VARCHAR(20), - ord_dt VARCHAR(20), - raw_json TEXT, - fetched_at VARCHAR(30) NOT NULL - ) CHARACTER SET utf8mb4 - """) - - # 5. 매수 체결 이력 (일일 한도용 - '산 시점' 날짜 기준 누적) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS buy_execution_log ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL, - strategy VARCHAR(50) NOT NULL, - buy_date VARCHAR(10) NOT NULL, - executed_at VARCHAR(30) NOT NULL, - amount DOUBLE NOT NULL, - qty INT NOT NULL - ) CHARACTER SET utf8mb4 - """) - - # 6. 매수 후보군 테이블 (target_universe 대체) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS target_candidates ( - code VARCHAR(20) NOT NULL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - score DOUBLE NOT NULL, - price DOUBLE NOT NULL, - scan_time VARCHAR(30) NOT NULL, - updated_at VARCHAR(30) NOT NULL - ) CHARACTER SET utf8mb4 - """) - - # 6-2. 매수 후보군 이력 (5분마다 쌓아서 백테스트 시 '실제 그 시각 유니버스' 사용 가능) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS target_candidates_history ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - slot_key VARCHAR(12) NOT NULL, - scan_time VARCHAR(30) NOT NULL, - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL DEFAULT '', - score DOUBLE NOT NULL DEFAULT 0, - price DOUBLE NOT NULL DEFAULT 0, - market CHAR(1) DEFAULT 'Q', - sector VARCHAR(100), - theme VARCHAR(100), - INDEX idx_slot (slot_key), - INDEX idx_scan (scan_time) - ) CHARACTER SET utf8mb4 - """) - - # 7. 종목 메타데이터 (테마·섹터·시장구분) — 스캐너가 채움, 조인 분석 용 - # market: 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF/기타 - # sector: 업종명 (KIS bstp_kor_isnm, 예: '반도체') - # theme : 주요 테마 (예: 'AI반도체', '2차전지', '원자력') - # - 직접 UPDATE 또는 별도 스크립트로 채움 - # theme_rank: 테마 내 대장주/추종주 순위 (1=핵심, 2=연관, 3=주변) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS stock_meta ( - code VARCHAR(20) NOT NULL PRIMARY KEY, - name VARCHAR(100) NOT NULL DEFAULT '', - market CHAR(1) NOT NULL DEFAULT 'Q', - sector_code VARCHAR(20), - sector VARCHAR(100), - theme VARCHAR(100), - theme_rank TINYINT DEFAULT 3, - updated_at VARCHAR(30) NOT NULL - ) CHARACTER SET utf8mb4 - """) - - # 8. env 공통 설정 (API·MM·인프라·전략 스위치) — 컬럼 수 축소 - gcols = ", ".join([f"`{k}` TEXT" for k in ENV_GLOBAL_KEYS]) - self.conn.execute(f""" - CREATE TABLE IF NOT EXISTS env_config ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - created_at VARCHAR(30) NOT NULL, - {gcols} - ) CHARACTER SET utf8mb4 - """) - # 8a. 전략별 설정 테이블 (config_scalp / config_short / …) - for tbl, keys in CONFIG_TABLE_KEYS.items(): - if tbl == "env_config": - continue - scols = ", ".join([f"`{k}` TEXT" for k in keys]) - self.conn.execute(f""" - CREATE TABLE IF NOT EXISTS {tbl} ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - created_at VARCHAR(30) NOT NULL, - {scols} - ) CHARACTER SET utf8mb4 - """) - - # 8. 키-값 저장소 (매터모스트 원격 조종: 마지막 AI 추천문, last_seen 등) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS kv_store ( - k VARCHAR(100) NOT NULL PRIMARY KEY, - v MEDIUMTEXT - ) CHARACTER SET utf8mb4 - """) - # 8b. env_config 컬럼 한도(Row size) 초과 키 — KIWOOM_WS_* · WHIPSAW_* 등 - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS env_config_ext ( - env_key VARCHAR(128) NOT NULL PRIMARY KEY, - env_value TEXT, - updated_at VARCHAR(30) NOT NULL - ) CHARACTER SET utf8mb4 - """) - - # 9. AI 분석 기록 (Butler !클로드분석/!애미분석 시 프롬프트 요약·응답 저장 → 나중에 꺼내보기) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ai_analysis_log ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - created_at VARCHAR(30) NOT NULL, - model VARCHAR(50) NOT NULL, - context_summary TEXT, - response MEDIUMTEXT - ) CHARACTER SET utf8mb4 - """) - - # 10. WebSocket 실시간 봉 집계 (백테스트용 — CandleAggregator 배치 INSERT) - # - is_confirmed=1 인 확정 봉만 저장 (진행 중 봉은 RAM에만 존재) - # - source: 'ws'=WebSocket틱 집계, 'rest'=갭보정 REST 조회 - # - UNIQUE(code, timeframe, candle_time) → ON DUPLICATE KEY UPDATE - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_candles ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(20) NOT NULL, - timeframe TINYINT NOT NULL, - candle_time VARCHAR(12) NOT NULL, - `open` DOUBLE NOT NULL, - high DOUBLE NOT NULL, - low DOUBLE NOT NULL, - close DOUBLE NOT NULL, - volume BIGINT NOT NULL DEFAULT 0, - rsi_2 DOUBLE, - rsi_3 DOUBLE, - rsi_5 DOUBLE, - is_confirmed TINYINT NOT NULL DEFAULT 1, - source VARCHAR(10) NOT NULL DEFAULT 'ws', - updated_at VARCHAR(30) NOT NULL, - UNIQUE KEY uq_candle (code, timeframe, candle_time) - ) CHARACTER SET utf8mb4 - """) - - # 11. WebSocket 실시간 체결 틱 (후보 종목, 배치 INSERT — TickRecorder) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_ticks ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - market VARCHAR(8) NOT NULL DEFAULT 'KR', - exchange VARCHAR(16) DEFAULT NULL, - code VARCHAR(32) NOT NULL, - tick_time VARCHAR(14) NOT NULL, - price DOUBLE NOT NULL, - volume BIGINT NOT NULL DEFAULT 0, - tick_seq BIGINT DEFAULT NULL, - session VARCHAR(8) DEFAULT NULL, - currency VARCHAR(8) NOT NULL DEFAULT 'KRW', - source VARCHAR(16) NOT NULL DEFAULT 'kis', - recv_ts VARCHAR(30) NOT NULL, - KEY idx_ws_ticks_lookup (market, code, tick_time), - KEY idx_ws_ticks_recv (recv_ts) - ) CHARACTER SET utf8mb4 - """) - - # 11b. TRIGGER 호가 스냅샷 (키움 0D — 백테·파람서치 재현) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_orderbook ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - market VARCHAR(8) NOT NULL DEFAULT 'KR', - code VARCHAR(32) NOT NULL, - snap_time VARCHAR(14) NOT NULL, - best_bid BIGINT NOT NULL DEFAULT 0, - best_ask BIGINT NOT NULL DEFAULT 0, - total_bid_qty BIGINT NOT NULL DEFAULT 0, - total_ask_qty BIGINT NOT NULL DEFAULT 0, - bid_qty_l3 BIGINT NOT NULL DEFAULT 0, - ask_qty_l3 BIGINT NOT NULL DEFAULT 0, - levels_json MEDIUMTEXT, - source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0d', - recv_ts VARCHAR(30) NOT NULL, - KEY idx_ws_orderbook_lookup (market, code, snap_time), - KEY idx_ws_orderbook_recv (recv_ts) - ) CHARACTER SET utf8mb4 - """) - - # 11c. TRIGGER 프로그램매매 스냅샷 (키움 0w) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_program ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - market VARCHAR(8) NOT NULL DEFAULT 'KR', - code VARCHAR(32) NOT NULL, - snap_time VARCHAR(14) NOT NULL, - buy_qty BIGINT NOT NULL DEFAULT 0, - sell_qty BIGINT NOT NULL DEFAULT 0, - net_qty BIGINT NOT NULL DEFAULT 0, - buy_amt BIGINT NOT NULL DEFAULT 0, - sell_amt BIGINT NOT NULL DEFAULT 0, - net_amt BIGINT NOT NULL DEFAULT 0, - source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0w', - recv_ts VARCHAR(30) NOT NULL, - KEY idx_ws_program_lookup (market, code, snap_time), - KEY idx_ws_program_recv (recv_ts) - ) CHARACTER SET utf8mb4 - """) - - # 12. 종목 유통/상장주식수 (키움 ka10001 — 백테 회전율·전략 공통) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS stock_share_meta ( - code VARCHAR(20) NOT NULL PRIMARY KEY, - flo_stk BIGINT NOT NULL DEFAULT 0, - dstr_stk BIGINT NOT NULL DEFAULT 0, - dstr_rt DOUBLE NULL, - source VARCHAR(16) NOT NULL DEFAULT 'ka10001', - updated_at VARCHAR(30) NOT NULL, - KEY idx_share_updated (updated_at) - ) CHARACTER SET utf8mb4 - """) - - self._migrate_add_columns() - self._migrate_env_config_to_columns() - logger.info("📊 DB 테이블 생성/확인 완료") - - def _migrate_add_columns(self): - """기존 DB에 누락된 컬럼 추가 (한 번만) — PRAGMA → information_schema 대체""" - try: - cols = self.conn.get_columns("trade_history") - if "env_snapshot" not in cols: - self.conn.execute("ALTER TABLE trade_history ADD COLUMN env_snapshot TEXT") - logger.info("📌 trade_history.env_snapshot 컬럼 추가") - if "size_class" not in cols: - self.conn.execute("ALTER TABLE trade_history ADD COLUMN size_class VARCHAR(20)") - logger.info("📌 trade_history.size_class 컬럼 추가") - except Exception as e: - logger.debug(f"migrate trade_history: {e}") - # ── active_trades PK 복합키 마이그레이션 (code → code+strategy) ────────── - # 두 봇(SHORT/SCALP)이 같은 종목을 독립 보유 가능하도록 PK 확장. - # 신규 설치는 DDL에서 처리됨. 기존 테이블은 여기서 한 번만 ALTER. - try: - cursor = self.conn.execute(""" - SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'active_trades' - AND CONSTRAINT_NAME = 'PRIMARY' - ORDER BY ORDINAL_POSITION - """) - pk_cols = [row[0] if isinstance(row, (list, tuple)) else row['COLUMN_NAME'] - for row in cursor.fetchall()] - if 'strategy' not in pk_cols: - logger.info("⚙️ active_trades PK 복합키 마이그레이션 시작 (code → code+strategy)") - # NULL strategy → 'MANUAL' 로 채움 (NOT NULL 변경 전 필수) - self.conn.execute("UPDATE active_trades SET strategy = 'MANUAL' WHERE strategy IS NULL OR strategy = ''") - # strategy 컬럼 NOT NULL DEFAULT 'MANUAL' 로 변경 후 PK 재구성 - self.conn.execute("ALTER TABLE active_trades MODIFY COLUMN strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL'") - self.conn.execute("ALTER TABLE active_trades DROP PRIMARY KEY, ADD PRIMARY KEY (code, strategy)") - logger.info("✅ active_trades PK 복합키(code, strategy) 변환 완료") - except Exception as e: - logger.debug("active_trades PK 마이그레이션 스킵(이미 완료 또는 신규): %s", e) - - try: - cols = self.conn.get_columns("active_trades") - if "size_class" not in cols: - self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class VARCHAR(20)") - logger.info("📌 active_trades.size_class 컬럼 추가") - for c in ML_ENTRY_FEATURE_COLUMNS: - if c not in cols: - self.conn.execute(f"ALTER TABLE active_trades ADD COLUMN `{c}` DOUBLE") - logger.info(f"📌 active_trades.{c} 컬럼 추가 (ML 진입 피처)") - except Exception as e: - logger.debug(f"migrate active_trades: {e}") - try: - self.migrate_trigger_eval_columns() - except Exception as e: - logger.debug("migrate_trigger_eval_columns: %s", e) - try: - cols = self.conn.get_columns("trade_history") - for c in ML_ENTRY_FEATURE_COLUMNS: - if c not in cols: - self.conn.execute(f"ALTER TABLE trade_history ADD COLUMN `{c}` DOUBLE") - logger.info(f"📌 trade_history.{c} 컬럼 추가 (ML 진입 피처)") - except Exception as e: - logger.debug(f"migrate trade_history ML columns: {e}") - try: - self._migrate_config_table_columns() - self._migrate_strategy_keys_from_env_config() - self._migrate_short_time_to_tail_time() - except Exception as e: - logger.debug(f"migrate config tables: {e}") - try: - cols = self.conn.get_columns("ws_candles") - if "holding_peak" not in cols: - self.conn.execute( - "ALTER TABLE ws_candles ADD COLUMN holding_peak DOUBLE NULL " - "COMMENT '보유 중 트레일 고점(WS 틱 max_price 스냅샷)'" - ) - logger.info("📌 ws_candles.holding_peak 컬럼 추가") - # RSI(7,14,21)·EMA(5,9,12,15,21,34) materialized — indicator_cache 정의 기준 - try: - from kis_trader.engine.indicator_cache import ensure_ws_candles_indicator_columns - n_added = ensure_ws_candles_indicator_columns(self) - if n_added: - logger.info("📌 ws_candles materialized 지표 컬럼 %d개 추가", n_added) - except Exception as mig_ex: - logger.debug("ws_candles indicator columns migrate: %s", mig_ex) - except Exception as e: - logger.debug(f"migrate ws_candles holding_peak: {e}") - # ── target_candidates 테마/섹터/시장구분 컬럼 추가 ────────────────── - try: - cols = self.conn.get_columns("target_candidates") - for col, ddl in [ - ("market", "CHAR(1) DEFAULT 'Q'"), - ("sector", "VARCHAR(100)"), - ("theme", "VARCHAR(100)"), - ]: - if col not in cols: - self.conn.execute( - f"ALTER TABLE target_candidates ADD COLUMN `{col}` {ddl}" - ) - logger.info(f"📌 target_candidates.{col} 컬럼 추가") - except Exception as e: - logger.debug(f"migrate target_candidates theme cols: {e}") - # ── target_candidates_history (후보 이력, 백테스트용) ────────────────── - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS target_candidates_history ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - slot_key VARCHAR(12) NOT NULL, - scan_time VARCHAR(30) NOT NULL, - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL DEFAULT '', - score DOUBLE NOT NULL DEFAULT 0, - price DOUBLE NOT NULL DEFAULT 0, - market CHAR(1) DEFAULT 'Q', - sector VARCHAR(100), - theme VARCHAR(100), - INDEX idx_slot (slot_key), - INDEX idx_scan (scan_time) - ) CHARACTER SET utf8mb4 - """) - logger.info("📌 target_candidates_history 테이블 확인/생성") - except Exception as e: - logger.warning(f"migrate target_candidates_history 실패(이력 미적재 가능): {e}") - # ── ws_price_validation (KIS↔키움 시세 검증, 마이그레이션 단계용) ──── - # 5초마다 같은 종목의 KIS WS 가격과 키움 WS 가격을 비교해 한 행 INSERT. - # diff_pct = (kiwoom - kis) / kis × 100. - # 운영에는 영향 없음 (검증 모드 ON 일 때만 채워짐). 1~2주 누적 후 - # 통계 분석 → 본격 마이그레이션 결정 근거. - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_price_validation ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - ts DATETIME(3) NOT NULL, - code VARCHAR(20) NOT NULL, - kis_price DOUBLE, - kiwoom_price DOUBLE, - diff_pct DOUBLE, - kis_age_ms INT, - kiwoom_age_ms INT, - INDEX idx_ts (ts), - INDEX idx_code (code), - INDEX idx_diff (diff_pct) - ) CHARACTER SET utf8mb4 - """) - logger.info("📌 ws_price_validation 테이블 확인/생성") - except Exception as e: - logger.warning(f"migrate ws_price_validation 실패: {e}") - # ── stock_share_meta (유통주식수, 백테·라이브 공통) ───────────────── - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS stock_share_meta ( - code VARCHAR(20) NOT NULL PRIMARY KEY, - flo_stk BIGINT NOT NULL DEFAULT 0, - dstr_stk BIGINT NOT NULL DEFAULT 0, - dstr_rt DOUBLE NULL, - source VARCHAR(16) NOT NULL DEFAULT 'ka10001', - updated_at VARCHAR(30) NOT NULL, - KEY idx_share_updated (updated_at) - ) CHARACTER SET utf8mb4 - """) - logger.info("📌 stock_share_meta 테이블 확인/생성") - except Exception as e: - logger.warning(f"migrate stock_share_meta 실패: {e}") - - def _migrate_env_config_to_columns(self): - """env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전""" - try: - cols = self.conn.get_columns("env_config") - if "snapshot_json" not in cols: - return - rows = self.conn.execute( - "SELECT id, created_at, snapshot_json FROM env_config ORDER BY id" - ).fetchall() - col_defs = ", ".join([f"`{k}` TEXT" for k in ENV_CONFIG_KEYS]) - self.conn.execute(f""" - CREATE TABLE IF NOT EXISTS env_config_new ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - created_at VARCHAR(30) NOT NULL, - {col_defs} - ) CHARACTER SET utf8mb4 - """) - key_list = ", ".join(f"`{k}`" for k in ENV_CONFIG_KEYS) - placeholders = ", ".join(["%s"] * (1 + len(ENV_CONFIG_KEYS))) - for row in rows: - snap = json.loads(row["snapshot_json"]) if row["snapshot_json"] else {} - vals = [row["created_at"]] + [snap.get(k) for k in ENV_CONFIG_KEYS] - self.conn.execute( - f"INSERT INTO env_config_new (created_at, {key_list}) VALUES ({placeholders})", - vals, - ) - self.conn.execute("DROP TABLE env_config") - self.conn.execute("ALTER TABLE env_config_new RENAME TO env_config") - logger.info("📌 env_config: snapshot_json -> 컬럼 스키마 마이그레이션 완료") - except Exception as e: - logger.debug(f"migrate env_config: {e}") - - # ============================================================ - # [CRUD] Active Trades (활성 트레이딩 관리) - # ============================================================ - - def upsert_trade(self, trade_data: Dict): - """ - 신규 매수하거나 정보 업데이트 (평단가, 수량 등) - - Args: - trade_data: 트레이드 정보 딕셔너리 - 필수: code, name, avg_buy_price, target_qty, current_qty, status - 선택: strategy, stop_price, target_price, max_price, atr_entry, total_invested - ML 학습용: entry_features (dict) 또는 rsi, volume_ratio 등 개별 키 - """ - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # 기본값 설정 - code = trade_data.get('code') - if not code: - logger.error("종목코드 누락: upsert 실패") - return False - - size_class = trade_data.get('size_class') - feats = trade_data.get('entry_features') or {} - feat_vals = [] - for k in ML_ENTRY_FEATURE_COLUMNS: - v = feats.get(k) if k in feats else trade_data.get(k) - feat_vals.append(v if isinstance(v, (int, float)) else None) - cols = ", ".join(["code", "name", "strategy", "avg_buy_price", "current_price", "stop_price", "target_price", - "max_price", "atr_entry", "target_qty", "current_qty", "total_invested", - "status", "buy_date", "updated_at", "size_class"] + list(ML_ENTRY_FEATURE_COLUMNS)) - placeholders = ", ".join(["%s"] * (16 + len(ML_ENTRY_FEATURE_COLUMNS))) - # MySQL: ON DUPLICATE KEY UPDATE (excluded. → VALUES()) - updates = ( - "avg_buy_price = VALUES(avg_buy_price), current_price = VALUES(current_price), " - "stop_price = COALESCE(VALUES(stop_price), stop_price), " - "target_price = COALESCE(VALUES(target_price), target_price), " - "atr_entry = COALESCE(VALUES(atr_entry), atr_entry), " - "current_qty = VALUES(current_qty), total_invested = VALUES(total_invested), " - "max_price = GREATEST(max_price, VALUES(max_price)), " - "status = VALUES(status), updated_at = VALUES(updated_at), " - "size_class = COALESCE(VALUES(size_class), size_class)" - ) - for c in ML_ENTRY_FEATURE_COLUMNS: - updates += f", `{c}` = COALESCE(VALUES(`{c}`), `{c}`)" - sql = f""" - INSERT INTO active_trades ( - {cols} - ) VALUES ({placeholders}) - ON DUPLICATE KEY UPDATE - {updates} - """ - try: - from kis_trader.utils.strategy_ids import canonical_strategy_id - _stored_strategy = canonical_strategy_id(trade_data.get("strategy", "MANUAL")) - except Exception: - _stored_strategy = trade_data.get("strategy", "MANUAL") or "MANUAL" - - params = ( - code, - trade_data.get('name', 'Unknown'), - _stored_strategy, - trade_data.get('avg_buy_price') or trade_data.get('buy_price', 0), - trade_data.get('current_price', 0), - trade_data.get('stop_price', 0), - trade_data.get('target_price', 0), - trade_data.get('max_price', trade_data.get('buy_price', 0)), - trade_data.get('atr_at_entry') or trade_data.get('atr_entry', 0), - trade_data.get('target_qty', trade_data.get('qty', 0)), - trade_data.get('current_qty') or trade_data.get('qty', 0), - trade_data.get('total_invested', 0), - trade_data.get('status', 'HOLDING'), - trade_data.get('buy_date', now), - now, - size_class, - ) + tuple(feat_vals) - - try: - with self.conn: - self.conn.execute(sql, params) - return True - except Exception as e: - logger.error(f"❌ upsert_trade 실패 ({code}): {e}") - return False - - def get_active_trades(self, strategy_prefix: Optional[str] = None): - """ - 활성 트레이딩 목록 조회 (봇 재시작 시 사용) - - Args: - strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT'면 'SHORT%'만 - (늘림목/단타 섞임 방지) - - Returns: - {종목코드: {trade_info}} 형태의 딕셔너리 - """ - try: - if strategy_prefix: - cursor = self.conn.execute( - "SELECT * FROM active_trades WHERE strategy LIKE %s", - (strategy_prefix.strip().upper() + "%",) - ) - else: - cursor = self.conn.execute("SELECT * FROM active_trades") - rows = cursor.fetchall() - - # 기존 JSON 포맷과 호환되도록 딕셔너리 변환 - result = {} - for row in rows: - code = row['code'] - result[code] = { - 'code': code, - 'name': row['name'], - 'strategy': row['strategy'], - 'buy_price': row['avg_buy_price'], # JSON 호환 - 'avg_buy_price': row['avg_buy_price'], - 'current_price': row['current_price'], - 'stop_price': row['stop_price'], - 'target_price': row['target_price'], - 'max_price': row['max_price'], - 'atr_at_entry': row['atr_entry'], - 'qty': row['current_qty'], # JSON 호환 - 'target_qty': row['target_qty'], - 'current_qty': row['current_qty'], - 'total_invested': row['total_invested'], - 'status': row['status'], - 'buy_date': row['buy_date'], - 'updated_at': row['updated_at'], - 'size_class': row['size_class'] if 'size_class' in row.keys() else None, - } - - logger.debug(f"📂 활성 트레이드 로드: {len(result)}개") - return result - - except Exception as e: - logger.error(f"❌ get_active_trades 실패: {e}") - return {} - - def get_active_trade(self, code: str) -> Optional[Dict]: - """ - 활성 트레이딩 단일 종목 조회. - 잔고 동기화 시 DB에 저장된 평단가를 폴백용으로 사용할 때 쓴다. - """ - try: - cursor = self.conn.execute( - "SELECT * FROM active_trades WHERE code = ?", - (code,), - ) - row = cursor.fetchone() - if not row: - return None - return { - "code": row["code"], - "name": row["name"], - "strategy": row["strategy"], - "avg_buy_price": row["avg_buy_price"], - "current_price": row["current_price"], - "stop_price": row["stop_price"], - "target_price": row["target_price"], - "max_price": row["max_price"], - "atr_entry": row["atr_entry"], - "target_qty": row["target_qty"], - "current_qty": row["current_qty"], - "total_invested": row["total_invested"], - "status": row["status"], - "buy_date": row["buy_date"], - "updated_at": row["updated_at"], - } - except Exception as e: - logger.error(f"❌ get_active_trade 실패 ({code}): {e}") - return None - - def update_current_price(self, code: str, current_price: float): - """현재가 업데이트 (매도 판단용)""" - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - with self.conn: - self.conn.execute( - "UPDATE active_trades SET current_price=?, updated_at=? WHERE code=?", - (current_price, now, code) - ) - except Exception as e: - logger.error(f"❌ 현재가 업데이트 실패 ({code}): {e}") - - def update_max_price(self, code: str, new_max_price: float): - """최고가 갱신 (트레일링 스탑용)""" - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - with self.conn: - # 기존 max_price보다 클 때만 업데이트 - self.conn.execute( - """UPDATE active_trades - SET max_price = MAX(max_price, ?), updated_at = ? - WHERE code = ?""", - (new_max_price, now, code) - ) - except Exception as e: - logger.error(f"❌ 최고가 갱신 실패 ({code}): {e}") - - def close_trade( - self, - code: str, - sell_price: float, - sell_reason: str = "", - env_snapshot: str = None, - size_class: str = None, - strategy: str = None, - realized_pnl_override: float = None, - ): - """ - 매도 완료 처리: active_trades 삭제 -> trade_history 이동 (INSERT만, env 스냅샷 포함) - - Args: - code: 종목코드 - sell_price: 매도가 - sell_reason: 매도 사유 - env_snapshot: 매도 시점 env JSON (백테스트/대시보드용) - size_class: 대/중/소형 (매수 시점 저장값) - strategy: 봇 전략 ID (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등) - 지정 시 해당 전략 row만 삭제 (다른 봇의 동일 종목 보호). - None이면 code 단독 조회 (단일 봇 운영 환경 호환). - """ - try: - # 1. 활성 트레이드 정보 조회 (strategy 지정 시 정확히 해당 row만 조회) - if strategy: - cursor = self.conn.execute( - "SELECT * FROM active_trades WHERE code=%s AND strategy=%s", - (code, strategy), - ) - else: - cursor = self.conn.execute("SELECT * FROM active_trades WHERE code=%s", (code,)) - trade = cursor.fetchone() - - if not trade: - logger.warning(f"⚠️ close_trade: {code} 종목이 active_trades에 없음") - return False - - # 2. 손익 계산 - buy_price = trade['avg_buy_price'] - qty = trade['current_qty'] - # realized_pnl_override 가 있으면 수수료·세금 반영 순손익을 외부에서 주입 - # 없으면 내부 계산 (수수료 미포함 gross) - if realized_pnl_override is not None: - realized_pnl = realized_pnl_override - else: - realized_pnl = (sell_price - buy_price) * qty - profit_rate = (realized_pnl / (buy_price * qty) * 100) if buy_price * qty > 0 else 0 - - # 3. 보유 시간 계산 - buy_time = datetime.datetime.strptime(trade['buy_date'], '%Y-%m-%d %H:%M:%S') - sell_time = datetime.datetime.now() - hold_minutes = int((sell_time - buy_time).total_seconds() / 60) - - # size_class는 active_trades에 있으면 그대로 사용 - if size_class is None and 'size_class' in trade.keys() and trade['size_class']: - size_class = trade['size_class'] - - # 4. trade_history에 저장 — strategy 는 kis_trader canonical ID (실거래 탭·봇 ID 일치) - try: - from kis_trader.utils.strategy_ids import canonical_strategy_id - hist_strategy = canonical_strategy_id(trade.get("strategy")) - except Exception: - hist_strategy = trade.get("strategy") - - feat_vals = [trade[c] if c in trade.keys() else None for c in ML_ENTRY_FEATURE_COLUMNS] - cols_th = "code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, env_snapshot, size_class" - if ML_ENTRY_FEATURE_COLUMNS: - cols_th += ", " + ", ".join(ML_ENTRY_FEATURE_COLUMNS) - placeholders = ", ".join(["?"] * (14 + len(ML_ENTRY_FEATURE_COLUMNS))) - with self.conn: - self.conn.execute(f""" - INSERT INTO trade_history ( - {cols_th} - ) VALUES ({placeholders}) - """, ( - trade['code'], - trade['name'], - hist_strategy, - buy_price, - sell_price, - qty, - profit_rate, - realized_pnl, - hold_minutes, - trade['buy_date'], - sell_time.strftime('%Y-%m-%d %H:%M:%S'), - sell_reason, - env_snapshot, - size_class, - ) + tuple(feat_vals)) - - # 5. active_trades에서 삭제 (strategy 지정 시 해당 봇 row만 삭제) - if strategy: - self.conn.execute( - "DELETE FROM active_trades WHERE code=%s AND strategy=%s", - (code, strategy), - ) - else: - self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,)) - - logger.info(f"✅ [{trade['name']}] 매매 종료: 수익률 {profit_rate:.2f}% ({realized_pnl:+,.0f}원)") - return True - - except Exception as e: - logger.error(f"❌ close_trade 실패 ({code}): {e}") - return False - - def delete_active_trade(self, code: str, strategy: str = None): - """ - 활성 트레이드 삭제 (긴급 정리용). - strategy 지정 시 해당 봇 row만 삭제, None이면 해당 종목 전체 삭제. - """ - try: - with self.conn: - if strategy: - self.conn.execute( - "DELETE FROM active_trades WHERE code=%s AND strategy=%s", - (code, strategy), - ) - else: - self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,)) - logger.info(f"🗑️ active_trade 삭제: {code}" + (f" [{strategy}]" if strategy else "")) - return True - except Exception as e: - logger.error(f"❌ 삭제 실패 ({code}): {e}") - return False - - def insert_buy_execution( - self, - code: str, - name: str, - strategy: str, - amount: float, - qty: int, - ): - """ - 매수 체결 이력 저장 (일일 한도용). '하루' = 산 날짜(buy_date) 기준. - """ - now = datetime.datetime.now() - buy_date = now.strftime("%Y-%m-%d") - executed_at = now.strftime("%Y-%m-%d %H:%M:%S") - try: - with self.conn: - self.conn.execute(""" - INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, (code, name, strategy, buy_date, executed_at, amount, qty)) - return True - except Exception as e: - logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}") - return False - - def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]: - """ - 해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수. - date_str: YYYY-MM-DD - Returns: - (누적 금액, 건수) - """ - try: - cursor = self.conn.execute(""" - SELECT COALESCE(SUM(amount), 0) AS total_amount, COUNT(*) AS cnt - FROM buy_execution_log - WHERE buy_date = %s AND strategy LIKE %s - """, (date_str, strategy_prefix.strip().upper() + "%")) - row = cursor.fetchone() - return (float(row["total_amount"]), int(row["cnt"])) - except Exception as e: - logger.error(f"❌ get_daily_buy_amount 실패: {e}") - return (0.0, 0) - - # ============================================================ - # [보강] 주문·체결 이력 (kt00007 / ka10076) - # ============================================================ - - def insert_order_execution( - self, source: str, row: dict, ord_dt: str = None, sell_tp: str = None, raw_json: str = None - ): - """주문·체결 1건 INSERT (보강용, 이력만 쌓음)""" - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - self.conn.execute(""" - INSERT INTO order_execution_history ( - source, ord_no, stk_cd, stk_nm, trde_tp, ord_qty, ord_uv, - cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - source, - row.get('ord_no') or row.get('orig_ord_no'), - row.get('stk_cd', ''), - row.get('stk_nm', ''), - row.get('trde_tp', ''), - str(row.get('ord_qty', '') or row.get('cntr_qty', '')), - str(row.get('ord_uv', '') or row.get('ord_pric', '') or row.get('cntr_uv', '')), - str(row.get('cntr_qty', '') or row.get('cnfm_qty', '')), - str(row.get('cntr_uv', '') or row.get('cntr_pric', '')), - row.get('ord_tm', ''), - row.get('cnfm_tm', ''), - sell_tp or '', - ord_dt or '', - raw_json, - now, - )) - self.conn.commit() - return True - except Exception as e: - logger.debug(f"insert_order_execution: {e}") - return False - - # ============================================================ - # [분석] 켈리 공식 및 통계 계산 - # ============================================================ - - def calculate_half_kelly(self, recent_days: int = 30) -> float: - """ - 하프 켈리 공식 계산 (과거 매매 기록 기반) - - Args: - recent_days: 최근 N일 데이터만 사용 - - Returns: - 하프 켈리 비율 (0.0 ~ 1.0) - 예: 0.15 리턴 -> "예수금의 15%씩 배팅하는 게 최적" - """ - try: - # 최근 N일 데이터 조회 - cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=recent_days)).strftime('%Y-%m-%d') - - cursor = self.conn.execute( - "SELECT profit_rate FROM trade_history WHERE sell_date >= ? ORDER BY sell_date DESC", - (cutoff_date,) - ) - rows = cursor.fetchall() - - if len(rows) < 20: # 최소 20건 이상 필요 - logger.warning(f"⚠️ 켈리 공식: 데이터 부족 ({len(rows)}건) -> 기본값 10% 리턴") - return 0.10 - - # 승률 계산 - wins = [r['profit_rate'] for r in rows if r['profit_rate'] > 0] - losses = [r['profit_rate'] for r in rows if r['profit_rate'] <= 0] - - total_count = len(rows) - win_count = len(wins) - win_rate = win_count / total_count - loss_rate = 1.0 - win_rate - - # 손익비 계산 (평균 수익 / 평균 손실) - if not wins or not losses: - logger.warning("⚠️ 켈리 공식: 승 또는 패만 있음 -> 기본값 10%") - return 0.10 - - avg_win = sum(wins) / len(wins) - avg_loss = abs(sum(losses) / len(losses)) - - if avg_loss == 0: - return 0.50 # 손실이 0이면 최대치 - - odds = avg_win / avg_loss - - # 켈리 공식: f = (p * b - q) / b - # p=승률, b=손익비, q=패율 - kelly_fraction = ((win_rate * odds) - loss_rate) / odds - - # 하프 켈리 (안전성 확보) - half_kelly = kelly_fraction * 0.5 - - # 음수면 0 리턴 (통계적으로 지는 구조) - final_kelly = max(0.0, min(half_kelly, 0.5)) # 최대 50%로 제한 - - logger.info( - f"📊 [켈리 분석] 승률:{win_rate*100:.1f}% | 손익비:{odds:.2f} | " - f"켈리:{kelly_fraction*100:.1f}% | 하프켈리:{final_kelly*100:.1f}%" - ) - - return final_kelly - - except Exception as e: - logger.error(f"❌ 켈리 계산 실패: {e}") - return 0.10 - - def get_recent_performance(self, days: int = 7) -> Tuple[float, int, int]: - """ - 최근 N일 성과 조회 - - Returns: - (총손익, 익절횟수, 손절횟수) - """ - try: - cutoff = (datetime.datetime.now() - datetime.timedelta(days=days)).strftime('%Y-%m-%d') - - cursor = self.conn.execute( - "SELECT realized_pnl FROM trade_history WHERE sell_date >= ?", - (cutoff,) - ) - rows = cursor.fetchall() - - total_pnl = sum([r['realized_pnl'] for r in rows]) - wins = len([r for r in rows if r['realized_pnl'] > 0]) - losses = len([r for r in rows if r['realized_pnl'] <= 0]) - - return total_pnl, wins, losses - - except Exception as e: - logger.error(f"❌ 성과 조회 실패: {e}") - return 0.0, 0, 0 - - def get_trade_stats(self) -> Dict: - """전체 매매 통계""" - try: - cursor = self.conn.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, - AVG(profit_rate) as avg_profit_rate, - SUM(realized_pnl) as total_pnl - FROM trade_history - """) - row = cursor.fetchone() - - return { - 'total_trades': row['total'] or 0, - 'win_trades': row['wins'] or 0, - 'win_rate': (row['wins'] / row['total'] * 100) if row['total'] > 0 else 0, - 'avg_profit_rate': row['avg_profit_rate'] or 0, - 'total_pnl': row['total_pnl'] or 0 - } - except Exception as e: - logger.error(f"❌ 통계 조회 실패: {e}") - return {} - - # ============================================================ - # [유틸] JSON 마이그레이션 - # ============================================================ - - def migrate_from_json(self, json_data: Dict): - """ - 기존 JSON 포트폴리오를 DB로 마이그레이션 - - Args: - json_data: portfolio.json 내용 (딕셔너리) - """ - count = 0 - for code, info in json_data.items(): - trade_data = info.copy() - trade_data['code'] = code - - # 필드 매핑 (JSON -> DB) - if 'target_qty' not in trade_data: - trade_data['target_qty'] = info.get('qty', 0) - if 'current_qty' not in trade_data: - trade_data['current_qty'] = info.get('qty', 0) - if 'total_invested' not in trade_data: - trade_data['total_invested'] = info.get('buy_price', 0) * info.get('qty', 0) - if 'status' not in trade_data: - trade_data['status'] = 'HOLDING' - - if self.upsert_trade(trade_data): - count += 1 - - logger.info(f"✅ JSON -> DB 마이그레이션 완료: {count}개 종목") - return count - - # ============================================================ - # [CRUD] Target Candidates (매수 후보군 관리) - # ============================================================ - - def update_target_candidates(self, candidates: List[Dict]): - """ - 매수 후보군 업데이트 (5분마다 호출) - - Args: - candidates: [{ - 'code': '005930', 'name': '삼성전자', - 'score': 5.2, 'price': 75000, - 'market': 'K', # 선택 (없으면 stock_meta에서 조회) - 'sector': '반도체', # 선택 - 'theme': 'AI반도체' # 선택 - }, ...] - """ - try: - scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 5분 슬롯 키 (백테스트 유니버스 이력 조회용): 2026-03-18 15:55:21 → 202603181555 - _now = datetime.datetime.now() - slot_min = (_now.hour * 60 + _now.minute) // 5 * 5 - slot_key = _now.strftime("%Y%m%d") + "%02d%02d" % (slot_min // 60, slot_min % 60) - - # 이력 테이블 없으면 생성 시도 (마이그레이션 누락/실패 시 자동 보정) - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS target_candidates_history ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - slot_key VARCHAR(12) NOT NULL, - scan_time VARCHAR(30) NOT NULL, - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL DEFAULT '', - score DOUBLE NOT NULL DEFAULT 0, - price DOUBLE NOT NULL DEFAULT 0, - market CHAR(1) DEFAULT 'Q', - sector VARCHAR(100), - theme VARCHAR(100), - INDEX idx_slot (slot_key), - INDEX idx_scan (scan_time) - ) CHARACTER SET utf8mb4 - """) - except Exception as e: - logger.debug("target_candidates_history CREATE 확인: %s", e) - - # stock_meta 캐시: 테마/섹터가 없는 항목을 DB에서 보완 - meta_cache: Dict[str, Dict] = {} - - # 기존 데이터 전체 삭제 (5분마다 새로 갱신) - with self.conn: - self.conn.execute("DELETE FROM target_candidates") - - for item in candidates: - code = item['code'] - # market/sector/theme 없으면 stock_meta에서 조회 - market = item.get('market') or "" - sector = item.get('sector') or "" - theme = item.get('theme') or "" - if not (market and sector and theme): - if code not in meta_cache: - try: - r = self.conn.execute( - "SELECT market, sector, theme FROM stock_meta WHERE code=%s", - (code,) - ).fetchone() - meta_cache[code] = dict(r) if r else {} - except Exception: - meta_cache[code] = {} - m = meta_cache[code] - market = market or m.get("market") or "Q" - sector = sector or m.get("sector") or "" - theme = theme or m.get("theme") or "" - - self.conn.execute(""" - INSERT INTO target_candidates - (code, name, score, price, scan_time, updated_at, market, sector, theme) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - code, - item.get('name', ''), - item.get('score', 0), - item.get('price', 0), - scan_time, - scan_time, - market, - sector, - theme, - )) - # [DEPRECATED] 구봇(kiwoom_universe_scanner 등) 의 5분 슬롯 이력 적재는 제거됨. - # 신봇(kis_trader/**) 은 RankingManager/ConditionSearchManager 가 - # ``insert_condition_universe_snapshot()`` 으로 초단위 event_time 스냅샷을 - # 기록한다. 구봇이 우발적으로 실행되더라도 이력 혼선을 일으키지 않도록 - # 이 경로에서의 history 적재는 의도적으로 하지 않는다. - # (과거 코드: INSERT INTO target_candidates_history ...) - - logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)}개 (slot_key={slot_key}, 이력 적재 안함)") - return True - - except Exception as e: - logger.error(f"❌ 후보군 저장 실패: {e}") - return False - - def add_target_candidate(self, candidate: Dict): - """ - 매수 후보군 개별 추가 (통과 즉시 저장용, UPSERT 방식) - - 500개 스캔 시 시간이 오래 걸려서 통과하는 즉시 DB에 저장 - - Args: - candidate: { - 'code': '005930', 'name': '삼성전자', - 'score': 5.2, 'price': 75000, - 'market': 'K', 'sector': '반도체', 'theme': 'AI반도체' # 선택 - } - """ - try: - scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - code = candidate['code'] - market = candidate.get('market') or "" - sector = candidate.get('sector') or "" - theme = candidate.get('theme') or "" - - # 없으면 stock_meta 에서 보완 - if not (market and sector and theme): - try: - r = self.conn.execute( - "SELECT market, sector, theme FROM stock_meta WHERE code=%s", - (code,) - ).fetchone() - if r: - m = dict(r) - market = market or m.get("market") or "Q" - sector = sector or m.get("sector") or "" - theme = theme or m.get("theme") or "" - except Exception: - pass - - with self.conn: - self.conn.execute(""" - INSERT INTO target_candidates - (code, name, score, price, scan_time, updated_at, market, sector, theme) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - name = VALUES(name), - score = VALUES(score), - price = VALUES(price), - scan_time = VALUES(scan_time), - updated_at = VALUES(updated_at), - market = VALUES(market), - sector = VALUES(sector), - theme = VALUES(theme) - """, ( - code, - candidate.get('name', ''), - candidate.get('score', 0), - candidate.get('price', 0), - scan_time, scan_time, - market, sector, theme, - )) - - return True - - except Exception as e: - logger.debug(f"후보 개별 저장 실패({candidate.get('code', '')}): {e}") - return False - - def get_target_candidates(self) -> List[Dict]: - """ - 매수 후보군 조회 (점수 순). - stock_meta JOIN으로 테마/섹터 보완 반환. - - Returns: - [{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000, - 'market': 'K', 'sector': '반도체', 'theme': 'AI반도체'}, ...] - """ - try: - cursor = self.conn.execute(""" - SELECT - t.code, t.name, t.score, t.price, t.scan_time, - COALESCE(t.market, m.market, 'Q') AS market, - COALESCE(t.sector, m.sector, '') AS sector, - COALESCE(t.theme, m.theme, '') AS theme - FROM target_candidates t - LEFT JOIN stock_meta m ON m.code = t.code - ORDER BY t.score DESC, t.price ASC - """) - rows = cursor.fetchall() - - result = [] - for row in rows: - result.append({ - 'code': row['code'], - 'name': row['name'], - 'score': row['score'], - 'price': row['price'], - 'scan_time': row['scan_time'], - 'market': row.get('market', 'Q'), - 'sector': row.get('sector', ''), - 'theme': row.get('theme', ''), - }) - - return result - - except Exception as e: - logger.error(f"❌ 후보군 조회 실패: {e}") - return [] - - def get_universe_history_for_backtest( - self, start_ymd: str, end_ymd: str - ) -> Dict[str, List[str]]: - """ - target_candidates_history에서 기간 내 5분별 유니버스 조회. - 백테스트 시 '실제 그 시각에 봇이 보던 후보'로 검사할 때 사용. - - Args: - start_ymd: 시작일 YYYYMMDD - end_ymd: 종료일 YYYYMMDD - - Returns: - slot_key(YYYYMMDDHHMM) → 해당 슬롯 후보 코드 리스트 (점수 순 유지) - """ - try: - start_key = start_ymd + "0000" - end_key = end_ymd + "2359" - rows = self.conn.execute(""" - SELECT slot_key, code - FROM target_candidates_history - WHERE slot_key >= %s AND slot_key <= %s - ORDER BY slot_key, score DESC - """, (start_key, end_key)).fetchall() - out: Dict[str, List[str]] = {} - for r in rows: - # 백테스트 엔진 _slot_key()는 항상 str → 조회 키도 str 통일 (DB 드라이버에 따라 int 올 수 있음) - sk = str(r["slot_key"]) - if sk not in out: - out[sk] = [] - out[sk].append(r["code"]) - return out - except Exception as e: - logger.debug(f"후보 이력 조회 실패: {e}") - return {} - - # ------------------------------------------------------------------ - # stock_meta 테마/섹터 메타데이터 헬퍼 - # ------------------------------------------------------------------ - - def upsert_stock_meta( - self, - code: str, - name: str = "", - market: str = "Q", - sector_code: str = "", - sector: str = "", - theme: str = "", - theme_rank: int = 3, - ) -> bool: - """ - 종목 메타데이터 저장/갱신 (스캐너·수동 입력 모두 사용). - - Args: - code : 종목코드 (6자리) - name : 종목명 - market : 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF - sector_code: 업종코드 (KIS bstp_cls_code 등) - sector : 업종명 (예: '반도체') - theme : 테마명 (예: 'AI반도체', '2차전지') - theme_rank : 테마 내 순위 (1=핵심주, 2=연관, 3=주변) - """ - try: - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with self.conn: - self.conn.execute(""" - INSERT INTO stock_meta - (code, name, market, sector_code, sector, theme, theme_rank, updated_at) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - name = IF(VALUES(name) != '', VALUES(name), name), - market = IF(VALUES(market) != '', VALUES(market), market), - sector_code = IF(VALUES(sector_code) != '', VALUES(sector_code), sector_code), - sector = IF(VALUES(sector) != '', VALUES(sector), sector), - theme = IF(VALUES(theme) != '', VALUES(theme), theme), - theme_rank = IF(VALUES(theme) != '', VALUES(theme_rank), theme_rank), - updated_at = VALUES(updated_at) - """, (code, name, market, sector_code, sector, theme, theme_rank, now)) - return True - except Exception as e: - logger.debug("upsert_stock_meta 실패(%s): %s", code, e) - return False - - def get_stock_meta(self, code: str) -> Optional[Dict]: - """종목 메타데이터 단건 조회. 없으면 None.""" - try: - row = self.conn.execute( - "SELECT * FROM stock_meta WHERE code = %s", (code,) - ).fetchone() - return dict(row) if row else None - except Exception as e: - logger.debug("get_stock_meta 실패(%s): %s", code, e) - return None - - def get_theme_momentum(self, theme: str, tf: int = 60) -> Dict: - """ - 테마 종목들의 최신 확정 봉(timeframe=tf) RSI 통계. - "이 테마가 지금 뜨겁냐 식었냐"를 숫자로 파악. - - Args: - theme: 테마명 (stock_meta.theme) - tf : 봉 단위 분 (기본 60분봉) - - Returns: - { - 'theme': 'AI반도체', - 'tf': 60, - 'count': 5, # 데이터 있는 종목 수 - 'avg_rsi3': 62.1, # 평균 RSI(3) - 'max_rsi3': 78.4, # 최고 RSI(3) - 'min_rsi3': 44.2, # 최저 RSI(3) - 'hot_count': 2, # RSI>70 (과열) 종목 수 - 'cold_count': 1, # RSI<30 (과매도) 종목 수 - } - """ - try: - row = self.conn.execute(""" - SELECT - COUNT(*) AS cnt, - AVG(w.rsi_3) AS avg_rsi, - MAX(w.rsi_3) AS max_rsi, - MIN(w.rsi_3) AS min_rsi, - SUM(w.rsi_3 > 70) AS hot_cnt, - SUM(w.rsi_3 < 30) AS cold_cnt - FROM ws_candles w - INNER JOIN stock_meta m ON m.code = w.code - WHERE m.theme = %s - AND w.timeframe = %s - AND w.is_confirmed = 1 - AND w.candle_time = ( - SELECT MAX(candle_time) - FROM ws_candles - WHERE code = w.code AND timeframe = w.timeframe - ) - """, (theme, tf)).fetchone() - - if not row or not row['cnt']: - return {'theme': theme, 'tf': tf, 'count': 0} - return { - 'theme': theme, - 'tf': tf, - 'count': int(row['cnt'] or 0), - 'avg_rsi3': round(float(row['avg_rsi'] or 0), 1), - 'max_rsi3': round(float(row['max_rsi'] or 0), 1), - 'min_rsi3': round(float(row['min_rsi'] or 0), 1), - 'hot_count': int(row['hot_cnt'] or 0), - 'cold_count':int(row['cold_cnt'] or 0), - } - except Exception as e: - logger.debug("get_theme_momentum 실패(%s, %dM): %s", theme, tf, e) - return {'theme': theme, 'tf': tf, 'count': 0} - - def get_all_theme_momentum(self, tf: int = 60) -> List[Dict]: - """ - 모든 테마의 최신 모멘텀 요약 (테마 히트맵). - 가장 뜨거운 테마부터 정렬 반환. - """ - try: - rows = self.conn.execute(""" - SELECT - m.theme, - COUNT(DISTINCT w.code) AS cnt, - AVG(w.rsi_3) AS avg_rsi, - SUM(w.rsi_3 > 70) AS hot_cnt, - SUM(w.rsi_3 < 30) AS cold_cnt - FROM ws_candles w - INNER JOIN stock_meta m ON m.code = w.code - WHERE m.theme IS NOT NULL AND m.theme != '' - AND w.timeframe = %s - AND w.is_confirmed = 1 - AND w.candle_time = ( - SELECT MAX(candle_time) - FROM ws_candles - WHERE code = w.code AND timeframe = w.timeframe - ) - GROUP BY m.theme - ORDER BY avg_rsi DESC - """, (tf,)).fetchall() - - return [{ - 'theme': r['theme'], - 'count': int(r['cnt'] or 0), - 'avg_rsi3': round(float(r['avg_rsi'] or 0), 1), - 'hot_count': int(r['hot_cnt'] or 0), - 'cold_count':int(r['cold_cnt'] or 0), - } for r in rows] - except Exception as e: - logger.debug("get_all_theme_momentum 실패: %s", e) - return [] - - def get_market_regime(self, codes: list = None, tf: int = 60) -> Dict: - """ - 영구 구독 ETF (KODEX200 등)의 최신 60분봉 RSI로 시장 방향 판단. - 스캘핑/꼬리잡기 진입 전 "지금 상승장인가?" 필터로 활용. - - Args: - codes: ETF 코드 리스트 (None=env PERMANENT_WS_CODES) - tf : 봉 단위 분 (기본 60) - - Returns: - { - 'is_bull': True, # avg_rsi > 50 → 상승 국면 - 'avg_rsi': 58.4, - 'details': [{'code': '069500', 'rsi': 61.2}, ...] - } - """ - try: - if not codes: - raw = self.conn.execute( - "SELECT PERMANENT_WS_CODES FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - perm = str(raw['PERMANENT_WS_CODES'] if raw else "") if raw else "" - codes = [c.strip() for c in perm.split(",") if c.strip()] or ["069500", "229200"] - - rows = self.conn.execute(""" - SELECT code, rsi_3 - FROM ws_candles - WHERE code IN ({}) - AND timeframe = %s - AND is_confirmed = 1 - AND candle_time = ( - SELECT MAX(candle_time) FROM ws_candles - WHERE code = ws_candles.code AND timeframe = ws_candles.timeframe - ) - """.format(",".join(["%s"] * len(codes))), (*codes, tf)).fetchall() - - details = [{'code': r['code'], 'rsi': round(float(r['rsi_3'] or 50), 1)} for r in rows] - avg_rsi = sum(d['rsi'] for d in details) / len(details) if details else 50.0 - return { - 'is_bull': avg_rsi > 50, - 'avg_rsi': round(avg_rsi, 1), - 'details': details, - } - except Exception as e: - logger.debug("get_market_regime 실패: %s", e) - return {'is_bull': True, 'avg_rsi': 50.0, 'details': []} - - def get_trades_by_date(self, date_str: str) -> List[Dict]: - """ - 특정 날짜의 매매 기록 조회 - - Args: - date_str: 날짜 (YYYYMMDD 또는 YYYY-MM-DD 모두 허용) - - Returns: - 매매 기록 리스트 - """ - try: - # YYYYMMDD(8자) → YYYY-MM-DD 변환, 이미 YYYY-MM-DD(10자)면 그대로 사용 - if len(date_str) == 8 and "-" not in date_str: - date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" - else: - date_formatted = date_str[:10] # 'YYYY-MM-DD'만 사용 (시간 부분 제거) - - cursor = self.conn.execute(""" - SELECT * FROM trade_history - WHERE DATE(sell_date) = %s - ORDER BY sell_date DESC - """, (date_formatted,)) - - rows = cursor.fetchall() - - result = [] - for row in rows: - result.append({ - 'id': row['id'], - 'code': row['code'], - 'name': row['name'], - 'strategy': row['strategy'], - 'buy_price': row['buy_price'], - 'sell_price': row['sell_price'], - 'qty': row['qty'], - 'profit_rate': row['profit_rate'], - 'realized_pnl': row['realized_pnl'], - 'hold_minutes': row['hold_minutes'], - 'buy_date': row['buy_date'], - 'sell_date': row['sell_date'], - 'sell_reason': row['sell_reason'] - }) - - return result - - except Exception as e: - logger.error(f"❌ 날짜별 조회 실패: {e}") - return [] - - # ============================================================ - # [ws_price_validation] KIS↔키움 시세 비교 검증 - # ============================================================ - - def insert_ws_price_validation( - self, - *, - code: str, - kis_price: Optional[float], - kiwoom_price: Optional[float], - kis_age_ms: Optional[int] = None, - kiwoom_age_ms: Optional[int] = None, - ) -> bool: - """단일 비교 결과 1행 INSERT. - - 둘 다 None 이면 저장 안 함. 한쪽만 있어도 저장(소스별 가용성 분석용). - diff_pct 는 둘 다 있을 때만 계산. - """ - if kis_price is None and kiwoom_price is None: - return False - diff_pct: Optional[float] = None - if kis_price not in (None, 0) and kiwoom_price is not None: - try: - diff_pct = (float(kiwoom_price) - float(kis_price)) / float(kis_price) * 100.0 - except (ValueError, ZeroDivisionError): - diff_pct = None - try: - now = datetime.datetime.now() - self.conn.execute( - "INSERT INTO ws_price_validation " - "(ts, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms) " - "VALUES (%s, %s, %s, %s, %s, %s, %s)", - (now, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms), - ) - return True - except Exception as e: - logger.debug("ws_price_validation INSERT 실패: %s", e) - return False - - def get_ws_validation_stats( - self, *, hours: int = 24, code: Optional[str] = None, - ) -> Dict[str, Any]: - """최근 N시간 검증 통계 (운영자용 분석). - - Returns: - { - "samples": 1234, - "both_present": 1100, # KIS·키움 둘 다 가격 있던 비율 - "avg_diff_pct": 0.012, - "max_abs_diff_pct": 0.45, - "stddev_diff_pct": 0.08, - "kis_only": 80, # KIS 만 가격 있던 횟수 (키움 미수신) - "kiwoom_only": 30, # 키움 만 가격 있던 횟수 - } - """ - try: - where = ["ts >= NOW() - INTERVAL %s HOUR"] - args: List[Any] = [hours] - if code: - where.append("code = %s") - args.append(code) - wsql = " AND ".join(where) - row = self.conn.execute(f""" - SELECT - COUNT(*) AS samples, - SUM(kis_price IS NOT NULL AND kiwoom_price IS NOT NULL) AS both_present, - AVG(diff_pct) AS avg_diff_pct, - MAX(ABS(diff_pct)) AS max_abs_diff_pct, - STDDEV(diff_pct) AS stddev_diff_pct, - SUM(kis_price IS NOT NULL AND kiwoom_price IS NULL) AS kis_only, - SUM(kis_price IS NULL AND kiwoom_price IS NOT NULL) AS kiwoom_only - FROM ws_price_validation - WHERE {wsql} - """, args).fetchone() - return dict(row) if row else {} - except Exception as e: - logger.debug("ws_validation_stats 조회 실패: %s", e) - return {} - - # ============================================================ - # [env_config + config_*] 관리자용 env (INSERT만 / 최신 1건 = 현재 설정) - # ============================================================ - - def _migrate_config_table_columns(self) -> None: - """config_* · env_config(글로벌) 테이블에 누락 컬럼 추가.""" - for tbl, keys in CONFIG_TABLE_KEYS.items(): - try: - cols = set(self.conn.get_columns(tbl)) - except Exception: - continue - for key in keys: - if key not in cols: - try: - self.conn.execute( - f"ALTER TABLE {tbl} ADD COLUMN `{key}` TEXT" - ) - logger.info("📌 %s.%s 컬럼 추가", tbl, key) - except Exception as e: - logger.debug("%s.%s 컬럼 추가 스킵: %s", tbl, key, e) - - def _migrate_strategy_keys_from_env_config(self) -> None: - """ - env_config 레거시 컬럼에만 있는 전략 ON/OFF·MM alias → config_* 최신 행으로 이전. - get_merged_env_snapshot() 폴백과 별도로, 전략 테이블에 명시 저장. - """ - by_table: Dict[str, Tuple[str, ...]] = {} - for k, tbl in _EXPLICIT_KEY_TABLE.items(): - if tbl not in by_table: - by_table[tbl] = () - by_table[tbl] = by_table[tbl] + (k,) - try: - env_row = self.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - if not env_row: - return - rk = env_row.keys() if hasattr(env_row, "keys") else [] - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - for tbl, keys in by_table.items(): - patch: Dict[str, str] = {} - all_keys = CONFIG_TABLE_KEYS.get(tbl, ()) - latest = self._read_latest_config_table(tbl, all_keys) - for k in keys: - if latest.get(k): - continue - if k not in rk: - continue - v = env_row[k] - if v is None or str(v).strip() == "": - continue - patch[k] = str(v) - if not patch: - continue - merged = dict(latest) - merged.update(patch) - self._insert_config_table_row(tbl, merged, all_keys, now) - logger.info("📌 %s ← env_config 값 이전: %s", tbl, list(patch.keys())) - except Exception as e: - logger.debug("strategy keys env→config migrate: %s", e) - - def _migrate_short_time_to_tail_time(self) -> None: - """ - 레거시 ``SHORT_TIME_START`` / ``SHORT_TIME_END`` → ``TAIL_TIME_*`` 이전 후 단일화. - - config_short 최신 행에 SHORT_TIME 값만 있고 TAIL_TIME 이 비어 있으면 복사 INSERT. - """ - try: - keys = CONFIG_TABLE_KEYS.get("config_short", ()) - if not keys: - return - latest = self._read_latest_config_table("config_short", keys) - st_short = str(latest.get("SHORT_TIME_START") or "").strip() - en_short = str(latest.get("SHORT_TIME_END") or "").strip() - st_tail = str(latest.get("TAIL_TIME_START") or "").strip() - en_tail = str(latest.get("TAIL_TIME_END") or "").strip() - patch: Dict[str, str] = {} - if st_short and not st_tail: - patch["TAIL_TIME_START"] = st_short - if en_short and not en_tail: - patch["TAIL_TIME_END"] = en_short - if not patch: - return - merged = dict(latest) - merged.update(patch) - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - rid = self._insert_config_table_row("config_short", merged, keys, now) - if rid: - logger.info( - "📌 config_short SHORT_TIME_* → TAIL_TIME_* 이전: %s (row id=%s)", - list(patch.keys()), rid, - ) - try: - from kis_trader.utils.env import invalidate_merged_env_cache - invalidate_merged_env_cache() - except Exception: - pass - except Exception as e: - logger.debug("SHORT_TIME→TAIL_TIME migrate: %s", e) - - def _insert_config_table_row( - self, - table: str, - snapshot: Dict[str, Any], - keys: Tuple[str, ...], - created_at: str, - ) -> Optional[int]: - """단일 config 테이블에 스냅샷 INSERT.""" - if not keys: - return None - try: - table_cols = set(self.conn.get_columns(table)) - except Exception: - return None - prev = self._read_latest_config_table(table, keys) - row_data: Dict[str, Any] = {} - for k in keys: - if k not in table_cols: - continue - v = snapshot.get(k) if k in snapshot else None - if v not in (None, ""): - row_data[k] = v - elif prev.get(k): - row_data[k] = prev[k] - if not row_data: - return None - valid = list(dict.fromkeys(row_data.keys())) - key_list = ", ".join(f"`{k}`" for k in valid) - placeholders = ", ".join(["%s"] * (1 + len(valid))) - vals = [created_at] + [row_data[k] for k in valid] - cur = self.conn.execute( - f"INSERT INTO {table} (created_at, {key_list}) VALUES ({placeholders})", - vals, - ) - return cur.lastrowid - - def _read_latest_config_table(self, table: str, keys: Tuple[str, ...]) -> Dict[str, str]: - """테이블 최신 행들에서 키별 마지막 비어 있지 않은 값 (부분 INSERT NULL 덮어쓰기 방지).""" - out: Dict[str, str] = {} - if not keys: - return out - try: - rows = self.conn.execute( - f"SELECT * FROM {table} ORDER BY id DESC LIMIT 30" - ).fetchall() - except Exception: - return out - if not rows: - return out - rk = rows[0].keys() if hasattr(rows[0], "keys") else [] - for k in keys: - if k not in rk: - continue - for row in rows: - v = row[k] - if v is not None and str(v).strip() != "": - out[k] = str(v) - break - if k not in out: - out[k] = "" - return out - - def _env_config_column_set(self) -> set: - """env_config 실제 DB 컬럼 (MariaDB row size 한도로 ENV_GLOBAL_KEYS 일부 미생성).""" - cached = getattr(self, "_env_config_cols_cache", None) - if cached is not None: - return cached - try: - cached = set(self.conn.get_columns("env_config")) - except Exception: - cached = set() - self._env_config_cols_cache = cached - return cached - - def _persist_env_config_overflow( - self, snapshot: Dict[str, Any], created_at: str, - ) -> int: - """ - env_config 테이블에 컬럼이 없는 글로벌 키 → env_config_ext UPSERT. - (Row size too large 로 ALTER 불가한 KIWOOM_WS_* · WHIPSAW_* · PROGRAM_* 등) - """ - cols = self._env_config_column_set() - saved = 0 - for k, v in (snapshot or {}).items(): - key = str(k).strip() - if not key or classify_config_key(key) != "env_config": - continue - if key in cols: - continue - if v is None: - continue - sv = str(v).strip() - if sv == "": - # 빈 문자열 = 명시적 클리어/OFF. - # ext 에 이전 값이 남아 있으면 머지 폴백에서 되살아나므로(예: - # UPDOWN_BOX_RATCHET_TIERS='' → 래칫 OFF 인데 직전 '2:1.5,5:1.0' 이 부활) - # 반드시 ext row 를 삭제해 "꺼짐"이 실제 반영되도록 한다. - try: - self.conn.execute( - "DELETE FROM env_config_ext WHERE env_key=%s", (key,) - ) - except Exception as ex: - logger.warning("env_config_ext 클리어 실패 %s: %s", key, ex) - continue - try: - self.conn.execute( - "INSERT INTO env_config_ext (env_key, env_value, updated_at) " - "VALUES (%s, %s, %s) " - "ON DUPLICATE KEY UPDATE env_value=VALUES(env_value), " - "updated_at=VALUES(updated_at)", - (key, sv, created_at), - ) - saved += 1 - except Exception as ex: - logger.warning("env_config_ext 저장 실패 %s: %s", key, ex) - if saved: - logger.info("📌 env_config_ext overflow 저장 %d키", saved) - return saved - - def get_merged_env_snapshot(self) -> Dict[str, str]: - """env_config + config_* 최신 행 병합 flat dict (get_env_from_db 호환).""" - merged: Dict[str, str] = {} - for tbl, keys in CONFIG_TABLE_KEYS.items(): - merged.update(self._read_latest_config_table(tbl, keys)) - # 레거시 env_config(381컬럼) 에만 있는 값 — 신규 테이블에 없을 때 폴백 - try: - row = self.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - if row: - rk = row.keys() if hasattr(row, "keys") else [] - for k in ENV_CONFIG_KEYS: - if k not in merged and k in rk: - v = row[k] - if v is not None and str(v).strip() != "": - merged[k] = str(v) - except Exception: - pass - # env_config_ext — 컬럼 한도 초과 키 + 레거시 overflow - try: - ext_rows = self.conn.execute( - "SELECT env_key, env_value FROM env_config_ext" - ).fetchall() - env_cols = self._env_config_column_set() - for er in ext_rows or []: - ek = er["env_key"] if isinstance(er, dict) else er[0] - ev = er["env_value"] if isinstance(er, dict) else er[1] - if not ek: - continue - sk = str(ek) - # 전략 config_* 테이블 키는 ext 가 덮어쓰지 않는다 (전략 테이블이 우선). - # 레거시로 ext 에 남은 TAIL_*/MOMENTUM_* 등이 config_short/_momentum 값을 - # 가리던 버그 방지 — ext 는 순수 글로벌(env_config) 키 폴백 전용. - if classify_config_key(sk) != "env_config": - continue - # ext 우선: env_config 컬럼 없음 · 또는 병합값 비어 있음 - if sk not in env_cols or not str(merged.get(sk) or "").strip(): - merged[sk] = "" if ev is None else str(ev) - except Exception: - pass - return merged - - def insert_env_snapshot(self, snapshot) -> Optional[int]: - """ - env 설정 INSERT — 테이블별 분리 저장. - env_config(공통) + config_scalp/short/momentum/breakout/updow. - Returns: env_config 행 id (없으면 마지막 성공 config_* id) - """ - try: - if isinstance(snapshot, str): - snapshot = json.loads(snapshot) if snapshot else {} - if not isinstance(snapshot, dict): - return None - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - primary_id: Optional[int] = None - for tbl, keys in CONFIG_TABLE_KEYS.items(): - rid = self._insert_config_table_row(tbl, snapshot, keys, now) - if rid and tbl == "env_config": - primary_id = rid - elif rid and primary_id is None: - primary_id = rid - if primary_id is None: - logger.error("config INSERT: 모든 테이블 저장 실패") - return None - ext_n = self._persist_env_config_overflow(snapshot, now) - logger.info( - "📌 env 스냅샷 저장 (env_config id=%s, 테이블 %d개, ext+%d)", - primary_id, - len(CONFIG_TABLE_KEYS), - ext_n, - ) - try: - from kis_trader.utils.env import invalidate_merged_env_cache - invalidate_merged_env_cache() - except Exception: - pass - return primary_id - except Exception as e: - logger.error(f"❌ env/config INSERT 실패: {e}") - return None - - def get_latest_env(self) -> Optional[Dict]: - """ - 최신 env — env_config + config_* 병합 snapshot. - Returns: - {"id": int, "created_at": str, "snapshot": dict} 또는 None - """ - try: - row = self.conn.execute( - "SELECT id, created_at FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - merged = self.get_merged_env_snapshot() - if not merged and not row: - return None - return { - "id": int(row["id"]) if row else 0, - "created_at": row["created_at"] if row else "", - "snapshot": merged, - } - except Exception as e: - logger.error(f"❌ env 최신 조회 실패: {e}") - return None - - def get_strategy_config_snapshot(self, strategy_id: str) -> Dict[str, str]: - """전략 ID(SCALP/SHORT/…) 해당 config_* 테이블만 조회.""" - tbl = STRATEGY_ID_TO_TABLE.get((strategy_id or "").upper()) - if not tbl: - return {} - keys = CONFIG_TABLE_KEYS.get(tbl, ()) - snap = self._read_latest_config_table(tbl, keys) - if snap: - return snap - return { - k: v - for k, v in self.get_merged_env_snapshot().items() - if classify_config_key(k) == tbl - } - - # ============================================================ - # [kv_store] 매터모스트 원격 조종용 키-값 (마지막 AI 추천, last_seen 등) - # ============================================================ - - def get_kv(self, key: str) -> Optional[str]: - """kv_store에서 값 조회. 없으면 None.""" - try: - row = self.conn.execute("SELECT v FROM kv_store WHERE k = ?", (key,)).fetchone() - return row["v"] if row else None - except Exception as e: - logger.debug(f"get_kv 실패: {e}") - return None - - def set_kv(self, key: str, value: str) -> bool: - """kv_store에 값 저장 (REPLACE INTO = INSERT OR REPLACE MySQL 버전).""" - try: - self.conn.execute("REPLACE INTO kv_store (k, v) VALUES (%s, %s)", (key, value)) - return True - except Exception as e: - logger.error(f"❌ set_kv 실패: {e}") - return False - - def set_last_ai_recommendations(self, text: str) -> bool: - """마지막 AI 수치 추천문 저장 (!적용 시 사용).""" - return self.set_kv("last_ai_recommendations", text) - - def get_last_ai_recommendations(self) -> Optional[str]: - """마지막 AI 수치 추천문 조회. 없으면 None.""" - return self.get_kv("last_ai_recommendations") - - # ------------------------------------------------------------------ - # [ai_analysis_log] Butler 분석 기록 (프롬프트 요약·응답 저장/조회) - # ------------------------------------------------------------------ - def insert_ai_analysis_log(self, model: str, context_summary: str, response: str) -> Optional[int]: - """AI 분석 한 건 저장. model=claude|gemini, context_summary=현재상태 요약, response=AI 전체 응답.""" - try: - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - cur = self.conn.execute( - "INSERT INTO ai_analysis_log (created_at, model, context_summary, response) VALUES (%s, %s, %s, %s)", - (now, model, (context_summary or "")[:2000], response or ""), - ) - return cur.lastrowid - except Exception as e: - logger.error("insert_ai_analysis_log 실패: %s", e) - return None - - def get_ai_analysis_log_list(self, limit: int = 10) -> list: - """최근 N건 목록. 각 항목: id, created_at, model, context_summary, response(앞 400자).""" - try: - rows = self.conn.execute( - """ - SELECT id, created_at, model, context_summary, response - FROM ai_analysis_log - ORDER BY id DESC - LIMIT ? - """, - (limit,), - ).fetchall() - out = [] - for r in rows: - resp = (r["response"] or "")[:400] - if len(r["response"] or "") > 400: - resp += "..." - out.append({"id": r["id"], "created_at": r["created_at"], "model": r["model"], - "context_summary": r["context_summary"], "response_preview": resp}) - return out - except Exception as e: - logger.error("get_ai_analysis_log_list 실패: %s", e) - return [] - - def get_ai_analysis_log_by_id(self, log_id: int) -> Optional[dict]: - """id로 전체 한 건 조회. 없으면 None.""" - try: - row = self.conn.execute( - "SELECT id, created_at, model, context_summary, response FROM ai_analysis_log WHERE id = ?", - (log_id,), - ).fetchone() - if not row: - return None - return {"id": row["id"], "created_at": row["created_at"], "model": row["model"], - "context_summary": row["context_summary"], "response": row["response"]} - except Exception as e: - logger.error("get_ai_analysis_log_by_id 실패: %s", e) - return None - - # ================================================================== - # ws_candles 헬퍼 (kis_scalping_ver1 전용) - # ================================================================== - - def upsert_ws_candle(self, code: str, timeframe: int, candle_time: str, - open_: float, high: float, low: float, close: float, - volume: int, is_confirmed: int = 0, source: str = "ws", - rsi_2: float = None, rsi_3: float = None, rsi_5: float = None, - holding_peak: float = None): - """ - 봉 1개를 INSERT OR REPLACE 로 저장. - - 진행 중인 봉(is_confirmed=0): 틱마다 close/high/low/volume 갱신 - - 확정 봉(is_confirmed=1): RSI 계산 후 최종 저장 - - holding_peak: SHORT 보유 중 WS 트레일 고점 (백테 실매 정합용, 선택) - """ - now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - with self.conn: - self.conn.execute(""" - INSERT INTO ws_candles - (code, timeframe, candle_time, open, high, low, close, volume, - rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(code, timeframe, candle_time) DO UPDATE SET - high = MAX(high, excluded.high), - low = MIN(low, excluded.low), - close = excluded.close, - volume = excluded.volume, - rsi_2 = COALESCE(excluded.rsi_2, rsi_2), - rsi_3 = COALESCE(excluded.rsi_3, rsi_3), - rsi_5 = COALESCE(excluded.rsi_5, rsi_5), - is_confirmed = excluded.is_confirmed, - source = excluded.source, - holding_peak = MAX(COALESCE(holding_peak, 0), COALESCE(excluded.holding_peak, 0)), - updated_at = excluded.updated_at - """, (code, timeframe, candle_time, open_, high, low, close, volume, - rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, now_str)) - except Exception as e: - logger.error("upsert_ws_candle 실패(%s): %s", code, e) - - def get_ws_candles(self, code: str, timeframe: int, limit: int = 100, - confirmed_only: bool = False) -> list: - """ - 최근 N개 봉 리스트 반환 (오래된 순 → 최신 순). - confirmed_only=True 면 확정 봉만 반환 (RSI 계산용). - """ - try: - cond = "AND is_confirmed = 1" if confirmed_only else "" - rows = self.conn.execute(f""" - SELECT candle_time, open, high, low, close, volume, - rsi_2, rsi_3, rsi_5, is_confirmed, source - FROM ws_candles - WHERE code = ? AND timeframe = ? {cond} - ORDER BY candle_time DESC - LIMIT ? - """, (code, timeframe, limit)).fetchall() - # 최신→오래된 순으로 왔으므로 역정렬해서 반환 (오래된→최신) - result = [dict(r) for r in rows] - result.reverse() - return result - except Exception as e: - logger.error("get_ws_candles 실패(%s): %s", code, e) - return [] - - def get_latest_ws_candle(self, code: str, timeframe: int) -> Optional[dict]: - """가장 최신 봉 1개 반환 (진행 중 봉 포함).""" - try: - row = self.conn.execute(""" - SELECT candle_time, open, high, low, close, volume, - rsi_2, rsi_3, rsi_5, is_confirmed - FROM ws_candles - WHERE code = ? AND timeframe = ? - ORDER BY candle_time DESC LIMIT 1 - """, (code, timeframe)).fetchone() - return dict(row) if row else None - except Exception as e: - logger.error("get_latest_ws_candle 실패(%s): %s", code, e) - return None - - def get_latest_confirmed_ws_candle(self, code: str, timeframe: int) -> Optional[dict]: - """가장 최신 확정 봉 1개 반환 (RSI 포함).""" - try: - row = self.conn.execute(""" - SELECT candle_time, open, high, low, close, volume, - rsi_2, rsi_3, rsi_5 - FROM ws_candles - WHERE code = ? AND timeframe = ? AND is_confirmed = 1 - ORDER BY candle_time DESC LIMIT 1 - """, (code, timeframe)).fetchone() - return dict(row) if row else None - except Exception as e: - 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일 이상 지난 봉 삭제).""" - cutoff = (datetime.datetime.now() - datetime.timedelta(days=keep_days)).strftime("%Y%m%d%H%M") - try: - with self.conn: - self.conn.execute( - "DELETE FROM ws_candles WHERE candle_time < ?", (cutoff,) - ) - except Exception as e: - logger.error("cleanup_old_ws_candles 실패: %s", e) - - # ================================================================== - # ws_ticks — 실시간 체결 틱 (TickRecorder 배치 INSERT) - # ================================================================== - - def ensure_ws_ticks_table(self) -> None: - """런타임 테이블 보장 (구 DB 마이그레이션).""" - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_ticks ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - market VARCHAR(8) NOT NULL DEFAULT 'KR', - exchange VARCHAR(16) DEFAULT NULL, - code VARCHAR(32) NOT NULL, - tick_time VARCHAR(14) NOT NULL, - price DOUBLE NOT NULL, - volume BIGINT NOT NULL DEFAULT 0, - tick_seq BIGINT DEFAULT NULL, - session VARCHAR(8) DEFAULT NULL, - currency VARCHAR(8) NOT NULL DEFAULT 'KRW', - source VARCHAR(16) NOT NULL DEFAULT 'kis', - recv_ts VARCHAR(30) NOT NULL, - KEY idx_ws_ticks_lookup (market, code, tick_time), - KEY idx_ws_ticks_recv (recv_ts) - ) CHARACTER SET utf8mb4 - """) - except Exception as e: - logger.debug("ensure_ws_ticks_table: %s", e) - - def insert_ws_ticks_batch(self, batch: List[Dict]) -> int: - """체결 틱 배치 append INSERT (중복 키 없음 — 이벤트 로그).""" - if not batch: - return 0 - rows = [] - for it in batch: - rows.append(( - (it.get("market") or "KR")[:8], - (it.get("exchange") or None), - (it.get("code") or "")[:32], - (it.get("tick_time") or "")[:14], - float(it.get("price") or 0), - int(it.get("volume") or 0), - it.get("tick_seq"), - (it.get("session") or None), - (it.get("currency") or "KRW")[:8], - (it.get("source") or "kis")[:16], - it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - )) - try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" - placeholders = ", ".join([ph] * len(rows)) - flat: List[Any] = [] - for row in rows: - flat.extend(row) - self.conn.execute( - f""" - INSERT INTO ws_ticks - (market, exchange, code, tick_time, price, volume, - tick_seq, session, currency, source, recv_ts) - VALUES {placeholders} - """, - tuple(flat), - ) - return len(rows) - except Exception as e: - logger.error("insert_ws_ticks_batch 실패: %s", e) - return 0 - - def get_ws_ticks( - self, - code: str, - *, - market: str = "KR", - start_tick_time: str = "", - end_tick_time: str = "", - limit: int = 5000, - ) -> List[Dict]: - """백테·분석용 틱 조회 (오래된→최신).""" - code = (code or "").strip() - if not code: - return [] - limit = max(1, min(int(limit), 500000)) - try: - cond = "market=%s AND code=%s" - params: List[Any] = [(market or "KR")[:8], code[:32]] - if start_tick_time: - cond += " AND tick_time >= %s" - params.append(start_tick_time[:14]) - if end_tick_time: - cond += " AND tick_time <= %s" - params.append(end_tick_time[:14]) - rows = self.conn.execute( - f""" - SELECT market, exchange, code, tick_time, price, volume, - tick_seq, session, currency, source, recv_ts - FROM ws_ticks - WHERE {cond} - ORDER BY tick_time ASC - LIMIT %s - """, - (*params, limit), - ).fetchall() - return [dict(r) for r in rows] - except Exception as e: - logger.error("get_ws_ticks 실패(%s): %s", code, e) - return [] - - def cleanup_old_ws_ticks(self, keep_days: int = 7) -> None: - """recv_ts 기준 오래된 틱 삭제.""" - if keep_days <= 0: - return - cutoff = ( - datetime.datetime.now() - datetime.timedelta(days=keep_days) - ).strftime("%Y-%m-%d %H:%M:%S") - try: - self.conn.execute( - "DELETE FROM ws_ticks WHERE recv_ts < %s", (cutoff,) - ) - logger.info("🧹 ws_ticks 정리 완료 (recv_ts < %s)", cutoff) - except Exception as e: - logger.error("cleanup_old_ws_ticks 실패: %s", e) - - # ================================================================== - # ws_orderbook / ws_program — TRIGGER 스냅샷 (TriggerSnapshotRecorder) - # ================================================================== - - def ensure_ws_orderbook_table(self) -> None: - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_orderbook ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - market VARCHAR(8) NOT NULL DEFAULT 'KR', - code VARCHAR(32) NOT NULL, - snap_time VARCHAR(14) NOT NULL, - best_bid BIGINT NOT NULL DEFAULT 0, - best_ask BIGINT NOT NULL DEFAULT 0, - total_bid_qty BIGINT NOT NULL DEFAULT 0, - total_ask_qty BIGINT NOT NULL DEFAULT 0, - bid_qty_l3 BIGINT NOT NULL DEFAULT 0, - ask_qty_l3 BIGINT NOT NULL DEFAULT 0, - levels_json MEDIUMTEXT, - source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0d', - recv_ts VARCHAR(30) NOT NULL, - KEY idx_ws_orderbook_lookup (market, code, snap_time), - KEY idx_ws_orderbook_recv (recv_ts) - ) CHARACTER SET utf8mb4 - """) - except Exception as e: - logger.debug("ensure_ws_orderbook_table: %s", e) - - def ensure_ws_program_table(self) -> None: - try: - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS ws_program ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - market VARCHAR(8) NOT NULL DEFAULT 'KR', - code VARCHAR(32) NOT NULL, - snap_time VARCHAR(14) NOT NULL, - buy_qty BIGINT NOT NULL DEFAULT 0, - sell_qty BIGINT NOT NULL DEFAULT 0, - net_qty BIGINT NOT NULL DEFAULT 0, - buy_amt BIGINT NOT NULL DEFAULT 0, - sell_amt BIGINT NOT NULL DEFAULT 0, - net_amt BIGINT NOT NULL DEFAULT 0, - source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0w', - recv_ts VARCHAR(30) NOT NULL, - KEY idx_ws_program_lookup (market, code, snap_time), - KEY idx_ws_program_recv (recv_ts) - ) CHARACTER SET utf8mb4 - """) - except Exception as e: - logger.debug("ensure_ws_program_table: %s", e) - - def migrate_trigger_eval_columns(self) -> None: - """ws_orderbook / ws_program — 판정 스냅샷 메타 컬럼 (filter_eval).""" - for table in ("ws_orderbook", "ws_program"): - try: - cols = self.conn.get_columns(table) - except Exception: - continue - if "strategy" not in cols: - self.conn.execute( - f"ALTER TABLE {table} ADD COLUMN strategy VARCHAR(16) DEFAULT NULL" - ) - logger.info("📌 %s.strategy 컬럼 추가", table) - if "reject_code" not in cols: - self.conn.execute( - f"ALTER TABLE {table} ADD COLUMN reject_code VARCHAR(64) DEFAULT NULL" - ) - logger.info("📌 %s.reject_code 컬럼 추가", table) - if "reject_msg" not in cols: - self.conn.execute( - f"ALTER TABLE {table} ADD COLUMN reject_msg VARCHAR(255) DEFAULT NULL" - ) - logger.info("📌 %s.reject_msg 컬럼 추가", table) - if "eval_price" not in cols: - self.conn.execute( - f"ALTER TABLE {table} ADD COLUMN eval_price BIGINT NOT NULL DEFAULT 0" - ) - logger.info("📌 %s.eval_price 컬럼 추가", table) - - def insert_ws_orderbook_eval_batch(self, batch: List[Dict]) -> int: - """판정 시점 호가 스냅샷 INSERT (filter_eval).""" - if not batch: - return 0 - rows = [] - for it in batch: - rows.append(( - (it.get("market") or "KR")[:8], - (it.get("code") or "")[:32], - (it.get("snap_time") or "")[:14], - int(it.get("best_bid") or 0), - int(it.get("best_ask") or 0), - int(it.get("total_bid_qty") or 0), - int(it.get("total_ask_qty") or 0), - int(it.get("bid_qty_l3") or 0), - int(it.get("ask_qty_l3") or 0), - it.get("levels_json") or "{}", - (it.get("source") or "filter_eval")[:16], - it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - (it.get("strategy") or "")[:16] or None, - (it.get("reject_code") or "")[:64] or None, - (it.get("reject_msg") or "")[:255] or None, - int(it.get("eval_price") or 0), - )) - try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" - placeholders = ", ".join([ph] * len(rows)) - flat: List[Any] = [] - for row in rows: - flat.extend(row) - self.conn.execute( - f""" - INSERT INTO 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, strategy, reject_code, reject_msg, eval_price) - VALUES {placeholders} - """, - tuple(flat), - ) - return len(rows) - except Exception as e: - logger.error("insert_ws_orderbook_eval_batch 실패: %s", e) - return self.insert_ws_orderbook_batch(batch) - - def insert_ws_program_eval_batch(self, batch: List[Dict]) -> int: - """판정 시점 프로그램 스냅샷 INSERT (filter_eval).""" - if not batch: - return 0 - rows = [] - for it in batch: - rows.append(( - (it.get("market") or "KR")[:8], - (it.get("code") or "")[:32], - (it.get("snap_time") or "")[:14], - int(it.get("buy_qty") or 0), - int(it.get("sell_qty") or 0), - int(it.get("net_qty") or 0), - int(it.get("buy_amt") or 0), - int(it.get("sell_amt") or 0), - int(it.get("net_amt") or 0), - (it.get("source") or "filter_eval")[:16], - it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - (it.get("strategy") or "")[:16] or None, - (it.get("reject_code") or "")[:64] or None, - (it.get("reject_msg") or "")[:255] or None, - int(it.get("eval_price") or 0), - )) - try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" - placeholders = ", ".join([ph] * len(rows)) - flat: List[Any] = [] - for row in rows: - flat.extend(row) - self.conn.execute( - f""" - INSERT INTO ws_program - (market, code, snap_time, buy_qty, sell_qty, net_qty, - buy_amt, sell_amt, net_amt, source, recv_ts, - strategy, reject_code, reject_msg, eval_price) - VALUES {placeholders} - """, - tuple(flat), - ) - return len(rows) - except Exception as e: - logger.error("insert_ws_program_eval_batch 실패: %s", e) - return self.insert_ws_program_batch(batch) - - def cleanup_old_ws_orderbook_eval(self, keep_days: int = 7) -> None: - """filter_eval 호가 스냅만 정리.""" - if keep_days <= 0: - return - cutoff = ( - datetime.datetime.now() - datetime.timedelta(days=keep_days) - ).strftime("%Y-%m-%d %H:%M:%S") - try: - self.conn.execute( - "DELETE FROM ws_orderbook WHERE source = 'filter_eval' AND recv_ts < %s", - (cutoff,), - ) - logger.info("🧹 ws_orderbook filter_eval 정리 완료 (recv_ts < %s)", cutoff) - except Exception as e: - logger.error("cleanup_old_ws_orderbook_eval 실패: %s", e) - - def cleanup_old_ws_program_eval(self, keep_days: int = 7) -> None: - """filter_eval 프로그램 스냅만 정리.""" - if keep_days <= 0: - return - cutoff = ( - datetime.datetime.now() - datetime.timedelta(days=keep_days) - ).strftime("%Y-%m-%d %H:%M:%S") - try: - self.conn.execute( - "DELETE FROM ws_program WHERE source = 'filter_eval' AND recv_ts < %s", - (cutoff,), - ) - logger.info("🧹 ws_program filter_eval 정리 완료 (recv_ts < %s)", cutoff) - except Exception as e: - logger.error("cleanup_old_ws_program_eval 실패: %s", e) - - def insert_ws_orderbook_batch(self, batch: List[Dict]) -> int: - if not batch: - return 0 - rows = [] - for it in batch: - rows.append(( - (it.get("market") or "KR")[:8], - (it.get("code") or "")[:32], - (it.get("snap_time") or "")[:14], - int(it.get("best_bid") or 0), - int(it.get("best_ask") or 0), - int(it.get("total_bid_qty") or 0), - int(it.get("total_ask_qty") or 0), - int(it.get("bid_qty_l3") or 0), - int(it.get("ask_qty_l3") or 0), - it.get("levels_json") or "{}", - (it.get("source") or "kiwoom_0d")[:16], - it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - )) - try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" - placeholders = ", ".join([ph] * len(rows)) - flat: List[Any] = [] - for row in rows: - flat.extend(row) - self.conn.execute( - f""" - INSERT INTO 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 {placeholders} - """, - tuple(flat), - ) - return len(rows) - except Exception as e: - logger.error("insert_ws_orderbook_batch 실패: %s", e) - return 0 - - def insert_ws_program_batch(self, batch: List[Dict]) -> int: - if not batch: - return 0 - rows = [] - for it in batch: - rows.append(( - (it.get("market") or "KR")[:8], - (it.get("code") or "")[:32], - (it.get("snap_time") or "")[:14], - int(it.get("buy_qty") or 0), - int(it.get("sell_qty") or 0), - int(it.get("net_qty") or 0), - int(it.get("buy_amt") or 0), - int(it.get("sell_amt") or 0), - int(it.get("net_amt") or 0), - (it.get("source") or "kiwoom_0w")[:16], - it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - )) - try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" - placeholders = ", ".join([ph] * len(rows)) - flat: List[Any] = [] - for row in rows: - flat.extend(row) - self.conn.execute( - f""" - INSERT INTO ws_program - (market, code, snap_time, buy_qty, sell_qty, net_qty, - buy_amt, sell_amt, net_amt, source, recv_ts) - VALUES {placeholders} - """, - tuple(flat), - ) - return len(rows) - except Exception as e: - logger.error("insert_ws_program_batch 실패: %s", e) - return 0 - - def cleanup_old_ws_orderbook(self, keep_days: int = 7) -> None: - if keep_days <= 0: - return - cutoff = ( - datetime.datetime.now() - datetime.timedelta(days=keep_days) - ).strftime("%Y-%m-%d %H:%M:%S") - try: - self.conn.execute( - "DELETE FROM ws_orderbook WHERE recv_ts < %s", (cutoff,) - ) - logger.info("🧹 ws_orderbook 정리 완료 (recv_ts < %s)", cutoff) - except Exception as e: - logger.error("cleanup_old_ws_orderbook 실패: %s", e) - - def cleanup_old_ws_program(self, keep_days: int = 7) -> None: - if keep_days <= 0: - return - cutoff = ( - datetime.datetime.now() - datetime.timedelta(days=keep_days) - ).strftime("%Y-%m-%d %H:%M:%S") - try: - self.conn.execute( - "DELETE FROM ws_program WHERE recv_ts < %s", (cutoff,) - ) - logger.info("🧹 ws_program 정리 완료 (recv_ts < %s)", cutoff) - except Exception as e: - logger.error("cleanup_old_ws_program 실패: %s", e) - - def close(self): - """DB 연결 종료 (호출자가 매번 close 하므로 로그는 DEBUG 로 강등).""" - if self.conn: - self.conn.close() - logger.debug("🔒 DB 연결 종료") +""" +트레이딩 봇 데이터베이스 관리 모듈 +- MariaDB 기반 (pymysql) — SQLite 에서 이전 +- 활성 트레이딩 관리 (active_trades) +- 매매 히스토리 관리 (trade_history) + +MariaDB 접속 정보 우선순위: + 1) 환경변수 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME + 2) 아래 DEFAULT 상수 (기본값) +""" +import json +import os +import datetime +import logging +import threading +from typing import Any, Dict, List, Optional, Tuple + +try: + import pymysql + import pymysql.cursors + _PYMYSQL_AVAILABLE = True +except ImportError: + _PYMYSQL_AVAILABLE = False + +logger = logging.getLogger("TradeDB") + +# ── MariaDB 기본 접속 정보 (환경변수로 재정의 가능) ───────────────── +_DB_HOST = os.environ.get("DB_HOST", "192.168.0.141") +_DB_PORT = int(os.environ.get("DB_PORT", "3306")) +_DB_USER = os.environ.get("DB_USER", "jae") +_DB_PASS = os.environ.get("DB_PASS", "1234") +_DB_NAME = os.environ.get("DB_NAME", "kis_quant_db") + + +# ══════════════════════════════════════════════════════════════════════ +# SQLite 호환 래퍼 — 기존 conn.execute() / with conn: 패턴 유지 +# ══════════════════════════════════════════════════════════════════════ +class _MariaDBConn: + """ + pymysql 연결을 sqlite3 인터페이스처럼 감싸는 래퍼. + - conn.execute(sql, params) → DictCursor 반환 (row['col'] 접근) + - with conn: ... → 자동커밋(autocommit=True) 이므로 no-op + - conn.commit() → 명시적 커밋 (autocommit=True라 호환) + - ? 플레이스홀더 → %s 자동 변환 + - SQLite-only DDL 키워드 자동 변환 + """ + + # SQL 텍스트 자동 변환 규칙 (SQLite → MySQL) + _REPLACE_PAIRS = [ + # DDL 키워드 + ("INTEGER PRIMARY KEY AUTOINCREMENT", "INT NOT NULL AUTO_INCREMENT PRIMARY KEY"), + ("INTEGER PRIMARY KEY", "INT NOT NULL PRIMARY KEY"), + ("AUTOINCREMENT", "AUTO_INCREMENT"), + # DML — SQLite 전용 구문 + ("INSERT OR REPLACE INTO", "REPLACE INTO"), + ("last_insert_rowid()", "LAST_INSERT_ID()"), + # ON CONFLICT 처리: 단순 패턴 제거 후 ON DUPLICATE KEY UPDATE 로 수동 변환 + # (복잡한 케이스는 각 메서드에서 직접 처리) + ] + + def __init__(self): + self._lock = threading.Lock() + self._conn = None + self._connect() + + def _connect(self): + """pymysql 연결 (재연결 포함).""" + if not _PYMYSQL_AVAILABLE: + raise ImportError( + "pymysql 미설치. 설치: pip install pymysql\n" + "또는: pip install PyMySQL" + ) + self._conn = pymysql.connect( + host=_DB_HOST, port=_DB_PORT, + user=_DB_USER, password=_DB_PASS, + database=_DB_NAME, + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, + read_timeout=30, + write_timeout=30, + ) + logger.debug("✅ MariaDB 연결 완료 (%s:%s/%s)", _DB_HOST, _DB_PORT, _DB_NAME) + + def _ensure_connected(self): + """연결 끊김 시 자동 재접속.""" + try: + self._conn.ping(reconnect=True) + except Exception: + try: + self._connect() + except Exception as e: + logger.error("❌ MariaDB 재접속 실패: %s", e) + raise + + @staticmethod + def _translate_sql(sql: str) -> str: + """SQLite 전용 SQL 구문을 MySQL 호환으로 변환.""" + for old, new in _MariaDBConn._REPLACE_PAIRS: + sql = sql.replace(old, new) + # ? → %s (파라미터 플레이스홀더) + # 단, 이미 %s 가 있는 경우 중복 변환 방지 + if "?" in sql: + sql = sql.replace("?", "%s") + return sql + + def execute(self, sql: str, params=None): + """ + SQL 실행. sqlite3.Connection.execute() 와 동일한 인터페이스. + 반환값: DictCursor (fetchone/fetchall/lastrowid 사용 가능) + """ + sql = self._translate_sql(sql) + with self._lock: + self._ensure_connected() + cur = self._conn.cursor() + cur.execute(sql, params or ()) + return cur + + def __enter__(self): + """with conn: 패턴 호환 (autocommit=True 이므로 실질적으로 no-op).""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def commit(self): + """명시적 커밋 (autocommit=True 환경에서 호환성 유지용).""" + try: + with self._lock: + self._conn.commit() + except Exception: + pass + + @property + def row_factory(self): + return None # DictCursor 가 이미 dict 반환, 호환용 stub + + @row_factory.setter + def row_factory(self, _): + pass # sqlite3 호환용 stub (무시) + + def close(self): + try: + self._conn.close() + except Exception: + pass + + # ── information_schema 기반 컬럼 목록 조회 (PRAGMA 대체) ────── + def get_columns(self, table_name: str) -> list: + """ + PRAGMA table_info() 대체. + 반환: 컬럼명 문자열 리스트 + """ + try: + cur = self.execute( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s " + "ORDER BY ORDINAL_POSITION", + (_DB_NAME, table_name), + ) + return [row["COLUMN_NAME"] for row in cur.fetchall()] + except Exception as e: + logger.debug("get_columns(%s) 실패: %s", table_name, e) + return [] + +# ML 학습용 진입 시점 피처 컬럼 (active_trades / trade_history 공통) +ML_ENTRY_FEATURE_COLUMNS = ( + "rsi", "volume_ratio", "tail_length_pct", "ma5_gap_pct", "ma20_gap_pct", + "foreign_net_buy", "institution_net_buy", "market_hour", +) + +# env_config 테이블 컬럼 (키 하나당 컬럼 하나, 추가/삭제 시 여기와 CREATE TABLE만 수정) +ENV_CONFIG_KEYS = ( + "STOP_LOSS_PCT", "SHOULDER_CUT_PCT", "SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_NET_PCT", "STOP_ATR_MULTIPLIER_TAIL", "TARGET_ATR_MULTIPLIER_TAIL", + "TAIL_ATR_SL_MIN_PCT", "TAIL_ATR_SL_MAX_PCT", "TAIL_ATR_TP_MIN_PCT", "TAIL_ATR_TP_MAX_PCT", + "MAX_POSITION_PCT", "USE_SLOT_CAP", "SLOT_CAP_PCT", "MAX_STOCKS", + "USE_KELLY", "RISK_PCT_PER_TRADE", "MIN_POSITION_AMOUNT", + "USE_RISK_CHECK", "DAILY_STOP_LOSS_PCT", "CONSECUTIVE_LOSS_LIMIT", + # 일일 익절 목표 — 전략별만 ({SID}_DAILY_PROFIT_* → config_*). + # 글로벌 DAILY_PROFIT_TARGET_ENABLED 폐기(헷갈림) — 금액/%/수동중단·알림·트레일 규칙만 마스터 유지. + "DAILY_PROFIT_TARGET_KRW", + "DAILY_PROFIT_TARGET_PCT", + "DAILY_PROFIT_TARGET_BUDGET_KRW", + "DAILY_PROFIT_HALT_NEW_BUYS", + "DAILY_PROFIT_NOTIFY_MM", + # 일일 누적손익 트레일 익절 (고점 대비 되돌림 시 신규매수 중단) — 마스터·총합 규칙 + "DAILY_PROFIT_MODE", + "DAILY_PROFIT_TRAIL_TIERS", + "DAILY_PROFIT_TRAIL_DROP_PCT", + "DAILY_PROFIT_TRAIL_ARM_KRW", + "DAILY_PROFIT_TRAIL_ARM_PCT", + "USE_BAN_SYSTEM", "BAN_HOURS", "USE_STOCK_FILTER", "RSI_OVERHEAT_THRESHOLD", + "MIN_RECOVERY_RATIO", "MAX_RECOVERY_RATIO", + "USE_TWAP", "TWAP_MIN_SPLIT", "TWAP_MAX_SPLIT", "TWAP_MIN_DELAY", "TWAP_MAX_DELAY", + "USE_ML_SIGNAL", "ML_MIN_PROBABILITY", "USE_NEWS_ANALYSIS", "NEWS_ANALYSIS_HOUR", "NEWS_MAX_COUNT", + "USE_QUICK_PROFIT_PROTECTION", "HIGH_PRICE_CHASE_THRESHOLD", "MAX_DAILY_CHANGE_PCT", + "MA20_MAX_ABOVE_PCT", "VOLUME_AVG_MULTIPLIER", + # 스캘핑 TRIGGER 거래량배수 (param_search vol_mult / 웹 저장). overflow 말고 env_config 정식 키. + "VOL_MULTIPLIER", + "CANDLE_OPEN_PRICE_BUFFER", + "MIN_CANDLE_LEN_TAIL", "MIN_PRICE_TAIL", "TAIL_SCORE_BASE", "TAIL_SCORE_RATIO_MULT", + "TAIL_RATIO_MIN", "TAIL_PCT_MIN", # 꼬리 비율/하락률 최소 (AI·봇 공통 기준) + "TAIL_SKIP_HTS_SCAN_DUPES", "TAIL_USE_INTRADAY_DROP", "TAIL_USE_MA20_FILTER", + "TAIL_USE_RSI_FILTER", "TAIL_USE_DAILY_RANGE_FILTER", "TAIL_USE_HIGH_CHASE_FILTER", + "TAIL_BAR_CHG_MIN_PCT", "TAIL_BAR_CHG_MAX_PCT", "TAIL_RSI_THRESHOLD", + "TAIL_PATTERN_HAMMER", "TAIL_PATTERN_PIN", "TAIL_PATTERN_ENGULFING", + "TAIL_PATTERN_PIERCING", "TAIL_PATTERN_HARAMI", "TAIL_PATTERN_DOJI", + "TAIL_PATTERN_MORNING_STAR", + "TAIL_PIN_CLOSE_UPPER_RATIO", "TAIL_PIN_MAX_UPPER_TAIL_RATIO", + "TAIL_ENGULF_MIN_BODY_RATIO", "TAIL_PIERCING_PENETRATE_RATIO", + "TAIL_HARAMI_MAX_BODY_RATIO", "TAIL_DOJI_BODY_MAX_RATIO", + "TAIL_MORNING_STAR_BODY_MAX_RATIO", + # 꼬리잡기 TAIL_* 통일 키 (레거시 MIN_DROP_RATE·STOP_LOSS_PCT 등 대체) + "TAIL_MIN_DROP_RATE", "TAIL_MIN_RECOVERY_RATIO", "TAIL_MAX_RECOVERY_3M", + "TAIL_STOP_LOSS_PCT", "TAIL_TAKE_PROFIT_PCT", + "TAIL_SHOULDER_MIN_HIGH_PCT", "TAIL_SHOULDER_CUT_PCT", + "TAIL_RATCHET_TIERS", "TAIL_MAX_HOLD_BARS", + "TAIL_TRAIL_PCT", "TAIL_TRAIL_ARM_PCT", + "TAIL_BACKTEST_USE_TICK_DB", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", + "TAIL_PARAM_SEARCH_MIN_START", + "TAIL_STOP_ATR_MULT", "TAIL_TARGET_ATR_MULT", + # 신호봉 거래량 폭증 필터 (param_search tail_vol_mult · 0=OFF) + "TAIL_VOL_MULT", "TAIL_VOL_WIN", + "TAIL_MIN_PRICE", "TAIL_MAX_DAILY_CHG", "TAIL_MA20_MAX_ABOVE_PCT", + "TAIL_MAX_LOSS_KRW", "TAIL_MIN_DROP_FOR_LOSS_CUT", + "TAIL_RSI_PERIOD", "TAIL_COOLDOWN_SEC", "TAIL_MAX_DAILY", + "TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW", "TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT", + "TAIL_REENTRY_MIN_EDGE_KRW", "TAIL_REENTRY_REQUIRE_NONNEG", + "TAIL_HIGH_CHASE_THR", "TAIL_TIME_START", "TAIL_TIME_END", + "TAIL_MAX_STOCKS", "TAIL_TOTAL_BUDGET_KRW", "TAIL_SLOT_MONEY", + "TAIL_ENTRY_MODE", "TAIL_LIMIT_ATR_MULT", "TAIL_LIMIT_ANCHOR", + "TAIL_LIMIT_VALID_BARS", "TAIL_LIMIT_FILL_SLIP_PCT", + "TAIL_MIN_INVEST_RATIO_OF_SLOT", + "TAIL_PARAM_SEARCH_ENTRY_MODE", + "TAIL_GRID_FAST_MAX_DAILY_CHG", "TAIL_GRID_COARSE_MAX_DAILY_CHG", + "TAIL_GRID_FAST_SYMBOL_LOSS_PCT", "TAIL_GRID_FAST_SYMBOL_LOSS_KRW", + "TAIL_GRID_FAST_REENTRY_MIN_EDGE", + "TAIL_GRID_FAST_LIMIT_ATR_MULT", "TAIL_GRID_COARSE_LIMIT_ATR_MULT", + # 더블 볼린저 DBBAND_* (config_dbband) + "DBBAND_BB_PERIOD", "DBBAND_BB_INNER_STD", "DBBAND_BB_OUTER_STD", + "DBBAND_TREND_MA_PERIOD", "DBBAND_USE_TREND_FILTER", "DBBAND_SIDE_MODE", + "DBBAND_ENTRY_VALID_BARS", "DBBAND_ENTRY_MODE", + "DBBAND_STOP_MODE", "DBBAND_STOP_BUFFER_PCT", "DBBAND_STOP_LOSS_PCT", + "DBBAND_TP_MODE", "DBBAND_TAKE_PROFIT_PCT", "DBBAND_RR_RATIO", + "DBBAND_EXIT_MODE", + "DBBAND_SHOULDER_MIN_HIGH_PCT", "DBBAND_SHOULDER_CUT_PCT", + "DBBAND_TRAIL_PCT", "DBBAND_TRAIL_ARM_PCT", + "DBBAND_TIME_START", "DBBAND_TIME_END", "DBBAND_COOLDOWN_SEC", + "DBBAND_MAX_DAILY", "DBBAND_MIN_PRICE", + "DBBAND_SLOT_MONEY", "DBBAND_MAX_STOCKS", "DBBAND_TOTAL_BUDGET_KRW", + "DBBAND_MAX_BUY_AMOUNT", "DBBAND_MAX_HOLD_BARS", "DBBAND_TIMEFRAME", + "DBBAND_MIN_INVEST_RATIO_OF_SLOT", "DBBAND_FORCE_EOD_EXIT", + "DBBAND_LIVE_MIN_CANDLES", "DBBAND_LIVE_SIGNAL_LOOKBACK_BARS", + "DBBAND_UNIVERSE_SOURCE", "DBBAND_CANDLE_FETCH_N", + "STRATEGY_DBBAND_ENABLED", "KIS_DBBAND_MM_CHANNEL", + "RANK_DBBAND_SORT", "RANK_DBBAND_LIMIT", + "CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ", + "DBBAND_GRID_BB_PERIOD0", "DBBAND_GRID_BB_PERIOD1", + "DBBAND_GRID_INNER_STD0", "DBBAND_GRID_INNER_STD1", + "DBBAND_GRID_OUTER_STD0", "DBBAND_GRID_OUTER_STD1", + "DBBAND_GRID_TREND_MA0", "DBBAND_GRID_TREND_MA1", + "DBBAND_GRID_SL0", "DBBAND_GRID_SL1", + "DBBAND_GRID_TP0", "DBBAND_GRID_TP1", + "DBBAND_GRID_RR0", "DBBAND_GRID_RR1", + "INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "SIZE_CLASS_LARGE_MIN", "SIZE_CLASS_MID_MIN", + # 단타 스캔 후보 점수 (전부 env/DB) + "SCAN_INVESTOR_NET_STRONG", "SCAN_INVESTOR_SCORE_STRONG", "SCAN_INVESTOR_SCORE_WEAK", + "SCAN_INVESTOR_BONUS_STRONG", "SCAN_INVESTOR_BONUS_WEAK", + "SCAN_VOLUME_BONUS_MIN", "SCAN_VOLUME_BONUS_POINT", + "SCAN_EXEC_STRENGTH_HIGH", "SCAN_EXEC_STRENGTH_MID", "SCAN_EXEC_BONUS_HIGH", "SCAN_EXEC_BONUS_MID", + "SCAN_SCORE_DROP_WEIGHT", "SCAN_SCORE_RECOVERY_WEIGHT", + "MIN_SCORE_DISPLAY", "MM_TOP_N", + "SLOT_MONEY_DEFAULT", "SLOT_BASE_AMOUNT_CAP", + "SIZE_CLASS_SMALL_RATIO", "SIZE_CLASS_MID_RATIO", + # 개미털기/유니버스 필터 추가 키 + "SCAN_MIN_PREV_DAY_PCT", "UPDATE_UNIVERSE_MIN_CANDIDATES", + "UPDATE_UNIVERSE_MIN_SCORE", "UPDATE_UNIVERSE_FALLBACK_TOP_N", "UPDATE_UNIVERSE_TOP_LOG", "UPDATE_UNIVERSE_TOP_N", + "SCAN_INTERVAL_SEC", # 유니버스 스캔 주기(초), kiwoom_universe_scanner 전용 + "USE_RANDOM_SPLIT", "FORCE_MARKET_OPEN", "FORCE_BUY_TEST", "TOTAL_DEPOSIT", + # POP/LOCK·금액 손절 관련 추가 키 + "ROUND_TRIP_COST_PCT", "POP_NET_PCT", "LOCK_NET_PCT", "MAX_LOSS_PER_TRADE_KRW", + # 금액손실컷 발동 최소 하락률(%): 이 하락률 미만이면 슬리피지/흔들림으로 간주하고 금액손실컷 미발동 (꼬리잡기) + "MIN_DROP_PCT_FOR_LOSS_CUT", + # 한투 API 관련 키 추가 (실전/모의 계좌 분리) + "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", + "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK", + "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", # 실전 계좌 (KIS_MOCK=false 시 사용) + "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", # 모의 계좌 (KIS_MOCK=true 시 사용) + "KIS_MOCK", + # 단타 봇 전용 키 + "TAKE_PROFIT_PCT", "MIN_DROP_RATE", "MIN_RECOVERY_RATIO_SHORT", + # 단타 매도 로직 (env/DB에서 수치 로드) + "SCALP_ATR_UP_MULT", "SCALP_ATR_DOWN_MULT", "SCALP_ATR_DROP_MULT", + "QUICK_PROFIT_PROTECT_HOURS", "QUICK_PROFIT_MAX_RATIO", "QUICK_PROFIT_CURRENT_MIN", + "MIN_HOLD_EARLY_TAKE_PCT", "MIN_HOLD_HIGH_PCT", "MIN_HOLD_DROP_FROM_HIGH", + "POST_HOLD_TAKE_PCT", "POST_HOLD_DROP_FROM_HIGH", + # 늘림목 봇 전용 키 + "MAX_PER", "MAX_PEG", "MIN_GROWTH_PCT", "DCA_INTERVALS", "DCA_AMOUNTS", + # Mattermost 및 AI 리포트 관련 키 + "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY", + "AI_JOURNAL_LINES", "ANTHROPIC_API_KEY", "CLAUDE_MODEL_ID", "CLAUDE_MAX_TOKENS", + "MM_BUTLER_CHANNEL", + # true=체결 알림을 전략 채널 + MATTERMOST_CHANNEL(통합) 양쪽 발송 + "MM_DUAL_CHANNEL_ENABLED", + # OpenRouter API 연동용 키 + "OPENROUTER_API_KEY", "OPENROUTER_MODEL_ID", + # 봇별 Mattermost 채널 — 전략별 config_* (KIS_LONG 은 홀딩봇 전용 → env_config) + "KIS_LONG_MM_CHANNEL", + # 롱 위시리스트/뉴스 리포트 전용 키 + "LONG_DAILY_LOOKBACK_DAYS", "LONG_MA_SHORT_DAYS", "LONG_MA_LONG_DAYS", + "LONG_REPORT_AM_HOUR", "LONG_REPORT_AM_MIN", + "LONG_REPORT_PM_HOUR", "LONG_REPORT_PM_MIN", + "LONG_NEWS_ENABLED", "LONG_NEWS_INTERVAL_MIN", + "LONG_NEWS_ACTIVE_START_HOUR", "LONG_NEWS_ACTIVE_END_HOUR", + "LONG_ANALYSIS_DELAY_MIN_SEC", "LONG_ANALYSIS_DELAY_MAX_SEC", + # RSI 기간: 스윙=14(기본), 단타=5, 스캘핑=3 권장 (DB에서 실시간 변경 가능) + "RSI_PERIOD", + # KIS WebSocket 관련 키 + "KIS_WS_URL_REAL", "KIS_WS_URL_MOCK", "KIS_WS_MOCK_ENABLED", + # KIS WS 재연결·구독 (키움 CNSRREQ_GAP 과 동일 — 구독 연속 폭주 시 세션 즉시 종료 방지) + "KIS_WS_SUBSCRIBE_GAP_MIN_SEC", "KIS_WS_SUBSCRIBE_GAP_MAX_SEC", + "KIS_WS_RECONNECT_SESSION_WAIT_SEC", "KIS_WS_RECONNECT_REFRESH_KEY", + "KIS_WS_APPROVAL_MIN_REISSUE_SEC", "KIS_WS_APPROVAL_MAX_VALID_SEC", + "ENV_MERGED_CACHE_TTL_SEC", + "KIS_WS_INSTANT_DROP_SEC", "KIS_WS_INSTANT_DROP_MAX", + "KIS_WS_INSTANT_DROP_COOLDOWN_SEC", + # 재진입 쿨다운: 매도 후 같은 종목 재매수를 N초 동안 차단 (반복매매 루프 방지) + "REENTRY_COOLDOWN_SEC", + # 꼬리잡기/단타 매수 허용 시간대 (HHMM 정수, 930=09:30, 1500=15:00) — 백테스트·실매 공통 + "TIME_START", "TIME_END", + # 매도 실패 백오프: 영업일 아님·장외 시간 오류 시 N초 동안 재시도 금지 (API 낭비·차단 방지) + "SELL_FAILURE_BACKOFF_SEC", + # 실잔고 inquire-balance 캐시 TTL(초) — 매도 루프 내 N종목 1회 조회 공유 + "BROKER_HOLDINGS_CACHE_TTL_SEC", + # 유령잔고(DB·메모리만 존재) 정리 후 동일 (전략,종목) 재시도 쿨다운(초) + "GHOST_POSITION_COOLDOWN_SEC", + # ── 스캘핑봇(kis_scalping_ver1) 전용 키 ────────────────────────────── + # RSI 과매도 임계값: 이 값 이하면 "과매도 → 되돌림 가능" 후보로 판단 + "SCALP_RSI_OVERSOLD", + "SCALP_RSI_PERIOD", + # RSI 과매수 임계값: 이 값 이상이면 신규 진입 금지 (고점 추격 방지) + "SCALP_RSI_OVERBOUGHT", + # 스캘핑봇이 사용할 봉 단위 (분): 1 또는 3 권장 + "SCALP_CANDLE_TIMEFRAME", + # 장 시작 후 몇 분 뒤부터 매매 허용 (장 시작 직후 변동성 회피) + "SCALP_MARKET_OPEN_WAIT_MIN", + # WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 + "SCALP_GAP_FILL_LIMIT", + # 확정 봉 기준 최소 거래량: 이 값 미만인 봉은 유동성 부족으로 무시 + "SCALP_MIN_VOLUME", + # 스캘핑·꼬리 MM alias → config_scalp / config_short + "KIS_SCALP_MM_CHANNEL", + "KIS_SHORT_MM_CHANNEL", + # 시스템 알림(시작·종료·장시작·장마감) 단일 MM alias (기본 default=키스명령봇) + "KIS_SYSTEM_MM_CHANNEL", + # 시작·종료 알림 추가 발송 MM alias (기본 stock — 재시작/종료 짝 확인용) + "KIS_LIFECYCLE_MM_CHANNEL", + # journalctl 오류감시 → Mattermost (scripts/kis_error_watch_mm.py) + "ERROR_WATCH_ENABLED", + "ERROR_WATCH_UNIT", + "ERROR_WATCH_MM_CHANNEL", + "ERROR_WATCH_COOLDOWN_SEC", + "ERROR_WATCH_CONTEXT_LINES", + "ERROR_WATCH_TRACEBACK_EXTRA_LINES", + "ERROR_WATCH_HEALTH_CHECK_SEC", + "ERROR_WATCH_GLOBAL_MIN_GAP_SEC", + "ERROR_WATCH_MATCH_REGEX", + "ERROR_WATCH_IGNORE_REGEX", + "ERROR_WATCH_MM_JITTER", + "ERROR_WATCH_STARTUP_NOTIFY", + + # 미등록 보유분 일괄 시장가매도 시 종목 간 간격(초) — 429 방지 + "BULK_SELL_INTERVAL_SEC", + # 수동매수 보호 종목코드(쉼표구분) — 일괄매도에서 무조건 제외 + "MANUAL_HOLD_CODES", + # 장마감 후 봇 고아(active_trades 미기록) 자동 복구 ON/OFF + "ORPHAN_RECONCILE_ENABLED", + # Pre-EOD 고아복구 — 가장 이른 EOD − N분 (EOD 청산 전 1회) + "ORPHAN_RECONCILE_PRE_EOD_ENABLED", + "ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", + # Pre/Post EOD 고아복구와 동일 잔고조회에서 DB有·브로커0 유령 삭제 + "GHOST_PURGE_ON_RECONCILE", + # insert 실패(주문DB중복) 시 브로커 체결 조회 복구 + "DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", + "DUPLICATE_ORDER_RECOVERY_WAIT_SEC", + # 장중 잔고 qty 드리프트 감시 (기본 OFF — REST 주기 폴링) + "INTRADAY_HOLDINGS_DRIFT_ENABLED", + "INTRADAY_HOLDINGS_DRIFT_INTERVAL_SEC", + "INTRADAY_HOLDINGS_DRIFT_AUTO_RECOVER", + # ws_candles 자동 정리 보존 일수 + "SCALP_CANDLE_KEEP_DAYS", + # ws_ticks 실시간 체결 틱 (RAM 링버퍼 + 배치 INSERT, C안) + "WS_TICK_SAVE_ENABLED", + "WS_TICK_BUFFER_MAX_PER_CODE", + "WS_TICK_DB_BATCH_SIZE", + "WS_TICK_DB_FLUSH_SEC", + "WS_TICK_WRITE_QUEUE_MAX", + "WS_TICK_KEEP_DAYS", + "WS_TICK_RECORD_SCOPE", + "WS_TICK_DEFAULT_MARKET", + "WS_TICK_DEFAULT_EXCHANGE", + "WS_TICK_DEFAULT_CURRENCY", + # 백테 틱재생: 실매 후보=키움 정합. 분봉에 kiwoom 있으면 kis(과거 volume 오염) 제외 + "WS_TICK_PREFER_KIWOOM", + # 후보/보유 이탈 후 틱 구독·저장 유지 (키움 한도 내, KIS 영구구독과 분리) + "WS_TICK_GRACE_SEC", + "WS_TICK_GRACE_HEADROOM", + # ws_orderbook / ws_program — TRIGGER 호가·프로그램 스냅샷 (백테 재현) + "WS_ORDERBOOK_SAVE_ENABLED", + "WS_PROGRAM_SAVE_ENABLED", + "WS_TRIGGER_SNAPSHOT_DB_INTERVAL_SEC", + "WS_TRIGGER_SNAPSHOT_DB_BATCH_SIZE", + "WS_TRIGGER_SNAPSHOT_DB_FLUSH_SEC", + "WS_TRIGGER_SNAPSHOT_WRITE_QUEUE_MAX", + "WS_ORDERBOOK_KEEP_DAYS", + "WS_PROGRAM_KEEP_DAYS", + # TRIGGER 필터 판정 시점 스냅샷 (실매 RAM → DB, 백테 재생) + "WS_TRIGGER_EVAL_SAVE_ENABLED", + "WS_ORDERBOOK_COLLECT_ENABLED", + "WS_PROGRAM_COLLECT_ENABLED", + "WS_TRIGGER_EVAL_DB_BATCH_SIZE", + "WS_TRIGGER_EVAL_DB_FLUSH_SEC", + "WS_TRIGGER_EVAL_WRITE_QUEUE_MAX", + "BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", + "BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", + "MOMENTUM_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", + "BREAKOUT_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", + "TAIL_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", + "BACKTEST_USE_TRIGGER_SNAPSHOT_DB", + "MOMENTUM_BACKTEST_USE_TRIGGER_SNAPSHOT_DB", + "MOMENTUM_BACKTEST_USE_TICK_EXIT", + "MOMENTUM_BACKTEST_USE_TICK_ENTRY", + "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", + "BREAKOUT_BACKTEST_USE_TICK_DB", + "BREAKOUT_BACKTEST_USE_TICK_EXIT", + "BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", + "BREAKOUT_BACKTEST_POLL_MS", + "BREAKOUT_BACKTEST_SELL_SLIP_PCT", + # 돌파 백테 전일 봉 웜업(실매 WS 버퍼 정합) · 유니버스 EXIT 디바운스 + "BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS", + "BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT", + "BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC", + "TAIL_BACKTEST_USE_TICK_EXIT", + "TAIL_BACKTEST_POLL_MS", + "TAIL_BACKTEST_SELL_SLIP_PCT", + "SCALP_BACKTEST_USE_TICK_EXIT", + "SCALP_BACKTEST_USE_TICK_ENTRY", + "SCALP_BACKTEST_TICK_FALLBACK_OHLC", + "SCALP_BACKTEST_POLL_MS", + "SCALP_BACKTEST_SELL_SLIP_PCT", + "RANGE_BREAK_BACKTEST_USE_TICK_EXIT", + "RANGE_BREAK_BACKTEST_TICK_FALLBACK_OHLC", + "RANGE_BREAK_BACKTEST_POLL_MS", + "RANGE_BREAK_BACKTEST_SELL_SLIP_PCT", + "DBBAND_BACKTEST_USE_TICK_EXIT", + "DBBAND_BACKTEST_TICK_FALLBACK_OHLC", + "DBBAND_BACKTEST_POLL_MS", + "DBBAND_BACKTEST_SELL_SLIP_PCT", + "MOMENTUM_BACKTEST_POLL_MS", + "MOMENTUM_BACKTEST_SELL_SLIP_PCT", + "MOMENTUM_BACKTEST_BUY_SLIP_PCT", + "MOMENTUM_BACKTEST_UNIVERSE_STRICT", + "MOMENTUM_BACKTEST_UNIVERSE_STRICT_LAG_MIN", + "MOMENTUM_BACKTEST_LIVE_SCAN_QUEUE", + "MOMENTUM_BACKTEST_SCAN_SEC", + "MOMENTUM_BACKTEST_UNIVERSE_SCAN_AT", + "MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", + "MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", + "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", + "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", + "MOMENTUM_COOLDOWN_ENGINE_ONLY", + "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", + "BREAKOUT_BACKTEST_USE_TRIGGER_SNAPSHOT_DB", + "TAIL_BACKTEST_USE_TRIGGER_SNAPSHOT_DB", + # 휩쏘(TRIGGER) 필터 — 공통 + 전략별 + "WHIPSAW_FILTER_ENABLED", + "WHIPSAW_BACKTEST_OHLC_FALLBACK", + "WHIPSAW_MODE", + "WHIPSAW_SUBBAR_SEC", + "WHIPSAW_LOOKBACK_SEC", + "WHIPSAW_DIP_PCT", + "WHIPSAW_RECOVERY_TOL_PCT", + "WHIPSAW_MIN_SUBBARS", + "WHIPSAW_TICK_BUFFER_LIMIT", + "MOMENTUM_WHIPSAW_FILTER_ENABLED", + "MOMENTUM_WHIPSAW_MODE", + "MOMENTUM_WHIPSAW_SUBBAR_SEC", + "MOMENTUM_WHIPSAW_LOOKBACK_SEC", + "MOMENTUM_WHIPSAW_DIP_PCT", + "MOMENTUM_WHIPSAW_RECOVERY_TOL_PCT", + "BREAKOUT_WHIPSAW_FILTER_ENABLED", + "BREAKOUT_WHIPSAW_MODE", + "BREAKOUT_WHIPSAW_SUBBAR_SEC", + "BREAKOUT_WHIPSAW_LOOKBACK_SEC", + "BREAKOUT_WHIPSAW_DIP_PCT", + "BREAKOUT_WHIPSAW_RECOVERY_TOL_PCT", + "TAIL_WHIPSAW_FILTER_ENABLED", + "TAIL_WHIPSAW_MODE", + "TAIL_WHIPSAW_SUBBAR_SEC", + "TAIL_WHIPSAW_LOOKBACK_SEC", + "TAIL_WHIPSAW_DIP_PCT", + "TAIL_WHIPSAW_RECOVERY_TOL_PCT", + # 스캘핑 전용 손절 % (꼬리잡기 STOP_LOSS_PCT와 분리, 기본 1.5%) + # 1분봉 초단타: 손절이 넓으면(-4%) 자금이 묶여 회전율 0 → 타이트하게 + "SCALP_STOP_LOSS_PCT", + # 스캘핑 전용 익절 % (꼬리잡기 TAKE_PROFIT_PCT와 분리, 기본 1.5%) + # 1분봉에서 +5% 익절은 도달 불가 → 박리다매 전략으로 1.5%씩 수익 적립 + "SCALP_TAKE_PROFIT_PCT", + # 스캘핑 익절 상한·어깨컷 (꼬리와 동일 1순위 청산) + "SCALP_TP_MAX_PCT", + "SCALP_SHOULDER_MIN_HIGH_PCT", "SCALP_SHOULDER_CUT_PCT", + # 스캘핑 낙폭 필터 % (꼬리잡기 MIN_DROP_RATE와 분리, 기본 1.5%) + # 3% 기준은 1분봉 소형주에서 너무 엄격 → 1.5%로 완화해 타점 빈도 증가 + "SCALP_MIN_DROP_RATE", + # 봉부족 감지 시 재갭보정 최소 간격(초): 같은 종목 중복 REST 호출 방지 (기본 30초) + "SCALP_GAP_RETRY_SEC", + # 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (기본 600=10분) + # 백테스트와 동일 파라미터로 맞추려면 DB에 값 저장 후 봇/백테스트 모두 이 값 사용 + "SCALP_REQUIRE_REVERSAL_CANDLE", # true=직전 음봉+현재 양봉 필수, false=RSI 과매도만으로 진입 허용 + "SCALP_COOLDOWN_SEC", + # 최소 보유 초: 진입 후 N초 미만이면 청산 검사 스킵 (0=OFF). Optuna/Grid min_hold_sec 와 동일 키. + "SCALP_MIN_HOLD_SEC", + # 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이 이상 올라야 트레일링 활성화 + # 0.5%면 수수료(~0.21%) 뺀 나머지만 이익 → 1.5 이상 권장 + "SCALP_TRAIL_TRIGGER_PCT", + # 트레일링/본절사수 후 최소 순이익(%): 수수료+세금 위에 이 값만큼 추가 마진 확보 + # 0이면 본절(수수료 이후 0원), 0.2면 최소 +0.2% 순이익 보장 + "SCALP_MIN_PROFIT_PCT", + # 스캘핑 전용 방어로직 (꼬리잡기와 값 분리: HIGH_PRICE_CHASE_THRESHOLD / MIN_PRICE_TAIL 등과 별도) + "SCALP_HIGH_PRICE_CHASE_THRESHOLD", # 고점추격 방지: 당일고가 대비 비율 이하면 진입 허용 (0.96 등) + "SCALP_MAX_DAILY_CHANGE_PCT", # 급등주 필터: 당일 등락률 이 값 초과 시 진입 금지 (%) + "SCALP_MIN_PRICE", # 최소 가격(원): 이 값 미만 종목 진입 금지 + "SCALP_MAX_LOSS_PER_TRADE_KRW", # 1회 최대 손실(원): 종목당 손실 상한 + "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", # 금액손실컷 발동 최소 하락률(%): 이 미만은 흔들림으로 미발동 + "SCALP_USE_DEFENSE_FILTERS", # true/false: 스캘핑 진입 방어 필터(낙폭/고점추격/급등/최소가격) ON/OFF + # kiwoom scalp_re SCAN 사용 시 HTS B/C/F/D 와 겹치는 TRIGGER(낙폭·RSI·되돌림) 생략. 비우면 UNIVERSE_SOURCE=condition 일 때 자동 true + "SCALP_SKIP_HTS_SCAN_DUPES", + # HTS C(MACD+Stoch 골든크로스) — TRIGGER 전용. true면 RSI reversal 대신 MACD 진입. + "SCALP_USE_MACD_CROSS", + "SCALP_MACD_FAST", "SCALP_MACD_SLOW", "SCALP_MACD_SIGNAL", + "SCALP_STOCH_K_PERIOD", "SCALP_STOCH_D_PERIOD", "SCALP_STOCH_SLOW", + # Optuna/Grid 탐색 축 (CSV, env_config — 웹·봇 기본값과 별도) + "SCALP_GRID_TRIGGER_RSI_OVERSOLD", "SCALP_GRID_TRIGGER_DROP_RATE", + "SCALP_GRID_TRIGGER_HIGH_CHASE_THR", "SCALP_GRID_TRIGGER_VOL_MULT", + "SCALP_GRID_TRIGGER_REQUIRE_REVERSAL", + "SCALP_GRID_SKIP_HTS_SCAN_DUPES", + "SCALP_GRID_EXIT_SL_PCT", "SCALP_GRID_EXIT_TP_PCT", "SCALP_GRID_EXIT_TP_MAX_PCT", + "SCALP_GRID_EXIT_SHOULDER_MIN_HIGH_PCT", "SCALP_GRID_EXIT_SHOULDER_CUT_PCT", + "SCALP_GRID_EXIT_MIN_HOLD_SEC", + "SCALP_SCORE_MDD_FLOOR", + # ── 단타봇(kis_short_ver2) 전용 키 ─────────────────────────────────── + # 켈리 공식 사용 여부 (true=켈리 적용, false=고정 비중) + "USE_KELLY_FORMULA", + # 켈리 공식 적용 배수 (0.25 = Full Kelly의 25%, 과도한 베팅 방지) + "KELLY_MULTIPLIER", + # 시장가 IOC 주문 사용 여부 (실전: true=IOC, false=일반 시장가) + "USE_MARKET_IOC", + # 체결 확인 엄격 모드: true=모의도 fill 없으면 가정 체결 금지 (실전 훈련) + "STRICT_FILL_VERIFY", + # 주문 직후 체결 조회 대기(초) — 시장가 + "ORDER_FILL_WAIT_SEC", + # 지정가 주문 직후 체결 조회 대기(초) + "LIMIT_ORDER_FILL_WAIT_SEC", + # heartbeat 미체결 재조회 간격(초) + "PENDING_FILL_POLL_INTERVAL_SEC", + # 미체결 재조회 시 get_execution 1회 대기(초) + "PENDING_FILL_POLL_SEC", + # (레거시) 매수 미체결 최대 대기 — PENDING_BUY_MAX_AGE_SEC 미설정 시 fallback + "PENDING_FILL_MAX_AGE_SEC", + # 매수 미체결 최대 대기(초) — 초과 시 취소·다음 신호 대기 + "PENDING_BUY_MAX_AGE_SEC", + # 익절·지정가 매도 미체결 최대 대기(초) + "PENDING_SELL_MAX_AGE_SEC", + # 손절·장마감·긴급 매도 미체결 최대 대기(초) — 짧게, 만료 시 시장가 재주문 + "PENDING_SELL_STOP_MAX_AGE_SEC", + # 손절 등 긴급 매도 만료 취소 후 즉시 시장가 재주문 + "SELL_PENDING_REORDER_ON_EXPIRE", + # 동일 전략·종목 미체결 매수 있으면 재주문 스킵 (체결 API 지연 시 주문 폭주 방지) + "BUY_DEDUP_PENDING", + # 매수 부분체결 후 잔량 자동 취소 (지정가·비-IOC 시장가) + "AUTO_CANCEL_PARTIAL_BUY_REMAINDER", + # heartbeat poll_pending_fills: 당일 체결 1 REST 일괄 조회 (실매 전용) + "PENDING_POLL_BATCH_FETCH", + # 잔고 연속조회 최대 페이지 (1p=실전50/모의20종목) — 보유 많을 때 누락 방지 + "BALANCE_MAX_PAGES", + # WebSocket 실시간 가격 캐시 유효기간(초): 이 시간 이상 지나면 REST 재조회 + "KIS_PRICE_CACHE_TTL_SEC", + # WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 (단타봇) + "SHORT_GAP_FILL_LIMIT", + # 매수 직후 최소 보유 시간(초): 이 기간 내 매도 신호 무시 (API 잔고 반영 지연 대응) + "MIN_HOLD_AFTER_BUY_SEC", + # 최소 보유 시간(시간): 이 기간 이전에는 손절 외 매도 금지 (꼬리잡기 전략 충분히 대기) + "MIN_HOLD_HOURS", + # 3개월 최대 회복 비율: 전고점 대비 이 비율 이상 회복한 종목은 추격 매수 제외 + "MAX_RECOVERY_RATIO_3M", + # 유니버스 상위 N개 후보 경량 체크 (매수 후보 1차 필터) + "CANDIDATE_LIST_TOP_N_LIGHT", + # 매수 신호 체크 시 사용할 유니버스 최대 종목 수 (과부하 방지) + "SCAN_UNIVERSE_MAX_CODES", + # 꼬리 캔들 패턴 인식 시 과거 몇 봉까지 확인할지 (lookback) + "TAIL_CANDLE_LOOKBACK", + # ── 레거시 단일 계좌 키 (KIS_APP_KEY_REAL/MOCK 이전 버전 호환용) ────── + "KIS_APP_KEY", "KIS_APP_SECRET", "KIS_ACCOUNT_NO", "KIS_ACCOUNT_CODE", + # ── mm_butler 전용 키 ──────────────────────────────────────────────── + # Gemini AI 모델 ID (gemini-2.5-flash, gemini-1.5-pro 등) + "GEMINI_MODEL_ID", + # MM 원격 명령 폴링 주기(초): 너무 짧으면 API 과부하 + "MM_BUTLER_POLL_SEC", + # AI 소스 텍스트 최대 길이(문자): 초과 시 잘라서 전송 (토큰 비용 관리) + "AI_SOURCE_MAX_CHARS", + # ── 수수료·거래세 (전략 공통) ────────────────────────────────────────── + # FEE_RATE_PCT : 위탁수수료율 (매수/매도 각각, 기본 0.015%) + # SELL_TAX_RATE_PCT: 증권거래세율 (매도 시만 부과, 기본 0.18% ← 2025 코스피/코스닥 공통) + "FEE_RATE_PCT", "SELL_TAX_RATE_PCT", + # ── 키움증권 REST API 키 (60분봉 과거 데이터 수집 전용) ──────────────── + # 실전/모의 분리 (KIS_MOCK 값에 따라 자동 선택) + # KIWOOM_APP_KEY_REAL : 키움증권 실전 앱키 + # KIWOOM_APP_SECRET_REAL: 키움증권 실전 시크릿 + # KIWOOM_APP_KEY_MOCK : 키움증권 모의 앱키 (mockapi.kiwoom.com) + # KIWOOM_APP_SECRET_MOCK: 키움증권 모의 시크릿 + # KIWOOM_APP_KEY / KIWOOM_APP_SECRET: 레거시 (단일 키 호환용) + "KIWOOM_APP_KEY_REAL", "KIWOOM_APP_SECRET_REAL", + "KIWOOM_APP_KEY_MOCK", "KIWOOM_APP_SECRET_MOCK", + "KIWOOM_APP_KEY", "KIWOOM_APP_SECRET", + # ── WebSocket 영구 구독 종목 (시장 방향 필터용) ───────────────────── + # KOSPI/KOSDAQ 지수 ETF는 매매 후보와 무관하게 항상 구독 유지. + # 60분봉 RSI 로 상승장/하락장 체크 → 스캘핑/꼬리잡기 진입 방향 결정. + # 기본값: KODEX200(069500), KODEX KOSDAQ150(229200) + # 콤마 구분 코드: "069500,229200" + "PERMANENT_WS_CODES", + # ── 시장 방향 필터 (상승장에서만 롱 진입) ────────────────────────── + # USE_MARKET_REGIME_FILTER: true=활성, false=비활성 (기본 false) + # MARKET_REGIME_MIN_RSI : ETF 60분봉 RSI 이 값 이상이어야 롱 진입 허용 (기본 48) + "USE_MARKET_REGIME_FILTER", "MARKET_REGIME_MIN_RSI", + # ── 테마 과열 필터 (테마 전체가 과열이면 신규 진입 억제) ─────────── + # USE_THEME_HEAT_FILTER : true=활성 (기본 false) + # THEME_HEAT_RSI_MAX : 테마 평균 RSI 이 값 초과면 진입 차단 (기본 72) + "USE_THEME_HEAT_FILTER", "THEME_HEAT_RSI_MAX", + # ── kis_trader 통합봇 전용 키 (전략 스위치 / 유니버스 / 랭킹) ──────── + # 전략 on/off → config_{전략} (classify_config_key) + "STRATEGY_SCALP_ENABLED", "STRATEGY_SHORT_ENABLED", "STRATEGY_BREAKOUT_ENABLED", + "STRATEGY_RANGE_BREAK_ENABLED", + "STRATEGY_UPDOW_ENABLED", + # 중복매매 정책: allow(기본)=전략별 ODNO·active_trades 분리 / block=동일종목 1전략만 + "STRATEGY_SAME_CODE_POLICY", + # 가용 예수금 캐시 (kv_store account.*) — 부족 시에만 매수 qty 축소 + "ORDER_CASH_PCT", "ORDER_CASH_FEE_BUFFER", "ORDER_CASH_DIVIDE_BY_MAX_STOCKS", + "ACCOUNT_CASH_PERSIST_SEC", + # 라이브 진입 타이밍 = 백테 (신호봉 확정 → 다음 봉 시가) + "SCALP_LIVE_BACKTEST_ALIGN", "SCALP_LIVE_SIGNAL_LOOKBACK_BARS", + "BACKTEST_EXIT_CHECKS_PER_BAR", + "SHORT_LIVE_BACKTEST_ALIGN", "SHORT_LIVE_SIGNAL_LOOKBACK_BARS", + "MOMENTUM_LIVE_BACKTEST_ALIGN", "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", + "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", + "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", "MOMENTUM_COOLDOWN_ENGINE_ONLY", + "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", + # MOMENTUM E조건(전일시가) — 실매매 최소 1분봉 수·갭보정 REST 조회량 (약 2영업일) + "MOMENTUM_LIVE_MIN_CANDLES", "MOMENTUM_GAP_FILL_LIMIT", + "BREAKOUT_LIVE_BACKTEST_ALIGN", "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", + "RANGE_BREAK_LIVE_BACKTEST_ALIGN", "RANGE_BREAK_LIVE_SIGNAL_LOOKBACK_BARS", + "BREAKOUT_ENTRY_MODE", + "BREAKOUT_INTRABAR_SLIPPAGE_PCT", + # 유니버스 소스: ranking(기본) | condition + "SCALP_UNIVERSE_SOURCE", "SHORT_UNIVERSE_SOURCE", "BREAKOUT_UNIVERSE_SOURCE", + "RANGE_BREAK_UNIVERSE_SOURCE", + # 랭킹 정렬/크기 — sort: volume|trading_value|strength|fluct_up|fluct_down + "RANK_SCALP_SORT", "RANK_SCALP_LIMIT", + "RANK_SHORT_SORT", "RANK_SHORT_LIMIT", + "RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT", + "RANK_RANGE_BREAK_SORT", "RANK_RANGE_BREAK_LIMIT", + # 랭킹 폴링 주기(초) — 기본 10초 (모의 TPS 5건/s 대비 안전, 시간당 720회) + "RANKING_POLL_INTERVAL_SEC", + # 조건검색 파라미터 + "KIS_HTS_ID", "CONDITION_POLL_INTERVAL_SEC", + # 조건검색 EXIT 유예 시간 — 한 번 빠진 종목을 N초간 universe 에 keep + # (단발성 EXIT/RE-ENTER 회전으로 WS 구독·캔들 데이터가 휘발되는 사이클 차단) + # 0 = 비활성(기존 동작), 권장 60 + "CONDITION_EXIT_GRACE_SEC", + # 매도 후 보유구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) + "POST_SELL_CANDLE_BACKFILL", + "POST_SELL_CANDLE_ROLLUP_3M", + "POST_SELL_CANDLE_MAX_BARS", + "POST_SELL_CANDLE_SLEEP_MIN_SEC", + "POST_SELL_CANDLE_SLEEP_MAX_SEC", + "POST_SELL_CANDLE_FORCE_MOCK", + "CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ", + "CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ", + "CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ", + "CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ", + # 키움 조건식 seq (선택). UNIVERSE_SOURCE=kiwoom_condition 일 때 CNSRLST name→seq 자동 해결. + "CONDITION_SCALP_KIWOOM_SEQ", + "CONDITION_SHORT_KIWOOM_SEQ", + "CONDITION_BREAKOUT_KIWOOM_SEQ", + "CONDITION_MOMENTUM_KIWOOM_SEQ", + # 키움 전용 이름 — KIS CONDITION_{SID}_NAME 과 다를 때만 설정 (예: SCALP scalp vs scalp_re) + "CONDITION_SCALP_KIWOOM_NAME", + "CONDITION_SHORT_KIWOOM_NAME", + "CONDITION_BREAKOUT_KIWOOM_NAME", + "CONDITION_MOMENTUM_KIWOOM_NAME", + # 유니버스 히스토리(백테스트용) — 기본 true, 배치 INSERT 로 DB 부담 최소화 + "UNIVERSE_HISTORY_SAVE", + # WS 갭보정 파라미터 (KIS_FALLBACK 은 기본 false — 모의서버 500 폭탄 회피) + "WS_GAP_FILL_OFF_HOURS", "WS_GAP_FILL_LIMIT", "WS_CANDLE_RAM_BUFFER", "WS_TIMEFRAMES", + "WS_GAP_FILL_KIS_FALLBACK", + # 저유동 종목 봉 강제확정 — 다음 체결 틱이 없어도 봉주기 경과 시 확정 처리 + # (2026-07-08 원티드랩 14분 무거래 → 신호 인식 지연 사례 대응, false로 즉시 롤백 가능) + "WS_CANDLE_FORCE_CONFIRM_ENABLED", + "WS_CANDLE_FORCE_CONFIRM_GRACE_SEC", + "WS_CANDLE_STALE_CHECK_INTERVAL_SEC", + "WS_GAP_FILL_MAX_RETRIES", "WS_GAP_FILL_RETRY_DELAY_SEC", + "WS_GAP_BULK_REFILL_DEBOUNCE_SEC", + "WS_GAP_FILL_PRIORITY_TFS", + "WS_GAP_FILL_TF_SLEEP_MIN_SEC", "WS_GAP_FILL_TF_SLEEP_MAX_SEC", + "WS_GAP_FILL_CODE_SLEEP_MIN_SEC", "WS_GAP_FILL_CODE_SLEEP_MAX_SEC", + "WS_GAP_FILL_PHASE_PAUSE_SEC", + "WS_GAP_FILL_WORKERS", + # 1M REST → RAM 3M 롤업 (꼬리 웜업·3M REST 생략) + "WS_GAP_ROLLUP_3M_FROM_1M", + "WS_GAP_FILL_CANDIDATE_MODE", + # 꼬리 백테: 1M→3M 구멍 보강 / 유니버스 scan_at + "TAIL_BT_SYNTH_3M_FROM_1M", + "TAIL_BACKTEST_UNIVERSE_SCAN_AT", + "TAIL_BT_INCLUSION_LOOKBACK", + # 꼬리 백테 전일 웜업 (실매 get_candles n=50 / SHORT_GAP_FILL_LIMIT 150 정합) + "TAIL_BACKTEST_CANDLE_WARMUP_BARS", + "TAIL_BACKTEST_REST_WARMUP", + "TAIL_BACKTEST_REST_WARMUP_BARS", + "TAIL_BACKTEST_REST_SLEEP_SEC", + "TAIL_BACKTEST_REST_MAX_CODES", + "TAIL_UNIVERSE_EXIT_DEBOUNCE_SEC", + "KIWOOM_CNSRREQ_GAP_MIN_SEC", "KIWOOM_CNSRREQ_GAP_MAX_SEC", + "KIWOOM_CNSRREQ_MAX_RETRIES", "KIWOOM_CNSRREQ_RETRY_SEC", + # 키움 Bye 후 조건검색 유령등록(900003) 방지: REQ 전 CLR + settle + "KIWOOM_CNSRCLR_BEFORE_REQ", + "KIWOOM_CNSRCLR_GAP_MIN_SEC", "KIWOOM_CNSRCLR_GAP_MAX_SEC", + "KIWOOM_CNSRCLR_SETTLE_SEC", + # 종목당 최대 매수금액 하드캡 (손절%가 작으면 포지션 사이즈 폭주 방지) + "MAX_BUY_AMOUNT_PER_STOCK", + "SCALP_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT", + # KIS REST 안정화 파라미터 + "KIS_MIN_INTERVAL_SEC", "KIS_REST_MAX_RETRIES", + "KIS_REST_BACKOFF_CAP_SEC", "KIS_REST_TIMEOUT_SEC", + # 도메인별 REST 최소 호출 간격 (한투 유량: 실전 18건/초, 모의 1건/초) + "KIS_MIN_INTERVAL_SEC_MOCK", "KIS_MIN_INTERVAL_SEC_REAL", + # 스캔 루프 회전율 sleep (REST 유량과 별개 · WS 따라가기 속도 · 비우면 전략별 기본값) + "SCAN_REJECT_SLEEP_MIN", "SCAN_REJECT_SLEEP_MAX", + "SCAN_BUY_OK_SLEEP_MIN", "SCAN_BUY_OK_SLEEP_MAX", + "SCAN_BUY_FAIL_SLEEP_MIN", "SCAN_BUY_FAIL_SLEEP_MAX", + "STRATEGY_LOOP_SLEEP_MIN", "STRATEGY_LOOP_SLEEP_MAX", + # ── 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 차단) ── + # 거래소 공식 서킷브레이커는 -8% 이지만, 봇 보호용으로 더 빨리(-2%) 반응. + # PANIC 모드 시 매수만 차단, 매도는 평소처럼 동작 (포지션 정리 가능). + "MARKET_GUARD_ENABLED", # 활성화 토글 (true/false). 백테스트 못하므로 운영 후 켜기 + "MARKET_GUARD_5MIN_DROP_PCT", # 5분 내 -N% 하락 시 PANIC 진입 (기본 2.0) + "MARKET_GUARD_DAILY_DROP_PCT", # 일중 누적 -N% 하락 시 PANIC 진입 (기본 3.0) + "MARKET_GUARD_RECOVERY_PCT", # PANIC 중 5분 +N% 반등 시 자동 해제 (기본 1.0) + "MARKET_GUARD_INDEX_CODE", # 감시 지수 ("0001"=KOSPI, "1001"=KOSDAQ, "both"=둘 다) + "MARKET_GUARD_POLL_SEC", # 지수 폴링 주기 초 (기본 30) + "MARKET_GUARD_INDEX_CANDLE_KEEP_MIN", # 재시작 시 ws_candles MG* 1분봉 복원 개수 (기본 20) + "MARKET_GUARD_PERSIST_STATE", # kv_store PANIC 영속 (기본 true) + # ── 전략별 후보 하드캡 (WS 구독 41 한도 보호 + 매수 체크 회전율 보장) ─ + # cond/ranking 매니저가 폭주해도 본 전략은 상위 N개만 처리. 0=무제한. + # 권장: 세 전략 합산 ≤ 30 (영구구독 + 보유 여유 11종목 확보). + "SCALP_CAND_LIMIT", "SHORT_CAND_LIMIT", "BREAKOUT_CAND_LIMIT", + "RANGE_BREAK_CAND_LIMIT", + # ── 전략별 매매 시간대 (HHMM 정수, 930=09:30, 1500=15:00) ────────── + # 꼬리(SHORT): config_short 의 TAIL_TIME_START/TAIL_TIME_END (SHORT_TIME_* 레거시 제거) + # 그 외 미설정 시 글로벌 TIME_START/TIME_END → 기본 900~1530. + "SCALP_TIME_START", "SCALP_TIME_END", + "BREAKOUT_TIME_START", "BREAKOUT_TIME_END", + # ── BREAKOUT(돌파) — HTS SCAN(널넬) + 봇 TRIGGER(엄격) ───────────────── + "BREAKOUT_LOOKBACK_MIN", "BREAKOUT_VOL_WIN", "BREAKOUT_VOL_MULT", + "BREAKOUT_PREV_CHG_MIN", "BREAKOUT_PREV_CHG_MAX", + "BREAKOUT_STOP_LOSS_PCT", "BREAKOUT_TAKE_PROFIT_PCT", "BREAKOUT_TRAIL_PCT", + "BREAKOUT_TRAIL_ARM_PCT", + "BREAKOUT_SHOULDER_MIN_HIGH_PCT", "BREAKOUT_SHOULDER_CUT_PCT", + "BREAKOUT_RATCHET_TIERS", "BREAKOUT_MAX_HOLD_BARS", + "BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", + "MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", + "TAIL_EOD_ENABLED", "TAIL_EOD_HM", + # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%) + "BREAKOUT_SL_MODE", "BREAKOUT_ATR_PERIOD", "BREAKOUT_ATR_SL_MULT", + "BREAKOUT_ATR_SL_MIN_PCT", "BREAKOUT_ATR_SL_MAX_PCT", + "BREAKOUT_GOLDEN_END_HM", + "BREAKOUT_GRID_COARSE_SHOULDER_SMIN", "BREAKOUT_GRID_COARSE_SHOULDER_CUT", + "BREAKOUT_GRID_FINE_SHOULDER_SMIN", "BREAKOUT_GRID_FINE_SHOULDER_CUT", + "BREAKOUT_GRID_FULL_SHOULDER_SMIN", "BREAKOUT_GRID_FULL_SHOULDER_CUT", + "BREAKOUT_MAX_DAILY_CHG", "BREAKOUT_MIN_PRICE", + "BREAKOUT_MIN_BAR_TRADE_VALUE_KRW", + "BREAKOUT_MIN_TURNOVER_1M_PCT", "BREAKOUT_SHARE_DENOM", "STOCK_SHARE_DENOM", + "BREAKOUT_SKIP_HTS_SCAN_DUPES", + # 가짜돌파(휩쏘) 필터 — 0=OFF. Optuna/웹 apply 시 config_breakout 컬럼 필요 + "BREAKOUT_CONFIRM_MARGIN_PCT", "BREAKOUT_BODY_MIN_PCT", + "BREAKOUT_GRID_FAST_SKIP_HTS_SCAN_DUPES", + "BREAKOUT_USE_EMA_FILTER", "BREAKOUT_EMA_FAST_PERIOD", "BREAKOUT_EMA_SLOW_PERIOD", + "BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "BREAKOUT_SLOT_MONEY", + "BREAKOUT_MAX_DAILY", "BREAKOUT_COOLDOWN_SEC", + "BREAKOUT_ENTRY_MODE", "BREAKOUT_INTRABAR_SLIPPAGE_PCT", + "BREAKOUT_TOTAL_BUDGET_KRW", "BREAKOUT_MAX_STOCKS", + "BREAKOUT_MAX_BUY_AMOUNT", "BREAKOUT_MIN_INVEST_RATIO_OF_SLOT", + # ── RANGE_BREAK(박스권 돌파) — HTS momentum SCAN + 봇 TRIGGER ───────────── + "RANGE_BREAK_BOX_LOOKBACK_MIN", "RANGE_BREAK_BOX_MAX_WIDTH_PCT", + "RANGE_BREAK_BOX_MIN_WIDTH_PCT", "RANGE_BREAK_SETUP_VOL_MAX_MULT", + "RANGE_BREAK_SETUP_BEAR_BARS_MIN", "RANGE_BREAK_VOL_MULT", "RANGE_BREAK_VOL_WIN", + "RANGE_BREAK_VOL_BASELINE_WIN", "RANGE_BREAK_BREAK_MARGIN_PCT", "RANGE_BREAK_BODY_MIN_PCT", + "RANGE_BREAK_TIME_START", "RANGE_BREAK_TIME_END_HM", + "RANGE_BREAK_STOP_LOSS_PCT", "RANGE_BREAK_TAKE_PROFIT_PCT", "RANGE_BREAK_TRAIL_PCT", + "RANGE_BREAK_TRAIL_ARM_PCT", "RANGE_BREAK_SHOULDER_MIN_HIGH_PCT", "RANGE_BREAK_SHOULDER_CUT_PCT", + "RANGE_BREAK_MAX_HOLD_BARS", "RANGE_BREAK_MAX_DAILY", "RANGE_BREAK_COOLDOWN_SEC", + "RANGE_BREAK_MAX_DAILY_CHG", "RANGE_BREAK_MIN_PRICE", "RANGE_BREAK_HIGH_CHASE_THR", + "RANGE_BREAK_USE_HIGH_CHASE_FILTER", "RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", + "RANGE_BREAK_SLOT_MONEY", + "RANGE_BREAK_MAX_BUY_AMOUNT", "RANGE_BREAK_MIN_INVEST_RATIO_OF_SLOT", + "RANGE_BREAK_FORCE_EOD_EXIT", + # ETN/ETF/스팩/우선주 자동 제외 토글 (기본 true) — 후보 매니저 결과를 한 번 더 거른다. + "EXCLUDE_NON_STOCK", + # 우선주 코드 끝자리(숫자) — name==code 일 때도 차단. KRX: 5·7·9 + 알파벳(K/L/M…) + "NON_STOCK_PREF_CODE_SUFFIXES", + "NON_STOCK_EXCLUDE_ALPHA_SUFFIX", + # ETF 등 이름 키워드(콤마). 비우면 코드 기본목록. '파워' 단독은 넣지 말 것(오탐). + "NON_STOCK_NAME_KEYWORDS", + # SCALP 일일 진입 횟수 + "SCALP_MAX_DAILY", + # ── MOMENTUM 전략 (SCALP reversal 과 분리된 독립 전략) ───────────── + # 키움 'momentum' 조건검색(E∧F∧H∧I). env 키는 MOMENTUM_* 전용. + "STRATEGY_MOMENTUM_ENABLED", + # MM 체결 알림 alias (mm_config.json channels 키) → config_momentum / config_breakout / config_updow + "KIS_MOMENTUM_MM_CHANNEL", + "KIS_BREAKOUT_MM_CHANNEL", + "KIS_RANGE_BREAK_MM_CHANNEL", + "KIS_UPDOW_MM_CHANNEL", + "MOMENTUM_UNIVERSE_SOURCE", + "RANK_MOMENTUM_SORT", "RANK_MOMENTUM_LIMIT", + "CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ", + "MOMENTUM_CAND_LIMIT", + "MOMENTUM_TIME_START", "MOMENTUM_TIME_END", + "MOMENTUM_MAX_STOCKS", + "MOMENTUM_MAX_BUY_AMOUNT", + "MOMENTUM_MAX_DAILY", + "MOMENTUM_MAX_DAILY_CHG", + "MOMENTUM_RSI_MIN", "MOMENTUM_RSI_MAX", + "MOMENTUM_VOL_MULT", "MOMENTUM_VOL_WIN", + "MOMENTUM_TIME_END_HM", + # 시가 대비 등락(%) 컷 — 파라서치 coarse 그리드 mom_max_from_open_pct 와 동일 의미 + "MOMENTUM_MAX_FROM_OPEN_PCT", "MOMENTUM_MIN_FROM_OPEN_PCT", + "MOMENTUM_SKIP_HTS_SCAN_DUPES", + "MOMENTUM_GRID_FAST_SKIP_HTS_SCAN_DUPES", + "MOMENTUM_STOP_LOSS_PCT", "MOMENTUM_TAKE_PROFIT_PCT", + "MOMENTUM_TP_MAX_PCT", + "MOMENTUM_SHOULDER_MIN_HIGH_PCT", "MOMENTUM_SHOULDER_CUT_PCT", + "MOMENTUM_MIN_DROP_RATE", + "MOMENTUM_SLOT_MONEY", + "MOMENTUM_MIN_PRICE", + "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", + "MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT", + "MOMENTUM_MIN_PROFIT_PCT", + "MOMENTUM_RATCHET_TIERS", + "MOMENTUM_TRAIL_PCT", + "MOMENTUM_TRAIL_ARM_PCT", + "MOMENTUM_MAX_HOLD_BARS", + "MOMENTUM_USE_DEFENSE_FILTERS", + "MOMENTUM_USE_HIGH_CHASE_FILTER", + "MOMENTUM_USE_DAILY_RANGE_FILTER", + "MOMENTUM_USE_EMA_FILTER", + "MOMENTUM_EMA_FAST_PERIOD", + "MOMENTUM_EMA_SLOW_PERIOD", + "MOMENTUM_USE_RSI_MAX_FILTER", + "MOMENTUM_PATTERN_BREAKOUT", + "MOMENTUM_PATTERN_PULLBACK", + "MOMENTUM_TRIGGER_E_CONFIRM", + "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", + "MOMENTUM_USE_VOL_TRIGGER", + "MOMENTUM_USE_RSI_FILTER", + "MOMENTUM_CHASE_LOOKBACK_MIN", + "MOMENTUM_PULLBACK_LOOKBACK_MIN", + "MOMENTUM_PULLBACK_MIN_PCT", + "MOMENTUM_PULLBACK_MAX_PCT", + "MOMENTUM_SETUP_VOL_MAX_MULT", + "MOMENTUM_SETUP_BEAR_BARS_MIN", + "MOMENTUM_HIGH_CHASE_THR", + "MOMENTUM_RSI_PERIOD", + "MOMENTUM_COOLDOWN_SEC", + "MOMENTUM_MIN_HOLD_SEC", + "MOMENTUM_FORCE_EOD_EXIT", + # ── UPDOW 전략 (직전봉 몸통 하락 → 다음봉 시가, kis_trader 독립 쓰레드) ─ + "UPDOW_UNIVERSE_SOURCE", + "RANK_UPDOW_SORT", "RANK_UPDOW_LIMIT", + "CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ", + "UPDOW_CAND_LIMIT", + "UPDOW_TIME_START", "UPDOW_TIME_END", + "UPDOW_REENTRY_COOLDOWN_SEC", + "UPDOW_MIN_CANDLE_LEN", "MIN_CANDLE_LEN_UPDOW", + "UPDOW_CANDLE_FETCH_N", + "UPDOW_LIVE_MIN_CANDLES", + "UPDOW_MAX_LOSS_PER_TRADE_KRW", + "UPDOW_MAX_BUY_AMOUNT", + # ── 전략별 동시 보유 종목 한도 (기존 글로벌 MAX_STOCKS 분리) ───────── + # 미설정(0/빈값) 시 글로벌 MAX_STOCKS 로 폴백 → 구버전 호환. + # 권장: 합계 ≤ MAX_STOCKS (계좌 슬롯 분산), 예: 3+2+2=7. + "SCALP_MAX_STOCKS", "SHORT_MAX_STOCKS", "BREAKOUT_MAX_STOCKS", + "RANGE_BREAK_MAX_STOCKS", "UPDOW_MAX_STOCKS", + # 전략별 시각순 포트폴리오 총 운용한도 (동시 보유 매입금 합 상한) + "SHORT_TOTAL_BUDGET_KRW", + "SCALP_TOTAL_BUDGET_KRW", + "MOMENTUM_TOTAL_BUDGET_KRW", + "BREAKOUT_TOTAL_BUDGET_KRW", + "RANGE_BREAK_TOTAL_BUDGET_KRW", + "UPDOW_TOTAL_BUDGET_KRW", + # 일일 익절 목표 (서브·전략별) — split_env_keys → config_{strategy} + "SCALP_DAILY_PROFIT_TARGET_ENABLED", + "SCALP_DAILY_PROFIT_TARGET_KRW", + "SCALP_DAILY_PROFIT_TARGET_PCT", + "SCALP_DAILY_PROFIT_HALT_NEW_BUYS", + "SCALP_DAILY_PROFIT_MODE", "SCALP_DAILY_PROFIT_TRAIL_TIERS", + "SCALP_DAILY_PROFIT_TRAIL_DROP_PCT", "SCALP_DAILY_PROFIT_TRAIL_ARM_KRW", "SCALP_DAILY_PROFIT_TRAIL_ARM_PCT", + "SHORT_DAILY_PROFIT_TARGET_ENABLED", + "SHORT_DAILY_PROFIT_TARGET_KRW", + "SHORT_DAILY_PROFIT_TARGET_PCT", + "SHORT_DAILY_PROFIT_HALT_NEW_BUYS", + "SHORT_DAILY_PROFIT_MODE", "SHORT_DAILY_PROFIT_TRAIL_TIERS", + "SHORT_DAILY_PROFIT_TRAIL_DROP_PCT", "SHORT_DAILY_PROFIT_TRAIL_ARM_KRW", "SHORT_DAILY_PROFIT_TRAIL_ARM_PCT", + "MOMENTUM_DAILY_PROFIT_TARGET_ENABLED", + "MOMENTUM_DAILY_PROFIT_TARGET_KRW", + "MOMENTUM_DAILY_PROFIT_TARGET_PCT", + "MOMENTUM_DAILY_PROFIT_HALT_NEW_BUYS", + "MOMENTUM_DAILY_PROFIT_MODE", "MOMENTUM_DAILY_PROFIT_TRAIL_TIERS", + "MOMENTUM_DAILY_PROFIT_TRAIL_DROP_PCT", "MOMENTUM_DAILY_PROFIT_TRAIL_ARM_KRW", "MOMENTUM_DAILY_PROFIT_TRAIL_ARM_PCT", + "BREAKOUT_DAILY_PROFIT_TARGET_ENABLED", + "BREAKOUT_DAILY_PROFIT_TARGET_KRW", + "BREAKOUT_DAILY_PROFIT_TARGET_PCT", + "BREAKOUT_DAILY_PROFIT_HALT_NEW_BUYS", + "BREAKOUT_DAILY_PROFIT_MODE", "BREAKOUT_DAILY_PROFIT_TRAIL_TIERS", + "BREAKOUT_DAILY_PROFIT_TRAIL_DROP_PCT", "BREAKOUT_DAILY_PROFIT_TRAIL_ARM_KRW", "BREAKOUT_DAILY_PROFIT_TRAIL_ARM_PCT", + "RANGE_BREAK_DAILY_PROFIT_TARGET_ENABLED", + "RANGE_BREAK_DAILY_PROFIT_TARGET_KRW", + "RANGE_BREAK_DAILY_PROFIT_TARGET_PCT", + "RANGE_BREAK_DAILY_PROFIT_HALT_NEW_BUYS", + "RANGE_BREAK_DAILY_PROFIT_MODE", "RANGE_BREAK_DAILY_PROFIT_TRAIL_TIERS", + "RANGE_BREAK_DAILY_PROFIT_TRAIL_DROP_PCT", "RANGE_BREAK_DAILY_PROFIT_TRAIL_ARM_KRW", "RANGE_BREAK_DAILY_PROFIT_TRAIL_ARM_PCT", + "UPDOW_DAILY_PROFIT_TARGET_ENABLED", + "UPDOW_DAILY_PROFIT_TARGET_KRW", + "UPDOW_DAILY_PROFIT_TARGET_PCT", + "UPDOW_DAILY_PROFIT_HALT_NEW_BUYS", + "UPDOW_DAILY_PROFIT_MODE", "UPDOW_DAILY_PROFIT_TRAIL_TIERS", + "UPDOW_DAILY_PROFIT_TRAIL_DROP_PCT", "UPDOW_DAILY_PROFIT_TRAIL_ARM_KRW", "UPDOW_DAILY_PROFIT_TRAIL_ARM_PCT", + "DBBAND_DAILY_PROFIT_TARGET_ENABLED", + "DBBAND_DAILY_PROFIT_TARGET_KRW", + "DBBAND_DAILY_PROFIT_TARGET_PCT", + "DBBAND_DAILY_PROFIT_HALT_NEW_BUYS", + "DBBAND_DAILY_PROFIT_MODE", "DBBAND_DAILY_PROFIT_TRAIL_TIERS", + "DBBAND_DAILY_PROFIT_TRAIL_DROP_PCT", "DBBAND_DAILY_PROFIT_TRAIL_ARM_KRW", "DBBAND_DAILY_PROFIT_TRAIL_ARM_PCT", + # ── 시세 WS 공급자 토글 (키움 시세 마이그레이션) ───────────────── + # 운영(매매 의사결정)에는 항상 KIS WS 만 사용. 키움 WS 는 검증 모드에서만 + # 백그라운드 동시 구독 → ws_price_validation 테이블에 가격 비교 기록. + # kis_only : 현행 (기본). 키움 WS 미기동. + # kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음) + # kiwoom_only : 시세를 키움으로 전환 (검증 통과 후에만 사용) + "WS_PROVIDER", + # true 시: KIS WS 는 PERMANENT_WS_CODES ∪ 보유 종목만 구독, 후보 종목은 키움 WS. + # 키움 WS 기동 필요(WS_PROVIDER=kis_with_validation 권장 또는 동시 true). + "WS_SUBSCRIBE_KIS_MINIMAL", + # 검증 비교 주기(초) — 너무 짧으면 부하, 너무 길면 표본 부족. 기본 5. + "WS_VALIDATION_INTERVAL_SEC", + # 차이 경고 임계(%). |diff| 가 이 값 이상이면 WARN 로그. 기본 0.10. + "WS_VALIDATION_DIFF_WARN_PCT", + # 키움 WS 시세는 KIS_MOCK 와 무관하게 항상 실키/실전 도메인 사용. + # 모의 도메인은 장외 데이터 제공 X / 실시간 표본도 빈약 → 시세 비교/마이그레이션 가치 없음. + # true (기본) = 실키 강제, false = KIS_MOCK 따라 자동 (디버그용). + "KIWOOM_WS_FORCE_REAL", + # 키움 WS: 그룹당 최대 구독 수·REG 전송 레이트(초기 기동 시 TRNM 건수 초과 완화) + "KIWOOM_WS_MAX_SUBSCRIPTIONS", + "KIWOOM_WS_REG_CHUNK_SIZE", + "KIWOOM_WS_REG_GAP_SEC", + "KIWOOM_WS_REG_DEBOUNCE_SEC", + # ── Updow(직전 분봉 음봉·몸통 하락 → 다음 봉 시가 매수) — 백테·웹·CLI 공통 ── + "UPDOW_BODY_DROP_MIN_PCT", + "UPDOW_TP_PCT", + "UPDOW_STOP_LOSS_PCT", + "UPDOW_ATR_USE_DYNAMIC", + "UPDOW_ATR_PERIOD", + "UPDOW_ATR_SL_MULT", + "UPDOW_ATR_TP_MULT", + "UPDOW_ATR_SL_MIN_PCT", + "UPDOW_ATR_SL_MAX_PCT", + "UPDOW_ATR_TP_MIN_PCT", + "UPDOW_ATR_TP_MAX_PCT", + "UPDOW_ATR_MULT_FLOOR", + "UPDOW_ATR_PCT_FLOOR", + "UPDOW_EXIT_PCT_FLOOR", + "UPDOW_SHOULDER_MIN_HIGH_PCT", + "UPDOW_SHOULDER_CUT_PCT", + "UPDOW_MAX_HOLD_BARS", + "UPDOW_EXIT_ON_GREEN", + "UPDOW_SLOT_MONEY", + "UPDOW_TF_MIN", + "UPDOW_GRID_BODY0", + "UPDOW_GRID_BODY1", + "UPDOW_GRID_BODY2", + "UPDOW_GRID_BODY3", + "UPDOW_GRID_TP0", + "UPDOW_GRID_TP1", + "UPDOW_GRID_TP2", + "UPDOW_GRID_TP3", + "UPDOW_GRID_TP4", + "UPDOW_GRID_SL0", + "UPDOW_GRID_SL1", + "UPDOW_GRID_SL2", + "UPDOW_GRID_SL3", + "UPDOW_GRID_HOLD0", + "UPDOW_GRID_HOLD1", + "UPDOW_GRID_HOLD2", + "UPDOW_GRID_HOLD3", + "UPDOW_GRID_HOLD4", + "UPDOW_GRID_SHOULDER_SMIN0", + "UPDOW_GRID_SHOULDER_SMIN1", + "UPDOW_GRID_SHOULDER_SMIN2", + "UPDOW_GRID_SHOULDER_CUT0", + "UPDOW_GRID_SHOULDER_CUT1", + "UPDOW_GRID_SHOULDER_CUT2", + "UPDOW_GRID_REGIME0", + "UPDOW_GRID_REGIME1", + "UPDOW_REGIME_MA_BARS", + "UPDOW_REGIME_MA_EASE_PCT", + "UPDOW_REGIME_MA_EASE_CAP", + "UPDOW_GRID_REGIME_EASE0", + "UPDOW_GRID_REGIME_EASE1", + "UPDOW_KOSPI_1MIN_PROXY_CODE", + "UPDOW_REGIME_PREFER_WS_CANDLES", + "UPDOW_REGIME_WS_CANDLE_MIN", + # ── 익절 호가 (OrderManager) — config_short 에 저장 ── + "SELL_USE_ORDERBOOK_ON_PROFIT", + "SELL_ORDERBOOK_BID_LEVELS", + "SELL_ORDERBOOK_DEPTH_MULT", + # ── 키움 WS 호가(0D) + TRIGGER 호가 필터 (전략별만 · 글로벌 ORDERBOOK_* 수치 폐기) ── + "KIWOOM_WS_ORDERBOOK_ENABLED", + "KIWOOM_WS_PROGRAM_ENABLED", + # 레거시 글로벌(읽기 이관용으로 키만 유지 · 엔진은 전략키만 사용) + "ORDERBOOK_FILTER_ENABLED", + "ORDERBOOK_MAX_SPREAD_PCT", + "ORDERBOOK_ENTRY_BID_LEVELS", + "ORDERBOOK_ENTRY_BID_DEPTH_MULT", + "ORDERBOOK_ENTRY_ASK_MAX_MULT", + "ORDERBOOK_MIN_BID_ASK_RATIO", + "ORDERBOOK_BREAKOUT_ASK_WALL_MAX_QTY", + "SCALP_ORDERBOOK_FILTER_ENABLED", + "SCALP_ORDERBOOK_MAX_SPREAD_PCT", + "SCALP_ORDERBOOK_ENTRY_BID_LEVELS", + "SCALP_ORDERBOOK_ENTRY_BID_DEPTH_MULT", + "SCALP_ORDERBOOK_ENTRY_ASK_MAX_MULT", + "SCALP_ORDERBOOK_MIN_BID_ASK_RATIO", + "MOMENTUM_ORDERBOOK_FILTER_ENABLED", + "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT", + "MOMENTUM_ORDERBOOK_ENTRY_BID_LEVELS", + "MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT", + "MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT", + "MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO", + "BREAKOUT_ORDERBOOK_FILTER_ENABLED", + "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT", + "BREAKOUT_ORDERBOOK_ENTRY_BID_LEVELS", + "BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT", + "BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT", + "BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO", + "BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY", + "TAIL_ORDERBOOK_FILTER_ENABLED", + "TAIL_ORDERBOOK_MAX_SPREAD_PCT", + "TAIL_ORDERBOOK_ENTRY_BID_LEVELS", + "TAIL_ORDERBOOK_ENTRY_BID_DEPTH_MULT", + "TAIL_ORDERBOOK_ENTRY_ASK_MAX_MULT", + "TAIL_ORDERBOOK_MIN_BID_ASK_RATIO", + # ── 키움 WS 프로그램매매(0w) + TRIGGER 프로그램 필터 ── + "PROGRAM_FILTER_ENABLED", + "PROGRAM_MIN_NET_BUY_QTY", + "PROGRAM_MIN_NET_BUY_AMT", + "PROGRAM_MAX_SELL_BUY_QTY_RATIO", + "PROGRAM_MIN_NET_DELTA_QTY", + "PROGRAM_SNAPSHOT_MAX_AGE_SEC", + "MOMENTUM_PROGRAM_FILTER_ENABLED", + "MOMENTUM_PROGRAM_MIN_NET_BUY_QTY", + "MOMENTUM_PROGRAM_MIN_NET_BUY_AMT", + "MOMENTUM_PROGRAM_MAX_SELL_BUY_QTY_RATIO", + "MOMENTUM_PROGRAM_MIN_NET_DELTA_QTY", + "MOMENTUM_PROGRAM_SNAPSHOT_MAX_AGE_SEC", + "BREAKOUT_PROGRAM_FILTER_ENABLED", + "BREAKOUT_PROGRAM_MIN_NET_BUY_QTY", + "BREAKOUT_PROGRAM_MIN_NET_BUY_AMT", + "BREAKOUT_PROGRAM_MAX_SELL_BUY_QTY_RATIO", + "BREAKOUT_PROGRAM_MIN_NET_DELTA_QTY", + "BREAKOUT_PROGRAM_SNAPSHOT_MAX_AGE_SEC", + "TAIL_PROGRAM_FILTER_ENABLED", + "TAIL_PROGRAM_MIN_NET_BUY_QTY", + "TAIL_PROGRAM_MIN_NET_BUY_AMT", + "TAIL_PROGRAM_MAX_SELL_BUY_QTY_RATIO", + "TAIL_PROGRAM_MIN_NET_DELTA_QTY", + "TAIL_PROGRAM_SNAPSHOT_MAX_AGE_SEC", +) + +# ── 전략별 config_* 테이블 분류 (config_schema.py) ───────────────────── +from config_schema import ( # noqa: E402 + CONFIG_TABLE_NAMES, + STRATEGY_ID_TO_TABLE, + _EXPLICIT_KEY_TABLE, + classify_config_key, + split_env_keys, +) + +_CONFIG_SPLIT = split_env_keys(ENV_CONFIG_KEYS) +ENV_GLOBAL_KEYS = _CONFIG_SPLIT["env_config"] +CONFIG_SCALP_KEYS = _CONFIG_SPLIT["config_scalp"] +CONFIG_SHORT_KEYS = _CONFIG_SPLIT["config_short"] +CONFIG_MOMENTUM_KEYS = _CONFIG_SPLIT["config_momentum"] +CONFIG_BREAKOUT_KEYS = _CONFIG_SPLIT["config_breakout"] +CONFIG_RANGE_BREAK_KEYS = _CONFIG_SPLIT["config_range_break"] +CONFIG_UPDOW_KEYS = _CONFIG_SPLIT["config_updow"] +CONFIG_DBBAND_KEYS = _CONFIG_SPLIT["config_dbband"] +CONFIG_TABLE_KEYS: Dict[str, Tuple[str, ...]] = { + "env_config": ENV_GLOBAL_KEYS, + "config_scalp": CONFIG_SCALP_KEYS, + "config_short": CONFIG_SHORT_KEYS, + "config_momentum": CONFIG_MOMENTUM_KEYS, + "config_breakout": CONFIG_BREAKOUT_KEYS, + "config_range_break": CONFIG_RANGE_BREAK_KEYS, + "config_updow": CONFIG_UPDOW_KEYS, + "config_dbband": CONFIG_DBBAND_KEYS, +} + + +class TradeDB: + """ + 트레이딩 봇용 MariaDB 데이터베이스 관리 클래스. + 기존 SQLite 인터페이스와 100% 호환 (db_path 인수는 무시됨). + + ※ 호출자가 매번 ``TradeDB()`` → ``db.close()`` 패턴으로 쓰므로, + DDL(`CREATE TABLE IF NOT EXISTS`) 과 INFO 로그는 **프로세스당 1회만** 실행한다. + (내부 클래스 변수 ``_tables_created`` 가드) + """ + _tables_created = False # 프로세스 내 테이블 생성 1회만 + _tables_lock = threading.RLock() # 마이그레이션 중 get_env→TradeDB 재진입 허용 + + def __init__(self, db_path="quant_bot.db"): + """ + Args: + db_path: 하위 호환용 (무시됨). MariaDB 접속 정보는 환경변수/모듈 상수 사용. + """ + self.db_path = db_path # 호환용 보존 + self.conn = _MariaDBConn() + # 마이그레이션(ws_candles RSI/EMA 컬럼 등)이 get_env_from_db → TradeDB() 재호출 시 + # 동일 인스턴스를 재사용 — 미등록이면 _tables_lock 데드락으로 기동 무음 hang. + try: + from kis_trader.utils.env import set_db + set_db(self) + except Exception: + pass + # 테이블 생성은 첫 인스턴스에서만 — 매 호출마다 21개 DDL 폭탄 방지 + with TradeDB._tables_lock: + if not TradeDB._tables_created: + self._create_tables() + logger.info( + "✅ TradeDB 초기화 완료: MariaDB %s:%s/%s", + _DB_HOST, _DB_PORT, _DB_NAME, + ) + TradeDB._tables_created = True + else: + # 이후 인스턴스는 조용히 — 같은 정보 매초 찍어 로그 오염 방지 + logger.debug( + "TradeDB 인스턴스 재생성 (테이블 스킵): %s:%s/%s", + _DB_HOST, _DB_PORT, _DB_NAME, + ) + + def _create_tables(self): + """DB 테이블 생성 (없을 경우)""" + with self.conn: + # 1. 활성 트레이딩 테이블 (현재 보유 중이거나 매수 중인 종목) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS active_trades ( + code VARCHAR(20) NOT NULL, -- 종목코드 + name VARCHAR(100) NOT NULL, -- 종목명 + strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL', -- 매매 전략 (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등) + PRIMARY KEY (code, strategy), -- 복합 PK: 같은 종목을 서로 다른 봇이 독립 보유 가능 + -- [가격 정보] + avg_buy_price DOUBLE NOT NULL, -- 평단가 + current_price DOUBLE, -- 현재가 (업데이트용) + stop_price DOUBLE, -- 손절가 + target_price REAL, -- 목표가 + max_price REAL, -- 최고가 (트레일링 스탑용) + atr_entry REAL, -- 진입 시 ATR 변동성 + + -- [수량 및 진행 상태 (분할매수용)] + target_qty INTEGER NOT NULL, -- 목표 매수 수량 + current_qty INTEGER NOT NULL,-- 현재 체결 수량 + total_invested REAL, -- 총 투입 금액 (수수료 제외) + + -- [상태 관리] + status TEXT NOT NULL, -- BUYING(매수중), HOLDING(보유중), SELLING(매도중) + buy_date TEXT NOT NULL, -- 첫 매수 시작 시간 + updated_at TEXT NOT NULL, -- 마지막 업데이트 시간 + size_class TEXT -- 대/중/소형 (매수 시점) + ) + """) + + # 2. 매매 기록 테이블 (손익 분석 & 켈리 공식용) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS trade_history ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + strategy VARCHAR(50), + buy_price DOUBLE NOT NULL, + sell_price DOUBLE NOT NULL, + qty INT NOT NULL, + profit_rate DOUBLE NOT NULL, + realized_pnl DOUBLE NOT NULL, + hold_minutes INT, + buy_date VARCHAR(30), + sell_date VARCHAR(30) NOT NULL, + sell_reason VARCHAR(200), + env_snapshot TEXT, + size_class VARCHAR(20) + ) CHARACTER SET utf8mb4 + """) + + # 3. 일일 손익 요약 테이블 (대시보드용) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS daily_summary ( + date VARCHAR(10) NOT NULL PRIMARY KEY, + start_asset DOUBLE, + end_asset DOUBLE, + total_trades INT, + win_trades INT, + total_pnl DOUBLE, + win_rate DOUBLE + ) CHARACTER SET utf8mb4 + """) + + # 4. 주문·체결 보강 테이블 (kt00007 / ka10076) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS order_execution_history ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + source VARCHAR(20) NOT NULL, + ord_no VARCHAR(30), + stk_cd VARCHAR(20), + stk_nm VARCHAR(100), + trde_tp VARCHAR(20), + ord_qty VARCHAR(20), + ord_uv VARCHAR(20), + cntr_qty VARCHAR(20), + cntr_uv VARCHAR(20), + ord_tm VARCHAR(20), + cnfm_tm VARCHAR(20), + sell_tp VARCHAR(20), + ord_dt VARCHAR(20), + raw_json TEXT, + fetched_at VARCHAR(30) NOT NULL + ) CHARACTER SET utf8mb4 + """) + + # 5. 매수 체결 이력 (일일 한도용 - '산 시점' 날짜 기준 누적) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS buy_execution_log ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + strategy VARCHAR(50) NOT NULL, + buy_date VARCHAR(10) NOT NULL, + executed_at VARCHAR(30) NOT NULL, + amount DOUBLE NOT NULL, + qty INT NOT NULL + ) CHARACTER SET utf8mb4 + """) + + # 6. 매수 후보군 테이블 (target_universe 대체) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS target_candidates ( + code VARCHAR(20) NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + score DOUBLE NOT NULL, + price DOUBLE NOT NULL, + scan_time VARCHAR(30) NOT NULL, + updated_at VARCHAR(30) NOT NULL + ) CHARACTER SET utf8mb4 + """) + + # 6-2. 매수 후보군 이력 (5분마다 쌓아서 백테스트 시 '실제 그 시각 유니버스' 사용 가능) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS target_candidates_history ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + slot_key VARCHAR(12) NOT NULL, + scan_time VARCHAR(30) NOT NULL, + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL DEFAULT '', + score DOUBLE NOT NULL DEFAULT 0, + price DOUBLE NOT NULL DEFAULT 0, + market CHAR(1) DEFAULT 'Q', + sector VARCHAR(100), + theme VARCHAR(100), + INDEX idx_slot (slot_key), + INDEX idx_scan (scan_time) + ) CHARACTER SET utf8mb4 + """) + + # 7. 종목 메타데이터 (테마·섹터·시장구분) — 스캐너가 채움, 조인 분석 용 + # market: 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF/기타 + # sector: 업종명 (KIS bstp_kor_isnm, 예: '반도체') + # theme : 주요 테마 (예: 'AI반도체', '2차전지', '원자력') + # - 직접 UPDATE 또는 별도 스크립트로 채움 + # theme_rank: 테마 내 대장주/추종주 순위 (1=핵심, 2=연관, 3=주변) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS stock_meta ( + code VARCHAR(20) NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL DEFAULT '', + market CHAR(1) NOT NULL DEFAULT 'Q', + sector_code VARCHAR(20), + sector VARCHAR(100), + theme VARCHAR(100), + theme_rank TINYINT DEFAULT 3, + updated_at VARCHAR(30) NOT NULL + ) CHARACTER SET utf8mb4 + """) + + # 8. env 공통 설정 (API·MM·인프라·전략 스위치) — 컬럼 수 축소 + gcols = ", ".join([f"`{k}` TEXT" for k in ENV_GLOBAL_KEYS]) + self.conn.execute(f""" + CREATE TABLE IF NOT EXISTS env_config ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at VARCHAR(30) NOT NULL, + {gcols} + ) CHARACTER SET utf8mb4 + """) + # 8a. 전략별 설정 테이블 (config_scalp / config_short / …) + for tbl, keys in CONFIG_TABLE_KEYS.items(): + if tbl == "env_config": + continue + scols = ", ".join([f"`{k}` TEXT" for k in keys]) + self.conn.execute(f""" + CREATE TABLE IF NOT EXISTS {tbl} ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at VARCHAR(30) NOT NULL, + {scols} + ) CHARACTER SET utf8mb4 + """) + + # 8. 키-값 저장소 (매터모스트 원격 조종: 마지막 AI 추천문, last_seen 등) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS kv_store ( + k VARCHAR(100) NOT NULL PRIMARY KEY, + v MEDIUMTEXT + ) CHARACTER SET utf8mb4 + """) + # 8b. env_config 컬럼 한도(Row size) 초과 키 — KIWOOM_WS_* · WHIPSAW_* 등 + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS env_config_ext ( + env_key VARCHAR(128) NOT NULL PRIMARY KEY, + env_value TEXT, + updated_at VARCHAR(30) NOT NULL + ) CHARACTER SET utf8mb4 + """) + + # 9. AI 분석 기록 (Butler !클로드분석/!애미분석 시 프롬프트 요약·응답 저장 → 나중에 꺼내보기) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ai_analysis_log ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at VARCHAR(30) NOT NULL, + model VARCHAR(50) NOT NULL, + context_summary TEXT, + response MEDIUMTEXT + ) CHARACTER SET utf8mb4 + """) + + # 10. WebSocket 실시간 봉 집계 (백테스트용 — CandleAggregator 배치 INSERT) + # - is_confirmed=1 인 확정 봉만 저장 (진행 중 봉은 RAM에만 존재) + # - source: 'ws'=WebSocket틱 집계, 'rest'=갭보정 REST 조회 + # - UNIQUE(code, timeframe, candle_time) → ON DUPLICATE KEY UPDATE + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_candles ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL, + timeframe TINYINT NOT NULL, + candle_time VARCHAR(12) NOT NULL, + `open` DOUBLE NOT NULL, + high DOUBLE NOT NULL, + low DOUBLE NOT NULL, + close DOUBLE NOT NULL, + volume BIGINT NOT NULL DEFAULT 0, + rsi_2 DOUBLE, + rsi_3 DOUBLE, + rsi_5 DOUBLE, + is_confirmed TINYINT NOT NULL DEFAULT 1, + source VARCHAR(10) NOT NULL DEFAULT 'ws', + updated_at VARCHAR(30) NOT NULL, + UNIQUE KEY uq_candle (code, timeframe, candle_time) + ) CHARACTER SET utf8mb4 + """) + + # 11. WebSocket 실시간 체결 틱 (후보 종목, 배치 INSERT — TickRecorder) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_ticks ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + market VARCHAR(8) NOT NULL DEFAULT 'KR', + exchange VARCHAR(16) DEFAULT NULL, + code VARCHAR(32) NOT NULL, + tick_time VARCHAR(14) NOT NULL, + price DOUBLE NOT NULL, + volume BIGINT NOT NULL DEFAULT 0, + tick_seq BIGINT DEFAULT NULL, + session VARCHAR(8) DEFAULT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'KRW', + source VARCHAR(16) NOT NULL DEFAULT 'kis', + recv_ts VARCHAR(30) NOT NULL, + KEY idx_ws_ticks_lookup (market, code, tick_time), + KEY idx_ws_ticks_recv (recv_ts) + ) CHARACTER SET utf8mb4 + """) + + # 11b. TRIGGER 호가 스냅샷 (키움 0D — 백테·파람서치 재현) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_orderbook ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + market VARCHAR(8) NOT NULL DEFAULT 'KR', + code VARCHAR(32) NOT NULL, + snap_time VARCHAR(14) NOT NULL, + best_bid BIGINT NOT NULL DEFAULT 0, + best_ask BIGINT NOT NULL DEFAULT 0, + total_bid_qty BIGINT NOT NULL DEFAULT 0, + total_ask_qty BIGINT NOT NULL DEFAULT 0, + bid_qty_l3 BIGINT NOT NULL DEFAULT 0, + ask_qty_l3 BIGINT NOT NULL DEFAULT 0, + levels_json MEDIUMTEXT, + source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0d', + recv_ts VARCHAR(30) NOT NULL, + KEY idx_ws_orderbook_lookup (market, code, snap_time), + KEY idx_ws_orderbook_recv (recv_ts) + ) CHARACTER SET utf8mb4 + """) + + # 11c. TRIGGER 프로그램매매 스냅샷 (키움 0w) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_program ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + market VARCHAR(8) NOT NULL DEFAULT 'KR', + code VARCHAR(32) NOT NULL, + snap_time VARCHAR(14) NOT NULL, + buy_qty BIGINT NOT NULL DEFAULT 0, + sell_qty BIGINT NOT NULL DEFAULT 0, + net_qty BIGINT NOT NULL DEFAULT 0, + buy_amt BIGINT NOT NULL DEFAULT 0, + sell_amt BIGINT NOT NULL DEFAULT 0, + net_amt BIGINT NOT NULL DEFAULT 0, + source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0w', + recv_ts VARCHAR(30) NOT NULL, + KEY idx_ws_program_lookup (market, code, snap_time), + KEY idx_ws_program_recv (recv_ts) + ) CHARACTER SET utf8mb4 + """) + + # 12. 종목 유통/상장주식수 (키움 ka10001 — 백테 회전율·전략 공통) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS stock_share_meta ( + code VARCHAR(20) NOT NULL PRIMARY KEY, + flo_stk BIGINT NOT NULL DEFAULT 0, + dstr_stk BIGINT NOT NULL DEFAULT 0, + dstr_rt DOUBLE NULL, + source VARCHAR(16) NOT NULL DEFAULT 'ka10001', + updated_at VARCHAR(30) NOT NULL, + KEY idx_share_updated (updated_at) + ) CHARACTER SET utf8mb4 + """) + + self._migrate_add_columns() + self._migrate_env_config_to_columns() + logger.info("📊 DB 테이블 생성/확인 완료") + + def _migrate_add_columns(self): + """기존 DB에 누락된 컬럼 추가 (한 번만) — PRAGMA → information_schema 대체""" + try: + cols = self.conn.get_columns("trade_history") + if "env_snapshot" not in cols: + self.conn.execute("ALTER TABLE trade_history ADD COLUMN env_snapshot TEXT") + logger.info("📌 trade_history.env_snapshot 컬럼 추가") + if "size_class" not in cols: + self.conn.execute("ALTER TABLE trade_history ADD COLUMN size_class VARCHAR(20)") + logger.info("📌 trade_history.size_class 컬럼 추가") + except Exception as e: + logger.debug(f"migrate trade_history: {e}") + # ── active_trades PK 복합키 마이그레이션 (code → code+strategy) ────────── + # 두 봇(SHORT/SCALP)이 같은 종목을 독립 보유 가능하도록 PK 확장. + # 신규 설치는 DDL에서 처리됨. 기존 테이블은 여기서 한 번만 ALTER. + try: + cursor = self.conn.execute(""" + SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'active_trades' + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION + """) + pk_cols = [row[0] if isinstance(row, (list, tuple)) else row['COLUMN_NAME'] + for row in cursor.fetchall()] + if 'strategy' not in pk_cols: + logger.info("⚙️ active_trades PK 복합키 마이그레이션 시작 (code → code+strategy)") + # NULL strategy → 'MANUAL' 로 채움 (NOT NULL 변경 전 필수) + self.conn.execute("UPDATE active_trades SET strategy = 'MANUAL' WHERE strategy IS NULL OR strategy = ''") + # strategy 컬럼 NOT NULL DEFAULT 'MANUAL' 로 변경 후 PK 재구성 + self.conn.execute("ALTER TABLE active_trades MODIFY COLUMN strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL'") + self.conn.execute("ALTER TABLE active_trades DROP PRIMARY KEY, ADD PRIMARY KEY (code, strategy)") + logger.info("✅ active_trades PK 복합키(code, strategy) 변환 완료") + except Exception as e: + logger.debug("active_trades PK 마이그레이션 스킵(이미 완료 또는 신규): %s", e) + + try: + cols = self.conn.get_columns("active_trades") + if "size_class" not in cols: + self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class VARCHAR(20)") + logger.info("📌 active_trades.size_class 컬럼 추가") + for c in ML_ENTRY_FEATURE_COLUMNS: + if c not in cols: + self.conn.execute(f"ALTER TABLE active_trades ADD COLUMN `{c}` DOUBLE") + logger.info(f"📌 active_trades.{c} 컬럼 추가 (ML 진입 피처)") + except Exception as e: + logger.debug(f"migrate active_trades: {e}") + try: + self.migrate_trigger_eval_columns() + except Exception as e: + logger.debug("migrate_trigger_eval_columns: %s", e) + try: + cols = self.conn.get_columns("trade_history") + for c in ML_ENTRY_FEATURE_COLUMNS: + if c not in cols: + self.conn.execute(f"ALTER TABLE trade_history ADD COLUMN `{c}` DOUBLE") + logger.info(f"📌 trade_history.{c} 컬럼 추가 (ML 진입 피처)") + except Exception as e: + logger.debug(f"migrate trade_history ML columns: {e}") + try: + self._migrate_config_table_columns() + self._migrate_strategy_keys_from_env_config() + self._migrate_short_time_to_tail_time() + except Exception as e: + logger.debug(f"migrate config tables: {e}") + try: + cols = self.conn.get_columns("ws_candles") + if "holding_peak" not in cols: + self.conn.execute( + "ALTER TABLE ws_candles ADD COLUMN holding_peak DOUBLE NULL " + "COMMENT '보유 중 트레일 고점(WS 틱 max_price 스냅샷)'" + ) + logger.info("📌 ws_candles.holding_peak 컬럼 추가") + # RSI(7,14,21)·EMA(5,9,12,15,21,34) materialized — indicator_cache 정의 기준 + try: + from kis_trader.engine.indicator_cache import ensure_ws_candles_indicator_columns + n_added = ensure_ws_candles_indicator_columns(self) + if n_added: + logger.info("📌 ws_candles materialized 지표 컬럼 %d개 추가", n_added) + except Exception as mig_ex: + logger.debug("ws_candles indicator columns migrate: %s", mig_ex) + except Exception as e: + logger.debug(f"migrate ws_candles holding_peak: {e}") + # ── target_candidates 테마/섹터/시장구분 컬럼 추가 ────────────────── + try: + cols = self.conn.get_columns("target_candidates") + for col, ddl in [ + ("market", "CHAR(1) DEFAULT 'Q'"), + ("sector", "VARCHAR(100)"), + ("theme", "VARCHAR(100)"), + ]: + if col not in cols: + self.conn.execute( + f"ALTER TABLE target_candidates ADD COLUMN `{col}` {ddl}" + ) + logger.info(f"📌 target_candidates.{col} 컬럼 추가") + except Exception as e: + logger.debug(f"migrate target_candidates theme cols: {e}") + # ── target_candidates_history (후보 이력, 백테스트용) ────────────────── + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS target_candidates_history ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + slot_key VARCHAR(12) NOT NULL, + scan_time VARCHAR(30) NOT NULL, + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL DEFAULT '', + score DOUBLE NOT NULL DEFAULT 0, + price DOUBLE NOT NULL DEFAULT 0, + market CHAR(1) DEFAULT 'Q', + sector VARCHAR(100), + theme VARCHAR(100), + INDEX idx_slot (slot_key), + INDEX idx_scan (scan_time) + ) CHARACTER SET utf8mb4 + """) + logger.info("📌 target_candidates_history 테이블 확인/생성") + except Exception as e: + logger.warning(f"migrate target_candidates_history 실패(이력 미적재 가능): {e}") + # ── ws_price_validation (KIS↔키움 시세 검증, 마이그레이션 단계용) ──── + # 5초마다 같은 종목의 KIS WS 가격과 키움 WS 가격을 비교해 한 행 INSERT. + # diff_pct = (kiwoom - kis) / kis × 100. + # 운영에는 영향 없음 (검증 모드 ON 일 때만 채워짐). 1~2주 누적 후 + # 통계 분석 → 본격 마이그레이션 결정 근거. + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_price_validation ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + ts DATETIME(3) NOT NULL, + code VARCHAR(20) NOT NULL, + kis_price DOUBLE, + kiwoom_price DOUBLE, + diff_pct DOUBLE, + kis_age_ms INT, + kiwoom_age_ms INT, + INDEX idx_ts (ts), + INDEX idx_code (code), + INDEX idx_diff (diff_pct) + ) CHARACTER SET utf8mb4 + """) + logger.info("📌 ws_price_validation 테이블 확인/생성") + except Exception as e: + logger.warning(f"migrate ws_price_validation 실패: {e}") + # ── stock_share_meta (유통주식수, 백테·라이브 공통) ───────────────── + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS stock_share_meta ( + code VARCHAR(20) NOT NULL PRIMARY KEY, + flo_stk BIGINT NOT NULL DEFAULT 0, + dstr_stk BIGINT NOT NULL DEFAULT 0, + dstr_rt DOUBLE NULL, + source VARCHAR(16) NOT NULL DEFAULT 'ka10001', + updated_at VARCHAR(30) NOT NULL, + KEY idx_share_updated (updated_at) + ) CHARACTER SET utf8mb4 + """) + logger.info("📌 stock_share_meta 테이블 확인/생성") + except Exception as e: + logger.warning(f"migrate stock_share_meta 실패: {e}") + + def _migrate_env_config_to_columns(self): + """env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전""" + try: + cols = self.conn.get_columns("env_config") + if "snapshot_json" not in cols: + return + rows = self.conn.execute( + "SELECT id, created_at, snapshot_json FROM env_config ORDER BY id" + ).fetchall() + col_defs = ", ".join([f"`{k}` TEXT" for k in ENV_CONFIG_KEYS]) + self.conn.execute(f""" + CREATE TABLE IF NOT EXISTS env_config_new ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at VARCHAR(30) NOT NULL, + {col_defs} + ) CHARACTER SET utf8mb4 + """) + key_list = ", ".join(f"`{k}`" for k in ENV_CONFIG_KEYS) + placeholders = ", ".join(["%s"] * (1 + len(ENV_CONFIG_KEYS))) + for row in rows: + snap = json.loads(row["snapshot_json"]) if row["snapshot_json"] else {} + vals = [row["created_at"]] + [snap.get(k) for k in ENV_CONFIG_KEYS] + self.conn.execute( + f"INSERT INTO env_config_new (created_at, {key_list}) VALUES ({placeholders})", + vals, + ) + self.conn.execute("DROP TABLE env_config") + self.conn.execute("ALTER TABLE env_config_new RENAME TO env_config") + logger.info("📌 env_config: snapshot_json -> 컬럼 스키마 마이그레이션 완료") + except Exception as e: + logger.debug(f"migrate env_config: {e}") + + # ============================================================ + # [CRUD] Active Trades (활성 트레이딩 관리) + # ============================================================ + + def upsert_trade(self, trade_data: Dict): + """ + 신규 매수하거나 정보 업데이트 (평단가, 수량 등) + + Args: + trade_data: 트레이드 정보 딕셔너리 + 필수: code, name, avg_buy_price, target_qty, current_qty, status + 선택: strategy, stop_price, target_price, max_price, atr_entry, total_invested + ML 학습용: entry_features (dict) 또는 rsi, volume_ratio 등 개별 키 + """ + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 기본값 설정 + code = trade_data.get('code') + if not code: + logger.error("종목코드 누락: upsert 실패") + return False + + size_class = trade_data.get('size_class') + feats = trade_data.get('entry_features') or {} + feat_vals = [] + for k in ML_ENTRY_FEATURE_COLUMNS: + v = feats.get(k) if k in feats else trade_data.get(k) + feat_vals.append(v if isinstance(v, (int, float)) else None) + cols = ", ".join(["code", "name", "strategy", "avg_buy_price", "current_price", "stop_price", "target_price", + "max_price", "atr_entry", "target_qty", "current_qty", "total_invested", + "status", "buy_date", "updated_at", "size_class"] + list(ML_ENTRY_FEATURE_COLUMNS)) + placeholders = ", ".join(["%s"] * (16 + len(ML_ENTRY_FEATURE_COLUMNS))) + # MySQL: ON DUPLICATE KEY UPDATE (excluded. → VALUES()) + updates = ( + "avg_buy_price = VALUES(avg_buy_price), current_price = VALUES(current_price), " + "stop_price = COALESCE(VALUES(stop_price), stop_price), " + "target_price = COALESCE(VALUES(target_price), target_price), " + "atr_entry = COALESCE(VALUES(atr_entry), atr_entry), " + "current_qty = VALUES(current_qty), total_invested = VALUES(total_invested), " + "max_price = GREATEST(max_price, VALUES(max_price)), " + "status = VALUES(status), updated_at = VALUES(updated_at), " + "size_class = COALESCE(VALUES(size_class), size_class)" + ) + for c in ML_ENTRY_FEATURE_COLUMNS: + updates += f", `{c}` = COALESCE(VALUES(`{c}`), `{c}`)" + sql = f""" + INSERT INTO active_trades ( + {cols} + ) VALUES ({placeholders}) + ON DUPLICATE KEY UPDATE + {updates} + """ + try: + from kis_trader.utils.strategy_ids import canonical_strategy_id + _stored_strategy = canonical_strategy_id(trade_data.get("strategy", "MANUAL")) + except Exception: + _stored_strategy = trade_data.get("strategy", "MANUAL") or "MANUAL" + + params = ( + code, + trade_data.get('name', 'Unknown'), + _stored_strategy, + trade_data.get('avg_buy_price') or trade_data.get('buy_price', 0), + trade_data.get('current_price', 0), + trade_data.get('stop_price', 0), + trade_data.get('target_price', 0), + trade_data.get('max_price', trade_data.get('buy_price', 0)), + trade_data.get('atr_at_entry') or trade_data.get('atr_entry', 0), + trade_data.get('target_qty', trade_data.get('qty', 0)), + trade_data.get('current_qty') or trade_data.get('qty', 0), + trade_data.get('total_invested', 0), + trade_data.get('status', 'HOLDING'), + trade_data.get('buy_date', now), + now, + size_class, + ) + tuple(feat_vals) + + try: + with self.conn: + self.conn.execute(sql, params) + return True + except Exception as e: + logger.error(f"❌ upsert_trade 실패 ({code}): {e}") + return False + + def get_active_trades(self, strategy_prefix: Optional[str] = None): + """ + 활성 트레이딩 목록 조회 (봇 재시작 시 사용) + + Args: + strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT'면 'SHORT%'만 + (늘림목/단타 섞임 방지) + + Returns: + {종목코드: {trade_info}} 형태의 딕셔너리 + """ + try: + if strategy_prefix: + cursor = self.conn.execute( + "SELECT * FROM active_trades WHERE strategy LIKE %s", + (strategy_prefix.strip().upper() + "%",) + ) + else: + cursor = self.conn.execute("SELECT * FROM active_trades") + rows = cursor.fetchall() + + # 기존 JSON 포맷과 호환되도록 딕셔너리 변환 + result = {} + for row in rows: + code = row['code'] + result[code] = { + 'code': code, + 'name': row['name'], + 'strategy': row['strategy'], + 'buy_price': row['avg_buy_price'], # JSON 호환 + 'avg_buy_price': row['avg_buy_price'], + 'current_price': row['current_price'], + 'stop_price': row['stop_price'], + 'target_price': row['target_price'], + 'max_price': row['max_price'], + 'atr_at_entry': row['atr_entry'], + 'qty': row['current_qty'], # JSON 호환 + 'target_qty': row['target_qty'], + 'current_qty': row['current_qty'], + 'total_invested': row['total_invested'], + 'status': row['status'], + 'buy_date': row['buy_date'], + 'updated_at': row['updated_at'], + 'size_class': row['size_class'] if 'size_class' in row.keys() else None, + } + + logger.debug(f"📂 활성 트레이드 로드: {len(result)}개") + return result + + except Exception as e: + logger.error(f"❌ get_active_trades 실패: {e}") + return {} + + def get_active_trade(self, code: str) -> Optional[Dict]: + """ + 활성 트레이딩 단일 종목 조회. + 잔고 동기화 시 DB에 저장된 평단가를 폴백용으로 사용할 때 쓴다. + """ + try: + cursor = self.conn.execute( + "SELECT * FROM active_trades WHERE code = ?", + (code,), + ) + row = cursor.fetchone() + if not row: + return None + return { + "code": row["code"], + "name": row["name"], + "strategy": row["strategy"], + "avg_buy_price": row["avg_buy_price"], + "current_price": row["current_price"], + "stop_price": row["stop_price"], + "target_price": row["target_price"], + "max_price": row["max_price"], + "atr_entry": row["atr_entry"], + "target_qty": row["target_qty"], + "current_qty": row["current_qty"], + "total_invested": row["total_invested"], + "status": row["status"], + "buy_date": row["buy_date"], + "updated_at": row["updated_at"], + } + except Exception as e: + logger.error(f"❌ get_active_trade 실패 ({code}): {e}") + return None + + def update_current_price(self, code: str, current_price: float): + """현재가 업데이트 (매도 판단용)""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + self.conn.execute( + "UPDATE active_trades SET current_price=?, updated_at=? WHERE code=?", + (current_price, now, code) + ) + except Exception as e: + logger.error(f"❌ 현재가 업데이트 실패 ({code}): {e}") + + def update_max_price(self, code: str, new_max_price: float): + """최고가 갱신 (트레일링 스탑용)""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + # 기존 max_price보다 클 때만 업데이트 + self.conn.execute( + """UPDATE active_trades + SET max_price = MAX(max_price, ?), updated_at = ? + WHERE code = ?""", + (new_max_price, now, code) + ) + except Exception as e: + logger.error(f"❌ 최고가 갱신 실패 ({code}): {e}") + + def close_trade( + self, + code: str, + sell_price: float, + sell_reason: str = "", + env_snapshot: str = None, + size_class: str = None, + strategy: str = None, + realized_pnl_override: float = None, + ): + """ + 매도 완료 처리: active_trades 삭제 -> trade_history 이동 (INSERT만, env 스냅샷 포함) + + Args: + code: 종목코드 + sell_price: 매도가 + sell_reason: 매도 사유 + env_snapshot: 매도 시점 env JSON (백테스트/대시보드용) + size_class: 대/중/소형 (매수 시점 저장값) + strategy: 봇 전략 ID (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등) + 지정 시 해당 전략 row만 삭제 (다른 봇의 동일 종목 보호). + None이면 code 단독 조회 (단일 봇 운영 환경 호환). + """ + try: + # 1. 활성 트레이드 정보 조회 (strategy 지정 시 정확히 해당 row만 조회) + if strategy: + cursor = self.conn.execute( + "SELECT * FROM active_trades WHERE code=%s AND strategy=%s", + (code, strategy), + ) + else: + cursor = self.conn.execute("SELECT * FROM active_trades WHERE code=%s", (code,)) + trade = cursor.fetchone() + + if not trade: + logger.warning(f"⚠️ close_trade: {code} 종목이 active_trades에 없음") + return False + + # 2. 손익 계산 + buy_price = trade['avg_buy_price'] + qty = trade['current_qty'] + # realized_pnl_override 가 있으면 수수료·세금 반영 순손익을 외부에서 주입 + # 없으면 내부 계산 (수수료 미포함 gross) + if realized_pnl_override is not None: + realized_pnl = realized_pnl_override + else: + realized_pnl = (sell_price - buy_price) * qty + profit_rate = (realized_pnl / (buy_price * qty) * 100) if buy_price * qty > 0 else 0 + + # 3. 보유 시간 계산 + buy_time = datetime.datetime.strptime(trade['buy_date'], '%Y-%m-%d %H:%M:%S') + sell_time = datetime.datetime.now() + hold_minutes = int((sell_time - buy_time).total_seconds() / 60) + + # size_class는 active_trades에 있으면 그대로 사용 + if size_class is None and 'size_class' in trade.keys() and trade['size_class']: + size_class = trade['size_class'] + + # 4. trade_history에 저장 — strategy 는 kis_trader canonical ID (실거래 탭·봇 ID 일치) + try: + from kis_trader.utils.strategy_ids import canonical_strategy_id + hist_strategy = canonical_strategy_id(trade.get("strategy")) + except Exception: + hist_strategy = trade.get("strategy") + + feat_vals = [trade[c] if c in trade.keys() else None for c in ML_ENTRY_FEATURE_COLUMNS] + cols_th = "code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, env_snapshot, size_class" + if ML_ENTRY_FEATURE_COLUMNS: + cols_th += ", " + ", ".join(ML_ENTRY_FEATURE_COLUMNS) + placeholders = ", ".join(["?"] * (14 + len(ML_ENTRY_FEATURE_COLUMNS))) + with self.conn: + self.conn.execute(f""" + INSERT INTO trade_history ( + {cols_th} + ) VALUES ({placeholders}) + """, ( + trade['code'], + trade['name'], + hist_strategy, + buy_price, + sell_price, + qty, + profit_rate, + realized_pnl, + hold_minutes, + trade['buy_date'], + sell_time.strftime('%Y-%m-%d %H:%M:%S'), + sell_reason, + env_snapshot, + size_class, + ) + tuple(feat_vals)) + + # 5. active_trades에서 삭제 (strategy 지정 시 해당 봇 row만 삭제) + if strategy: + self.conn.execute( + "DELETE FROM active_trades WHERE code=%s AND strategy=%s", + (code, strategy), + ) + else: + self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,)) + + logger.info(f"✅ [{trade['name']}] 매매 종료: 수익률 {profit_rate:.2f}% ({realized_pnl:+,.0f}원)") + return True + + except Exception as e: + logger.error(f"❌ close_trade 실패 ({code}): {e}") + return False + + def delete_active_trade(self, code: str, strategy: str = None): + """ + 활성 트레이드 삭제 (긴급 정리용). + strategy 지정 시 해당 봇 row만 삭제, None이면 해당 종목 전체 삭제. + """ + try: + with self.conn: + if strategy: + self.conn.execute( + "DELETE FROM active_trades WHERE code=%s AND strategy=%s", + (code, strategy), + ) + else: + self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,)) + logger.info(f"🗑️ active_trade 삭제: {code}" + (f" [{strategy}]" if strategy else "")) + return True + except Exception as e: + logger.error(f"❌ 삭제 실패 ({code}): {e}") + return False + + def insert_buy_execution( + self, + code: str, + name: str, + strategy: str, + amount: float, + qty: int, + ): + """ + 매수 체결 이력 저장 (일일 한도용). '하루' = 산 날짜(buy_date) 기준. + """ + now = datetime.datetime.now() + buy_date = now.strftime("%Y-%m-%d") + executed_at = now.strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + self.conn.execute(""" + INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (code, name, strategy, buy_date, executed_at, amount, qty)) + return True + except Exception as e: + logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}") + return False + + def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]: + """ + 해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수. + date_str: YYYY-MM-DD + Returns: + (누적 금액, 건수) + """ + try: + cursor = self.conn.execute(""" + SELECT COALESCE(SUM(amount), 0) AS total_amount, COUNT(*) AS cnt + FROM buy_execution_log + WHERE buy_date = %s AND strategy LIKE %s + """, (date_str, strategy_prefix.strip().upper() + "%")) + row = cursor.fetchone() + return (float(row["total_amount"]), int(row["cnt"])) + except Exception as e: + logger.error(f"❌ get_daily_buy_amount 실패: {e}") + return (0.0, 0) + + # ============================================================ + # [보강] 주문·체결 이력 (kt00007 / ka10076) + # ============================================================ + + def insert_order_execution( + self, source: str, row: dict, ord_dt: str = None, sell_tp: str = None, raw_json: str = None + ): + """주문·체결 1건 INSERT (보강용, 이력만 쌓음)""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute(""" + INSERT INTO order_execution_history ( + source, ord_no, stk_cd, stk_nm, trde_tp, ord_qty, ord_uv, + cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + source, + row.get('ord_no') or row.get('orig_ord_no'), + row.get('stk_cd', ''), + row.get('stk_nm', ''), + row.get('trde_tp', ''), + str(row.get('ord_qty', '') or row.get('cntr_qty', '')), + str(row.get('ord_uv', '') or row.get('ord_pric', '') or row.get('cntr_uv', '')), + str(row.get('cntr_qty', '') or row.get('cnfm_qty', '')), + str(row.get('cntr_uv', '') or row.get('cntr_pric', '')), + row.get('ord_tm', ''), + row.get('cnfm_tm', ''), + sell_tp or '', + ord_dt or '', + raw_json, + now, + )) + self.conn.commit() + return True + except Exception as e: + logger.debug(f"insert_order_execution: {e}") + return False + + # ============================================================ + # [분석] 켈리 공식 및 통계 계산 + # ============================================================ + + def calculate_half_kelly(self, recent_days: int = 30) -> float: + """ + 하프 켈리 공식 계산 (과거 매매 기록 기반) + + Args: + recent_days: 최근 N일 데이터만 사용 + + Returns: + 하프 켈리 비율 (0.0 ~ 1.0) + 예: 0.15 리턴 -> "예수금의 15%씩 배팅하는 게 최적" + """ + try: + # 최근 N일 데이터 조회 + cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=recent_days)).strftime('%Y-%m-%d') + + cursor = self.conn.execute( + "SELECT profit_rate FROM trade_history WHERE sell_date >= ? ORDER BY sell_date DESC", + (cutoff_date,) + ) + rows = cursor.fetchall() + + if len(rows) < 20: # 최소 20건 이상 필요 + logger.warning(f"⚠️ 켈리 공식: 데이터 부족 ({len(rows)}건) -> 기본값 10% 리턴") + return 0.10 + + # 승률 계산 + wins = [r['profit_rate'] for r in rows if r['profit_rate'] > 0] + losses = [r['profit_rate'] for r in rows if r['profit_rate'] <= 0] + + total_count = len(rows) + win_count = len(wins) + win_rate = win_count / total_count + loss_rate = 1.0 - win_rate + + # 손익비 계산 (평균 수익 / 평균 손실) + if not wins or not losses: + logger.warning("⚠️ 켈리 공식: 승 또는 패만 있음 -> 기본값 10%") + return 0.10 + + avg_win = sum(wins) / len(wins) + avg_loss = abs(sum(losses) / len(losses)) + + if avg_loss == 0: + return 0.50 # 손실이 0이면 최대치 + + odds = avg_win / avg_loss + + # 켈리 공식: f = (p * b - q) / b + # p=승률, b=손익비, q=패율 + kelly_fraction = ((win_rate * odds) - loss_rate) / odds + + # 하프 켈리 (안전성 확보) + half_kelly = kelly_fraction * 0.5 + + # 음수면 0 리턴 (통계적으로 지는 구조) + final_kelly = max(0.0, min(half_kelly, 0.5)) # 최대 50%로 제한 + + logger.info( + f"📊 [켈리 분석] 승률:{win_rate*100:.1f}% | 손익비:{odds:.2f} | " + f"켈리:{kelly_fraction*100:.1f}% | 하프켈리:{final_kelly*100:.1f}%" + ) + + return final_kelly + + except Exception as e: + logger.error(f"❌ 켈리 계산 실패: {e}") + return 0.10 + + def get_recent_performance(self, days: int = 7) -> Tuple[float, int, int]: + """ + 최근 N일 성과 조회 + + Returns: + (총손익, 익절횟수, 손절횟수) + """ + try: + cutoff = (datetime.datetime.now() - datetime.timedelta(days=days)).strftime('%Y-%m-%d') + + cursor = self.conn.execute( + "SELECT realized_pnl FROM trade_history WHERE sell_date >= ?", + (cutoff,) + ) + rows = cursor.fetchall() + + total_pnl = sum([r['realized_pnl'] for r in rows]) + wins = len([r for r in rows if r['realized_pnl'] > 0]) + losses = len([r for r in rows if r['realized_pnl'] <= 0]) + + return total_pnl, wins, losses + + except Exception as e: + logger.error(f"❌ 성과 조회 실패: {e}") + return 0.0, 0, 0 + + def get_trade_stats(self) -> Dict: + """전체 매매 통계""" + try: + cursor = self.conn.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, + AVG(profit_rate) as avg_profit_rate, + SUM(realized_pnl) as total_pnl + FROM trade_history + """) + row = cursor.fetchone() + + return { + 'total_trades': row['total'] or 0, + 'win_trades': row['wins'] or 0, + 'win_rate': (row['wins'] / row['total'] * 100) if row['total'] > 0 else 0, + 'avg_profit_rate': row['avg_profit_rate'] or 0, + 'total_pnl': row['total_pnl'] or 0 + } + except Exception as e: + logger.error(f"❌ 통계 조회 실패: {e}") + return {} + + # ============================================================ + # [유틸] JSON 마이그레이션 + # ============================================================ + + def migrate_from_json(self, json_data: Dict): + """ + 기존 JSON 포트폴리오를 DB로 마이그레이션 + + Args: + json_data: portfolio.json 내용 (딕셔너리) + """ + count = 0 + for code, info in json_data.items(): + trade_data = info.copy() + trade_data['code'] = code + + # 필드 매핑 (JSON -> DB) + if 'target_qty' not in trade_data: + trade_data['target_qty'] = info.get('qty', 0) + if 'current_qty' not in trade_data: + trade_data['current_qty'] = info.get('qty', 0) + if 'total_invested' not in trade_data: + trade_data['total_invested'] = info.get('buy_price', 0) * info.get('qty', 0) + if 'status' not in trade_data: + trade_data['status'] = 'HOLDING' + + if self.upsert_trade(trade_data): + count += 1 + + logger.info(f"✅ JSON -> DB 마이그레이션 완료: {count}개 종목") + return count + + # ============================================================ + # [CRUD] Target Candidates (매수 후보군 관리) + # ============================================================ + + def update_target_candidates(self, candidates: List[Dict]): + """ + 매수 후보군 업데이트 (5분마다 호출) + + Args: + candidates: [{ + 'code': '005930', 'name': '삼성전자', + 'score': 5.2, 'price': 75000, + 'market': 'K', # 선택 (없으면 stock_meta에서 조회) + 'sector': '반도체', # 선택 + 'theme': 'AI반도체' # 선택 + }, ...] + """ + try: + scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + # 5분 슬롯 키 (백테스트 유니버스 이력 조회용): 2026-03-18 15:55:21 → 202603181555 + _now = datetime.datetime.now() + slot_min = (_now.hour * 60 + _now.minute) // 5 * 5 + slot_key = _now.strftime("%Y%m%d") + "%02d%02d" % (slot_min // 60, slot_min % 60) + + # 이력 테이블 없으면 생성 시도 (마이그레이션 누락/실패 시 자동 보정) + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS target_candidates_history ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + slot_key VARCHAR(12) NOT NULL, + scan_time VARCHAR(30) NOT NULL, + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL DEFAULT '', + score DOUBLE NOT NULL DEFAULT 0, + price DOUBLE NOT NULL DEFAULT 0, + market CHAR(1) DEFAULT 'Q', + sector VARCHAR(100), + theme VARCHAR(100), + INDEX idx_slot (slot_key), + INDEX idx_scan (scan_time) + ) CHARACTER SET utf8mb4 + """) + except Exception as e: + logger.debug("target_candidates_history CREATE 확인: %s", e) + + # stock_meta 캐시: 테마/섹터가 없는 항목을 DB에서 보완 + meta_cache: Dict[str, Dict] = {} + + # 기존 데이터 전체 삭제 (5분마다 새로 갱신) + with self.conn: + self.conn.execute("DELETE FROM target_candidates") + + for item in candidates: + code = item['code'] + # market/sector/theme 없으면 stock_meta에서 조회 + market = item.get('market') or "" + sector = item.get('sector') or "" + theme = item.get('theme') or "" + if not (market and sector and theme): + if code not in meta_cache: + try: + r = self.conn.execute( + "SELECT market, sector, theme FROM stock_meta WHERE code=%s", + (code,) + ).fetchone() + meta_cache[code] = dict(r) if r else {} + except Exception: + meta_cache[code] = {} + m = meta_cache[code] + market = market or m.get("market") or "Q" + sector = sector or m.get("sector") or "" + theme = theme or m.get("theme") or "" + + self.conn.execute(""" + INSERT INTO target_candidates + (code, name, score, price, scan_time, updated_at, market, sector, theme) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + code, + item.get('name', ''), + item.get('score', 0), + item.get('price', 0), + scan_time, + scan_time, + market, + sector, + theme, + )) + # [DEPRECATED] 구봇(kiwoom_universe_scanner 등) 의 5분 슬롯 이력 적재는 제거됨. + # 신봇(kis_trader/**) 은 RankingManager/ConditionSearchManager 가 + # ``insert_condition_universe_snapshot()`` 으로 초단위 event_time 스냅샷을 + # 기록한다. 구봇이 우발적으로 실행되더라도 이력 혼선을 일으키지 않도록 + # 이 경로에서의 history 적재는 의도적으로 하지 않는다. + # (과거 코드: INSERT INTO target_candidates_history ...) + + logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)}개 (slot_key={slot_key}, 이력 적재 안함)") + return True + + except Exception as e: + logger.error(f"❌ 후보군 저장 실패: {e}") + return False + + def add_target_candidate(self, candidate: Dict): + """ + 매수 후보군 개별 추가 (통과 즉시 저장용, UPSERT 방식) + - 500개 스캔 시 시간이 오래 걸려서 통과하는 즉시 DB에 저장 + + Args: + candidate: { + 'code': '005930', 'name': '삼성전자', + 'score': 5.2, 'price': 75000, + 'market': 'K', 'sector': '반도체', 'theme': 'AI반도체' # 선택 + } + """ + try: + scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + code = candidate['code'] + market = candidate.get('market') or "" + sector = candidate.get('sector') or "" + theme = candidate.get('theme') or "" + + # 없으면 stock_meta 에서 보완 + if not (market and sector and theme): + try: + r = self.conn.execute( + "SELECT market, sector, theme FROM stock_meta WHERE code=%s", + (code,) + ).fetchone() + if r: + m = dict(r) + market = market or m.get("market") or "Q" + sector = sector or m.get("sector") or "" + theme = theme or m.get("theme") or "" + except Exception: + pass + + with self.conn: + self.conn.execute(""" + INSERT INTO target_candidates + (code, name, score, price, scan_time, updated_at, market, sector, theme) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + score = VALUES(score), + price = VALUES(price), + scan_time = VALUES(scan_time), + updated_at = VALUES(updated_at), + market = VALUES(market), + sector = VALUES(sector), + theme = VALUES(theme) + """, ( + code, + candidate.get('name', ''), + candidate.get('score', 0), + candidate.get('price', 0), + scan_time, scan_time, + market, sector, theme, + )) + + return True + + except Exception as e: + logger.debug(f"후보 개별 저장 실패({candidate.get('code', '')}): {e}") + return False + + def get_target_candidates(self) -> List[Dict]: + """ + 매수 후보군 조회 (점수 순). + stock_meta JOIN으로 테마/섹터 보완 반환. + + Returns: + [{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000, + 'market': 'K', 'sector': '반도체', 'theme': 'AI반도체'}, ...] + """ + try: + cursor = self.conn.execute(""" + SELECT + t.code, t.name, t.score, t.price, t.scan_time, + COALESCE(t.market, m.market, 'Q') AS market, + COALESCE(t.sector, m.sector, '') AS sector, + COALESCE(t.theme, m.theme, '') AS theme + FROM target_candidates t + LEFT JOIN stock_meta m ON m.code = t.code + ORDER BY t.score DESC, t.price ASC + """) + rows = cursor.fetchall() + + result = [] + for row in rows: + result.append({ + 'code': row['code'], + 'name': row['name'], + 'score': row['score'], + 'price': row['price'], + 'scan_time': row['scan_time'], + 'market': row.get('market', 'Q'), + 'sector': row.get('sector', ''), + 'theme': row.get('theme', ''), + }) + + return result + + except Exception as e: + logger.error(f"❌ 후보군 조회 실패: {e}") + return [] + + def get_universe_history_for_backtest( + self, start_ymd: str, end_ymd: str + ) -> Dict[str, List[str]]: + """ + target_candidates_history에서 기간 내 5분별 유니버스 조회. + 백테스트 시 '실제 그 시각에 봇이 보던 후보'로 검사할 때 사용. + + Args: + start_ymd: 시작일 YYYYMMDD + end_ymd: 종료일 YYYYMMDD + + Returns: + slot_key(YYYYMMDDHHMM) → 해당 슬롯 후보 코드 리스트 (점수 순 유지) + """ + try: + start_key = start_ymd + "0000" + end_key = end_ymd + "2359" + rows = self.conn.execute(""" + SELECT slot_key, code + FROM target_candidates_history + WHERE slot_key >= %s AND slot_key <= %s + ORDER BY slot_key, score DESC + """, (start_key, end_key)).fetchall() + out: Dict[str, List[str]] = {} + for r in rows: + # 백테스트 엔진 _slot_key()는 항상 str → 조회 키도 str 통일 (DB 드라이버에 따라 int 올 수 있음) + sk = str(r["slot_key"]) + if sk not in out: + out[sk] = [] + out[sk].append(r["code"]) + return out + except Exception as e: + logger.debug(f"후보 이력 조회 실패: {e}") + return {} + + # ------------------------------------------------------------------ + # stock_meta 테마/섹터 메타데이터 헬퍼 + # ------------------------------------------------------------------ + + def upsert_stock_meta( + self, + code: str, + name: str = "", + market: str = "Q", + sector_code: str = "", + sector: str = "", + theme: str = "", + theme_rank: int = 3, + ) -> bool: + """ + 종목 메타데이터 저장/갱신 (스캐너·수동 입력 모두 사용). + + Args: + code : 종목코드 (6자리) + name : 종목명 + market : 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF + sector_code: 업종코드 (KIS bstp_cls_code 등) + sector : 업종명 (예: '반도체') + theme : 테마명 (예: 'AI반도체', '2차전지') + theme_rank : 테마 내 순위 (1=핵심주, 2=연관, 3=주변) + """ + try: + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with self.conn: + self.conn.execute(""" + INSERT INTO stock_meta + (code, name, market, sector_code, sector, theme, theme_rank, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + name = IF(VALUES(name) != '', VALUES(name), name), + market = IF(VALUES(market) != '', VALUES(market), market), + sector_code = IF(VALUES(sector_code) != '', VALUES(sector_code), sector_code), + sector = IF(VALUES(sector) != '', VALUES(sector), sector), + theme = IF(VALUES(theme) != '', VALUES(theme), theme), + theme_rank = IF(VALUES(theme) != '', VALUES(theme_rank), theme_rank), + updated_at = VALUES(updated_at) + """, (code, name, market, sector_code, sector, theme, theme_rank, now)) + return True + except Exception as e: + logger.debug("upsert_stock_meta 실패(%s): %s", code, e) + return False + + def get_stock_meta(self, code: str) -> Optional[Dict]: + """종목 메타데이터 단건 조회. 없으면 None.""" + try: + row = self.conn.execute( + "SELECT * FROM stock_meta WHERE code = %s", (code,) + ).fetchone() + return dict(row) if row else None + except Exception as e: + logger.debug("get_stock_meta 실패(%s): %s", code, e) + return None + + def get_theme_momentum(self, theme: str, tf: int = 60) -> Dict: + """ + 테마 종목들의 최신 확정 봉(timeframe=tf) RSI 통계. + "이 테마가 지금 뜨겁냐 식었냐"를 숫자로 파악. + + Args: + theme: 테마명 (stock_meta.theme) + tf : 봉 단위 분 (기본 60분봉) + + Returns: + { + 'theme': 'AI반도체', + 'tf': 60, + 'count': 5, # 데이터 있는 종목 수 + 'avg_rsi3': 62.1, # 평균 RSI(3) + 'max_rsi3': 78.4, # 최고 RSI(3) + 'min_rsi3': 44.2, # 최저 RSI(3) + 'hot_count': 2, # RSI>70 (과열) 종목 수 + 'cold_count': 1, # RSI<30 (과매도) 종목 수 + } + """ + try: + row = self.conn.execute(""" + SELECT + COUNT(*) AS cnt, + AVG(w.rsi_3) AS avg_rsi, + MAX(w.rsi_3) AS max_rsi, + MIN(w.rsi_3) AS min_rsi, + SUM(w.rsi_3 > 70) AS hot_cnt, + SUM(w.rsi_3 < 30) AS cold_cnt + FROM ws_candles w + INNER JOIN stock_meta m ON m.code = w.code + WHERE m.theme = %s + AND w.timeframe = %s + AND w.is_confirmed = 1 + AND w.candle_time = ( + SELECT MAX(candle_time) + FROM ws_candles + WHERE code = w.code AND timeframe = w.timeframe + ) + """, (theme, tf)).fetchone() + + if not row or not row['cnt']: + return {'theme': theme, 'tf': tf, 'count': 0} + return { + 'theme': theme, + 'tf': tf, + 'count': int(row['cnt'] or 0), + 'avg_rsi3': round(float(row['avg_rsi'] or 0), 1), + 'max_rsi3': round(float(row['max_rsi'] or 0), 1), + 'min_rsi3': round(float(row['min_rsi'] or 0), 1), + 'hot_count': int(row['hot_cnt'] or 0), + 'cold_count':int(row['cold_cnt'] or 0), + } + except Exception as e: + logger.debug("get_theme_momentum 실패(%s, %dM): %s", theme, tf, e) + return {'theme': theme, 'tf': tf, 'count': 0} + + def get_all_theme_momentum(self, tf: int = 60) -> List[Dict]: + """ + 모든 테마의 최신 모멘텀 요약 (테마 히트맵). + 가장 뜨거운 테마부터 정렬 반환. + """ + try: + rows = self.conn.execute(""" + SELECT + m.theme, + COUNT(DISTINCT w.code) AS cnt, + AVG(w.rsi_3) AS avg_rsi, + SUM(w.rsi_3 > 70) AS hot_cnt, + SUM(w.rsi_3 < 30) AS cold_cnt + FROM ws_candles w + INNER JOIN stock_meta m ON m.code = w.code + WHERE m.theme IS NOT NULL AND m.theme != '' + AND w.timeframe = %s + AND w.is_confirmed = 1 + AND w.candle_time = ( + SELECT MAX(candle_time) + FROM ws_candles + WHERE code = w.code AND timeframe = w.timeframe + ) + GROUP BY m.theme + ORDER BY avg_rsi DESC + """, (tf,)).fetchall() + + return [{ + 'theme': r['theme'], + 'count': int(r['cnt'] or 0), + 'avg_rsi3': round(float(r['avg_rsi'] or 0), 1), + 'hot_count': int(r['hot_cnt'] or 0), + 'cold_count':int(r['cold_cnt'] or 0), + } for r in rows] + except Exception as e: + logger.debug("get_all_theme_momentum 실패: %s", e) + return [] + + def get_market_regime(self, codes: list = None, tf: int = 60) -> Dict: + """ + 영구 구독 ETF (KODEX200 등)의 최신 60분봉 RSI로 시장 방향 판단. + 스캘핑/꼬리잡기 진입 전 "지금 상승장인가?" 필터로 활용. + + Args: + codes: ETF 코드 리스트 (None=env PERMANENT_WS_CODES) + tf : 봉 단위 분 (기본 60) + + Returns: + { + 'is_bull': True, # avg_rsi > 50 → 상승 국면 + 'avg_rsi': 58.4, + 'details': [{'code': '069500', 'rsi': 61.2}, ...] + } + """ + try: + if not codes: + raw = self.conn.execute( + "SELECT PERMANENT_WS_CODES FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + perm = str(raw['PERMANENT_WS_CODES'] if raw else "") if raw else "" + codes = [c.strip() for c in perm.split(",") if c.strip()] or ["069500", "229200"] + + rows = self.conn.execute(""" + SELECT code, rsi_3 + FROM ws_candles + WHERE code IN ({}) + AND timeframe = %s + AND is_confirmed = 1 + AND candle_time = ( + SELECT MAX(candle_time) FROM ws_candles + WHERE code = ws_candles.code AND timeframe = ws_candles.timeframe + ) + """.format(",".join(["%s"] * len(codes))), (*codes, tf)).fetchall() + + details = [{'code': r['code'], 'rsi': round(float(r['rsi_3'] or 50), 1)} for r in rows] + avg_rsi = sum(d['rsi'] for d in details) / len(details) if details else 50.0 + return { + 'is_bull': avg_rsi > 50, + 'avg_rsi': round(avg_rsi, 1), + 'details': details, + } + except Exception as e: + logger.debug("get_market_regime 실패: %s", e) + return {'is_bull': True, 'avg_rsi': 50.0, 'details': []} + + def get_trades_by_date(self, date_str: str) -> List[Dict]: + """ + 특정 날짜의 매매 기록 조회 + + Args: + date_str: 날짜 (YYYYMMDD 또는 YYYY-MM-DD 모두 허용) + + Returns: + 매매 기록 리스트 + """ + try: + # YYYYMMDD(8자) → YYYY-MM-DD 변환, 이미 YYYY-MM-DD(10자)면 그대로 사용 + if len(date_str) == 8 and "-" not in date_str: + date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" + else: + date_formatted = date_str[:10] # 'YYYY-MM-DD'만 사용 (시간 부분 제거) + + cursor = self.conn.execute(""" + SELECT * FROM trade_history + WHERE DATE(sell_date) = %s + ORDER BY sell_date DESC + """, (date_formatted,)) + + rows = cursor.fetchall() + + result = [] + for row in rows: + result.append({ + 'id': row['id'], + 'code': row['code'], + 'name': row['name'], + 'strategy': row['strategy'], + 'buy_price': row['buy_price'], + 'sell_price': row['sell_price'], + 'qty': row['qty'], + 'profit_rate': row['profit_rate'], + 'realized_pnl': row['realized_pnl'], + 'hold_minutes': row['hold_minutes'], + 'buy_date': row['buy_date'], + 'sell_date': row['sell_date'], + 'sell_reason': row['sell_reason'] + }) + + return result + + except Exception as e: + logger.error(f"❌ 날짜별 조회 실패: {e}") + return [] + + # ============================================================ + # [ws_price_validation] KIS↔키움 시세 비교 검증 + # ============================================================ + + def insert_ws_price_validation( + self, + *, + code: str, + kis_price: Optional[float], + kiwoom_price: Optional[float], + kis_age_ms: Optional[int] = None, + kiwoom_age_ms: Optional[int] = None, + ) -> bool: + """단일 비교 결과 1행 INSERT. + + 둘 다 None 이면 저장 안 함. 한쪽만 있어도 저장(소스별 가용성 분석용). + diff_pct 는 둘 다 있을 때만 계산. + """ + if kis_price is None and kiwoom_price is None: + return False + diff_pct: Optional[float] = None + if kis_price not in (None, 0) and kiwoom_price is not None: + try: + diff_pct = (float(kiwoom_price) - float(kis_price)) / float(kis_price) * 100.0 + except (ValueError, ZeroDivisionError): + diff_pct = None + try: + now = datetime.datetime.now() + self.conn.execute( + "INSERT INTO ws_price_validation " + "(ts, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms) " + "VALUES (%s, %s, %s, %s, %s, %s, %s)", + (now, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms), + ) + return True + except Exception as e: + logger.debug("ws_price_validation INSERT 실패: %s", e) + return False + + def get_ws_validation_stats( + self, *, hours: int = 24, code: Optional[str] = None, + ) -> Dict[str, Any]: + """최근 N시간 검증 통계 (운영자용 분석). + + Returns: + { + "samples": 1234, + "both_present": 1100, # KIS·키움 둘 다 가격 있던 비율 + "avg_diff_pct": 0.012, + "max_abs_diff_pct": 0.45, + "stddev_diff_pct": 0.08, + "kis_only": 80, # KIS 만 가격 있던 횟수 (키움 미수신) + "kiwoom_only": 30, # 키움 만 가격 있던 횟수 + } + """ + try: + where = ["ts >= NOW() - INTERVAL %s HOUR"] + args: List[Any] = [hours] + if code: + where.append("code = %s") + args.append(code) + wsql = " AND ".join(where) + row = self.conn.execute(f""" + SELECT + COUNT(*) AS samples, + SUM(kis_price IS NOT NULL AND kiwoom_price IS NOT NULL) AS both_present, + AVG(diff_pct) AS avg_diff_pct, + MAX(ABS(diff_pct)) AS max_abs_diff_pct, + STDDEV(diff_pct) AS stddev_diff_pct, + SUM(kis_price IS NOT NULL AND kiwoom_price IS NULL) AS kis_only, + SUM(kis_price IS NULL AND kiwoom_price IS NOT NULL) AS kiwoom_only + FROM ws_price_validation + WHERE {wsql} + """, args).fetchone() + return dict(row) if row else {} + except Exception as e: + logger.debug("ws_validation_stats 조회 실패: %s", e) + return {} + + # ============================================================ + # [env_config + config_*] 관리자용 env (INSERT만 / 최신 1건 = 현재 설정) + # ============================================================ + + def _migrate_config_table_columns(self) -> None: + """config_* · env_config(글로벌) 테이블에 누락 컬럼 추가.""" + for tbl, keys in CONFIG_TABLE_KEYS.items(): + try: + cols = set(self.conn.get_columns(tbl)) + except Exception: + continue + for key in keys: + if key not in cols: + try: + self.conn.execute( + f"ALTER TABLE {tbl} ADD COLUMN `{key}` TEXT" + ) + logger.info("📌 %s.%s 컬럼 추가", tbl, key) + except Exception as e: + logger.debug("%s.%s 컬럼 추가 스킵: %s", tbl, key, e) + + def _migrate_strategy_keys_from_env_config(self) -> None: + """ + env_config 레거시 컬럼에만 있는 전략 ON/OFF·MM alias → config_* 최신 행으로 이전. + get_merged_env_snapshot() 폴백과 별도로, 전략 테이블에 명시 저장. + """ + by_table: Dict[str, Tuple[str, ...]] = {} + for k, tbl in _EXPLICIT_KEY_TABLE.items(): + if tbl not in by_table: + by_table[tbl] = () + by_table[tbl] = by_table[tbl] + (k,) + try: + env_row = self.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + if not env_row: + return + rk = env_row.keys() if hasattr(env_row, "keys") else [] + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + for tbl, keys in by_table.items(): + patch: Dict[str, str] = {} + all_keys = CONFIG_TABLE_KEYS.get(tbl, ()) + latest = self._read_latest_config_table(tbl, all_keys) + for k in keys: + if latest.get(k): + continue + if k not in rk: + continue + v = env_row[k] + if v is None or str(v).strip() == "": + continue + patch[k] = str(v) + if not patch: + continue + merged = dict(latest) + merged.update(patch) + self._insert_config_table_row(tbl, merged, all_keys, now) + logger.info("📌 %s ← env_config 값 이전: %s", tbl, list(patch.keys())) + except Exception as e: + logger.debug("strategy keys env→config migrate: %s", e) + + def _migrate_short_time_to_tail_time(self) -> None: + """ + 레거시 ``SHORT_TIME_START`` / ``SHORT_TIME_END`` → ``TAIL_TIME_*`` 이전 후 단일화. + + config_short 최신 행에 SHORT_TIME 값만 있고 TAIL_TIME 이 비어 있으면 복사 INSERT. + """ + try: + keys = CONFIG_TABLE_KEYS.get("config_short", ()) + if not keys: + return + latest = self._read_latest_config_table("config_short", keys) + st_short = str(latest.get("SHORT_TIME_START") or "").strip() + en_short = str(latest.get("SHORT_TIME_END") or "").strip() + st_tail = str(latest.get("TAIL_TIME_START") or "").strip() + en_tail = str(latest.get("TAIL_TIME_END") or "").strip() + patch: Dict[str, str] = {} + if st_short and not st_tail: + patch["TAIL_TIME_START"] = st_short + if en_short and not en_tail: + patch["TAIL_TIME_END"] = en_short + if not patch: + return + merged = dict(latest) + merged.update(patch) + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + rid = self._insert_config_table_row("config_short", merged, keys, now) + if rid: + logger.info( + "📌 config_short SHORT_TIME_* → TAIL_TIME_* 이전: %s (row id=%s)", + list(patch.keys()), rid, + ) + try: + from kis_trader.utils.env import invalidate_merged_env_cache + invalidate_merged_env_cache() + except Exception: + pass + except Exception as e: + logger.debug("SHORT_TIME→TAIL_TIME migrate: %s", e) + + def _insert_config_table_row( + self, + table: str, + snapshot: Dict[str, Any], + keys: Tuple[str, ...], + created_at: str, + ) -> Optional[int]: + """단일 config 테이블에 스냅샷 INSERT.""" + if not keys: + return None + try: + table_cols = set(self.conn.get_columns(table)) + except Exception: + return None + prev = self._read_latest_config_table(table, keys) + row_data: Dict[str, Any] = {} + for k in keys: + if k not in table_cols: + continue + v = snapshot.get(k) if k in snapshot else None + if v not in (None, ""): + row_data[k] = v + elif prev.get(k): + row_data[k] = prev[k] + if not row_data: + return None + valid = list(dict.fromkeys(row_data.keys())) + key_list = ", ".join(f"`{k}`" for k in valid) + placeholders = ", ".join(["%s"] * (1 + len(valid))) + vals = [created_at] + [row_data[k] for k in valid] + cur = self.conn.execute( + f"INSERT INTO {table} (created_at, {key_list}) VALUES ({placeholders})", + vals, + ) + return cur.lastrowid + + def _read_latest_config_table(self, table: str, keys: Tuple[str, ...]) -> Dict[str, str]: + """테이블 최신 행들에서 키별 마지막 비어 있지 않은 값 (부분 INSERT NULL 덮어쓰기 방지).""" + out: Dict[str, str] = {} + if not keys: + return out + try: + rows = self.conn.execute( + f"SELECT * FROM {table} ORDER BY id DESC LIMIT 30" + ).fetchall() + except Exception: + return out + if not rows: + return out + rk = rows[0].keys() if hasattr(rows[0], "keys") else [] + for k in keys: + if k not in rk: + continue + for row in rows: + v = row[k] + if v is not None and str(v).strip() != "": + out[k] = str(v) + break + if k not in out: + out[k] = "" + return out + + def _env_config_column_set(self) -> set: + """env_config 실제 DB 컬럼 (MariaDB row size 한도로 ENV_GLOBAL_KEYS 일부 미생성).""" + cached = getattr(self, "_env_config_cols_cache", None) + if cached is not None: + return cached + try: + cached = set(self.conn.get_columns("env_config")) + except Exception: + cached = set() + self._env_config_cols_cache = cached + return cached + + def _persist_env_config_overflow( + self, snapshot: Dict[str, Any], created_at: str, + ) -> int: + """ + env_config 테이블에 컬럼이 없는 글로벌 키 → env_config_ext UPSERT. + (Row size too large 로 ALTER 불가한 KIWOOM_WS_* · WHIPSAW_* · PROGRAM_* 등) + """ + cols = self._env_config_column_set() + saved = 0 + for k, v in (snapshot or {}).items(): + key = str(k).strip() + if not key or classify_config_key(key) != "env_config": + continue + if key in cols: + continue + if v is None: + continue + sv = str(v).strip() + if sv == "": + # 빈 문자열 = 명시적 클리어/OFF. + # ext 에 이전 값이 남아 있으면 머지 폴백에서 되살아나므로(예: + # UPDOWN_BOX_RATCHET_TIERS='' → 래칫 OFF 인데 직전 '2:1.5,5:1.0' 이 부활) + # 반드시 ext row 를 삭제해 "꺼짐"이 실제 반영되도록 한다. + try: + self.conn.execute( + "DELETE FROM env_config_ext WHERE env_key=%s", (key,) + ) + except Exception as ex: + logger.warning("env_config_ext 클리어 실패 %s: %s", key, ex) + continue + try: + self.conn.execute( + "INSERT INTO env_config_ext (env_key, env_value, updated_at) " + "VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE env_value=VALUES(env_value), " + "updated_at=VALUES(updated_at)", + (key, sv, created_at), + ) + saved += 1 + except Exception as ex: + logger.warning("env_config_ext 저장 실패 %s: %s", key, ex) + if saved: + logger.info("📌 env_config_ext overflow 저장 %d키", saved) + return saved + + def get_merged_env_snapshot(self) -> Dict[str, str]: + """env_config + config_* 최신 행 병합 flat dict (get_env_from_db 호환).""" + merged: Dict[str, str] = {} + for tbl, keys in CONFIG_TABLE_KEYS.items(): + merged.update(self._read_latest_config_table(tbl, keys)) + # 레거시 env_config(381컬럼) 에만 있는 값 — 신규 테이블에 없을 때 폴백 + try: + row = self.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + if row: + rk = row.keys() if hasattr(row, "keys") else [] + for k in ENV_CONFIG_KEYS: + if k not in merged and k in rk: + v = row[k] + if v is not None and str(v).strip() != "": + merged[k] = str(v) + except Exception: + pass + # env_config_ext — 컬럼 한도 초과 키 + 레거시 overflow + try: + ext_rows = self.conn.execute( + "SELECT env_key, env_value FROM env_config_ext" + ).fetchall() + env_cols = self._env_config_column_set() + for er in ext_rows or []: + ek = er["env_key"] if isinstance(er, dict) else er[0] + ev = er["env_value"] if isinstance(er, dict) else er[1] + if not ek: + continue + sk = str(ek) + # 전략 config_* 테이블 키는 ext 가 덮어쓰지 않는다 (전략 테이블이 우선). + # 레거시로 ext 에 남은 TAIL_*/MOMENTUM_* 등이 config_short/_momentum 값을 + # 가리던 버그 방지 — ext 는 순수 글로벌(env_config) 키 폴백 전용. + if classify_config_key(sk) != "env_config": + continue + # ext 우선: env_config 컬럼 없음 · 또는 병합값 비어 있음 + if sk not in env_cols or not str(merged.get(sk) or "").strip(): + merged[sk] = "" if ev is None else str(ev) + except Exception: + pass + return merged + + def _read_config_table_as_of( + self, table: str, keys: Tuple[str, ...], as_of: str, + ) -> Dict[str, str]: + """테이블에서 created_at <= as_of 인 행만 보고 키별 마지막 비어 있지 않은 값.""" + out: Dict[str, str] = {} + if not keys or not as_of: + return out + try: + rows = self.conn.execute( + f"SELECT * FROM {table} WHERE created_at <= %s ORDER BY id DESC LIMIT 30", + (as_of,), + ).fetchall() + except Exception: + return out + if not rows: + return out + rk = rows[0].keys() if hasattr(rows[0], "keys") else [] + for k in keys: + if k not in rk: + continue + for row in rows: + v = row[k] + if v is not None and str(v).strip() != "": + out[k] = str(v) + break + if k not in out: + out[k] = "" + return out + + def get_merged_env_snapshot_as_of(self, as_of: str) -> Dict[str, str]: + """env_config + config_* — as_of 시각(YYYY-MM-DD HH:MM:SS) 이하 최신 행 병합.""" + merged: Dict[str, str] = {} + if not as_of: + return self.get_merged_env_snapshot() + for tbl, keys in CONFIG_TABLE_KEYS.items(): + merged.update(self._read_config_table_as_of(tbl, keys, as_of)) + try: + row = self.conn.execute( + "SELECT * FROM env_config WHERE created_at <= %s ORDER BY id DESC LIMIT 1", + (as_of,), + ).fetchone() + if row: + rk = row.keys() if hasattr(row, "keys") else [] + for k in ENV_CONFIG_KEYS: + if k not in merged and k in rk: + v = row[k] + if v is not None and str(v).strip() != "": + merged[k] = str(v) + except Exception: + pass + try: + ext_rows = self.conn.execute( + "SELECT env_key, env_value FROM env_config_ext WHERE updated_at <= %s", + (as_of,), + ).fetchall() + env_cols = self._env_config_column_set() + for er in ext_rows or []: + ek = er["env_key"] if isinstance(er, dict) else er[0] + ev = er["env_value"] if isinstance(er, dict) else er[1] + if not ek: + continue + sk = str(ek) + if classify_config_key(sk) != "env_config": + continue + if sk not in env_cols or not str(merged.get(sk) or "").strip(): + merged[sk] = "" if ev is None else str(ev) + except Exception: + pass + return merged + + def list_env_change_times_in_range( + self, start_ymd: str, end_ymd: str, + ) -> List[str]: + """구간 내 config_* / env_config INSERT 시각(created_at) 목록 — 오름차순.""" + sy = str(start_ymd or "")[:8] + ey = str(end_ymd or "")[:8] + if len(sy) < 8 or len(ey) < 8: + return [] + start_bound = f"{sy[:4]}-{sy[4:6]}-{sy[6:8]} 00:00:00" + end_bound = f"{ey[:4]}-{ey[4:6]}-{ey[6:8]} 23:59:59" + found: set = set() + found.add(start_bound) + for tbl in CONFIG_TABLE_KEYS: + try: + rows = self.conn.execute( + f"SELECT DISTINCT created_at FROM {tbl} " + "WHERE created_at >= %s AND created_at <= %s ORDER BY created_at ASC", + (start_bound, end_bound), + ).fetchall() + except Exception: + continue + for row in rows or []: + ca = row["created_at"] if isinstance(row, dict) else row[0] + if ca: + found.add(str(ca)) + return sorted(found) + + def insert_env_snapshot(self, snapshot) -> Optional[int]: + """ + env 설정 INSERT — 테이블별 분리 저장. + env_config(공통) + config_scalp/short/momentum/breakout/updow. + Returns: env_config 행 id (없으면 마지막 성공 config_* id) + """ + try: + if isinstance(snapshot, str): + snapshot = json.loads(snapshot) if snapshot else {} + if not isinstance(snapshot, dict): + return None + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + primary_id: Optional[int] = None + for tbl, keys in CONFIG_TABLE_KEYS.items(): + rid = self._insert_config_table_row(tbl, snapshot, keys, now) + if rid and tbl == "env_config": + primary_id = rid + elif rid and primary_id is None: + primary_id = rid + if primary_id is None: + logger.error("config INSERT: 모든 테이블 저장 실패") + return None + ext_n = self._persist_env_config_overflow(snapshot, now) + logger.info( + "📌 env 스냅샷 저장 (env_config id=%s, 테이블 %d개, ext+%d)", + primary_id, + len(CONFIG_TABLE_KEYS), + ext_n, + ) + try: + from kis_trader.utils.env import invalidate_merged_env_cache + invalidate_merged_env_cache() + except Exception: + pass + return primary_id + except Exception as e: + logger.error(f"❌ env/config INSERT 실패: {e}") + return None + + def get_latest_env(self) -> Optional[Dict]: + """ + 최신 env — env_config + config_* 병합 snapshot. + Returns: + {"id": int, "created_at": str, "snapshot": dict} 또는 None + """ + try: + row = self.conn.execute( + "SELECT id, created_at FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + merged = self.get_merged_env_snapshot() + if not merged and not row: + return None + return { + "id": int(row["id"]) if row else 0, + "created_at": row["created_at"] if row else "", + "snapshot": merged, + } + except Exception as e: + logger.error(f"❌ env 최신 조회 실패: {e}") + return None + + def get_strategy_config_snapshot(self, strategy_id: str) -> Dict[str, str]: + """전략 ID(SCALP/SHORT/…) 해당 config_* 테이블만 조회.""" + tbl = STRATEGY_ID_TO_TABLE.get((strategy_id or "").upper()) + if not tbl: + return {} + keys = CONFIG_TABLE_KEYS.get(tbl, ()) + snap = self._read_latest_config_table(tbl, keys) + if snap: + return snap + return { + k: v + for k, v in self.get_merged_env_snapshot().items() + if classify_config_key(k) == tbl + } + + # ============================================================ + # [kv_store] 매터모스트 원격 조종용 키-값 (마지막 AI 추천, last_seen 등) + # ============================================================ + + def get_kv(self, key: str) -> Optional[str]: + """kv_store에서 값 조회. 없으면 None.""" + try: + row = self.conn.execute("SELECT v FROM kv_store WHERE k = ?", (key,)).fetchone() + return row["v"] if row else None + except Exception as e: + logger.debug(f"get_kv 실패: {e}") + return None + + def set_kv(self, key: str, value: str) -> bool: + """kv_store에 값 저장 (REPLACE INTO = INSERT OR REPLACE MySQL 버전).""" + try: + self.conn.execute("REPLACE INTO kv_store (k, v) VALUES (%s, %s)", (key, value)) + return True + except Exception as e: + logger.error(f"❌ set_kv 실패: {e}") + return False + + def set_last_ai_recommendations(self, text: str) -> bool: + """마지막 AI 수치 추천문 저장 (!적용 시 사용).""" + return self.set_kv("last_ai_recommendations", text) + + def get_last_ai_recommendations(self) -> Optional[str]: + """마지막 AI 수치 추천문 조회. 없으면 None.""" + return self.get_kv("last_ai_recommendations") + + # ------------------------------------------------------------------ + # [ai_analysis_log] Butler 분석 기록 (프롬프트 요약·응답 저장/조회) + # ------------------------------------------------------------------ + def insert_ai_analysis_log(self, model: str, context_summary: str, response: str) -> Optional[int]: + """AI 분석 한 건 저장. model=claude|gemini, context_summary=현재상태 요약, response=AI 전체 응답.""" + try: + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cur = self.conn.execute( + "INSERT INTO ai_analysis_log (created_at, model, context_summary, response) VALUES (%s, %s, %s, %s)", + (now, model, (context_summary or "")[:2000], response or ""), + ) + return cur.lastrowid + except Exception as e: + logger.error("insert_ai_analysis_log 실패: %s", e) + return None + + def get_ai_analysis_log_list(self, limit: int = 10) -> list: + """최근 N건 목록. 각 항목: id, created_at, model, context_summary, response(앞 400자).""" + try: + rows = self.conn.execute( + """ + SELECT id, created_at, model, context_summary, response + FROM ai_analysis_log + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + out = [] + for r in rows: + resp = (r["response"] or "")[:400] + if len(r["response"] or "") > 400: + resp += "..." + out.append({"id": r["id"], "created_at": r["created_at"], "model": r["model"], + "context_summary": r["context_summary"], "response_preview": resp}) + return out + except Exception as e: + logger.error("get_ai_analysis_log_list 실패: %s", e) + return [] + + def get_ai_analysis_log_by_id(self, log_id: int) -> Optional[dict]: + """id로 전체 한 건 조회. 없으면 None.""" + try: + row = self.conn.execute( + "SELECT id, created_at, model, context_summary, response FROM ai_analysis_log WHERE id = ?", + (log_id,), + ).fetchone() + if not row: + return None + return {"id": row["id"], "created_at": row["created_at"], "model": row["model"], + "context_summary": row["context_summary"], "response": row["response"]} + except Exception as e: + logger.error("get_ai_analysis_log_by_id 실패: %s", e) + return None + + # ================================================================== + # ws_candles 헬퍼 (kis_scalping_ver1 전용) + # ================================================================== + + def upsert_ws_candle(self, code: str, timeframe: int, candle_time: str, + open_: float, high: float, low: float, close: float, + volume: int, is_confirmed: int = 0, source: str = "ws", + rsi_2: float = None, rsi_3: float = None, rsi_5: float = None, + holding_peak: float = None): + """ + 봉 1개를 INSERT OR REPLACE 로 저장. + - 진행 중인 봉(is_confirmed=0): 틱마다 close/high/low/volume 갱신 + - 확정 봉(is_confirmed=1): RSI 계산 후 최종 저장 + - holding_peak: SHORT 보유 중 WS 트레일 고점 (백테 실매 정합용, 선택) + """ + now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + self.conn.execute(""" + INSERT INTO ws_candles + (code, timeframe, candle_time, open, high, low, close, volume, + rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(code, timeframe, candle_time) DO UPDATE SET + high = MAX(high, excluded.high), + low = MIN(low, excluded.low), + close = excluded.close, + volume = excluded.volume, + rsi_2 = COALESCE(excluded.rsi_2, rsi_2), + rsi_3 = COALESCE(excluded.rsi_3, rsi_3), + rsi_5 = COALESCE(excluded.rsi_5, rsi_5), + is_confirmed = excluded.is_confirmed, + source = excluded.source, + holding_peak = MAX(COALESCE(holding_peak, 0), COALESCE(excluded.holding_peak, 0)), + updated_at = excluded.updated_at + """, (code, timeframe, candle_time, open_, high, low, close, volume, + rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, now_str)) + except Exception as e: + logger.error("upsert_ws_candle 실패(%s): %s", code, e) + + def get_ws_candles(self, code: str, timeframe: int, limit: int = 100, + confirmed_only: bool = False) -> list: + """ + 최근 N개 봉 리스트 반환 (오래된 순 → 최신 순). + confirmed_only=True 면 확정 봉만 반환 (RSI 계산용). + """ + try: + cond = "AND is_confirmed = 1" if confirmed_only else "" + rows = self.conn.execute(f""" + SELECT candle_time, open, high, low, close, volume, + rsi_2, rsi_3, rsi_5, is_confirmed, source + FROM ws_candles + WHERE code = ? AND timeframe = ? {cond} + ORDER BY candle_time DESC + LIMIT ? + """, (code, timeframe, limit)).fetchall() + # 최신→오래된 순으로 왔으므로 역정렬해서 반환 (오래된→최신) + result = [dict(r) for r in rows] + result.reverse() + return result + except Exception as e: + logger.error("get_ws_candles 실패(%s): %s", code, e) + return [] + + def get_latest_ws_candle(self, code: str, timeframe: int) -> Optional[dict]: + """가장 최신 봉 1개 반환 (진행 중 봉 포함).""" + try: + row = self.conn.execute(""" + SELECT candle_time, open, high, low, close, volume, + rsi_2, rsi_3, rsi_5, is_confirmed + FROM ws_candles + WHERE code = ? AND timeframe = ? + ORDER BY candle_time DESC LIMIT 1 + """, (code, timeframe)).fetchone() + return dict(row) if row else None + except Exception as e: + logger.error("get_latest_ws_candle 실패(%s): %s", code, e) + return None + + def get_latest_confirmed_ws_candle(self, code: str, timeframe: int) -> Optional[dict]: + """가장 최신 확정 봉 1개 반환 (RSI 포함).""" + try: + row = self.conn.execute(""" + SELECT candle_time, open, high, low, close, volume, + rsi_2, rsi_3, rsi_5 + FROM ws_candles + WHERE code = ? AND timeframe = ? AND is_confirmed = 1 + ORDER BY candle_time DESC LIMIT 1 + """, (code, timeframe)).fetchone() + return dict(row) if row else None + except Exception as e: + 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일 이상 지난 봉 삭제).""" + cutoff = (datetime.datetime.now() - datetime.timedelta(days=keep_days)).strftime("%Y%m%d%H%M") + try: + with self.conn: + self.conn.execute( + "DELETE FROM ws_candles WHERE candle_time < ?", (cutoff,) + ) + except Exception as e: + logger.error("cleanup_old_ws_candles 실패: %s", e) + + # ================================================================== + # ws_ticks — 실시간 체결 틱 (TickRecorder 배치 INSERT) + # ================================================================== + + def ensure_ws_ticks_table(self) -> None: + """런타임 테이블 보장 (구 DB 마이그레이션).""" + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_ticks ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + market VARCHAR(8) NOT NULL DEFAULT 'KR', + exchange VARCHAR(16) DEFAULT NULL, + code VARCHAR(32) NOT NULL, + tick_time VARCHAR(14) NOT NULL, + price DOUBLE NOT NULL, + volume BIGINT NOT NULL DEFAULT 0, + tick_seq BIGINT DEFAULT NULL, + session VARCHAR(8) DEFAULT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'KRW', + source VARCHAR(16) NOT NULL DEFAULT 'kis', + recv_ts VARCHAR(30) NOT NULL, + KEY idx_ws_ticks_lookup (market, code, tick_time), + KEY idx_ws_ticks_recv (recv_ts) + ) CHARACTER SET utf8mb4 + """) + except Exception as e: + logger.debug("ensure_ws_ticks_table: %s", e) + + def insert_ws_ticks_batch(self, batch: List[Dict]) -> int: + """체결 틱 배치 append INSERT (중복 키 없음 — 이벤트 로그).""" + if not batch: + return 0 + rows = [] + for it in batch: + rows.append(( + (it.get("market") or "KR")[:8], + (it.get("exchange") or None), + (it.get("code") or "")[:32], + (it.get("tick_time") or "")[:14], + float(it.get("price") or 0), + int(it.get("volume") or 0), + it.get("tick_seq"), + (it.get("session") or None), + (it.get("currency") or "KRW")[:8], + (it.get("source") or "kis")[:16], + it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + )) + try: + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + placeholders = ", ".join([ph] * len(rows)) + flat: List[Any] = [] + for row in rows: + flat.extend(row) + self.conn.execute( + f""" + INSERT INTO ws_ticks + (market, exchange, code, tick_time, price, volume, + tick_seq, session, currency, source, recv_ts) + VALUES {placeholders} + """, + tuple(flat), + ) + return len(rows) + except Exception as e: + logger.error("insert_ws_ticks_batch 실패: %s", e) + return 0 + + def get_ws_ticks( + self, + code: str, + *, + market: str = "KR", + start_tick_time: str = "", + end_tick_time: str = "", + limit: int = 5000, + ) -> List[Dict]: + """백테·분석용 틱 조회 (오래된→최신).""" + code = (code or "").strip() + if not code: + return [] + limit = max(1, min(int(limit), 500000)) + try: + cond = "market=%s AND code=%s" + params: List[Any] = [(market or "KR")[:8], code[:32]] + if start_tick_time: + cond += " AND tick_time >= %s" + params.append(start_tick_time[:14]) + if end_tick_time: + cond += " AND tick_time <= %s" + params.append(end_tick_time[:14]) + rows = self.conn.execute( + f""" + SELECT market, exchange, code, tick_time, price, volume, + tick_seq, session, currency, source, recv_ts + FROM ws_ticks + WHERE {cond} + ORDER BY tick_time ASC + LIMIT %s + """, + (*params, limit), + ).fetchall() + return [dict(r) for r in rows] + except Exception as e: + logger.error("get_ws_ticks 실패(%s): %s", code, e) + return [] + + def cleanup_old_ws_ticks(self, keep_days: int = 7) -> None: + """recv_ts 기준 오래된 틱 삭제.""" + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute( + "DELETE FROM ws_ticks WHERE recv_ts < %s", (cutoff,) + ) + logger.info("🧹 ws_ticks 정리 완료 (recv_ts < %s)", cutoff) + except Exception as e: + logger.error("cleanup_old_ws_ticks 실패: %s", e) + + # ================================================================== + # ws_orderbook / ws_program — TRIGGER 스냅샷 (TriggerSnapshotRecorder) + # ================================================================== + + def ensure_ws_orderbook_table(self) -> None: + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_orderbook ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + market VARCHAR(8) NOT NULL DEFAULT 'KR', + code VARCHAR(32) NOT NULL, + snap_time VARCHAR(14) NOT NULL, + best_bid BIGINT NOT NULL DEFAULT 0, + best_ask BIGINT NOT NULL DEFAULT 0, + total_bid_qty BIGINT NOT NULL DEFAULT 0, + total_ask_qty BIGINT NOT NULL DEFAULT 0, + bid_qty_l3 BIGINT NOT NULL DEFAULT 0, + ask_qty_l3 BIGINT NOT NULL DEFAULT 0, + levels_json MEDIUMTEXT, + source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0d', + recv_ts VARCHAR(30) NOT NULL, + KEY idx_ws_orderbook_lookup (market, code, snap_time), + KEY idx_ws_orderbook_recv (recv_ts) + ) CHARACTER SET utf8mb4 + """) + except Exception as e: + logger.debug("ensure_ws_orderbook_table: %s", e) + + def ensure_ws_program_table(self) -> None: + try: + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS ws_program ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + market VARCHAR(8) NOT NULL DEFAULT 'KR', + code VARCHAR(32) NOT NULL, + snap_time VARCHAR(14) NOT NULL, + buy_qty BIGINT NOT NULL DEFAULT 0, + sell_qty BIGINT NOT NULL DEFAULT 0, + net_qty BIGINT NOT NULL DEFAULT 0, + buy_amt BIGINT NOT NULL DEFAULT 0, + sell_amt BIGINT NOT NULL DEFAULT 0, + net_amt BIGINT NOT NULL DEFAULT 0, + source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0w', + recv_ts VARCHAR(30) NOT NULL, + KEY idx_ws_program_lookup (market, code, snap_time), + KEY idx_ws_program_recv (recv_ts) + ) CHARACTER SET utf8mb4 + """) + except Exception as e: + logger.debug("ensure_ws_program_table: %s", e) + + def migrate_trigger_eval_columns(self) -> None: + """ws_orderbook / ws_program — 판정 스냅샷 메타 컬럼 (filter_eval).""" + for table in ("ws_orderbook", "ws_program"): + try: + cols = self.conn.get_columns(table) + except Exception: + continue + if "strategy" not in cols: + self.conn.execute( + f"ALTER TABLE {table} ADD COLUMN strategy VARCHAR(16) DEFAULT NULL" + ) + logger.info("📌 %s.strategy 컬럼 추가", table) + if "reject_code" not in cols: + self.conn.execute( + f"ALTER TABLE {table} ADD COLUMN reject_code VARCHAR(64) DEFAULT NULL" + ) + logger.info("📌 %s.reject_code 컬럼 추가", table) + if "reject_msg" not in cols: + self.conn.execute( + f"ALTER TABLE {table} ADD COLUMN reject_msg VARCHAR(255) DEFAULT NULL" + ) + logger.info("📌 %s.reject_msg 컬럼 추가", table) + if "eval_price" not in cols: + self.conn.execute( + f"ALTER TABLE {table} ADD COLUMN eval_price BIGINT NOT NULL DEFAULT 0" + ) + logger.info("📌 %s.eval_price 컬럼 추가", table) + + def insert_ws_orderbook_eval_batch(self, batch: List[Dict]) -> int: + """판정 시점 호가 스냅샷 INSERT (filter_eval).""" + if not batch: + return 0 + rows = [] + for it in batch: + rows.append(( + (it.get("market") or "KR")[:8], + (it.get("code") or "")[:32], + (it.get("snap_time") or "")[:14], + int(it.get("best_bid") or 0), + int(it.get("best_ask") or 0), + int(it.get("total_bid_qty") or 0), + int(it.get("total_ask_qty") or 0), + int(it.get("bid_qty_l3") or 0), + int(it.get("ask_qty_l3") or 0), + it.get("levels_json") or "{}", + (it.get("source") or "filter_eval")[:16], + it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + (it.get("strategy") or "")[:16] or None, + (it.get("reject_code") or "")[:64] or None, + (it.get("reject_msg") or "")[:255] or None, + int(it.get("eval_price") or 0), + )) + try: + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + placeholders = ", ".join([ph] * len(rows)) + flat: List[Any] = [] + for row in rows: + flat.extend(row) + self.conn.execute( + f""" + INSERT INTO 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, strategy, reject_code, reject_msg, eval_price) + VALUES {placeholders} + """, + tuple(flat), + ) + return len(rows) + except Exception as e: + logger.error("insert_ws_orderbook_eval_batch 실패: %s", e) + return self.insert_ws_orderbook_batch(batch) + + def insert_ws_program_eval_batch(self, batch: List[Dict]) -> int: + """판정 시점 프로그램 스냅샷 INSERT (filter_eval).""" + if not batch: + return 0 + rows = [] + for it in batch: + rows.append(( + (it.get("market") or "KR")[:8], + (it.get("code") or "")[:32], + (it.get("snap_time") or "")[:14], + int(it.get("buy_qty") or 0), + int(it.get("sell_qty") or 0), + int(it.get("net_qty") or 0), + int(it.get("buy_amt") or 0), + int(it.get("sell_amt") or 0), + int(it.get("net_amt") or 0), + (it.get("source") or "filter_eval")[:16], + it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + (it.get("strategy") or "")[:16] or None, + (it.get("reject_code") or "")[:64] or None, + (it.get("reject_msg") or "")[:255] or None, + int(it.get("eval_price") or 0), + )) + try: + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + placeholders = ", ".join([ph] * len(rows)) + flat: List[Any] = [] + for row in rows: + flat.extend(row) + self.conn.execute( + f""" + INSERT INTO ws_program + (market, code, snap_time, buy_qty, sell_qty, net_qty, + buy_amt, sell_amt, net_amt, source, recv_ts, + strategy, reject_code, reject_msg, eval_price) + VALUES {placeholders} + """, + tuple(flat), + ) + return len(rows) + except Exception as e: + logger.error("insert_ws_program_eval_batch 실패: %s", e) + return self.insert_ws_program_batch(batch) + + def cleanup_old_ws_orderbook_eval(self, keep_days: int = 7) -> None: + """filter_eval 호가 스냅만 정리.""" + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute( + "DELETE FROM ws_orderbook WHERE source = 'filter_eval' AND recv_ts < %s", + (cutoff,), + ) + logger.info("🧹 ws_orderbook filter_eval 정리 완료 (recv_ts < %s)", cutoff) + except Exception as e: + logger.error("cleanup_old_ws_orderbook_eval 실패: %s", e) + + def cleanup_old_ws_program_eval(self, keep_days: int = 7) -> None: + """filter_eval 프로그램 스냅만 정리.""" + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute( + "DELETE FROM ws_program WHERE source = 'filter_eval' AND recv_ts < %s", + (cutoff,), + ) + logger.info("🧹 ws_program filter_eval 정리 완료 (recv_ts < %s)", cutoff) + except Exception as e: + logger.error("cleanup_old_ws_program_eval 실패: %s", e) + + def insert_ws_orderbook_batch(self, batch: List[Dict]) -> int: + if not batch: + return 0 + rows = [] + for it in batch: + rows.append(( + (it.get("market") or "KR")[:8], + (it.get("code") or "")[:32], + (it.get("snap_time") or "")[:14], + int(it.get("best_bid") or 0), + int(it.get("best_ask") or 0), + int(it.get("total_bid_qty") or 0), + int(it.get("total_ask_qty") or 0), + int(it.get("bid_qty_l3") or 0), + int(it.get("ask_qty_l3") or 0), + it.get("levels_json") or "{}", + (it.get("source") or "kiwoom_0d")[:16], + it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + )) + try: + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + placeholders = ", ".join([ph] * len(rows)) + flat: List[Any] = [] + for row in rows: + flat.extend(row) + self.conn.execute( + f""" + INSERT INTO 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 {placeholders} + """, + tuple(flat), + ) + return len(rows) + except Exception as e: + logger.error("insert_ws_orderbook_batch 실패: %s", e) + return 0 + + def insert_ws_program_batch(self, batch: List[Dict]) -> int: + if not batch: + return 0 + rows = [] + for it in batch: + rows.append(( + (it.get("market") or "KR")[:8], + (it.get("code") or "")[:32], + (it.get("snap_time") or "")[:14], + int(it.get("buy_qty") or 0), + int(it.get("sell_qty") or 0), + int(it.get("net_qty") or 0), + int(it.get("buy_amt") or 0), + int(it.get("sell_amt") or 0), + int(it.get("net_amt") or 0), + (it.get("source") or "kiwoom_0w")[:16], + it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + )) + try: + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + placeholders = ", ".join([ph] * len(rows)) + flat: List[Any] = [] + for row in rows: + flat.extend(row) + self.conn.execute( + f""" + INSERT INTO ws_program + (market, code, snap_time, buy_qty, sell_qty, net_qty, + buy_amt, sell_amt, net_amt, source, recv_ts) + VALUES {placeholders} + """, + tuple(flat), + ) + return len(rows) + except Exception as e: + logger.error("insert_ws_program_batch 실패: %s", e) + return 0 + + def cleanup_old_ws_orderbook(self, keep_days: int = 7) -> None: + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute( + "DELETE FROM ws_orderbook WHERE recv_ts < %s", (cutoff,) + ) + logger.info("🧹 ws_orderbook 정리 완료 (recv_ts < %s)", cutoff) + except Exception as e: + logger.error("cleanup_old_ws_orderbook 실패: %s", e) + + def cleanup_old_ws_program(self, keep_days: int = 7) -> None: + if keep_days <= 0: + return + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=keep_days) + ).strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute( + "DELETE FROM ws_program WHERE recv_ts < %s", (cutoff,) + ) + logger.info("🧹 ws_program 정리 완료 (recv_ts < %s)", cutoff) + except Exception as e: + logger.error("cleanup_old_ws_program 실패: %s", e) + + def close(self): + """DB 연결 종료 (호출자가 매번 close 하므로 로그는 DEBUG 로 강등).""" + if self.conn: + self.conn.close() + logger.debug("🔒 DB 연결 종료") diff --git a/deploy/kis_error_watch_mm.service b/deploy/kis_error_watch_mm.service new file mode 100644 index 0000000..46cc011 --- /dev/null +++ b/deploy/kis_error_watch_mm.service @@ -0,0 +1,15 @@ +[Unit] +Description=KIS Error Watch → Mattermost (journalctl -f) +After=network.target kis_trader_main.service +Wants=kis_trader_main.service + +[Service] +User=hoon +WorkingDirectory=/home/hoon/kis_bot +ExecStart=/home/hoon/kis_bot/.venv/bin/python -u scripts/kis_error_watch_mm.py +Environment=PYTHONUNBUFFERED=1 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/docs/BREAKOUT_LIVE_BT_QA.md b/docs/BREAKOUT_LIVE_BT_QA.md new file mode 100644 index 0000000..49f59cb --- /dev/null +++ b/docs/BREAKOUT_LIVE_BT_QA.md @@ -0,0 +1,122 @@ +# 돌파 실매↔백테 괴리 Q&A (2026-07-16 기준) + +> **패치 적용 완료 (2026-07-17).** 실매 봇은 재시작하지 않음. 백테 웹만 재시작. + +--- + +## 1번 — 웜업 넣으면 되나? + +**네 → 적용됨.** + +- env: `BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS` (기본 **50**, DB 시드) +- `prepend_breakout_candle_warmup` — 기간 시작 전 1분봉 prepend +- 포트폴리오 `all_times`는 `_backtest_period_start_key` 이후만 (전일 시계 오염 방지) +- 모멘텀 `MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS`와 동일 계열 + +--- + +## 2번 — 유니버스 sticky/grace + +**실매 따라가기 → 적용됨.** + +### 동작 + +- 공통 `resolve_universe_exit_debounce_sec()` → 기본값 = `CONDITION_EXIT_GRACE_SEC`(운영 **120**) +- 돌파/꼬리/모멘텀 타임라인 `debounce_sec`에 연결 +- 전략별 오버라이드: `BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC` / `TAIL_*` / `MOMENTUM_*` (있으면 우선, `0`=OFF) + +### 09:07 1종 스냅샷 점검 결과 + +| event_time | n | +|---|---| +| 03:16 | 22 | +| 09:07 | **1** (248170만) | +| 09:11 | 2 | + +- history는 **effective 유니버스 저장** 설계 → 09:07에 1종이면 그 tick의 live effective도 1종으로 보는 게 맞음 (저장이 grace를 빼먹은 버그로 단정하지 않음). 새벽 스냅샷 후 장초 첫 변동·콜드스타트 가능성이 큼. +- 디바운스 ON 시: 09:07 스냅샷에서도 overnight 종목을 **120초** 유지 → 09:11(약 4분 후)에는 신규 스냅샷 기준으로 정리. 실매 EXIT grace와 같은 전환 규칙. + +### 영향 범위 + +- **백테 유니버스 재생** (돌파·꼬리·모멘텀 등 타임라인 사용 전략). 실매 주문 로직 자체는 변경 없음. +- 망가뜨리는 패치가 아니라 **실매 grace 정합** 쪽. 백테 거래수/PnL은 실매에 가까워지도록 **바뀔 수 있음**. + +--- + +## 3번 — 슬롯 늘려서 테스트 + +**사용자 검증용 (코드 변경 없음).** 총한도/동시보유↑로 슬롯 경쟁 효과 확인. + +--- + +## 4번 — 미청산 flatten / EOD + +**적용됨 (돌파·모멘텀·RANGE_BREAK).** + +- 공통 `flatten_remaining_portfolio_trades` — 루프 끝 미청산을 **마지막 확정봉 종가**로 장부 기록 +- `eod_enabled`면 `sell_reason=eod`, 아니면 `bt_flatten` +- skip_stats: `bt_flatten_count` +- 모멘텀: 웜업은 기존, **flatten은 이번에 추가** (이전 “미완” 해소) + +--- + +## (부수) invest_cap 이중 % 스케일 + +**적용됨 (돌파·RANGE_BREAK).** + +- `_resolve_breakout_sl_pct_ui`: `stop_loss_pct`(비율) → UI% ; `sl_pct`만 있으면 UI%/비율 자동 판별 +- `breakout_invest_amount_krw`에 **UI% 한 번만** 전달 (×100 이중 제거) + +--- + +## 패치 상태 + +| 순위 | 항목 | 상태 | +|------|------|------| +| 1 | 돌파 백테 **전일 봉 웜업** | **완료** | +| 2 | 유니버스 **sticky/grace 디바운스** + 09:07 점검 | **완료** | +| 3 | 슬롯/한도 검증용 백테 | 사용자 | +| 4 | 백테 종료 **미청산 flatten** | **완료** (돌파·모멘텀·레인지) | +| 부수 | `invest_cap` `%` 이중 스케일 | **완료** | + +--- + +## 관련 파일 + +- 웜업·디바운스: `kis_trader/backtest/breakout_backtest_common.py` +- 공통 디바운스: `kis_trader/backtest/universe_timeline.py` (`resolve_universe_exit_debounce_sec`) +- flatten: `kis_trader/backtest/backtest_portfolio_common.py` +- 포트폴리오: `breakout_portfolio_backtest.py`, `momentum_portfolio_backtest.py`, `range_break_portfolio_backtest.py` +- 꼬리 디바운스: `tail_backtest_common.py` +- env 키: `database.py` (`BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS` 등) + +--- + +## 검증 + +- `py_compile` OK +- 스모크: debounce(03:16→09:07 유지→09:11 정리) / invest_cap(30만) / flatten(`eod`) → `SMOKE_OK` +- DB: `BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS=50` 시드, config 컬럼 추가 +- `kis_backtest_web.service` 재시작 (`active` + HTTP 200) +- 브라우저: `http://192.168.0.149:5050/` → **돌파매매 백테스트** 탭 클릭, 폼·「백테스트 실행」버튼 정상 표시 + +### 잔여 / 다음 확인 + +| 순위 | 항목 | 상태 | +|------|------|------| +| 다음 | **2번 검증** — 장초 이노테나·한울: 실매 RAM/틱 vs DB 확정봉 `직전봉약세` 가설 검증 (후보, 미확정) | **다음 순서** | +| 후보 | 1번 A — 마지막 봉 경과 시 포트폴리오 루프에서 **즉시** 슬롯 해제 (전 전략 공통) | 대기 — 저유동은 C로도 분 생성 불가 시 필요 | +| 진행 | **1번 C — 판 뒤 1회 REST 백필** | **적용** (아래) | +| — | 3번 슬롯 경쟁 | 패스 | + +### 1번 C 적용 (2026-07-17) + +- 모듈: `kis_trader/engine/post_sell_candle_backfill.py` +- 실매 훅: `order_manager._finalize_sell_fill` → 매도 후 **비동기 1회** (시세=키움 **실키**) +- env: `POST_SELL_CANDLE_BACKFILL` (기본 true), `POST_SELL_CANDLE_ROLLUP_3M`, sleep 1~3초 +- 즉시 백필 CLI: `scripts/backfill_trade_candles.py --like '2026-07-16%'` +- 로그: `/tmp/backfill_trade_candles_0716c.log` (및 선행 0716b) +- 한계: 거래 자체가 없는 분(저유동)은 REST에도 없음 → **C만으로는 완전 메꿈 불가**, A 보완 여지 + +- 웹 돌파 **2026-07-16** 재백테 ↔ 실매 비교는 C 반영 후 / 2번 검증과 병행 +- 실매 봇: post-sell 훅 반영하려면 **재시작 필요** (백필 CLI는 봇 없이 완료 가능) diff --git a/kis_approval_manager.py b/kis_approval_manager.py new file mode 100644 index 0000000..566c08a --- /dev/null +++ b/kis_approval_manager.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +kis_approval_manager.py — KIS WebSocket approval_key 파일 캐시 (국내·해외 WS 공유) +================================================================================== +REST access_token(.kis_token_cache_*.json) 과 동일 패턴: + - 프로세스·재시작·국내/해외 WS 가 **하나의 approval_key** 를 공유 + - KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지 + - 파일 잠금으로 동시 발급(국내+해외·재시작) 경합 방지 + +문제(기존): + - kis_ws / kis_ws_overseas 가 각각 /oauth2/Approval 호출 → 나중 키가 앞 키 무효화 + - 메모리만 사용 → 재시작마다 새 발급 → invalid approval 루프 +""" +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).parent +CACHE_MOCK = ROOT / ".kis_approval_cache_mock.json" +CACHE_REAL = ROOT / ".kis_approval_cache_real.json" +LOCK_FILE = ROOT / ".kis_approval_manager.lock" +LOCK_TIMEOUT_S = 60 + +DEFAULT_MIN_REISSUE_SEC = 21600.0 +DEFAULT_MAX_VALID_SEC = 82800.0 + +_am_instances: dict = {} +_am_instances_lock = threading.Lock() + + +def _min_reissue_sec() -> float: + try: + raw = os.environ.get("KIS_WS_APPROVAL_MIN_REISSUE_SEC", "").strip() + return max(0.0, float(raw)) if raw else DEFAULT_MIN_REISSUE_SEC + except (ValueError, TypeError): + return DEFAULT_MIN_REISSUE_SEC + + +def _max_valid_sec() -> float: + try: + raw = os.environ.get("KIS_WS_APPROVAL_MAX_VALID_SEC", "").strip() + return max(3600.0, float(raw)) if raw else DEFAULT_MAX_VALID_SEC + except (ValueError, TypeError): + return DEFAULT_MAX_VALID_SEC + + +def _acquire_lock() -> bool: + deadline = time.time() + LOCK_TIMEOUT_S + while time.time() < deadline: + try: + fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + try: + if time.time() - LOCK_FILE.stat().st_mtime > 300: + LOCK_FILE.unlink(missing_ok=True) + continue + except Exception: + pass + time.sleep(1) + except Exception as exc: + logger.warning("approval 잠금 획득 실패: %s", exc) + return False + logger.warning("approval 잠금 타임아웃 → 파일 캐시 재사용 시도") + return False + + +def _release_lock() -> None: + try: + LOCK_FILE.unlink(missing_ok=True) + except Exception: + pass + + +def _issue_rest(app_key: str, app_secret: str, base_url: str) -> Optional[str]: + url = f"{base_url.rstrip('/')}/oauth2/Approval" + body = { + "grant_type": "client_credentials", + "appkey": app_key, + "secretkey": app_secret, + } + try: + resp = requests.post(url, json=body, timeout=10) + data = resp.json() + key = str(data.get("approval_key") or "").strip() + if key: + return key + logger.error("❌ approval_key REST 발급 실패: %s", data) + except Exception as exc: + logger.error("❌ approval_key REST 요청 예외: %s", exc) + return None + + +class KISApprovalManager: + """KIS WebSocket approval_key 싱글톤 — 실전/모의 각 1개.""" + + @classmethod + def instance(cls, is_mock: bool) -> "KISApprovalManager": + with _am_instances_lock: + if is_mock not in _am_instances: + _am_instances[is_mock] = cls(is_mock) + return _am_instances[is_mock] + + def __init__(self, is_mock: bool) -> None: + self._is_mock = bool(is_mock) + self._mode_str = "모의" if is_mock else "실전" + self._cache_path = CACHE_MOCK if is_mock else CACHE_REAL + self._lock = threading.Lock() + self._approval_key: Optional[str] = None + self._issued_ts: float = 0.0 + self._app_key_prefix: str = "" + self._load_from_file() + + def _load_from_file(self) -> None: + if not self._cache_path.exists(): + return + try: + data = json.loads(self._cache_path.read_text(encoding="utf-8")) + key = str(data.get("approval_key") or "").strip() + ts = float(data.get("issued_at_epoch") or 0.0) + prefix = str(data.get("app_key_prefix") or "") + if not key or ts <= 0: + return + age = time.time() - ts + if age >= _max_valid_sec(): + return + self._approval_key = key + self._issued_ts = ts + self._app_key_prefix = prefix + except Exception as exc: + logger.debug("approval 파일 로드 실패 [%s]: %s", self._mode_str, exc) + + def _save_to_file(self) -> None: + if not self._approval_key or self._issued_ts <= 0: + return + try: + self._cache_path.write_text( + json.dumps( + { + "approval_key": self._approval_key, + "issued_at": datetime.fromtimestamp(self._issued_ts).strftime( + "%Y-%m-%d %H:%M:%S" + ), + "issued_at_epoch": self._issued_ts, + "app_key_prefix": self._app_key_prefix, + "mock": self._is_mock, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + except Exception as exc: + logger.warning("approval 파일 저장 실패 (%s): %s", self._cache_path.name, exc) + + def age_sec(self) -> float: + if not self._issued_ts: + return 999999.0 + return max(0.0, time.time() - self._issued_ts) + + def reload_from_file(self) -> Optional[str]: + """REST 없이 파일 캐시만 다시 읽기 (다른 프로세스 갱신 반영).""" + with self._lock: + self._load_from_file() + return self._approval_key + + @property + def issued_ts(self) -> float: + return self._issued_ts + + def get_approval_key( + self, + app_key: str, + app_secret: str, + base_url: str, + *, + force_refresh: bool = False, + ) -> Optional[str]: + """ + 유효한 approval_key 반환. + - 24h 이내: 파일/메모리 캐시 재사용 (재연결 포함) + - 6h 미경과: force_refresh=True 여도 REST 재발급 금지 + - 6h 경과 + 24h 미만 + force_refresh: REST 재발급 허용 + - 24h 경과: REST 재발급 + """ + app_key = (app_key or "").strip() + app_secret = (app_secret or "").strip() + if not app_key or not app_secret: + return None + + min_reissue = _min_reissue_sec() + max_valid = _max_valid_sec() + + with self._lock: + self._load_from_file() + age = self.age_sec() + prefix_ok = ( + not self._app_key_prefix + or app_key.startswith(self._app_key_prefix) + or self._app_key_prefix.startswith(app_key[:8]) + ) + if self._approval_key and age < max_valid and prefix_ok: + if force_refresh and age >= min_reissue: + pass + elif force_refresh and age < min_reissue: + logger.info( + "🔑 approval_key REST 재발급 생략 (KIS 6h — %.0f분/%.0fh, %s 재사용)", + age / 60, + min_reissue / 3600, + self._cache_path.name, + ) + return self._approval_key + else: + return self._approval_key + + if not _acquire_lock(): + with self._lock: + self._load_from_file() + return self._approval_key + + try: + with self._lock: + self._load_from_file() + age = self.age_sec() + if self._approval_key and age < max_valid: + if not force_refresh or age < min_reissue: + return self._approval_key + + key = _issue_rest(app_key, app_secret, base_url) + if not key: + with self._lock: + return self._approval_key + + now = time.time() + with self._lock: + self._approval_key = key + self._issued_ts = now + self._app_key_prefix = app_key[:8] + self._save_to_file() + logger.info( + "✅ WebSocket approval_key 발급·저장 (%s, 앞8자: %s…, " + "다음 REST 가능: %.0fh 후 → %s)", + self._mode_str, + key[:8], + min_reissue / 3600, + self._cache_path.name, + ) + return key + finally: + _release_lock() + + +def get_approval_status(is_mock: bool) -> dict: + mgr = KISApprovalManager.instance(is_mock) + with mgr._lock: + mgr._load_from_file() + age_h = round(mgr.age_sec() / 3600.0, 2) if mgr._approval_key else -1.0 + valid = bool(mgr._approval_key) and mgr.age_sec() < _max_valid_sec() + return { + "valid": valid, + "key_prefix": (mgr._approval_key[:8] + "…") if mgr._approval_key else "", + "age_h": age_h, + "file": str(mgr._cache_path.name), + } diff --git a/kis_trader/backtest/backtest_env_timeline.py b/kis_trader/backtest/backtest_env_timeline.py new file mode 100644 index 0000000..e66d252 --- /dev/null +++ b/kis_trader/backtest/backtest_env_timeline.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +웹 백테스트 전용 env 타임라인 — 실매·파람서치 경로에는 부착하지 않는다. + +``backtest_env_timeline=True`` (웹 bt_meta) 일 때만 ``_env_timeline`` 을 params 에 +넣고, 포트폴리오/단일 루프에서 ``apply_env_timeline_at`` 으로 TRIGGER·포트폴리오 +숫자를 시점별 갱신한다. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from kis_trader.backtest.env_timeline import EnvTimeline, build_env_timeline + +# params 에 남겨 두는 구조·메타 키 — env 스냅샷이 덮어쓰지 않음 +_PRESERVE_PARAM_KEYS = frozenset({ + "_env_timeline", + "_env_timeline_evt_key", + "_universe_timeline", + "_momentum_universe_timeline", + "_universe_timeline_meta", + "backtest_env_timeline", + "portfolio_mode", + "scan_interval_min", + "backtest_universe_scan_at", + "backtest_vol_fill_cap_pct", + "strict_fill_verify", + "limit_fill_slip_pct", + "fee_rate", + "sell_tax", + "slot_money", + "max_stocks", + "total_budget_krw", + "_backtest_period_start_key", + "_backtest_log_verdict_by_code", + "_portfolio_skip_stats", + "share_denom_by_code", +}) + + +def _truthy(val: Any) -> bool: + if val is None: + return False + s = str(val).strip().lower() + return s in ("1", "true", "t", "y", "yes", "on") + + +def backtest_env_timeline_enabled(params: Optional[Dict[str, Any]] = None) -> bool: + """웹 백테 env 타임라인 ON 여부 — 기본 False (파람서치·실매 영향 없음).""" + if params is None: + return False + if params.get("backtest_env_timeline") is not None: + return _truthy(params.get("backtest_env_timeline")) + return params.get("_env_timeline") is not None + + +def attach_backtest_env_timeline_to_params( + engine_params: Dict[str, Any], + meta_out: Optional[Dict[str, Any]], + strategy_id: str, +) -> None: + """웹 백테 구간 env 타임라인 부착 — meta_out['backtest_env_timeline']=True 일 때만.""" + if not backtest_env_timeline_enabled(engine_params): + if meta_out and _truthy(meta_out.get("backtest_env_timeline")): + engine_params["backtest_env_timeline"] = True + else: + return + db = (meta_out or {}).get("db") + start_key = str((meta_out or {}).get("start_key") or "") + end_key = str((meta_out or {}).get("end_key") or "") + start_ymd = start_key[:8] if len(start_key) >= 8 else "" + end_ymd = end_key[:8] if len(end_key) >= 8 else "" + if not start_ymd or not end_ymd: + return + tl = build_env_timeline(db, start_ymd, end_ymd) + if tl is None: + return + engine_params["_env_timeline"] = tl + engine_params["_strategy_id_for_env_tl"] = str(strategy_id or "").strip().upper() + if meta_out is not None: + meta_out["backtest_env_timeline"] = True + meta_out["env_timeline_snapshots"] = tl.snapshot_count + + +def engine_defaults_from_env_row( + strategy_id: str, + env_row: Dict[str, Any], +) -> Dict[str, Any]: + """전략별 get_*_defaults_from_db — env_row 스냅샷만 사용 (DB 재조회 없음). + + 반환값은 반드시 **엔진 비율**(0.02=2%) 이어야 한다. + 웹이 ``ui% → breakout_ui_to_engine_params`` 한 뒤 타임라인이 UI% 를 다시 + 덮어쓰면 trail/shoulder 가 100배 커져 청산·슬롯이 붕괴한다 (BREAKOUT 전용 함정). + """ + sid = str(strategy_id or "").strip().upper() + if sid in ("SHORT", "TAIL"): + from kis_trader.engine.tail_engine import get_tail_defaults_from_db + return get_tail_defaults_from_db(env_row=env_row) + if sid == "MOMENTUM": + from kis_trader.engine.momentum_engine import get_momentum_defaults_from_db + return get_momentum_defaults_from_db(env_row=env_row) + if sid == "SCALP": + from kis_trader.engine.scalping_engine import get_scalping_defaults_from_db + return get_scalping_defaults_from_db(env_row=env_row) + if sid == "BREAKOUT": + from kis_trader.backtest.breakout_backtest_common import get_breakout_defaults_from_env_row + from kis_trader.strategies.breakout import breakout_ui_to_engine_params + + # get_breakout_defaults_from_env_row = 웹 폼용 UI% → 엔진 비율로 변환 후 적용 + ui = get_breakout_defaults_from_env_row(env_row) + eng = breakout_ui_to_engine_params(ui) + # buy_params 동기화·invest_cap 이 읽는 별칭 (값은 엔진 비율) + eng["sl_pct"] = float(eng.get("stop_loss_pct", 0.0) or 0.0) + eng["tp_pct"] = float(eng.get("take_profit_pct", 0.0) or 0.0) + if eng.get("shoulder_min_high") is not None: + eng["shoulder_min_high_pct"] = float(eng["shoulder_min_high"]) + # ui_to_engine 이 빼먹는 트리거 스냅샷 플래그 + for k in ("ob_filter_enabled", "pg_filter_enabled", "max_spread_pct"): + if k in ui: + eng[k] = ui[k] + return eng + if sid == "RANGE_BREAK": + from kis_trader.engine.range_break_engine import get_range_break_defaults_from_db + return get_range_break_defaults_from_db(env_row=env_row) + if sid == "DBBAND": + from kis_trader.engine.dbband_engine import get_dbband_defaults_from_db + return get_dbband_defaults_from_db(env_row=env_row) + return {} + + +def apply_env_timeline_at( + params: Dict[str, Any], + candle_time: str, + strategy_id: Optional[str] = None, +) -> bool: + """ + 봉 시각에 맞는 env 스냅샷을 params 에 반영. 스냅샷 변경 시에만 True. + + 포트폴리오 루프 변수(max_stocks, slot_money 등)는 호출측에서 params 재읽기. + """ + tl: Optional[EnvTimeline] = params.get("_env_timeline") + if tl is None: + return False + snap, evt_key = tl.snapshot_at(candle_time) + if not snap or not evt_key: + return False + if evt_key == params.get("_env_timeline_evt_key"): + return False + sid = str( + strategy_id or params.get("_strategy_id_for_env_tl") or "" + ).strip().upper() + defaults = engine_defaults_from_env_row(sid, snap) + for k, v in defaults.items(): + if k in _PRESERVE_PARAM_KEYS: + continue + params[k] = v + params["_env_timeline_evt_key"] = evt_key + return True diff --git a/kis_trader/backtest/backtest_portfolio_common.py b/kis_trader/backtest/backtest_portfolio_common.py index d51c89d..9cc4461 100644 --- a/kis_trader/backtest/backtest_portfolio_common.py +++ b/kis_trader/backtest/backtest_portfolio_common.py @@ -8,13 +8,134 @@ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional, Tuple + +def flatten_remaining_portfolio_trades( + portfolio: Dict[str, Dict[str, Any]], + ctx_by_code: Dict[str, Dict[str, Any]], + all_trades: List[Dict[str, Any]], + *, + params: Optional[Dict[str, Any]] = None, + strategy: str = "", + default_reason: str = "bt_flatten", +) -> int: + """루프 종료 후 미청산 포지션을 마지막 확정봉 종가로 장부에 남긴다. + + 분봉이 중간에 끊겨 EOD/max_hold 판정이 안 돈 종목(실매는 벽시계로 청산) 정합용. + ``eod_enabled`` 이면 sell_reason=``eod``, 아니면 ``bt_flatten``. + """ + reason = str(default_reason or "bt_flatten") + if params is not None and strategy: + try: + from kis_trader.engine.strategy_eod import resolve_strategy_eod_params + + eod_on, _eod_hm = resolve_strategy_eod_params(params, strategy) + if eod_on: + reason = "eod" + except Exception: + pass + + n = 0 + for code in list(portfolio.keys()): + pos = portfolio[code] + ctx = ctx_by_code.get(code) + entry_t = str(pos.get("entry_time") or "") + last = None + if ctx and ctx.get("candles"): + candles = ctx["candles"] + for c in reversed(candles): + ct = str(c.get("candle_time") or "") + if not ct: + continue + if entry_t and ct < entry_t[:12]: + continue + last = c + break + if last is None and candles: + last = candles[-1] + if last is None: + exit_price = float(pos.get("entry_price") or 0) + sell_time = entry_t + else: + exit_price = float(last.get("close") or 0) + sell_time = str(last.get("candle_time") or entry_t) + if exit_price <= 0 or not sell_time: + del portfolio[code] + continue + trade: Dict[str, Any] = { + "code": code, + "buy_time": pos["entry_time"], + "sell_time": sell_time, + "buy_price": pos["entry_price"], + "sell_price": round(exit_price, 2), + "qty": pos.get("qty", 1), + "pnl": 0, + "sell_reason": reason, + "hold_min": 0, + "exit_source": "bt_flatten", + } + if strategy: + trade["strategy"] = strategy + if pos.get("rsi") is not None: + try: + trade["rsi_entry"] = round(float(pos["rsi"]), 1) + except (TypeError, ValueError): + pass + all_trades.append(trade) + if ctx is not None: + day = sell_time[:8] + try: + from kis_trader.engine.scalping_engine import _t2dt + + ctx.setdefault("last_exit_dt", {})[day] = _t2dt(sell_time) + except Exception: + pass + del portfolio[code] + n += 1 + return n + + +def load_portfolio_env_row(db: Any = None) -> Dict[str, Any]: + """웹·실매와 동일 — ``get_latest_env()`` 병합 스냅샷. + + ``SELECT * FROM env_config`` 만 쓰면 ``config_momentum`` 등의 + ``*_TOTAL_BUDGET_KRW`` 가 빠져 ``total_budget≤0 → max_stocks×slot`` + (예: 동시20×30만=**600만**) 폴백이 난다. Optuna/Grid 공통 금지. + """ + own_db = db is None + if own_db: + from database import TradeDB + db = TradeDB() + try: + latest = db.get_latest_env() + if latest and isinstance(latest.get("snapshot"), dict): + return dict(latest["snapshot"]) + row = db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + return dict(row) if row else {} + finally: + if own_db: + try: + db.close() + except Exception: + pass + + # 전략별 env 키 (첫 매칭 우선) STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = { "TAIL": { - "max_stocks": ("TAIL_MAX_STOCKS",), - "total_budget": ("TAIL_TOTAL_BUDGET_KRW",), - "slot": ("TAIL_SLOT_MONEY",), - "per_stock_cap": ("TAIL_MAX_BUY_AMOUNT",), + "max_stocks": ("TAIL_MAX_STOCKS", "SHORT_MAX_STOCKS"), + "total_budget": ("TAIL_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"), + "slot": ("TAIL_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), + "per_stock_cap": ("TAIL_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT"), + "min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"), + }, + # 실매 strategy_id=SHORT — TAIL 과 동일 env. 없으면 SCALP 폴백되어 한도 600만이 찍히는 버그 방지. + "SHORT": { + "max_stocks": ("SHORT_MAX_STOCKS", "TAIL_MAX_STOCKS"), + "total_budget": ("SHORT_TOTAL_BUDGET_KRW", "TAIL_TOTAL_BUDGET_KRW"), + "slot": ("TAIL_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), + "per_stock_cap": ("SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT"), "min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"), }, "SCALP": { @@ -232,17 +353,59 @@ _KNOWN_STRATEGY_PREFIXES: Tuple[str, ...] = ( def _is_foreign_strategy_key(key: str, own_prefix: str) -> bool: """key 가 '자기 전략 prefix 가 아닌' 다른 전략 전용 키인지. (무prefix 공용키는 False).""" + # SHORT ↔ TAIL 동일 전략군 — 서로의 prefix 는 자기 키로 취급 + own_set = {own_prefix} + if own_prefix in ("SHORT_", "TAIL_"): + own_set.update(("SHORT_", "TAIL_")) for pfx in _KNOWN_STRATEGY_PREFIXES: if key.startswith(pfx): - return pfx != own_prefix + return pfx not in own_set return False +def portfolio_env_keys(strategy: str) -> frozenset: + """파람서치 apply 시 제외할 슬롯·동시보유·총한도·1회상한 env 키 집합. + + 웹「봇에 설정저장」은 운영 한도를 의도적으로 바꿀 수 있으므로 이 키를 써도 된다. + Optuna/Grid 1등 적용은 slot×max_stocks 로 총한도를 덮어쓰는 사고(예: 120만→600만)를 + 막기 위해 ``strip_portfolio_keys_from_apply_patch`` 로 제거한다. + """ + strat = (strategy or "").upper() + keys = STRATEGY_PORTFOLIO_KEYS.get(strat) + if not keys: + return frozenset() + own_prefix = strat + "_" + out: set = set() + for group in ("slot", "per_stock_cap", "max_stocks", "total_budget"): + for k in keys.get(group, ()): + if _is_foreign_strategy_key(k, own_prefix): + continue + out.add(k) + return frozenset(out) + + +def strip_portfolio_keys_from_apply_patch( + patch: Dict[str, str], + strategy: str, + *extra_strategies: str, +) -> Dict[str, str]: + """파람서치/Optuna apply patch 에서 포트폴리오(슬롯·한도) 키 제거.""" + if not patch: + return patch + skip: set = set(portfolio_env_keys(strategy)) + for s in extra_strategies: + skip |= set(portfolio_env_keys(s)) + if not skip: + return patch + return {k: v for k, v in patch.items() if k not in skip} + + def portfolio_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]: - """1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save·파라서치 apply 공통). + """1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save 전용 권장). ※ 쓰기 시 '자기 전략 키 + 공용 무prefix 키' 만 기록한다. 읽기용 fallback 튜플에 섞인 다른 전략 전용 키(SCALP_*, SHORT_* 등)는 제외 → 남의 전략 설정 오염 금지. + ※ 파람서치/Optuna apply 경로에서는 호출하지 말 것 — ``strip_portfolio_keys_from_apply_patch`` 참고. """ patch: Dict[str, str] = {} strat = (strategy or "").upper() @@ -322,10 +485,8 @@ def build_budget_warning( def _t2dt(candle_time: str) -> datetime: - s = str(candle_time).strip().replace("-", "").replace(":", "").replace(" ", "") - if len(s) >= 14: - return datetime.strptime(s[:14], "%Y%m%d%H%M%S") - return datetime.strptime(s[:12], "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(candle_time) def backtest_slip_pct(params: Optional[Dict[str, Any]]) -> float: @@ -368,9 +529,21 @@ def vol_capped_qty(qty: int, candle: Optional[Dict[str, Any]], cap_pct: float) - def _daily_halt_cfg(p: Dict[str, Any]) -> Dict[str, Any]: - """백테 daily_profit 시뮬용 cfg — params 우선 → env(DB) 폴백. (실매 load_* 와 동일 키).""" + """백테 daily_profit 시뮬용 cfg — params 우선 → 전략접두 env → 마스터 env (실매 load_* 와 동일).""" from kis_trader.utils.env import get_env_from_db, get_env_float + sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "").strip().upper() + if sid in ("TAIL", ""): + sid = "SHORT" + pfx = sid # SHORT / MOMENTUM / BREAKOUT / SCALP … + + def _env_str(key: str, default: str = "") -> str: + # 전략 서브키 우선 → 마스터 폴백 (실매 load_strategy_profit_target 과 동일) + raw = str(get_env_from_db(f"{pfx}_{key}", "") or "").strip() + if raw != "": + return raw + return str(get_env_from_db(key, default) or default).strip() + def _pf(param_keys: Tuple[str, ...], key_env: str, default: float = 0.0) -> float: # daily 전용 키(daily_trail_*) 우선 → 파람서치 호환 키 → env 폴백. # 주의: 꼬리 개별포지션 'trail_arm_pct' 와 충돌 방지 위해 arm_pct 는 @@ -379,15 +552,18 @@ def _daily_halt_cfg(p: Dict[str, Any]) -> Dict[str, Any]: v = p.get(k) if v is not None: return float(v or 0.0) - return float(get_env_float(key_env, default) or 0.0) + try: + return float(_env_str(key_env, str(default)) or default) + except (TypeError, ValueError): + return float(get_env_float(key_env, default) or 0.0) mode = str( - p.get("daily_profit_mode") or get_env_from_db("DAILY_PROFIT_MODE", "fixed") or "fixed" + p.get("daily_profit_mode") or _env_str("DAILY_PROFIT_MODE", "fixed") or "fixed" ).lower() # 다단계 tier(우선) — 전용키 daily_trail_tiers → env. 비면 단일 drop 폴백. tiers = p.get("daily_trail_tiers") if tiers is None: - tiers = get_env_from_db("DAILY_PROFIT_TRAIL_TIERS", "") + tiers = _env_str("DAILY_PROFIT_TRAIL_TIERS", "") return { "enabled": True, "mode": mode, @@ -414,14 +590,22 @@ def apply_daily_profit_halt_sim( 고점대비 trail_drop_pct% 되돌림이 처음 발생한 시각 이후의 신규 진입을 차단한다. (보유분 청산은 그대로 — 실매 buy_allowed 와 동일: 신규매수만 중단.) - 명시 게이트 BACKTEST_DAILY_PROFIT_TRAIL(또는 params['_backtest_daily_profit_trail']) - OFF 면 그대로 반환 → 기존 백테 동작 100% 불변. + 게이트: + - params['_backtest_daily_profit_trail'] 명시 시 그 값 (웹 손익감시 스위치). + - 미지정이면 실매 ``{pfx}_DAILY_PROFIT_TARGET_ENABLED`` 추종. """ p = params or {} gate = p.get("_backtest_daily_profit_trail") if gate is None: - from kis_trader.utils.env import get_env_bool - gate = get_env_bool("BACKTEST_DAILY_PROFIT_TRAIL", False) + sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper() + if sid == "TAIL": + sid = "SHORT" + try: + from kis_trader.engine.daily_profit_halt import load_strategy_profit_target + gate = bool(load_strategy_profit_target(sid).get("enabled")) + except Exception: + from kis_trader.utils.env import get_env_bool + gate = get_env_bool(f"{sid}_DAILY_PROFIT_TARGET_ENABLED", False) if not bool(gate): return trades diff --git a/kis_trader/backtest/breakout_backtest_common.py b/kis_trader/backtest/breakout_backtest_common.py index 2d822d7..39a6af2 100644 --- a/kis_trader/backtest/breakout_backtest_common.py +++ b/kis_trader/backtest/breakout_backtest_common.py @@ -31,13 +31,187 @@ from kis_trader.engine.indicator_cache import ( from kis_trader.share.stock_share import attach_share_denoms_to_params from kis_trader.strategies.breakout import ( breakout_backtest_wants_tick_replay, + breakout_invest_amount_krw, breakout_min_bars_required, + normalize_breakout_max_loss_krw, + resolve_breakout_skip_hts_scan_dupes, run_breakout_backtest, ) BREAKOUT_STRATEGY_ID = "BREAKOUT" +def _bo_golden_end_to_hm(s: str) -> int: + """'10:30' 또는 HHMM → 1030 (실패 시 1030).""" + try: + raw = str(s or "").strip() + if ":" in raw: + hh, mm = raw.split(":", 1) + return int(hh) * 100 + int(mm) + if raw.isdigit(): + return int(raw[:4]) if len(raw) >= 4 else int(raw) + except Exception: + pass + return 1030 + + +def _breakout_trigger_filter_enabled( + env: Dict[str, Any], + *, + prefix: str, + kind: str, + global_key: str, +) -> bool: + """전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌.""" + sk = f"{prefix}_{kind}_FILTER_ENABLED" + raw = env.get(sk) + if raw not in (None, "", "None"): + return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") + if kind == "ORDERBOOK": + return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기 + raw_g = env.get(global_key) + if raw_g not in (None, "", "None"): + return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on") + return True + + +def get_breakout_defaults_from_env_row(env: Dict[str, Any]) -> Dict[str, Any]: + """돌파 엔진 params — env_row 스냅샷만 사용 (웹 백테 env 타임라인).""" + fee_rate, sell_tax, _slot = fee_and_slot_from_env_row(env, strategy="BREAKOUT") + + def pick(keys: Tuple[str, ...], default: Any, cast=float): + for k in keys: + v = env.get(k) + if v not in (None, "", "None"): + try: + return cast(v) + except (ValueError, TypeError): + continue + return default + + sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float) + tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float) + tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float) + tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float) + smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float) + sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float) + + def pct_ui(ratio: float) -> float: + av = abs(float(ratio)) + if av == 0: + return 0.0 + return round(av * 100, 3) if av < 0.5 else round(av, 3) + + time_end_raw = env.get("BREAKOUT_TIME_END") + if time_end_raw not in (None, "", "None"): + try: + time_end_hm = int(float(time_end_raw)) + except (ValueError, TypeError): + time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30"))) + else: + time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30"))) + + cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float) + if cd_sec and cd_sec > 0: + cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec) + else: + re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float) + cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec) + + sl_pct_ui = pct_ui(sl_r) + max_loss_raw = pick( + ("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"), + 200_000, + lambda v: int(float(v)), + ) + max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw) + slot_cap = pick( + ("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), + 2_000_000, + lambda v: int(float(v)), + ) + slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap)) + + portfolio = resolve_breakout_portfolio_params( + env, None, slot_money=float(slot_money), + ) + max_stocks_v = int(portfolio["max_stocks"]) + total_budget_v = int(float(portfolio["total_budget_krw"])) + + eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip() + if eod_raw in ("", "None"): + eod_hm = "15:15" + elif ":" in eod_raw: + eod_hm = eod_raw + elif len(eod_raw) == 4 and eod_raw.isdigit(): + eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}" + else: + eod_hm = eod_raw + eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED") + if eod_enabled_raw in (None, "", "None"): + eod_enabled = True + else: + eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") + + return { + "lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))), + "vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))), + "vol_mult": pick(("BREAKOUT_VOL_MULT",), 0.0, float), + "min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float), + "prev_chg_min": pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float), + "prev_chg_max": pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float), + "sl_pct": sl_pct_ui, + "tp_pct": pct_ui(tp_r), + "trail_pct": pct_ui(tr_r), + "trail_arm_pct": pct_ui(tra_r), + "shoulder_min_high_pct": pct_ui(smh_r), + "shoulder_cut_pct": pct_ui(sc_r), + "sl_mode": str(env.get("BREAKOUT_SL_MODE") or "fixed").strip().lower() or "fixed", + "atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))), + "atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float), + "atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float), + "atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float), + "max_hold_bars": pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))), + "ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""), + "time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))), + "time_end_hm": time_end_hm, + "eod_enabled": eod_enabled, + "eod_hm": eod_hm, + "max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))), + "cooldown_min": cooldown_min, + "max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float), + "min_price": pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float), + "confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float), + "body_min_pct": pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float), + "max_loss_krw": max_loss_krw, + "slot_money": slot_money, + "max_stocks": max_stocks_v, + "total_budget_krw": total_budget_v, + "fee_rate_pct": fee_rate * 100.0 if fee_rate < 1 else fee_rate, + "sell_tax_pct": sell_tax * 100.0 if sell_tax < 1 else sell_tax, + "entry_mode": str(env.get("BREAKOUT_ENTRY_MODE") or "intrabar").strip().lower(), + "intrabar_slippage_pct": float(pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float)), + "use_ema_filter": ( + str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower() + in ("1", "true", "t", "y", "yes", "on") + if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None") + else False + ), + "ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))), + "ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))), + "skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env), + "ob_filter_enabled": _breakout_trigger_filter_enabled( + env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED", + ), + "pg_filter_enabled": _breakout_trigger_filter_enabled( + env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED", + ), + "max_spread_pct": pick( + ("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, float, + ), + } + + def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool: """백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합). @@ -56,6 +230,76 @@ def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] return get_env_bool("BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT", True) +def breakout_universe_exit_debounce_sec() -> int: + """실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지.""" + from kis_trader.backtest.universe_timeline import resolve_universe_exit_debounce_sec + + return resolve_universe_exit_debounce_sec( + strategy_env_key="BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC", + default_when_no_grace=0, + ) + + +def breakout_backtest_candle_warmup_bars() -> int: + """백테 지표·lookback warm-up — 실매 WS 전일봉 버퍼와 동일하게 전일 봉 선행.""" + from kis_trader.utils.env import get_env_int + + return max(0, int(get_env_int("BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS", 50))) + + +def prepend_breakout_candle_warmup( + db, + candles_by_code: Dict[str, List[Dict]], + period_start_key: str, + *, + warmup_bars: Optional[int] = None, +) -> int: + """ + ``period_start_key``(YYYYMMDDHHMM) 이전 N봉(1분)을 종목별로 prepend. + lookback/vol_window 판별용 — 루프 시각(all_times)에는 기간일만 포함. + """ + wb = ( + breakout_backtest_candle_warmup_bars() + if warmup_bars is None + else max(0, int(warmup_bars)) + ) + if wb <= 0 or db is None or not period_start_key: + return 0 + ps = str(period_start_key)[:12] + ind_cols = ws_candles_select_indicator_cols(db) + total_prepended = 0 + for code, rows in list(candles_by_code.items()): + if not rows: + continue + first_period_idx = None + for i, r in enumerate(rows): + ct = str(r.get("candle_time") or "") + if ct >= ps: + first_period_idx = i + break + if first_period_idx is None: + continue + if first_period_idx > 0: + continue + first_ct = str(rows[first_period_idx].get("candle_time") or "") + if not first_ct: + continue + warm_rows = db.conn.execute( + f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} " + "FROM ws_candles WHERE timeframe=1 AND code=%s " + "AND candle_time < %s ORDER BY candle_time DESC LIMIT %s", + [code, first_ct, wb], + ).fetchall() + if not warm_rows: + continue + prefix = [dict(r) for r in reversed(warm_rows)] + candles_by_code[code] = prefix + [dict(r) for r in rows] + total_prepended += len(prefix) + if total_prepended > 0: + materialize_ws_candles_batch(db, candles_by_code, 1) + return total_prepended + + def date_keys(start: str, end: str) -> Tuple[str, str, str, str]: """YYYY-MM-DD → candle_time 키 및 ymd.""" start_key = start.replace("-", "") + "0000" @@ -156,9 +400,21 @@ def run_breakout_backtest_web_aligned( engine_params.setdefault("scan_interval_min", 1) engine_params.setdefault("portfolio_mode", True) + # ── 전일 봉 웜업 (실매 WS 버퍼 정합) ───────────────────────────────── + warmup_prepended = 0 + _sk_w = str((meta_out or {}).get("start_key") or "") + if meta_out is not None and _sk_w: + engine_params["_backtest_period_start_key"] = str(_sk_w)[:12] + _db_w = meta_out.get("db") + if _db_w is not None: + warmup_prepended = prepend_breakout_candle_warmup( + _db_w, candles_by_code, str(_sk_w)[:12], + ) + # ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합) ────────────── # 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전 # 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정. + # EXIT 디바운스 = CONDITION_EXIT_GRACE_SEC (실매 sticky/grace 정합). if universe_by_slot is not None and breakout_backtest_universe_scan_at_enabled(engine_params): _sk = str((meta_out or {}).get("start_key") or "") _ek = str((meta_out or {}).get("end_key") or "") @@ -173,16 +429,18 @@ def run_breakout_backtest_web_aligned( _sk, _ek = min(_days), max(_days) if len(_sk) >= 8 and len(_ek) >= 8: from kis_trader.backtest.universe_timeline import build_universe_timeline + _deb = breakout_universe_exit_debounce_sec() _tl = build_universe_timeline( strategy_id=BREAKOUT_STRATEGY_ID, start_ymd=_sk[:8], end_ymd=_ek[:8], - debounce_sec=0, strict=False, strict_lag_minutes=0, + debounce_sec=_deb, strict=False, strict_lag_minutes=0, ) if _tl is not None: engine_params["_universe_timeline"] = _tl if meta_out is not None: meta_out["universe_timing"] = "scan_at" meta_out["universe_timeline_snapshots"] = _tl.snapshot_count + meta_out["universe_exit_debounce_sec"] = _deb db_for_share = (meta_out or {}).get("db") if db_for_share and "share_denom_by_code" not in engine_params: @@ -222,6 +480,9 @@ def run_breakout_backtest_web_aligned( if snap_meta.get("log_verdict_by_code"): engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"] + from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params + attach_backtest_env_timeline_to_params(engine_params, meta_out, BREAKOUT_STRATEGY_ID) + trades = run_breakout_backtest( candles_by_code, engine_params, @@ -237,8 +498,15 @@ def run_breakout_backtest_web_aligned( if meta_out is not None: skip_stats = engine_params.get("_portfolio_skip_stats") or {} meta_out["skip_stats"] = dict(skip_stats) + if warmup_prepended > 0 or breakout_backtest_candle_warmup_bars() > 0: + meta_out["skip_stats"]["candle_warmup_bars"] = breakout_backtest_candle_warmup_bars() + meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended meta_out["engine_params"] = engine_params if tick_meta: + from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes + tick_meta = enrich_tick_meta_with_traded_codes( + tick_meta, candles_by_code, loaded_ticks, trades, + ) meta_out["tick_backtest"] = tick_meta mode = engine_params.get("entry_mode", "intrabar") if tick_meta.get("ws_tick_rows_loaded", 0) > 0: diff --git a/kis_trader/backtest/breakout_portfolio_backtest.py b/kis_trader/backtest/breakout_portfolio_backtest.py index c95795f..ad29ee8 100644 --- a/kis_trader/backtest/breakout_portfolio_backtest.py +++ b/kis_trader/backtest/breakout_portfolio_backtest.py @@ -7,6 +7,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from kis_trader.backtest.backtest_portfolio_common import ( + flatten_remaining_portfolio_trades, min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, @@ -14,7 +15,12 @@ from kis_trader.backtest.backtest_portfolio_common import ( from kis_trader.engine.scalping_engine import ( _t2dt, _to_bool, - check_sell_signal_backtest_bar, +) +from kis_trader.engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + collect_minute_ticks, + resolve_backtest_sell, ) from kis_trader.share.stock_share import share_denom_for_code from kis_trader.engine.indicator_cache import ( @@ -25,6 +31,8 @@ from kis_trader.engine.whipsaw_filter import inject_whipsaw_ticks_into_params from kis_trader.backtest.trigger_snapshot_loader import inject_trigger_snapshots_into_params from kis_trader.strategies.breakout import ( _bt_slot_key, + breakout_backtest_tick_fallback_ohlc, + breakout_backtest_use_tick_exit, breakout_entry_mode, breakout_invest_amount_krw, breakout_scan_buy_at_bar, @@ -108,11 +116,20 @@ def _total_budget_from_params(params: Dict[str, Any]) -> float: return 0.0 +def _resolve_breakout_sl_pct_ui(params: Dict[str, Any]) -> float: + """실매와 동일 — ``stop_loss_pct``(비율) → UI%, 없으면 ``sl_pct``(UI% 또는 비율).""" + if params.get("stop_loss_pct") not in (None, ""): + return abs(float(params["stop_loss_pct"])) * 100.0 + raw = abs(float(params.get("sl_pct", 2.0))) + # UI% 는 보통 ≥0.5, 비율은 0.05 등 + return raw if raw >= 0.5 else raw * 100.0 + + def _resolve_breakout_invest_cap(params: Dict[str, Any]) -> float: slot_money = float(params.get("slot_money", 2_000_000)) - sl_pct = abs(float(params.get("stop_loss_pct", params.get("sl_pct", -0.02)))) + sl_pct_ui = _resolve_breakout_sl_pct_ui(params) max_loss_krw = normalize_breakout_max_loss_krw(params.get("max_loss_krw", 200_000)) - return breakout_invest_amount_krw(max_loss_krw, sl_pct * 100.0, slot_money) + return breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_money) def run_breakout_backtest_portfolio( @@ -127,7 +144,7 @@ def run_breakout_backtest_portfolio( 시각순 포트폴리오 돌파 백테스트. - 매수: ``check_buy_signal_breakout_live`` → 다음 봉 시가 예약 - - 매도: ``check_sell_signal_breakout_live`` via ``check_sell_signal_backtest_bar`` + - 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_breakout_live`` """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) @@ -157,9 +174,16 @@ def run_breakout_backtest_portfolio( attach_indicator_caches_to_params(buy_params, codes_candles) skipped_micro_buys = 0 + use_tick_exit = bool(ticks_by_code) and breakout_backtest_use_tick_exit(params) + tick_fallback_ohlc = breakout_backtest_tick_fallback_ohlc(params) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="BREAKOUT_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="BREAKOUT_BACKTEST_SELL_SLIP_PCT") + tick_exit_count = 0 + ohlc_exit_count = 0 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() + period_start = str(params.get("_backtest_period_start_key") or "")[:12] for code, raw_rows in codes_candles.items(): if len(raw_rows) < min_bars: continue @@ -189,7 +213,10 @@ def run_breakout_backtest_portfolio( "pending_entry": None, } for c in candles: - all_times_set.add(c["candle_time"]) + ct = str(c.get("candle_time") or "") + if period_start and ct < period_start: + continue + all_times_set.add(ct) all_times = sorted(all_times_set) portfolio: Dict[str, Dict[str, Any]] = {} @@ -197,7 +224,33 @@ def run_breakout_backtest_portfolio( universe_timeline = params.get("_universe_timeline") + from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at + for t in all_times: + if apply_env_timeline_at(params, t, "BREAKOUT"): + max_stocks = _max_stocks_from_params(params) + slot_money = float(params.get("slot_money", 2_000_000)) + total_budget = _total_budget_from_params(params) + if total_budget <= 0: + total_budget = float(max_stocks * slot_money) + invest_cap = _resolve_breakout_invest_cap(params) + time_start_hm = int(params.get("time_start_hm", 900)) + time_end_hm = int(params.get("time_end_hm", 1030)) + buy_params["time_start_hm"] = time_start_hm + buy_params["time_end_hm"] = time_end_hm + for _bk in ( + "lookback_min", "vol_window", "vol_mult", "prev_chg_min", "prev_chg_max", + "sl_pct", "tp_pct", "stop_loss_pct", "take_profit_pct", + "trail_pct", "trail_arm_pct", + "shoulder_min_high", "shoulder_min_high_pct", "shoulder_cut_pct", + "max_daily", "cooldown_min", "max_daily_chg", "min_price", "skip_hts_scan_dupes", + "confirm_margin_pct", "body_min_pct", "ratchet_tiers", "max_hold_bars", + "sl_mode", "atr_period", "atr_sl_mult", "atr_sl_min_pct", "atr_sl_max_pct", + "eod_enabled", "eod_hm", "entry_mode", + ): + if _bk in params: + buy_params[_bk] = params[_bk] + slot_key = _bt_slot_key(t, int(params.get("scan_interval_min", 1))) # 초단위 유니버스(실매 정합) — 타임라인 우선, 없으면 1분 슬롯 폴백 uni_codes = _universe_codes_at(t, slot_key, universe_timeline, universe_by_slot) @@ -270,21 +323,33 @@ def run_breakout_backtest_portfolio( bar = dict(c) if "open" not in bar or bar.get("open") in (None, ""): bar["open"] = float(c.get("open") or cl) - res = check_sell_signal_backtest_bar( + minute_ticks = ( + collect_minute_ticks(ticks_by_code, code, t) if use_tick_exit else None + ) + res = resolve_backtest_sell( pos, bar, params, is_eod=is_eod, sell_fn=check_sell_signal_breakout_live, low_mode="current", + ticks=minute_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, ) if not res: continue - reason, exit_price = res + reason, exit_price, sell_time, _hold_min, exit_src = res + if exit_src == "ws_ticks": + tick_exit_count += 1 + else: + ohlc_exit_count += 1 all_trades.append({ "code": code, "buy_time": pos["entry_time"], - "sell_time": t, + "sell_time": sell_time or t, "buy_price": pos["entry_price"], "sell_price": round(exit_price, 2), "qty": pos.get("qty", 1), @@ -292,7 +357,7 @@ def run_breakout_backtest_portfolio( "sell_reason": reason, "hold_min": 0, }) - ctx["last_exit_dt"][day] = _t2dt(t) + ctx["last_exit_dt"][day] = _t2dt(sell_time or t) del portfolio[code] # ── Phase 2: 신규 매수 신호 ── @@ -407,7 +472,19 @@ def run_breakout_backtest_portfolio( _pri, pick_code, pe = candidates[0] ctx_by_code[pick_code]["pending_entry"] = pe + skip_stats: Dict[str, Any] = {} if skipped_micro_buys: - params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys} + skip_stats["skipped_micro_buys"] = skipped_micro_buys + if tick_exit_count or ohlc_exit_count: + skip_stats["tick_exit_count"] = tick_exit_count + skip_stats["ohlc_exit_count"] = ohlc_exit_count + flat_n = flatten_remaining_portfolio_trades( + portfolio, ctx_by_code, all_trades, + params=params, strategy="BREAKOUT", + ) + if flat_n: + skip_stats["bt_flatten_count"] = flat_n + if skip_stats: + params["_portfolio_skip_stats"] = skip_stats all_trades.sort(key=lambda x: x["sell_time"]) return all_trades diff --git a/kis_trader/backtest/breakout_tick_loader.py b/kis_trader/backtest/breakout_tick_loader.py index fd7c847..2c9d9dd 100644 --- a/kis_trader/backtest/breakout_tick_loader.py +++ b/kis_trader/backtest/breakout_tick_loader.py @@ -6,7 +6,7 @@ from __future__ import annotations from collections import defaultdict from typing import Any, Dict, List, Optional, Set, Tuple -from ..utils.env import get_env_from_db +from ..utils.env import get_env_bool, get_env_from_db from ..utils.logger import get_logger logger = get_logger("kis_trader.breakout_tick_loader") @@ -19,6 +19,23 @@ def _candle_keys_to_tick_range(start_key: str, end_key: str) -> Tuple[str, str]: return s + "00", e + "59" +def _prefer_kiwoom_minute_ticks( + ticks: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + 실매 후보 경로는 키움 체결량(FID15) 기준. + 같은 분에 kis(과거 BIDP1 오염 volume)가 섞이면 거래량 필터가 가짜 통과하므로 + 키움 틱이 1건이라도 있으면 키움만 사용. 키움 없으면 kis/기타 유지. + """ + if not ticks: + return ticks + kw = [ + t for t in ticks + if str(t.get("source") or "").strip().lower() == "kiwoom" + ] + return kw if kw else ticks + + def load_breakout_ticks_by_code( db, start_key: str, @@ -35,6 +52,7 @@ def load_breakout_ticks_by_code( """ mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper() tt_start, tt_end = _candle_keys_to_tick_range(start_key, end_key) + prefer_kw = get_env_bool("WS_TICK_PREFER_KIWOOM", True) try: if hasattr(db, "ensure_ws_ticks_table"): @@ -83,33 +101,276 @@ def load_breakout_ticks_by_code( bucket.append(tick) total += 1 + # 실매(키움 구독) 정합: 분봉 단위 키움 우선 (모멘텀/꼬리 로더가 이 함수 재사용) + if prefer_kw and out: + kept = 0 + for code, minutes in out.items(): + for mk, ticks in list(minutes.items()): + filtered = _prefer_kiwoom_minute_ticks(ticks) + minutes[mk] = filtered + kept += len(filtered) + if kept != total: + logger.info( + "📌 WS_TICK_PREFER_KIWOOM: raw=%d → kept=%d (분봉내 키움 우선)", + total, kept, + ) + total = kept + return dict(out), total +def _infer_bar_tf_min(candles: List[Dict], default: int = 1) -> int: + """연속 candle_time 간격의 중앙값으로 봉주기(분) 추정. 실패 시 default.""" + from kis_trader.engine.candle_rollup import minute_diff + + times = sorted({ + str(c.get("candle_time") or "")[:12] + for c in (candles or []) + if len(str(c.get("candle_time") or "")[:12]) >= 12 + }) + if len(times) < 2: + return max(1, int(default)) + diffs = [] + for i in range(1, min(len(times), 80)): + d = minute_diff(times[i - 1], times[i]) + if d is not None and d > 0: + diffs.append(int(d)) + if not diffs: + return max(1, int(default)) + diffs.sort() + return max(1, int(diffs[len(diffs) // 2])) + + +def _bar_has_ticks( + candle_time: str, + minute_map: Dict[str, List], + tf_min: int, +) -> bool: + """ + N분봉이면 시작~시작+tf 구간의 **어느 1분이라도** 틱이 있으면 커버. + (옛 로직은 시작분만 봐 → 꼬리 3M에서 커버가 과소 집계됨) + """ + from kis_trader.engine.candle_rollup import add_candle_minutes + + ct = str(candle_time or "")[:12] + if len(ct) < 12 or not minute_map: + return False + tf = max(1, int(tf_min)) + for i in range(tf): + mk = ct if i == 0 else add_candle_minutes(ct, i) + if mk and minute_map.get(mk): + return True + return False + + def tick_coverage_stats( codes_candles: Dict[str, List[Dict]], ticks_by_code: Dict[str, Dict[str, List[Dict]]], + *, + bar_tf_min: Optional[int] = None, ) -> Dict[str, Any]: - """분봉 대비 틱 보유 비율 (백테 메타용).""" + """ + 분봉 대비 틱 보유 비율 (백테 메타용). + + 왜 '전체 %'가 낮아 보이는가 + ───────────────────────── + 분봉(ws_candles)은 REST 갭보정으로 **구독 전·틱 없는 구간**까지 채워지고, + 틱(ws_ticks)은 **구독 중·체결 있는 분**만 쌓인다. 분모에 REST 봉을 넣으면 + 커버가 낮게 나온다 (기록 누락이 아님). + + 반환 키 + ─────── + - tick_bar_coverage_pct: **구독구간**(종목별 첫틱~끝틱) 커버 — UI 주표시 + - tick_bar_coverage_pct_all: 전체 봉(REST 웜업 포함) + - tick_bar_coverage_pct_friend: 틱이 1건이라도 있는 종목만 + """ total_bars = 0 - bars_with_ticks = 0 + covered_all = 0 + covered_exact_legacy = 0 + friend_bars = 0 + friend_covered = 0 + sub_bars = 0 + sub_covered = 0 codes_with_any = 0 - for code, candles in codes_candles.items(): + + for code, candles in (codes_candles or {}).items(): minute_map = ticks_by_code.get(code) or {} - if minute_map: + has_ticks = bool(minute_map) + if has_ticks: codes_with_any += 1 - for c in candles: + tf = int(bar_tf_min) if bar_tf_min and int(bar_tf_min) > 0 else _infer_bar_tf_min( + candles, default=1, + ) + tick_keys = [str(k)[:12] for k in minute_map.keys() if k] + first_m = min(tick_keys) if tick_keys else "" + last_m = max(tick_keys) if tick_keys else "" + + for c in candles or []: ct = str(c.get("candle_time") or "")[:12] - if not ct: + if len(ct) < 12: continue total_bars += 1 + hit = _bar_has_ticks(ct, minute_map, tf) if minute_map.get(ct): - bars_with_ticks += 1 - ratio = (bars_with_ticks / total_bars * 100.0) if total_bars else 0.0 + covered_exact_legacy += 1 + if hit: + covered_all += 1 + if has_ticks: + friend_bars += 1 + if hit: + friend_covered += 1 + # 구독구간: 첫 틱 분 ~ 마지막 틱 분 (봉 시작 시각 기준) + if first_m and last_m and first_m <= ct <= last_m: + sub_bars += 1 + if hit: + sub_covered += 1 + + pct_all = (covered_all / total_bars * 100.0) if total_bars else 0.0 + pct_friend = (friend_covered / friend_bars * 100.0) if friend_bars else 0.0 + pct_sub = (sub_covered / sub_bars * 100.0) if sub_bars else 0.0 + pct_legacy = (covered_exact_legacy / total_bars * 100.0) if total_bars else 0.0 + return { + # UI·로그 주표시 = 구독구간 (실매 '보면서 기록'에 가장 가깝다) + "tick_bar_coverage_pct": round(pct_sub, 2), + "tick_bar_coverage_pct_subscribed": round(pct_sub, 2), + "tick_bars_subscribed_total": sub_bars, + "tick_bars_subscribed_covered": sub_covered, + "tick_bar_coverage_pct_all": round(pct_all, 2), + "tick_bar_coverage_pct_friend": round(pct_friend, 2), + "tick_bar_coverage_pct_legacy_exact": round(pct_legacy, 2), "tick_bars_total": total_bars, - "tick_bars_covered": bars_with_ticks, - "tick_bar_coverage_pct": round(ratio, 2), + "tick_bars_covered": covered_all, + "tick_bars_friend_total": friend_bars, + "tick_bars_friend_covered": friend_covered, "tick_codes_with_data": codes_with_any, - "tick_codes_total": len(codes_candles), + "tick_codes_total": len(codes_candles or {}), } + + +def enrich_tick_meta_with_traded_codes( + tick_meta: Optional[Dict[str, Any]], + candles_by_code: Dict[str, List[Dict]], + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], + trades: Optional[List[Dict]], + *, + bar_tf_min: Optional[int] = None, +) -> Dict[str, Any]: + """ + 백테 **체결(거래) 종목만** 구독구간 커버를 tick_meta 에 보강. + UI: ``거래종목 NN%`` — 산 친구 칸만 센 값. + """ + out: Dict[str, Any] = dict(tick_meta or {}) + codes = sorted({ + str(t.get("code") or "").strip() + for t in (trades or []) + if str(t.get("code") or "").strip() + }) + out["tick_codes_traded"] = len(codes) + if not codes: + return out + sub_candles = { + c: candles_by_code[c] + for c in codes + if c in (candles_by_code or {}) and candles_by_code.get(c) + } + if not sub_candles: + return out + ticks = ticks_by_code or {} + sub_ticks = {c: ticks.get(c) or {} for c in sub_candles} + sub = tick_coverage_stats(sub_candles, sub_ticks, bar_tf_min=bar_tf_min) + out["tick_bar_coverage_pct_traded"] = sub.get("tick_bar_coverage_pct") + out["tick_bars_traded_subscribed_total"] = sub.get("tick_bars_subscribed_total") + out["tick_bars_traded_subscribed_covered"] = sub.get("tick_bars_subscribed_covered") + return out + + +def build_tick_coverage_meta_for_day( + db, + codes: Set[str], + start_ymd: str, + end_ymd: str, + *, + timeframe: int = 1, + trades: Optional[List[Dict]] = None, +) -> Dict[str, Any]: + """ + 실매·운영 탭용: 당일(기간) ws_candles + ws_ticks 로 분봉커버 메타 생성. + UI 통일 — 백테 ``tick_backtest`` 와 동일 키. + """ + codes_clean = sorted({str(c).strip() for c in (codes or set()) if str(c).strip()}) + if not codes_clean or not db: + return {} + start_key = str(start_ymd or "").replace("-", "")[:8] + "0900" + end_key = str(end_ymd or start_ymd or "").replace("-", "")[:8] + "1530" + if len(start_key) < 12 or len(end_key) < 12: + return {} + day_like = start_key[:8] + "%" + tf = max(1, int(timeframe or 1)) + placeholders = ",".join(["%s"] * len(codes_clean)) + try: + rows = db.conn.execute( + f"SELECT code, candle_time, open, high, low, close, volume " + f"FROM ws_candles WHERE timeframe=%s AND candle_time LIKE %s " + f"AND code IN ({placeholders})", + tuple([tf, day_like] + codes_clean), + ).fetchall() + except Exception as e: + logger.warning("build_tick_coverage_meta_for_day candles: %s", e) + return {} + candles_by_code: Dict[str, List[Dict]] = defaultdict(list) + for r in rows or []: + code = str(r.get("code") or "").strip() + ct = str(r.get("candle_time") or "")[:12] + if not code or len(ct) < 12: + continue + candles_by_code[code].append({ + "candle_time": ct, + "open": r.get("open"), + "high": r.get("high"), + "low": r.get("low"), + "close": r.get("close"), + "volume": r.get("volume"), + }) + if not candles_by_code: + return {"tick_codes_traded": len(codes_clean), "tick_bars_total": 0} + ticks_by_code, tick_rows = load_breakout_ticks_by_code( + db, start_key, end_key, set(candles_by_code.keys()), + ) + meta = tick_coverage_stats(dict(candles_by_code), ticks_by_code, bar_tf_min=tf) + meta["ws_tick_rows_loaded"] = tick_rows + trade_list = trades if trades is not None else [{"code": c} for c in codes_clean] + meta = enrich_tick_meta_with_traded_codes( + meta, dict(candles_by_code), ticks_by_code, trade_list, bar_tf_min=tf, + ) + return meta + + +def first_tick_minute_key( + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], + code: str, +) -> Optional[str]: + """종목 ws_ticks 중 가장 이른 분봉 키(YYYYMMDDHHMM). 없으면 None.""" + if not ticks_by_code or not code: + return None + minute_map = ticks_by_code.get(code) or {} + if not minute_map: + return None + keys = [str(k)[:12] for k in minute_map.keys() if k] + return min(keys) if keys else None + + +def entry_before_first_tick( + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], + code: str, + entry_bar_time: str, +) -> bool: + """정합용: 진입봉이 첫 틱 분 이전이면 True (매수 스킵 대상).""" + first = first_tick_minute_key(ticks_by_code, code) + if not first: + return False + et = str(entry_bar_time or "")[:12] + if not et: + return False + return et < first + diff --git a/kis_trader/backtest/dbband_backtest_common.py b/kis_trader/backtest/dbband_backtest_common.py index 06f39de..fdc8175 100644 --- a/kis_trader/backtest/dbband_backtest_common.py +++ b/kis_trader/backtest/dbband_backtest_common.py @@ -78,7 +78,8 @@ def load_dbband_candles_by_code( def _t2dt(candle_time: str) -> datetime: - return datetime.strptime(str(candle_time)[:12], "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(candle_time) def normalize_stored_min_candles(candles: List[Dict]) -> List[Dict]: diff --git a/kis_trader/backtest/env_timeline.py b/kis_trader/backtest/env_timeline.py new file mode 100644 index 0000000..68a3ec7 --- /dev/null +++ b/kis_trader/backtest/env_timeline.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +env 타임라인 — 백테스트 시 env_config / config_* 변경 시각별 스냅샷 재생. + +실매·파람서치는 최신 env 1행만 사용. 웹 백테만 ``EnvTimeline`` 으로 +봉 시각(candle_time)마다 그 시점까지의 DB 설정을 적용한다. +""" +from __future__ import annotations + +from bisect import bisect_right +from typing import Any, Dict, List, Optional, Tuple + + +def _created_at_to_key(created_at: str) -> str: + """``YYYY-MM-DD HH:MM:SS`` → ``YYYYMMDDHHMMSS``.""" + s = str(created_at or "").strip() + if len(s) >= 19: + return ( + s[0:4] + s[5:7] + s[8:10] + + s[11:13] + s[14:16] + s[17:19] + ) + return "" + + +def _candle_time_to_lookup_key(candle_time: str) -> str: + """분봉 키(YYYYMMDDHHMM) → 해당 분 마감초(…59) lookup 키.""" + t = str(candle_time or "")[:12] + if len(t) < 12: + return "" + return t + "59" + + +def _ymd_to_start_bound(ymd: str) -> str: + y = str(ymd or "")[:8] + if len(y) < 8: + return "" + return f"{y[:4]}-{y[4:6]}-{y[6:8]} 00:00:00" + + +class EnvTimeline: + """env 변경 이벤트 시계열 — ``snapshot_at`` 으로 봉 시각별 merged env 조회.""" + + def __init__(self, events: List[Tuple[str, Dict[str, Any]]]) -> None: + deduped: List[Tuple[str, Dict[str, Any]]] = [] + prev_sig: Optional[Tuple[str, ...]] = None + for key, snap in events: + if not key: + continue + sig = tuple(sorted((k, str(v)) for k, v in snap.items())) + if sig == prev_sig: + continue + prev_sig = sig + deduped.append((key, dict(snap))) + self._keys: List[str] = [e[0] for e in deduped] + self._snaps: List[Dict[str, Any]] = [e[1] for e in deduped] + + @property + def snapshot_count(self) -> int: + return len(self._keys) + + def snapshot_at(self, candle_time: str) -> Tuple[Dict[str, Any], str]: + """봉 시각 기준 적용 env 스냅샷 + 이벤트 키(YYYYMMDDHHMMSS).""" + if not self._keys: + return {}, "" + qkey = _candle_time_to_lookup_key(candle_time) + if not qkey: + return dict(self._snaps[0]), self._keys[0] + idx = bisect_right(self._keys, qkey) - 1 + if idx < 0: + return {}, "" + return dict(self._snaps[idx]), self._keys[idx] + + +def build_env_timeline( + db: Any, + start_ymd: str, + end_ymd: str, +) -> Optional[EnvTimeline]: + """백테 구간 env 변경 이력 → ``EnvTimeline`` (변경 없으면 1스냅샷).""" + if db is None: + return None + sy = str(start_ymd or "")[:8] + ey = str(end_ymd or "")[:8] + if len(sy) < 8 or len(ey) < 8: + return None + if not hasattr(db, "list_env_change_times_in_range"): + return None + if not hasattr(db, "get_merged_env_snapshot_as_of"): + return None + + start_bound = _ymd_to_start_bound(sy) + event_times = db.list_env_change_times_in_range(sy, ey) + if not event_times: + event_times = [start_bound] + + events: List[Tuple[str, Dict[str, Any]]] = [] + for as_of in event_times: + merged = db.get_merged_env_snapshot_as_of(as_of) + key = _created_at_to_key(as_of) + if not key: + continue + events.append((key, merged)) + + if not events: + return None + return EnvTimeline(events) diff --git a/kis_trader/backtest/momentum_backtest_common.py b/kis_trader/backtest/momentum_backtest_common.py index 5723a1e..3ee6d74 100644 --- a/kis_trader/backtest/momentum_backtest_common.py +++ b/kis_trader/backtest/momentum_backtest_common.py @@ -8,7 +8,8 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Optional, Tuple +import time +from typing import Any, Dict, List, Optional, Set, Tuple from kis_trader.backtest.backtest_portfolio_common import ( attach_scalp_trade_pnl, @@ -49,20 +50,16 @@ def momentum_universe_exit_debounce_sec() -> int: """ 백테 EXIT 디바운스(초). - 실매 ``CONDITION_EXIT_GRACE_SEC``(기본 120)가 이미 스냅샷에 반영되어 있으면 - 이중 디바운스를 쓰지 않음 — env ``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 로만 강제 가능. + 실매 ``CONDITION_EXIT_GRACE_SEC`` 와 동일하게 스냅샷 축소 시 N초 유지 + (overnight→장초 wipe / 단발 EXIT 노이즈 정합). + ``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 가 있으면 그 값 우선(0=OFF). """ - from kis_trader.utils.env import get_env_from_db, get_env_int - raw = get_env_from_db("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", None) - if raw not in (None, ""): - try: - return max(0, int(float(raw))) - except (TypeError, ValueError): - pass - grace = int(get_env_int("CONDITION_EXIT_GRACE_SEC", 0)) - if grace > 0: - return 0 - return max(0, int(get_env_int("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", 30))) + from kis_trader.backtest.universe_timeline import resolve_universe_exit_debounce_sec + + return resolve_universe_exit_debounce_sec( + strategy_env_key="MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", + default_when_no_grace=30, + ) def momentum_backtest_candle_warmup_bars() -> int: @@ -124,6 +121,177 @@ def prepend_momentum_candle_warmup( return total_prepended +# 종목×기간일 단위 REST 웜업 캐시 (프로세스 메모리만 — DB 미기록) +_REST_WARMUP_PREFIX_CACHE: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} + + +def _momentum_rows_have_prev_day(rows: List[Dict], period_day: str) -> bool: + """기간 시작일 이전 분봉이 있으면 HTS E(전일시가) 해석 가능.""" + pd = str(period_day or "")[:8] + if not pd: + return True + for r in rows or []: + ct = str(r.get("candle_time") or "") + if len(ct) >= 8 and ct[:8] < pd: + return True + return False + + +def _kiwoom_gap_credentials() -> Tuple[str, str, bool]: + """실매 갭보정과 동일 — REAL 우선, 없으면 LEGACY.""" + from kis_trader.utils.env import get_env_from_db, get_env_bool + + key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip() + secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip() + if not key or not secret: + key = (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip() + secret = (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip() + is_mock = get_env_bool("MOMENTUM_BACKTEST_REST_KIWOOM_MOCK", False) + return key, secret, is_mock + + +def inject_momentum_rest_warmup_memory( + candles_by_code: Dict[str, List[Dict]], + period_start_key: str, + *, + universe_by_slot: Optional[Dict[str, List[str]]] = None, +) -> Dict[str, int]: + """ + DB 전일봉이 없을 때 키움 ka10080 REST를 종목당 1회 호출해 **메모리에만** prepend. + + - 실매: 유니버스 편입 → 갭보정 RAM (DB 구데이터 미사용) + - 백테/Optuna: DB warmup 실패 시 동일 REST 1회로 E·지표 워밍업 + - DB INSERT 없음. 프로세스 캐시로 웹 재호출·Optuna trial 재조회 방지. + """ + from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int + from kis_trader.utils.logger import get_logger + + log = get_logger("kis_trader.momentum_backtest") + stats = {"need": 0, "ok": 0, "fail": 0, "cache_hit": 0, "bars": 0, "skipped": 0} + if not get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP", True): + stats["skipped"] = 1 + return stats + ps = str(period_start_key or "")[:12] + if len(ps) < 8 or not candles_by_code: + return stats + period_day = ps[:8] + + target: Set[str] + if universe_by_slot: + target = set() + for codes in universe_by_slot.values(): + for c in codes or []: + if c: + target.add(str(c).strip()) + target &= set(candles_by_code.keys()) + else: + target = set(candles_by_code.keys()) + + need_codes = [ + c for c in sorted(target) + if not _momentum_rows_have_prev_day(candles_by_code.get(c) or [], period_day) + ] + stats["need"] = len(need_codes) + if not need_codes: + return stats + + max_codes = int(get_env_int("MOMENTUM_BACKTEST_REST_MAX_CODES", 0)) + if max_codes > 0: + need_codes = need_codes[:max_codes] + + n_bars = max( + 50, + int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS", 700)), + ) + sleep_sec = float(get_env_float("MOMENTUM_BACKTEST_REST_SLEEP_SEC", 0.25)) + kw_key, kw_secret, is_mock = _kiwoom_gap_credentials() + if not kw_key or not kw_secret: + log.warning("⚠️ 모멘텀 REST 웜업 스킵 — 키움 앱키/시크릿 없음") + stats["fail"] = len(need_codes) + return stats + + from kis_trader.ws.kis_ws import get_kiwoom_candles_df + + log.info( + "📡 모멘텀 REST 웜업(메모리): 전일봉 부족 %d종목 · 종목당 1회 ka10080 n=%d (DB 미기록)", + len(need_codes), n_bars, + ) + for i, code in enumerate(need_codes): + rows = candles_by_code.get(code) or [] + if not rows: + stats["fail"] += 1 + continue + cache_key = (code, period_day) + cached = _REST_WARMUP_PREFIX_CACHE.get(cache_key) + if cached is not None: + stats["cache_hit"] += 1 + prefix = [dict(r) for r in cached] + else: + try: + df = get_kiwoom_candles_df( + code, 1, kw_key, kw_secret, is_mock=is_mock, n=n_bars, + ) + except Exception as e: + log.warning("⚠️ REST 웜업 실패 %s: %s", code, e) + stats["fail"] += 1 + continue + if df is None or getattr(df, "empty", True): + stats["fail"] += 1 + continue + first_ct = "" + for r in rows: + ct = str(r.get("candle_time") or "") + if ct >= ps: + first_ct = ct[:12] + break + if not first_ct: + first_ct = ps + existing = {str(r.get("candle_time") or "")[:12] for r in rows} + prefix = [] + try: + for _, rec in df.iterrows(): + t = str(rec.get("time") or "")[:12] + if len(t) < 12 or t >= first_ct or t in existing: + continue + op = float(rec.get("open") or 0) + if op <= 0: + continue + prefix.append({ + "candle_time": t, + "open": op, + "high": float(rec.get("high") or op), + "low": float(rec.get("low") or op), + "close": float(rec.get("close") or op), + "volume": int(float(rec.get("volume") or 0)), + "is_confirmed": 1, + "_rest_warmup": 1, + }) + except Exception as e: + log.warning("⚠️ REST 웜업 파싱 실패 %s: %s", code, e) + stats["fail"] += 1 + continue + prefix.sort(key=lambda x: str(x.get("candle_time") or "")) + _REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix] + if sleep_sec > 0 and i + 1 < len(need_codes): + time.sleep(sleep_sec) + + if not prefix: + stats["fail"] += 1 + continue + if not _momentum_rows_have_prev_day(prefix, period_day): + stats["fail"] += 1 + continue + candles_by_code[code] = [dict(r) for r in prefix] + [dict(r) for r in rows] + stats["ok"] += 1 + stats["bars"] += len(prefix) + + log.info( + "✅ 모멘텀 REST 웜업 완료: ok=%d fail=%d cache=%d bars=%d", + stats["ok"], stats["fail"], stats["cache_hit"], stats["bars"], + ) + return stats + + def date_keys(start: str, end: str) -> Tuple[str, str, str, str]: start_key = start.replace("-", "") + "0000" end_key = end.replace("-", "") + "2359" @@ -236,6 +404,7 @@ def run_momentum_backtest_web_aligned( ) -> List[Dict]: p = dict(params) warmup_prepended = 0 + rest_warmup_stats: Dict[str, int] = {} period_start_key = "" if meta_out is not None: start_key_meta = str(meta_out.get("start_key") or "") @@ -247,6 +416,14 @@ def run_momentum_backtest_web_aligned( warmup_prepended = prepend_momentum_candle_warmup( db_meta, candles_by_code, period_start_key, ) + if not period_start_key: + period_start_key = str(p.get("_backtest_period_start_key") or "")[:12] + if len(period_start_key) >= 8: + rest_warmup_stats = inject_momentum_rest_warmup_memory( + candles_by_code, + period_start_key, + universe_by_slot=universe_by_slot, + ) p["slot_money"] = float(slot_money) p["fee_rate"] = float(fee_rate) p["sell_tax"] = float(sell_tax) @@ -331,6 +508,8 @@ def run_momentum_backtest_web_aligned( strategy_id=MOMENTUM_STRATEGY_ID, use_saved_history=universe_by_slot is not None, ) + from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params + attach_backtest_env_timeline_to_params(p, meta_out, MOMENTUM_STRATEGY_ID) trades = run_momentum_backtest( candles_by_code, p, universe_by_slot=universe_by_slot, ticks_by_code=loaded_ticks or ticks_by_code, @@ -347,6 +526,8 @@ def run_momentum_backtest_web_aligned( if warmup_prepended > 0 or momentum_backtest_candle_warmup_bars() > 0: meta_out["skip_stats"]["candle_warmup_bars"] = momentum_backtest_candle_warmup_bars() meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended + if rest_warmup_stats: + meta_out["skip_stats"]["rest_warmup"] = rest_warmup_stats meta_out["universe_timing"] = ( "strict" if momentum_backtest_universe_strict_enabled() else "minute" ) @@ -355,6 +536,10 @@ def run_momentum_backtest_web_aligned( if snap_meta: meta_out["trigger_snapshot_backtest"] = snap_meta if tick_meta: + from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes + tick_meta = enrich_tick_meta_with_traded_codes( + tick_meta, candles_by_code, loaded_ticks, trades, + ) meta_out["tick_backtest"] = tick_meta return trades diff --git a/kis_trader/backtest/momentum_portfolio_backtest.py b/kis_trader/backtest/momentum_portfolio_backtest.py index 50aadb6..521a047 100644 --- a/kis_trader/backtest/momentum_portfolio_backtest.py +++ b/kis_trader/backtest/momentum_portfolio_backtest.py @@ -13,6 +13,7 @@ from datetime import datetime, timedelta from kis_trader.backtest.backtest_portfolio_common import ( attach_scalp_trade_pnl, backtest_slip_pct, + flatten_remaining_portfolio_trades, min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, @@ -37,11 +38,13 @@ from kis_trader.engine.momentum_tick_replay import ( collect_minute_ticks, momentum_backtest_live_scan_queue_enabled, momentum_backtest_scan_sec, + momentum_backtest_skip_pre_subscribe, momentum_backtest_use_tick_exit, momentum_live_align_enabled, resolve_momentum_sell_for_bar, try_momentum_sell_on_ticks, ) +from kis_trader.backtest.momentum_tick_loader import entry_before_first_tick from kis_trader.backtest.momentum_universe_timeline import ( MomentumUniverseTimeline, momentum_backtest_universe_scan_at_enabled, @@ -363,6 +366,7 @@ def _collect_buy_candidates( tp_pct: float, min_tick_time: str = "", eval_memo: Optional[Dict[Tuple, Any]] = None, + skip_pre_sub: bool = False, ) -> List[Tuple[Tuple[int, str], str, Dict[str, Any]]]: candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = [] # 순회 대상 종목: 유니버스가 있으면 그 종목만 순회 (전종목 261개 → 유니버스 ~28개). @@ -416,6 +420,11 @@ def _collect_buy_candidates( entry_open = float(next_c["open"]) if entry_open <= 0: continue + # 정합용: 구독(첫 틱) 전 진입봉 제외 — 파람 기본 OFF + if skip_pre_sub and entry_before_first_tick( + ticks_by_code, code, entry_bar_time, + ): + continue eval_params = dict(params) ic = get_indicator_cache_from_params(params, code) if ic is not None: @@ -516,6 +525,7 @@ def run_momentum_backtest_portfolio( entry_stats: Dict[str, int] = {} live_align = momentum_live_align_enabled(params) live_scan_queue = momentum_backtest_live_scan_queue_enabled(params) + skip_pre_sub = momentum_backtest_skip_pre_subscribe(params) scan_sec = momentum_backtest_scan_sec(params) universe_timeline = params.get("_momentum_universe_timeline") use_scan_at = ( @@ -560,12 +570,23 @@ def run_momentum_backtest_portfolio( scan_events = 0 scan_buys = 0 + from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at + if live_scan_queue and live_align: time_start_hm, time_end_hm = _time_bounds_hm(params) scan_keys = _build_scan_time_keys(all_times_set, scan_sec, time_start_hm, time_end_hm) for scan_key in scan_keys: scan_events += 1 bar_t = scan_key[:12] + if apply_env_timeline_at(params, bar_t, "MOMENTUM"): + max_stocks = _max_stocks_from_params(params) + slot_money = float(params.get("slot_money", 300_000)) + total_budget = _total_budget_from_params(params) + if total_budget <= 0: + total_budget = float(max_stocks * slot_money) + invest_cap = _resolve_invest_cap_krw(params, slot_money) + sl_pct = abs(float(params.get("sl_pct", 0.015))) + tp_pct = effective_tp_pct_from_params(params) slot_key = _slot_key(bar_t, int(params.get("scan_interval_min", 1))) tick_exit_count, ohlc_exit_count = _process_sells_for_scan( @@ -609,6 +630,7 @@ def run_momentum_backtest_portfolio( tp_pct=tp_pct, min_tick_time=scan_key, eval_memo=eval_memo, + skip_pre_sub=skip_pre_sub, ) if not candidates: continue @@ -629,6 +651,15 @@ def run_momentum_backtest_portfolio( skipped_micro_buys += 1 else: for t in all_times: + if apply_env_timeline_at(params, t, "MOMENTUM"): + max_stocks = _max_stocks_from_params(params) + slot_money = float(params.get("slot_money", 300_000)) + total_budget = _total_budget_from_params(params) + if total_budget <= 0: + total_budget = float(max_stocks * slot_money) + invest_cap = _resolve_invest_cap_krw(params, slot_money) + sl_pct = abs(float(params.get("sl_pct", 0.015))) + tp_pct = effective_tp_pct_from_params(params) slot_key = _slot_key(t, int(params.get("scan_interval_min", 1))) pending_codes = [ @@ -720,6 +751,7 @@ def run_momentum_backtest_portfolio( program_by_code=program_by_code, sl_pct=sl_pct, tp_pct=tp_pct, + skip_pre_sub=skip_pre_sub, ) if not candidates: continue @@ -762,6 +794,12 @@ def run_momentum_backtest_portfolio( skip_stats["universe_mode"] = "minute_slot" else: skip_stats["buy_queue_mode"] = "minute_legacy" + flat_n = flatten_remaining_portfolio_trades( + portfolio, ctx_by_code, all_trades, + params=params, strategy=MOMENTUM_STRATEGY_ID, + ) + if flat_n: + skip_stats["bt_flatten_count"] = flat_n if skip_stats: params["_portfolio_skip_stats"] = skip_stats diff --git a/kis_trader/backtest/momentum_random_benchmark.py b/kis_trader/backtest/momentum_random_benchmark.py index 9ec8051..40176af 100644 --- a/kis_trader/backtest/momentum_random_benchmark.py +++ b/kis_trader/backtest/momentum_random_benchmark.py @@ -43,6 +43,7 @@ from kis_trader.backtest import scalping_backtest_common as sbc # noqa: E402 from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402 attach_scalp_trade_pnl, backtest_slip_pct, + load_portfolio_env_row, min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, @@ -260,7 +261,9 @@ def run_portfolio( if rng is None: eval_params = dict(params) - eval_params.setdefault("skip_hts_scan_dupes", universe_by_slot is not None) + if "skip_hts_scan_dupes" not in eval_params: + from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes + eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes() state = { "daily_cnt": ctx["daily_cnt"].get(day, 0), "last_exit_dt": ctx["last_exit_dt"].get(day), @@ -434,12 +437,7 @@ def _run_benchmark(args: argparse.Namespace) -> int: fixed = _mom_fixed_defaults() rsi_period = int(fixed.get("rsi_period", 3)) - db = TradeDB() - try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} - finally: - db.close() + env_row = load_portfolio_env_row() fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") portfolio = sbc.resolve_scalp_portfolio_params( diff --git a/kis_trader/backtest/momentum_rr_crossval.py b/kis_trader/backtest/momentum_rr_crossval.py index fdc9369..53cc60c 100644 --- a/kis_trader/backtest/momentum_rr_crossval.py +++ b/kis_trader/backtest/momentum_rr_crossval.py @@ -34,6 +34,7 @@ logging.getLogger("TradeDB").setLevel(logging.WARNING) from database import TradeDB from kis_trader.backtest import scalping_backtest_common as sbc +from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row from kis_trader.backtest.param_search_momentum import ( _evaluate_momentum_chunk, _load_candles_for_search, @@ -220,12 +221,7 @@ def phase_rr_sweep( universe, src = _load_universe(train_start, train_end) print(f" 캔들 종목 {len(candles)} | 유니버스={src} 슬롯={len(universe or {})}") - db = TradeDB() - try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} - finally: - db.close() + env_row = load_portfolio_env_row() fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") results: List[Dict[str, Any]] = [] @@ -273,12 +269,7 @@ def phase_crossval_presets( universe, src = _load_universe(oos_start, oos_end) print(f" 캔들 종목 {len(candles)} | 유니버스={src}") - db = TradeDB() - try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} - finally: - db.close() + env_row = load_portfolio_env_row() fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") oos_rows: List[Dict[str, Any]] = [] @@ -379,12 +370,7 @@ def phase_crossval_search_json( candles = _load_candles_for_search(oos_start, oos_end, int(fixed["rsi_period"])) universe, _ = _load_universe(oos_start, oos_end) - db = TradeDB() - try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} - finally: - db.close() + env_row = load_portfolio_env_row() fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") oos_rows: List[Dict[str, Any]] = [] diff --git a/kis_trader/backtest/momentum_rr_quick_eval.py b/kis_trader/backtest/momentum_rr_quick_eval.py index 7929ad5..a31630c 100644 --- a/kis_trader/backtest/momentum_rr_quick_eval.py +++ b/kis_trader/backtest/momentum_rr_quick_eval.py @@ -11,7 +11,7 @@ ROOT = os.path.dirname(os.path.dirname(HERE)) if ROOT not in sys.path: sys.path.insert(0, ROOT) -from database import TradeDB +from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row from kis_trader.backtest.param_search_momentum import ( _evaluate_momentum_chunk, _load_candles_for_search, @@ -60,10 +60,7 @@ def main() -> None: uni_cache[label], src = _load_universe(s, e) print(f" {label} {s}~{e}: candles={len(data_cache[label])} uni={src}") - db = TradeDB() - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} - db.close() + env_row = load_portfolio_env_row() from kis_trader.backtest import scalping_backtest_common as sbc fee, tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") diff --git a/kis_trader/backtest/momentum_tick_loader.py b/kis_trader/backtest/momentum_tick_loader.py index 382c822..f7f4997 100644 --- a/kis_trader/backtest/momentum_tick_loader.py +++ b/kis_trader/backtest/momentum_tick_loader.py @@ -3,10 +3,17 @@ from __future__ import annotations from kis_trader.backtest.breakout_tick_loader import ( + entry_before_first_tick, + first_tick_minute_key, load_breakout_ticks_by_code, tick_coverage_stats, ) load_momentum_ticks_by_code = load_breakout_ticks_by_code -__all__ = ["load_momentum_ticks_by_code", "tick_coverage_stats"] +__all__ = [ + "load_momentum_ticks_by_code", + "tick_coverage_stats", + "first_tick_minute_key", + "entry_before_first_tick", +] diff --git a/kis_trader/backtest/optuna_breakout.py b/kis_trader/backtest/optuna_breakout.py index b91e014..8518012 100644 --- a/kis_trader/backtest/optuna_breakout.py +++ b/kis_trader/backtest/optuna_breakout.py @@ -16,6 +16,8 @@ from optuna.samplers import RandomSampler, TPESampler from database import TradeDB from kis_trader.backtest import breakout_backtest_common as bbc from kis_trader.backtest.optuna_search_space import breakout_grid_axis_keys, suggest_breakout_params +from kis_trader.backtest.optuna_mode_combo import enrich_out_data_with_mode_combo +from kis_trader.backtest.optuna_common import announce_optuna_json_path, release_shared_tick_store from kis_trader.backtest.param_search_breakout import ( _bo_fixed_defaults, _load_candles_for_search, @@ -102,8 +104,8 @@ def prepare_breakout_search_context( db = TradeDB() try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env_row = load_portfolio_env_row(db) finally: db.close() @@ -333,79 +335,119 @@ def run_breakout_optuna( t0 = time.time() try: study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=show_progress) - finally: - # 탐색 종료(또는 예외) 시 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치). - _store = getattr(ctx, "shared_tick_store", None) - if _store is not None: + elapsed = time.time() - t0 + + passing: List[Dict[str, Any]] = [] + for trial in study.trials: + if trial.state != optuna.trial.TrialState.COMPLETE: + continue + if not trial.user_attrs.get("gates_ok"): + continue + merged_raw = trial.user_attrs.get("merged_json") or "{}" try: - _store.unlink() - except Exception: - pass - ctx.shared_tick_store = None - elapsed = time.time() - t0 + merged = json.loads(merged_raw) + except json.JSONDecodeError: + merged = dict(trial.params) + passing.append({ + "params": dict(trial.params), + "merged_params": merged, + "total_trades": int(trial.user_attrs.get("total_trades") or 0), + "win_rate": float(trial.user_attrs.get("win_rate") or 0), + "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), + "pf": float(trial.user_attrs.get("pf") or 0), + "optuna_trial_number": trial.number, + }) - passing: List[Dict[str, Any]] = [] - for trial in study.trials: - if trial.state != optuna.trial.TrialState.COMPLETE: - continue - if not trial.user_attrs.get("gates_ok"): - continue - merged_raw = trial.user_attrs.get("merged_json") or "{}" - try: - merged = json.loads(merged_raw) - except json.JSONDecodeError: - merged = dict(trial.params) - passing.append({ - "params": dict(trial.params), - "merged_params": merged, - "total_trades": int(trial.user_attrs.get("total_trades") or 0), - "win_rate": float(trial.user_attrs.get("win_rate") or 0), - "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), - "pf": float(trial.user_attrs.get("pf") or 0), - "optuna_trial_number": trial.number, - }) + if sort_by == "win_rate": + passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) + else: + passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) + profitable = [r for r in passing if r["total_pnl"] > 0] + if profitable: + passing = profitable - if sort_by == "win_rate": - passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) - else: - passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) - profitable = [r for r in passing if r["total_pnl"] > 0] - if profitable: - passing = profitable + hints: Dict[str, str] = {} + out_data = { + "engine": "optuna", + "strategy": "breakout", + "mode": ctx.mode, + "start": ctx.start, + "end": ctx.end, + "slot_money": int(ctx.slot_money), + "max_stocks": ctx.max_stocks, + "total_budget_krw": int(ctx.total_budget_krw), + "backtest_days": ctx.period_days, + "min_trades": min_trades, + "min_win_rate": min_win_rate, + "min_pf": min_pf, + "sort_by": sort_by, + "grid_keys": ctx.grid_keys, + "grid_axis_hints": {k: hints[k] for k in ctx.grid_keys if k in hints}, + "optuna_study_name": study_name, + "optuna_storage": storage_url, + "optuna_n_trials_requested": n_trials, + "optuna_trials_completed": len(study.trials), + "optuna_best_value": study.best_value if study.best_trial else None, + "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, + "elapsed_sec": round(elapsed, 1), + "results": passing[:5000], + } - hints: Dict[str, str] = {} - out_data = { - "engine": "optuna", - "strategy": "breakout", - "mode": ctx.mode, - "start": ctx.start, - "end": ctx.end, - "slot_money": int(ctx.slot_money), - "max_stocks": ctx.max_stocks, - "total_budget_krw": int(ctx.total_budget_krw), - "backtest_days": ctx.period_days, - "min_trades": min_trades, - "min_win_rate": min_win_rate, - "min_pf": min_pf, - "sort_by": sort_by, - "grid_keys": ctx.grid_keys, - "grid_axis_hints": {k: hints[k] for k in ctx.grid_keys if k in hints}, - "optuna_study_name": study_name, - "optuna_storage": storage_url, - "optuna_n_trials_requested": n_trials, - "optuna_trials_completed": len(study.trials), - "optuna_best_value": study.best_value if study.best_trial else None, - "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, - "elapsed_sec": round(elapsed, 1), - "results": passing[:5000], - } - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - out_path = os.path.join(_results_dir_for_write(), f"optuna_breakout_{ctx.mode}_{ts}.json") - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - logger.info("💾 Optuna 결과 저장: %s", out_path) - study._kis_export_path = out_path # type: ignore[attr-defined] - return study + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out_path = os.path.join(_results_dir_for_write(), f"optuna_breakout_{ctx.mode}_{ts}.json") + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="breakout", mode=ctx.mode, note="중간저장(mode 전)", log=logger, + ) + + def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return evaluate_breakout_param_combo( + combo, + base_fixed=ctx.base_fixed, + grid_keys=ctx.grid_keys, + codes_candles=ctx.codes_candles, + min_trades=1, + min_win_rate=0.0, + min_pf=0.0, + universe_by_slot=ctx.universe_by_slot, + slot_money=ctx.slot_money, + max_stocks=ctx.max_stocks, + total_budget_krw=ctx.total_budget_krw, + fee_rate=ctx.fee_rate, + sell_tax=ctx.sell_tax, + period_days=ctx.period_days, + cache_holder=ctx.cache_holder, + ticks_by_code=ctx.ticks_by_code, + orderbook_by_code=ctx.orderbook_by_code, + program_by_code=ctx.program_by_code, + log_verdict_by_code=ctx.log_verdict_by_code, + share_denom_by_code=ctx.share_denom_by_code, + ) + + def _save_partial(_data: Dict[str, Any]) -> None: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="breakout", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, + ) + + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="breakout", mode=ctx.mode, note="최종 JSON", log=logger, + ) + study._kis_export_path = out_path # type: ignore[attr-defined] + return study + finally: + release_shared_tick_store(ctx, log=logger) def apply_best_breakout_trial(study: optuna.Study) -> bool: diff --git a/kis_trader/backtest/optuna_common.py b/kis_trader/backtest/optuna_common.py index 848df45..0c250ad 100644 --- a/kis_trader/backtest/optuna_common.py +++ b/kis_trader/backtest/optuna_common.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging import os -from typing import Optional +from typing import Any, Optional from urllib.parse import quote_plus from kis_trader.utils.env import get_env_from_db @@ -18,7 +18,7 @@ logger = logging.getLogger("optuna_common") # Optuna 전용 MariaDB (매매 DB kis_quant_db 와 분리) DEFAULT_OPTUNA_DB_NAME = "kis_optuna" -OPTUNA_STRATEGIES = ("tail", "momentum", "breakout") +OPTUNA_STRATEGIES = ("tail", "momentum", "breakout", "scalp") def mariadb_creds() -> dict: @@ -146,3 +146,66 @@ def resolve_study_name( def optuna_run_lock_name(strategy: str) -> str: return f"{strategy}_param_search_optuna" + + +def release_shared_tick_store(ctx: Any, *, log: Optional[logging.Logger] = None) -> None: + """ + Optuna ctx.shared_tick_store 해제. + + 주의: ticks_by_code 가 공유메모리 뷰인 경우, unlink 이후 접근하면 + SIGBUS/강제종료(트레이스백 없음) 난다. 최빈(mode_combo) 실측·JSON 저장이 + 끝난 뒤에만 호출할 것. optimize() 직후 즉시 unlink 금지. + """ + lg = log or logger + store = getattr(ctx, "shared_tick_store", None) + if store is None: + return + try: + store.unlink() + except Exception as exc: + lg.warning("⚠️ shared_tick_store unlink 실패: %s", exc) + try: + ctx.shared_tick_store = None + except Exception: + pass + + + +def announce_optuna_json_path( + out_path: str, + *, + strategy: str = "", + mode: str = "", + note: str = "", + log: Optional[logging.Logger] = None, +) -> str: + """ + 결과 JSON 절대경로를 터미널·로그에 눈에 띄게 고지. + 또한 logs/optuna___latest.jsonpath 에 기록 (없으면 strategy만). + """ + abs_path = os.path.abspath(str(out_path or "").strip()) + lg = log or logger + tag = note.strip() or "결과 JSON" + line = f"📁 [{tag}] {abs_path}" + # logger + print 이중 — nohup 로그·터미널 모두에서 바로 보이게 + lg.info("%s", line) + print(line, flush=True) + print(f"OPTUNA_RESULT_JSON={abs_path}", flush=True) + + try: + root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + logs_dir = os.path.join(root, "logs") + os.makedirs(logs_dir, exist_ok=True) + s = (strategy or "optuna").strip().lower() or "optuna" + m = (mode or "run").strip().lower() or "run" + for name in ( + f"optuna_{s}_{m}_latest.jsonpath", + f"optuna_{s}_latest.jsonpath", + "optuna_latest.jsonpath", + ): + with open(os.path.join(logs_dir, name), "w", encoding="utf-8") as f: + f.write(abs_path + "\n") + except OSError as exc: + lg.warning("⚠️ jsonpath 사이드카 기록 실패: %s", exc) + return abs_path + diff --git a/kis_trader/backtest/optuna_mode_combo.py b/kis_trader/backtest/optuna_mode_combo.py new file mode 100644 index 0000000..d51a0f4 --- /dev/null +++ b/kis_trader/backtest/optuna_mode_combo.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +kis_trader/backtest/optuna_mode_combo.py — Optuna Top-N 최빈 조합 추출·실측 백테 +================================================================================ +파람서치 종료 후 JSON/로그에 넣기 위한 공통 유틸. + +기준: + 1) results 중 total_pnl 있는 행만 + 2) PnL 내림차순 Top-N (기본 20, env OPTUNA_MODE_TOP_N) + 3) 축별 단순 최빈(표수, PnL 가중 없음) → mode_combo + 4) evaluate_fn(mode_combo) 로 1회 실측 백테 (게이트는 호출측 min_* 에 따름) + +apply 는 하지 않음 — 확인용 리포트만. +""" +from __future__ import annotations + +import logging +from collections import Counter +from typing import Any, Callable, Dict, List, Optional + +from kis_trader.utils.env import get_env_int + +logger = logging.getLogger("optuna_mode_combo") + +EvalFn = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]] + + +def resolve_mode_top_n(default: int = 20) -> int: + """Top-N — env OPTUNA_MODE_TOP_N (기본 20).""" + n = int(get_env_int("OPTUNA_MODE_TOP_N", int(default))) + return max(1, n) + + +def mode_combo_from_results( + results: List[Dict[str, Any]], + *, + top_n: int = 20, + grid_keys: Optional[List[str]] = None, + params_key: str = "params", +) -> Dict[str, Any]: + """ + Top-N(PnL) 축별 최빈 → mode_combo + 빈도 메타. + + Returns: + { + "top_n": int, + "pool_size": int, + "params": {축: 최빈값}, + "freq": {축: {"value": ..., "count": n, "of": pool}}, + "top_pnls": [...], + } + """ + rows = [ + r for r in (results or []) + if r.get("total_pnl") is not None and abs(float(r.get("total_pnl") or 0)) < 1e15 + ] + rows.sort( + key=lambda r: ( + -float(r.get("total_pnl") or 0), + -float(r.get("win_rate") or 0), + -int(r.get("total_trades") or 0), + ) + ) + pool = rows[: max(1, int(top_n))] + if not pool: + return { + "top_n": int(top_n), + "pool_size": 0, + "params": {}, + "freq": {}, + "top_pnls": [], + } + + # 축 집합: grid_keys 우선, 없으면 Top pool params 합집합 + keys: List[str] = [] + if grid_keys: + keys = [k for k in grid_keys if k] + if not keys: + seen = set() + for r in pool: + for k in (r.get(params_key) or {}).keys(): + if k not in seen: + seen.add(k) + keys.append(k) + + params: Dict[str, Any] = {} + freq: Dict[str, Any] = {} + for k in keys: + c: Counter = Counter() + samples: Dict[str, Any] = {} + for r in pool: + v = (r.get(params_key) or {}).get(k) + if v is None and params_key != "merged_params": + v = (r.get("merged_params") or {}).get(k) + s = str(v) + c[s] += 1 + samples.setdefault(s, v) + if not c: + continue + best_s, cnt = c.most_common(1)[0] + params[k] = samples[best_s] + freq[k] = {"value": params[k], "count": int(cnt), "of": len(pool)} + + return { + "top_n": int(top_n), + "pool_size": len(pool), + "params": params, + "freq": freq, + "top_pnls": [float(r.get("total_pnl") or 0) for r in pool[:10]], + } + + +def _bt_summary(result: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not result: + return { + "ok": False, + "total_pnl": None, + "total_trades": None, + "win_rate": None, + "pf": None, + "note": "evaluate returned None (게이트·0건·invalid)", + } + return { + "ok": True, + "total_pnl": float(result.get("total_pnl") or 0), + "total_trades": int(result.get("total_trades") or 0), + "win_rate": float(result.get("win_rate") or 0), + "pf": float(result.get("pf") or 0) if result.get("pf") is not None else None, + "score": float(result.get("score") or 0) if result.get("score") is not None else None, + } + + +def enrich_out_data_with_mode_combo( + out_data: Dict[str, Any], + *, + evaluate_fn: Optional[EvalFn] = None, + top_n: Optional[int] = None, + grid_keys: Optional[List[str]] = None, + params_key: str = "params", + log: Optional[logging.Logger] = None, + on_partial_save: Optional[Callable[[Dict[str, Any]], None]] = None, +) -> Dict[str, Any]: + """ + out_data['results'] 기준 최빈 추출 → (선택) 실측 백테 → out_data['mode_combo'] 기록 + 로그. + + evaluate_fn: mode params → evaluate_*_param_combo 결과 dict 또는 None. + on_partial_save: 최빈 params 기록 직후(실측 전) 호출 — JSON에 mode_combo가 남도록. + """ + lg = log or logger + n = int(top_n) if top_n is not None else resolve_mode_top_n(20) + keys = grid_keys or list(out_data.get("grid_keys") or []) + mode_meta = mode_combo_from_results( + list(out_data.get("results") or []), + top_n=n, + grid_keys=keys or None, + params_key=params_key, + ) + report: Dict[str, Any] = { + "method": "top_n_per_axis_mode", + "top_n": mode_meta["top_n"], + "pool_size": mode_meta["pool_size"], + "params": mode_meta["params"], + "freq": mode_meta["freq"], + "top_pnls": mode_meta["top_pnls"], + "backtest": None, + "vs_best": None, + "note": "trial 번호 없음(축별 최빈 조립). optuna_best_trial_number 와 별개.", + } + + best_pnl = None + best_tr = None + res0 = (out_data.get("results") or [None])[0] + if res0: + best_pnl = float(res0.get("total_pnl") or 0) + best_tr = int(res0.get("total_trades") or 0) + + lg.info( + "📊 [mode] Top-%d 최빈 추출 | pool=%d | top_pnls=%s", + mode_meta["top_n"], + mode_meta["pool_size"], + mode_meta["top_pnls"][:5], + ) + if mode_meta["params"]: + # 축별 빈도 요약 (짧게) + bits = [] + for k, meta in list(mode_meta["freq"].items())[:12]: + bits.append(f"{k}={meta['value']}({meta['count']}/{meta['of']})") + lg.info("📊 [mode] params(일부): %s", " | ".join(bits)) + + # 실측 전에 먼저 JSON에 박아 둠 (실측 중 죽어도 mode_combo.params 는 남음) + out_data["mode_combo"] = report + if on_partial_save is not None: + try: + on_partial_save(out_data) + except Exception as exc: + lg.warning("⚠️ mode_combo 부분저장 실패: %s", exc) + + if evaluate_fn is not None and mode_meta["params"]: + try: + bt = evaluate_fn(dict(mode_meta["params"])) + report["backtest"] = _bt_summary(bt) + if report["backtest"].get("ok"): + lg.info( + "🧪 [mode] 실측 백테 | pnl=%s | trades=%s | wr=%.1f%% | pf=%s", + report["backtest"]["total_pnl"], + report["backtest"]["total_trades"], + float(report["backtest"]["win_rate"] or 0), + report["backtest"].get("pf"), + ) + else: + lg.warning("🧪 [mode] 실측 백테 실패/게이트: %s", report["backtest"].get("note")) + except Exception as exc: + report["backtest"] = {"ok": False, "error": str(exc)} + lg.warning("🧪 [mode] 실측 백테 예외: %s", exc) + + if best_pnl is not None and report.get("backtest") and report["backtest"].get("ok"): + mode_pnl = float(report["backtest"]["total_pnl"] or 0) + report["vs_best"] = { + "best_pnl": best_pnl, + "best_trades": best_tr, + "mode_pnl": mode_pnl, + "mode_trades": report["backtest"].get("total_trades"), + "delta_pnl": round(mode_pnl - best_pnl, 2), + } + lg.info( + "📐 [mode vs #1] best_pnl=%s (%s건) | mode_pnl=%s (%s건) | Δ=%+.0f", + best_pnl, + best_tr, + mode_pnl, + report["backtest"].get("total_trades"), + mode_pnl - best_pnl, + ) + + out_data["mode_combo"] = report + return out_data diff --git a/kis_trader/backtest/optuna_momentum.py b/kis_trader/backtest/optuna_momentum.py index 08dfce6..2c84c9b 100644 --- a/kis_trader/backtest/optuna_momentum.py +++ b/kis_trader/backtest/optuna_momentum.py @@ -17,6 +17,8 @@ from database import TradeDB from kis_trader.backtest import momentum_backtest_common as mbc from kis_trader.backtest import scalping_backtest_common as sbc from kis_trader.backtest.optuna_search_space import momentum_grid_axis_keys, suggest_momentum_params +from kis_trader.backtest.optuna_mode_combo import enrich_out_data_with_mode_combo +from kis_trader.backtest.optuna_common import announce_optuna_json_path, release_shared_tick_store from kis_trader.backtest.param_search_cli_common import ( apply_session_to_fixed, combo_passes_search_filters, @@ -81,7 +83,7 @@ def prepare_momentum_search_context( ) -> Optional[MomentumSearchContext]: grids = _momentum_grids() if mode not in grids: - logger.error("❌ 모멘텀 mode: %s (fast/rr/coarse/fine/full)", mode) + logger.error("❌ 모멘텀 mode: %s (fast/exit/rr/coarse/fine/wide/full)", mode) return None base_fixed = _mom_fixed_defaults() @@ -108,8 +110,8 @@ def prepare_momentum_search_context( db = TradeDB() try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env_row = load_portfolio_env_row(db) finally: db.close() @@ -174,6 +176,24 @@ def prepare_momentum_search_context( else: base_fixed["scan_interval_min"] = 1 + # DB 전일봉 없으면 키움 REST 1회/종목 → 메모리 prepend (실매 갭보정 정합, DB 미기록) + try: + from kis_trader.backtest.momentum_backtest_common import ( + inject_momentum_rest_warmup_memory, + ) + _rw = inject_momentum_rest_warmup_memory( + codes_candles, + start_key, + universe_by_slot=universe_by_slot, + ) + if int(_rw.get("ok") or 0) > 0 or int(_rw.get("need") or 0) > 0: + logger.info( + "📡 REST 웜업: need=%s ok=%s fail=%s bars=%s", + _rw.get("need"), _rw.get("ok"), _rw.get("fail"), _rw.get("bars"), + ) + except Exception as exc: + logger.warning("⚠️ REST 웜업 스킵: %s", exc) + grid = grids[mode] _ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult") _ob_sweeping = any(len(set(grid.get(k) or [])) > 1 for k in _ob_axes) @@ -345,84 +365,129 @@ def run_momentum_optuna( t0 = time.time() try: study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=show_progress) - finally: - # 탐색 종료(또는 예외) 시 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치). - _store = getattr(ctx, "shared_tick_store", None) - if _store is not None: + elapsed = time.time() - t0 + + passing: List[Dict[str, Any]] = [] + for trial in study.trials: + if trial.state != optuna.trial.TrialState.COMPLETE: + continue + if not trial.user_attrs.get("gates_ok"): + continue + merged_raw = trial.user_attrs.get("merged_json") or "{}" try: - _store.unlink() - except Exception: - pass - ctx.shared_tick_store = None - elapsed = time.time() - t0 + merged = json.loads(merged_raw) + except json.JSONDecodeError: + merged = dict(trial.params) + row = { + "params": dict(trial.params), + "merged_params": merged, + "total_trades": int(trial.user_attrs.get("total_trades") or 0), + "win_rate": float(trial.user_attrs.get("win_rate") or 0), + "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), + "pf": float(trial.user_attrs.get("pf") or 0), + "mdd": float(trial.user_attrs.get("mdd") or 0), + "score": float(trial.user_attrs.get("score") or 0), + "optuna_trial_number": trial.number, + } + passing.append(row) - passing: List[Dict[str, Any]] = [] - for trial in study.trials: - if trial.state != optuna.trial.TrialState.COMPLETE: - continue - if not trial.user_attrs.get("gates_ok"): - continue - merged_raw = trial.user_attrs.get("merged_json") or "{}" - try: - merged = json.loads(merged_raw) - except json.JSONDecodeError: - merged = dict(trial.params) - row = { - "params": dict(trial.params), - "merged_params": merged, - "total_trades": int(trial.user_attrs.get("total_trades") or 0), - "win_rate": float(trial.user_attrs.get("win_rate") or 0), - "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), - "pf": float(trial.user_attrs.get("pf") or 0), - "mdd": float(trial.user_attrs.get("mdd") or 0), - "score": float(trial.user_attrs.get("score") or 0), - "optuna_trial_number": trial.number, + if sort_by == "score": + passing.sort(key=lambda r: (-r["score"], -r["total_pnl"], -r["win_rate"])) + elif sort_by == "win_rate": + passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) + else: + passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) + + profitable = [r for r in passing if r["total_pnl"] > 0] + if profitable: + passing = profitable + + out_data = { + "engine": "optuna", + "strategy": "momentum", + "mode": ctx.mode, + "start": ctx.start, + "end": ctx.end, + "slot_money": int(ctx.slot_money), + "max_stocks": ctx.max_stocks, + "total_budget_krw": int(ctx.total_budget_krw), + "backtest_days": ctx.period_days, + "min_trades": min_trades, + "min_win_rate": min_win_rate, + "min_pf": min_pf, + "sort_by": sort_by, + "grid_keys": ctx.grid_keys, + "grid_axis_hints": {k: MOMENTUM_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in MOMENTUM_GRID_AXIS_HINTS_KO}, + "optuna_study_name": study_name, + "optuna_storage": storage_url, + "optuna_n_trials_requested": n_trials, + "optuna_trials_completed": len(study.trials), + "optuna_best_value": study.best_value if study.best_trial else None, + "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, + "elapsed_sec": round(elapsed, 1), + "results": passing[:5000], } - passing.append(row) - if sort_by == "score": - passing.sort(key=lambda r: (-r["score"], -r["total_pnl"], -r["win_rate"])) - elif sort_by == "win_rate": - passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) - else: - passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out_path = os.path.join(_results_dir_for_write(), f"optuna_momentum_{ctx.mode}_{ts}.json") + # 최빈 실측 전에 먼저 저장·경로 고지 (실측이 길어도 바로 파일 열 수 있게) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="momentum", mode=ctx.mode, note="중간저장(mode 전)", log=logger, + ) - profitable = [r for r in passing if r["total_pnl"] > 0] - if profitable: - passing = profitable + def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]: + # 최빈 Frankenstein 실측 — 게이트는 느슨하게(리포트용) + # ※ shared_tick_store 가 아직 살아 있어야 함 (optimize 직후 unlink 금지) + return evaluate_momentum_param_combo( + combo, + base_fixed=ctx.base_fixed, + grid_keys=ctx.grid_keys, + codes_candles=ctx.codes_candles, + min_trades=1, + min_win_rate=0.0, + min_pf=0.0, + universe_by_slot=ctx.universe_by_slot, + slot_money=ctx.slot_money, + max_stocks=ctx.max_stocks, + total_budget_krw=ctx.total_budget_krw, + fee_rate=ctx.fee_rate, + sell_tax=ctx.sell_tax, + period_days=ctx.period_days, + cache_holder=ctx.cache_holder, + ticks_by_code=ctx.ticks_by_code, + orderbook_by_code=ctx.orderbook_by_code, + program_by_code=ctx.program_by_code, + log_verdict_by_code=ctx.log_verdict_by_code, + start_key=ctx.start_key, + end_key=ctx.end_key, + ) - out_data = { - "engine": "optuna", - "strategy": "momentum", - "mode": ctx.mode, - "start": ctx.start, - "end": ctx.end, - "slot_money": int(ctx.slot_money), - "max_stocks": ctx.max_stocks, - "total_budget_krw": int(ctx.total_budget_krw), - "backtest_days": ctx.period_days, - "min_trades": min_trades, - "min_win_rate": min_win_rate, - "min_pf": min_pf, - "sort_by": sort_by, - "grid_keys": ctx.grid_keys, - "grid_axis_hints": {k: MOMENTUM_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in MOMENTUM_GRID_AXIS_HINTS_KO}, - "optuna_study_name": study_name, - "optuna_storage": storage_url, - "optuna_n_trials_requested": n_trials, - "optuna_trials_completed": len(study.trials), - "optuna_best_value": study.best_value if study.best_trial else None, - "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, - "elapsed_sec": round(elapsed, 1), - "results": passing[:5000], - } - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - out_path = os.path.join(_results_dir_for_write(), f"optuna_momentum_{ctx.mode}_{ts}.json") - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - logger.info("💾 Optuna 결과 저장: %s", out_path) - study._kis_export_path = out_path # type: ignore[attr-defined] - return study + def _save_partial(_data: Dict[str, Any]) -> None: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="momentum", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, + ) + + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="momentum", mode=ctx.mode, note="최종 JSON", log=logger, + ) + study._kis_export_path = out_path # type: ignore[attr-defined] + return study + finally: + # mode_combo 실측이 ticks 공유메모리 뷰를 쓰므로, 여기서 해제 (optimize 직후 X) + release_shared_tick_store(ctx, log=logger) def apply_best_momentum_trial(study: optuna.Study) -> bool: diff --git a/kis_trader/backtest/optuna_scalping.py b/kis_trader/backtest/optuna_scalping.py new file mode 100644 index 0000000..8e4f6f4 --- /dev/null +++ b/kis_trader/backtest/optuna_scalping.py @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +"""kis_trader/backtest/optuna_scalping.py — 스캘핑(Reversal) Optuna (Grid add-on).""" +from __future__ import annotations + +import json +import logging +import os +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +import optuna +from optuna.samplers import RandomSampler, TPESampler + +from database import TradeDB +from kis_trader.backtest import scalping_backtest_common as sbc +from kis_trader.backtest.breakout_tick_loader import ( + load_breakout_ticks_by_code, + tick_coverage_stats, +) +from kis_trader.backtest.optuna_search_space import scalp_grid_axis_keys, suggest_scalp_params +from kis_trader.backtest.optuna_mode_combo import enrich_out_data_with_mode_combo +from kis_trader.backtest.optuna_common import announce_optuna_json_path, release_shared_tick_store +from kis_trader.backtest.param_search_cli_common import ( + apply_session_to_fixed, + combo_passes_search_filters, + format_session_hm, +) +from kis_trader.backtest.param_search_scalping import ( + SCALP_GRID_AXIS_HINTS_KO, + _fixed_defaults, + _load_candles_for_search, + _scalp_grids, + _ui_to_engine_params, + apply_params_to_db, + evaluate_scalp_param_combo, +) +from kis_trader.backtest.tail_param_search import _results_dir_for_write +from kis_trader.engine import scalping_engine as se +from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params +from kis_trader.utils.env import get_env_bool, get_env_float + +logger = logging.getLogger("param_search_optuna") + +_FAIL_OBJECTIVE = -1e18 + + +@dataclass +class ScalpSearchContext: + start: str + end: str + mode: str + base_fixed: Dict[str, Any] + codes_candles: Dict[str, List[Dict]] + universe_by_slot: Optional[Dict[str, List[str]]] + universe_source: str + fee_rate: float + sell_tax: float + slot_money: float + max_stocks: int + total_budget_krw: float + period_days: int + portfolio: Dict[str, Any] + grid_keys: List[str] + ticks_by_code: Any = None + tick_rows: int = 0 + tick_backtest_meta: Dict[str, Any] = field(default_factory=dict) + cache_holder: Dict[str, Any] = field(default_factory=dict) + shared_tick_store: Any = None # ws_ticks 공유메모리 핸들 (종료 시 unlink) + orderbook_by_code: Dict[str, Any] = field(default_factory=dict) + program_by_code: Dict[str, Any] = field(default_factory=dict) + orderbook_filter: str = "off" + + +def prepare_scalp_search_context( + start: str, + end: str, + mode: str, + *, + use_fallback_universe: bool = False, + time_start_hm: Optional[int] = None, + time_end_hm: Optional[int] = None, + slot_money: Optional[float] = None, + max_stocks: Optional[int] = None, + total_budget_krw: Optional[float] = None, + orderbook_filter: str = "off", +) -> Optional[ScalpSearchContext]: + grids = _scalp_grids() + if mode not in grids: + logger.error("❌ 스캘핑 mode: %s (fast/trigger/exit/coarse/fine/full/wide)", mode) + return None + + base_fixed = _fixed_defaults() + apply_session_to_fixed(base_fixed, time_start_hm=time_start_hm, time_end_hm=time_end_hm) + + _ob_mode = (orderbook_filter or "off").strip().lower() + if _ob_mode == "off": + base_fixed["_orderbook_filter_enabled"] = False + elif _ob_mode == "on": + base_fixed["_orderbook_filter_enabled"] = True + ob_filter_on = bool(base_fixed.get("_orderbook_filter_enabled")) or _ob_mode == "auto" + logger.info( + "📌 호가필터: %s (%s)", + _ob_mode.upper(), + "적용" if ob_filter_on else "스킵 — 코어 파라미터 순수 탐색 (실매 ORDERBOOK도 OFF 권장 정합)", + ) + + db = TradeDB() + try: + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env_row = load_portfolio_env_row(db) + finally: + db.close() + + fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="SCALP") + portfolio = sbc.resolve_scalp_portfolio_params( + env_row, None, strategy="SCALP", + slot_money=slot_money if slot_money is not None else slot_from_env, + max_stocks=max_stocks, + total_budget_krw=total_budget_krw, + ) + slot_money_f = float(portfolio["slot_money"]) + max_stocks_i = int(portfolio["max_stocks"]) + total_budget_f = float(portfolio["total_budget_krw"]) + period_days = max( + 1, + (datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1, + ) + logger.info( + "💼 포트폴리오: 1회 %s원 | 동시 %d종 | 총한도 %s원 | 매매 %s", + f"{slot_money_f:,.0f}", max_stocks_i, f"{total_budget_f:,.0f}", + format_session_hm(base_fixed), + ) + + grid_axes = grids[mode] + rsi_cands = grid_axes.get("rsi_period") or [base_fixed.get("rsi_period") or 3] + try: + rsi_period = max(int(float(x)) for x in rsi_cands) + except (TypeError, ValueError): + rsi_period = int(base_fixed.get("rsi_period") or 3) + codes_candles = _load_candles_for_search(start, end, rsi_period) + if not codes_candles: + logger.error("❌ 캔들 데이터 없음") + return None + logger.info("✅ 데이터 로드: %s종목", len(codes_candles)) + + # ── 틱재생(ws_ticks) — 웹·실매 정합 (돌파/모멘텀 Optuna 와 동일) ── + start_key = (start.replace("-", "") + "0000") if start else "202601010000" + end_key = (end.replace("-", "") + "2359") if end else "999912312359" + ticks_by_code: Dict[str, Any] = {} + tick_rows = 0 + tick_backtest_meta: Dict[str, Any] = {} + engine_probe = _ui_to_engine_params(base_fixed) + if sbc._scalp_backtest_wants_ticks(engine_probe): + _tick_db = TradeDB() + try: + ticks_by_code, tick_rows = load_breakout_ticks_by_code( + _tick_db, start_key, end_key, set(codes_candles.keys()), + ) + tick_backtest_meta = tick_coverage_stats(codes_candles, ticks_by_code) + tick_backtest_meta["ws_tick_rows_loaded"] = tick_rows + cov = tick_backtest_meta.get("tick_bar_coverage_pct", 0) + logger.info( + "✅ ws_ticks %s건 | 분봉 커버리지 %s%% (%s/%s종목)", + f"{tick_rows:,}", + cov, + tick_backtest_meta.get("tick_codes_with_data", 0), + tick_backtest_meta.get("tick_codes_total", 0), + ) + if tick_rows <= 0: + logger.warning( + "⚠️ ws_ticks 없음 — SCALP Optuna 가 OHLC만 사용 " + "(틱 수집 후 재탐색, FALLBACK_OHLC 기본 OFF)" + ) + else: + _fb = get_env_bool("SCALP_BACKTEST_TICK_FALLBACK_OHLC", False) + logger.info( + "📌 틱재생(ws_ticks): ON — OHLC 폴백 %s", + "ON" if _fb else "OFF", + ) + finally: + _tick_db.close() + + shared_tick_store = None + if get_env_bool("OPTUNA_PARAM_SEARCH_SHARED_TICKS", True) and ticks_by_code: + from kis_trader.backtest.shared_ticks import build_shared_ticks_view + _view, shared_tick_store = build_shared_ticks_view(ticks_by_code, enabled=True) + if shared_tick_store is not None: + import atexit as _atexit + _atexit.register(shared_tick_store.unlink) + logger.info("📦 ws_ticks 공유메모리 ON (Optuna) — dict 사본 제거, RAM 절감") + ticks_by_code = _view + import gc as _gc + _gc.collect() + try: + import ctypes as _ctypes + _ctypes.CDLL("libc.so.6").malloc_trim(0) + except Exception: + pass + + start_ymd = start.replace("-", "") if start else "" + end_ymd = end.replace("-", "") if end else "" + universe_by_slot = None + universe_source = "sim" + fallback_sim_interval = 5 + + if not use_fallback_universe and start_ymd and end_ymd: + history, src, n_slots, scan_iv = sbc.resolve_scalp_universe( + start_ymd, end_ymd, use_saved_history=True, strategy_id="SCALP", + ) + if history: + universe_by_slot = history + universe_source = src + base_fixed["scan_interval_min"] = scan_iv + avg = sum(len(v) for v in history.values()) / max(1, n_slots) + logger.info("✅ 유니버스: SCALP 이력 | %d슬롯 · 평균 %.1f종목", n_slots, avg) + + if universe_by_slot is None: + universe_top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20")) + universe_min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0")) + universe_by_slot = se.build_universe_simulation( + codes_candles, + top_n=universe_top_n, + min_score=universe_min_score, + scan_interval_min=fallback_sim_interval, + ) + base_fixed["scan_interval_min"] = fallback_sim_interval + universe_source = "sim" + logger.info("📌 유니버스: 개미털기 시뮬 fallback (%d분)", fallback_sim_interval) + else: + base_fixed["scan_interval_min"] = 1 + + cache_holder: Dict[str, Any] = {} + attach_indicator_caches_to_params(cache_holder, codes_candles) + + # 호가·프로그램 스냅샷 (필터 ON + 그리드 스윕/재생용) + orderbook_by_code: Dict[str, Any] = {} + program_by_code: Dict[str, Any] = {} + _ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult") + _ob_sweeping = any(len(set(grid_axes.get(k) or [])) > 1 for k in _ob_axes) + if ob_filter_on: + from kis_trader.backtest.trigger_snapshot_loader import load_trigger_snapshots_by_code + _ob_db = TradeDB() + try: + engine_probe["_orderbook_filter_enabled"] = True + orderbook_by_code, program_by_code, trigger_snap_meta = load_trigger_snapshots_by_code( + _ob_db, start_key, end_key, set(codes_candles.keys()), + engine_params=engine_probe, strategy="SCALP", + ) + ob_rows = int(trigger_snap_meta.get("ws_orderbook_rows_loaded") or 0) + pg_rows = int(trigger_snap_meta.get("ws_program_rows_loaded") or 0) + logger.info( + "✅ TRIGGER 스냅샷 ws_orderbook %s건 | ws_program %s건%s", + f"{ob_rows:,}", f"{pg_rows:,}", + " (호가축 스윕)" if _ob_sweeping else "", + ) + if ob_rows <= 0: + logger.warning( + "⚠️ ws_orderbook 거의 없음 — 호가필터 ON 이어도 스냅샷 없으면 통과(미차단). " + "수집 늘린 뒤 재탐색 권장." + ) + finally: + _ob_db.close() + + return ScalpSearchContext( + start=start, + end=end, + mode=mode, + base_fixed=base_fixed, + codes_candles=codes_candles, + universe_by_slot=universe_by_slot, + universe_source=universe_source, + fee_rate=fee_rate, + sell_tax=sell_tax, + slot_money=slot_money_f, + max_stocks=max_stocks_i, + total_budget_krw=total_budget_f, + period_days=period_days, + portfolio=portfolio, + grid_keys=scalp_grid_axis_keys(mode), + ticks_by_code=ticks_by_code, + tick_rows=int(tick_rows), + tick_backtest_meta=tick_backtest_meta, + cache_holder=cache_holder, + shared_tick_store=shared_tick_store, + orderbook_by_code=orderbook_by_code, + program_by_code=program_by_code, + orderbook_filter=_ob_mode, + ) + + +def _make_sampler(name: str, seed: Optional[int]): + n = (name or "tpe").strip().lower() + if n == "random": + return RandomSampler(seed=seed) + return TPESampler(seed=seed, multivariate=True) + + +def _scalp_objective_value(result: Dict[str, Any], sort_by: str) -> float: + pnl = float(result["total_pnl"]) + if sort_by == "score": + mdd_floor = get_env_float("SCALP_SCORE_MDD_FLOOR", 5000.0) + mdd = float(result.get("mdd") or 0) + return pnl / max(mdd, mdd_floor) + if sort_by == "win_rate": + return float(result["win_rate"]) + return pnl + + +def run_scalp_optuna( + ctx: ScalpSearchContext, + *, + n_trials: int, + storage_url: str, + study_name: str, + min_trades: int, + min_win_rate: float, + min_pf: float, + sort_by: str = "pnl", + sampler_name: str = "tpe", + seed: Optional[int] = None, + n_jobs: int = 1, + show_progress: bool = True, +) -> optuna.Study: + study = optuna.create_study( + study_name=study_name, + storage=storage_url, + load_if_exists=True, + direction="maximize", + sampler=_make_sampler(sampler_name, seed), + ) + + def objective(trial: optuna.Trial) -> float: + combo = suggest_scalp_params(trial, ctx.mode) + result = evaluate_scalp_param_combo( + combo, + base_fixed=ctx.base_fixed, + grid_keys=ctx.grid_keys, + codes_candles=ctx.codes_candles, + min_trades=min_trades, + min_win_rate=min_win_rate, + min_pf=min_pf, + universe_by_slot=ctx.universe_by_slot, + slot_money=ctx.slot_money, + max_stocks=ctx.max_stocks, + total_budget_krw=ctx.total_budget_krw, + fee_rate=ctx.fee_rate, + sell_tax=ctx.sell_tax, + period_days=ctx.period_days, + cache_holder=ctx.cache_holder, + ticks_by_code=ctx.ticks_by_code, + orderbook_by_code=ctx.orderbook_by_code, + program_by_code=ctx.program_by_code, + ) + if result is None: + trial.set_user_attr("gates_ok", False) + return _FAIL_OBJECTIVE + obj = _scalp_objective_value(result, sort_by) + trial.set_user_attr("gates_ok", True) + trial.set_user_attr("total_pnl", float(result["total_pnl"])) + trial.set_user_attr("win_rate", float(result["win_rate"])) + trial.set_user_attr("pf", float(result.get("pf") or 0)) + trial.set_user_attr("mdd", float(result.get("mdd") or 0)) + trial.set_user_attr( + "score", + float(obj if sort_by == "score" else _scalp_objective_value(result, "score")), + ) + trial.set_user_attr("total_trades", int(result["total_trades"])) + trial.set_user_attr( + "merged_json", + json.dumps(result.get("merged_params") or {}, ensure_ascii=False), + ) + return float(obj) + + logger.info( + "🔬 Optuna SCALP | study=%s | mode=%s | trials=%d | sort=%s | universe=%s | ticks=%s", + study_name, ctx.mode, n_trials, sort_by, ctx.universe_source, + f"{ctx.tick_rows:,}", + ) + t0 = time.time() + try: + study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=show_progress) + elapsed = time.time() - t0 + + passing: List[Dict[str, Any]] = [] + for trial in study.trials: + if trial.state != optuna.trial.TrialState.COMPLETE: + continue + if not trial.user_attrs.get("gates_ok"): + continue + merged_raw = trial.user_attrs.get("merged_json") or "{}" + try: + merged = json.loads(merged_raw) + except json.JSONDecodeError: + merged = dict(trial.params) + row = { + "params": dict(trial.params), + "merged_params": merged, + "total_trades": int(trial.user_attrs.get("total_trades") or 0), + "win_rate": float(trial.user_attrs.get("win_rate") or 0), + "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), + "pf": float(trial.user_attrs.get("pf") or 0), + "mdd": float(trial.user_attrs.get("mdd") or 0), + "score": float(trial.user_attrs.get("score") or 0), + "optuna_trial_number": trial.number, + } + passing.append(row) + + if sort_by == "score": + passing.sort(key=lambda r: (-r["score"], -r["total_pnl"], -r["win_rate"])) + elif sort_by == "win_rate": + passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) + else: + passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) + + profitable = [r for r in passing if r["total_pnl"] > 0] + if profitable: + passing = profitable + + out_data = { + "engine": "optuna", + "strategy": "scalp", + "mode": ctx.mode, + "start": ctx.start, + "end": ctx.end, + "universe_source": ctx.universe_source, + "slot_money": int(ctx.slot_money), + "max_stocks": ctx.max_stocks, + "total_budget_krw": int(ctx.total_budget_krw), + "backtest_days": ctx.period_days, + "min_trades": min_trades, + "min_win_rate": min_win_rate, + "min_pf": min_pf, + "sort_by": sort_by, + "grid_keys": ctx.grid_keys, + "grid_axis_hints": { + k: SCALP_GRID_AXIS_HINTS_KO[k] + for k in ctx.grid_keys if k in SCALP_GRID_AXIS_HINTS_KO + }, + "optuna_study_name": study_name, + "optuna_storage": storage_url, + "optuna_n_trials_requested": n_trials, + "optuna_trials_completed": len(study.trials), + "optuna_best_value": study.best_value if study.best_trial else None, + "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, + "elapsed_sec": round(elapsed, 1), + "ws_tick_rows_loaded": int(ctx.tick_rows), + "tick_backtest": ctx.tick_backtest_meta, + "results": passing[:5000], + } + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out_path = os.path.join(_results_dir_for_write(), f"optuna_scalp_{ctx.mode}_{ts}.json") + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="scalp", mode=ctx.mode, note="중간저장(mode 전)", log=logger, + ) + + def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return evaluate_scalp_param_combo( + combo, + base_fixed=ctx.base_fixed, + grid_keys=ctx.grid_keys, + codes_candles=ctx.codes_candles, + min_trades=1, + min_win_rate=0.0, + min_pf=0.0, + universe_by_slot=ctx.universe_by_slot, + slot_money=ctx.slot_money, + max_stocks=ctx.max_stocks, + total_budget_krw=ctx.total_budget_krw, + fee_rate=ctx.fee_rate, + sell_tax=ctx.sell_tax, + period_days=ctx.period_days, + cache_holder=ctx.cache_holder, + ticks_by_code=ctx.ticks_by_code, + orderbook_by_code=ctx.orderbook_by_code, + program_by_code=ctx.program_by_code, + ) + + def _save_partial(_data: Dict[str, Any]) -> None: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="scalp", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, + ) + + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="scalp", mode=ctx.mode, note="최종 JSON", log=logger, + ) + study._kis_export_path = out_path # type: ignore[attr-defined] + return study + finally: + release_shared_tick_store(ctx, log=logger) + + +def apply_best_scalp_trial(study: optuna.Study) -> bool: + if not study.best_trial or study.best_value <= _FAIL_OBJECTIVE + 1: + logger.warning("⚠️ 적용할 best trial 없음") + return False + pnl = float(study.best_trial.user_attrs.get("total_pnl") or 0) + if pnl <= 0: + logger.warning("⚠️ Best trial 총손익 ≤ 0 — DB 미적용") + return False + merged_raw = study.best_trial.user_attrs.get("merged_json") or "{}" + merged = json.loads(merged_raw) + apply_params_to_db(merged) + logger.info("🚀 [Optuna apply-best] scalp trial #%d → env_config", study.best_trial.number) + return True diff --git a/kis_trader/backtest/optuna_search_space.py b/kis_trader/backtest/optuna_search_space.py index 85e6cdd..76b959c 100644 --- a/kis_trader/backtest/optuna_search_space.py +++ b/kis_trader/backtest/optuna_search_space.py @@ -15,6 +15,7 @@ from kis_trader.backtest.param_search_momentum import ( _momentum_combo_grid_valid, _momentum_grids, ) +from kis_trader.backtest.param_search_scalping import _scalp_grids from kis_trader.backtest.tail_param_search import _tail_grids @@ -40,6 +41,12 @@ def _suggest_from_grid(trial: optuna.Trial, grid: Dict[str, List[Any]]) -> Dict[ return combo +def suggest_scalp_params(trial: optuna.Trial, mode: str) -> Dict[str, Any]: + combo = _suggest_from_grid(trial, _scalp_grids()[mode]) + combo["use_macd_cross"] = False + return combo + + def suggest_tail_params(trial: optuna.Trial, mode: str) -> Dict[str, Any]: return _suggest_from_grid(trial, _tail_grids(mode)) @@ -59,6 +66,10 @@ def suggest_breakout_params(trial: optuna.Trial, mode: str) -> Dict[str, Any]: return combo +def scalp_grid_axis_keys(mode: str) -> List[str]: + return list(_scalp_grids()[mode].keys()) + + def tail_grid_axis_keys(mode: str) -> List[str]: return list(_tail_grids(mode).keys()) diff --git a/kis_trader/backtest/param_search_apply_snapshot.py b/kis_trader/backtest/param_search_apply_snapshot.py index 15906c8..04995c9 100644 --- a/kis_trader/backtest/param_search_apply_snapshot.py +++ b/kis_trader/backtest/param_search_apply_snapshot.py @@ -42,7 +42,7 @@ for _p in (ROOT, HERE): from database import TradeDB # noqa: E402 from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402 merge_param_search_apply_source, - portfolio_env_patch, + strip_portfolio_keys_from_apply_patch, session_env_patch, ) @@ -125,73 +125,52 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]: patch["MOMENTUM_RSI_MIN"] = str(gi("mom_rsi_min", 50)) patch["MOMENTUM_RSI_MAX"] = str(gi("mom_rsi_max", 80)) - patch["SCALP_MOM_RSI_MIN"] = patch["MOMENTUM_RSI_MIN"] - patch["SCALP_MOM_RSI_MAX"] = patch["MOMENTUM_RSI_MAX"] patch["MOMENTUM_VOL_MULT"] = str(gf("mom_vol_mult", 1.5)) - patch["SCALP_MOM_VOL_MULT"] = patch["MOMENTUM_VOL_MULT"] patch["MOMENTUM_VOL_WIN"] = str(gi("mom_vol_win", 5)) - patch["SCALP_MOM_VOL_WIN"] = patch["MOMENTUM_VOL_WIN"] patch["MOMENTUM_TIME_END_HM"] = str(gi("mom_time_end_hm", 1430)) - patch["SCALP_MOM_TIME_END_HM"] = patch["MOMENTUM_TIME_END_HM"] ts = m.get("time_start_hm") if ts not in (None, ""): patch["MOMENTUM_TIME_START"] = str(int(float(ts))) + # ※ 모멘텀 apply 는 MOMENTUM_* 만 기록. SCALP_* 를 덮으면 스캘핑 실매/웹이 오염되고, + # 엔진은 MOMENTUM_COOLDOWN_SEC 등을 읽는데 SCALP_COOLDOWN 만 써서 apply 가 무효가 된다. sl_r = str(abs(gf("sl_pct", 1.5)) / 100.0) tp_r = str(abs(gf("tp_pct", 2.5)) / 100.0) patch["MOMENTUM_STOP_LOSS_PCT"] = sl_r - patch["SCALP_STOP_LOSS_PCT"] = sl_r patch["MOMENTUM_TAKE_PROFIT_PCT"] = tp_r - patch["SCALP_TAKE_PROFIT_PCT"] = tp_r - patch["SCALP_ATR_UP_MULT"] = str(abs(gf("trail_trigger", 0.7)) / 100.0) - patch["SCALP_ATR_DOWN_MULT"] = str(abs(gf("trail_stop", 0.4)) / 100.0) - - patch["SCALP_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60)) + patch["MOMENTUM_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60)) md = str(gi("max_daily", 5)) patch["MOMENTUM_MAX_DAILY"] = md - patch["SCALP_MAX_DAILY"] = md sm = m.get("slot_money") if sm not in (None, ""): sms = str(int(float(sm))) patch["MOMENTUM_SLOT_MONEY"] = sms - patch["SLOT_MONEY_DEFAULT"] = sms patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms - patch["MAX_BUY_AMOUNT_PER_STOCK"] = sms hc = gf("high_chase_thr", 0.96) ratio_hc = hc if 0 < hc <= 1 else hc / 100.0 - sr = str(ratio_hc) - patch["HIGH_CHASE_THR"] = sr - patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr - patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr + patch["MOMENTUM_HIGH_CHASE_THR"] = str(ratio_hc) - vchg = str(gf("max_daily_chg", 20.0)) - patch["MAX_DAILY_CHG"] = vchg - patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg - patch["MAX_DAILY_CHANGE_PCT"] = vchg + patch["MOMENTUM_MAX_DAILY_CHG"] = str(gf("max_daily_chg", 20.0)) mp = str(gf("min_price", 1000)) patch["MOMENTUM_MIN_PRICE"] = mp - patch["SCALP_MIN_PRICE"] = mp ml = str(int(float(m.get("max_loss_krw") or 200000))) patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml - patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml - patch["MAX_LOSS_PER_TRADE_KRW"] = ml mm = str(gf("min_margin", 0.2)) patch["MOMENTUM_MIN_PROFIT_PCT"] = mm - patch["SCALP_MIN_PROFIT_PCT"] = mm if "use_defense_filters" in m: - patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(m.get("use_defense_filters")) + patch["MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(m.get("use_defense_filters")) if "mom_max_from_open_pct" in m and m.get("mom_max_from_open_pct") not in (None, ""): patch["MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(m["mom_max_from_open_pct"])) @@ -200,32 +179,39 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]: x = m.get("tp_max_pct") if x not in (None, ""): - sr = str(abs(float(x)) / 100.0) - patch["MOMENTUM_TP_MAX_PCT"] = sr - patch["SCALP_TP_MAX_PCT"] = sr + patch["MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0) x = m.get("shoulder_min_high") if x not in (None, ""): - sr = str(abs(float(x)) / 100.0) - patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = sr - patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr - patch["SHOULDER_MIN_HIGH_PCT"] = sr + patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = m.get("shoulder_cut_pct") if x not in (None, ""): - sr = str(abs(float(x)) / 100.0) - patch["MOMENTUM_SHOULDER_CUT_PCT"] = sr - patch["SCALP_SHOULDER_CUT_PCT"] = sr - patch["SHOULDER_CUT_PCT"] = sr + patch["MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) - # 전용 트레일(momentum_engine) — UI 퍼센트(0.5) → 엔진 비율(0.005) 저장. - # (기존엔 trail_trigger/stop=SCALP_ATR_*만 기록돼 MOMENTUM_TRAIL_PCT가 0으로 남던 버그 수정) - x = m.get("trail_pct") + # 전용 트레일(momentum_engine) — UI 퍼센트 → MOMENTUM_TRAIL_* 비율. + # trail_pct/arm 우선. 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 같이 OFF(0). + has_trail_pct = "trail_pct" in m and m.get("trail_pct") not in (None, "") + has_trail_arm = "trail_arm_pct" in m and m.get("trail_arm_pct") not in (None, "") + has_legacy_trail = ( + ("trail_trigger" in m and m.get("trail_trigger") not in (None, "")) + or ("trail_stop" in m and m.get("trail_stop") not in (None, "")) + ) + if has_trail_pct: + patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(m["trail_pct"])) / 100.0) + elif has_legacy_trail and not has_trail_arm: + patch["MOMENTUM_TRAIL_PCT"] = "0" + if has_trail_arm: + patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(m["trail_arm_pct"])) / 100.0) + elif has_legacy_trail and not has_trail_pct: + patch["MOMENTUM_TRAIL_ARM_PCT"] = "0" + + # 래칫·최대보유 — exit Optuna 1위 적용 시 누락되지 않도록 (BREAKOUT_RATCHET_TIERS 와 동일 계열) + if "ratchet_tiers" in m: + patch["MOMENTUM_RATCHET_TIERS"] = str(m.get("ratchet_tiers") or "").strip() + x = m.get("max_hold_bars") if x not in (None, ""): - patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0) - x = m.get("trail_arm_pct") - if x not in (None, ""): - patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0) + patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x))) if "use_ema_filter" in m: patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(m.get("use_ema_filter")) @@ -258,6 +244,9 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]: if x not in (None, ""): patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x))) + from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch + patch.update(orderbook_params_to_env_patch("MOMENTUM", m)) + return patch @@ -362,7 +351,8 @@ def _patch_from_breakout_merged(m: Dict[str, Any]) -> Dict[str, str]: x = gv("max_daily") if x is not None: - patch["BREAKOUT_MAX_DAILY"] = str(int(float(x))) + # 일일횟수는 포트폴리오성 운영값 — Optuna apply 가 1로 덮지 않음(실매·웹에서 별도) + pass x = gv("cooldown_min") if x is not None: patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60)) @@ -412,6 +402,9 @@ def _patch_from_breakout_merged(m: Dict[str, Any]) -> Dict[str, str]: if x is not None: patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x))) + from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch + patch.update(orderbook_params_to_env_patch("BREAKOUT", m)) + return patch @@ -584,29 +577,30 @@ def main(argv: Optional[List[str]] = None) -> int: if strategy == "MOMENTUM": merged = merge_param_search_apply_source(item, data) patch = _patch_from_momentum_merged(merged) - patch.update(portfolio_env_patch("MOMENTUM", merged)) patch.update(session_env_patch("MOMENTUM", merged)) + patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM") elif strategy == "BREAKOUT": merged = merge_param_search_apply_source(item, data) patch = _patch_from_breakout_merged(merged) - patch.update(portfolio_env_patch("BREAKOUT", merged)) patch.update(session_env_patch("BREAKOUT", merged)) + patch = strip_portfolio_keys_from_apply_patch(patch, "BREAKOUT") elif strategy == "SCALP": merged = merge_param_search_apply_source(item, data) ds = item.get("db_snapshot") if isinstance(ds, dict) and ds: + # 구 JSON db_snapshot 에 포트폴리오(600만 등)가 있어도 apply 시 제외 patch = {str(k): str(v) for k, v in ds.items() if v not in (None, "")} - patch.update(portfolio_env_patch("SCALP", merged)) else: try: - import param_search_scalping as pss # noqa: WPS433 + from kis_trader.backtest import param_search_scalping as pss # noqa: WPS433 patch = pss._params_to_db_snapshot(merged) except Exception as e: print(f"❌ SCALP 스냅샷 생성 실패: {e}") return 9 + patch = strip_portfolio_keys_from_apply_patch(patch, "SCALP") if not patch: print("❌ 적용할 패치가 비어 있습니다.") diff --git a/kis_trader/backtest/param_search_breakout.py b/kis_trader/backtest/param_search_breakout.py index b5a10b5..cadab28 100644 --- a/kis_trader/backtest/param_search_breakout.py +++ b/kis_trader/backtest/param_search_breakout.py @@ -62,7 +62,7 @@ from database import TradeDB from kis_trader.backtest import breakout_backtest_common as bbc from kis_trader.backtest.backtest_portfolio_common import ( merge_param_search_apply_source, - portfolio_env_patch, + strip_portfolio_keys_from_apply_patch, session_env_patch, ) from kis_trader.backtest.param_search_cli_common import ( @@ -97,6 +97,7 @@ from kis_trader.strategies.breakout import ( breakout_min_bars_required, breakout_ui_to_engine_params, normalize_breakout_max_loss_krw, + resolve_breakout_skip_hts_scan_dupes, ) from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params from kis_trader.utils.env import get_env_bool, get_env_from_db, get_env_float, get_env_int # noqa: E402 @@ -143,6 +144,19 @@ def _parse_csv_strs(env_key: str, fallback: List[str]) -> List[str]: return out if out else list(fallback) +def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]: + raw = get_env_from_db(env_key, "") + if not raw or str(raw).strip() in ("", "None"): + return list(fallback) + out: List[bool] = [] + for chunk in str(raw).replace("|", ",").split(","): + chunk = chunk.strip().lower() + if not chunk: + continue + out.append(chunk in ("1", "true", "t", "y", "yes", "on")) + return out if out else list(fallback) + + def _parse_csv_tiers(env_key: str, fallback: List[str]) -> List[str]: """세미콜론(;) 구분 래칫 티어 후보 → 그리드 문자열 축. @@ -188,10 +202,16 @@ def _bo_fixed_defaults() -> Dict[str, Any]: """ db = TradeDB() try: - row = db.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - env = dict(row) if row else {} + # config_breakout + env_config 병합 — SKIP_HTS 등이 env_config 단독 SELECT 에 없음 + if hasattr(db, "get_strategy_config_snapshot"): + env = dict(db.get_strategy_config_snapshot("BREAKOUT") or {}) + merged = db.get_merged_env_snapshot() if hasattr(db, "get_merged_env_snapshot") else {} + for k, v in (merged or {}).items(): + if k not in env or env.get(k) in (None, "", "None"): + env[k] = v + else: + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env = load_portfolio_env_row(db) finally: db.close() @@ -254,6 +274,10 @@ def _bo_fixed_defaults() -> Dict[str, Any]: "eod_hm": ( str(_pick("BREAKOUT_EOD_HM", default="15:15")).strip() or "15:15" ), + # 가짜돌파 필터 (0=OFF) — DB 컬럼 없으면 0 + "confirm_margin_pct": float(_pick("BREAKOUT_CONFIRM_MARGIN_PCT", default=0.0)), + "body_min_pct": float(_pick("BREAKOUT_BODY_MIN_PCT", default=0.0)), + "skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env), } @@ -324,228 +348,403 @@ def _breakout_grids() -> Dict[str, Dict[str, List]]: 어깨축은 익절(7~15%) 미도달 시 고점 추적·되돌림 컷 — 스캘핑용 0.3%급과 구분. """ return { - # [FAST] TRIGGER 거래량 = 유통주식 1분 회전율(%) 스윕 · 급등주 한도 스윕. - # ※ vol_mult(직전봉평균×배수)는 그리드 밖 — FIXED vol_mult=0(OFF). HTS 조건 1.2배는 SCAN 전용. - # env: BREAKOUT_GRID_FAST_MIN_TURNOVER_1M_PCT=0.05,0.1,0.15 + # [FAST] wide(7/15) Top 근방 스모크 — vol2.5·tp3/12·sl4·trail1.5·sh5/0.5·end1530 분지 + # 실매 앵커: chg25 · vol2/w1 · turn0.1 · price3000 · atr · trail3 · end1300 "fast": { - # ★ 당일시가 대비 누적 상승 한도(%). HTS 전일대비 등락과 다름. "max_daily_chg": _parse_csv_floats( - "BREAKOUT_GRID_FAST_MAX_DAILY_CHG", [15.0, 20.0, 25.0, 30.0], + "BREAKOUT_GRID_FAST_MAX_DAILY_CHG", [25.0, 40.0], + ), + "vol_mult": _parse_csv_floats( + "BREAKOUT_GRID_FAST_VOL_MULT", [2.0, 2.5], + ), + "vol_window": _parse_csv_ints( + "BREAKOUT_GRID_FAST_VOL_WINDOW", [1, 10], ), - # ★ TRIGGER 1차 거래량 — 1분 거래량÷유통주식×100 (ka10001). vol_mult 와 별개. "min_turnover_1m_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_MIN_TURNOVER_1M_PCT", [0.05, 0.1, 0.15], + "BREAKOUT_GRID_FAST_MIN_TURNOVER_1M_PCT", [0.1, 0.15], ), "prev_chg_min": _parse_csv_floats( "BREAKOUT_GRID_FAST_PREV_CHG_MIN", [0.5], ), - # ★ 과열컷 — 직전봉 등락 상한(%). 너무 달군 봉에 추격 진입 회피(휩쏘 방어). "prev_chg_max": _parse_csv_floats( - "BREAKOUT_GRID_FAST_PREV_CHG_MAX", [5.0, 10.0, 15.0], + "BREAKOUT_GRID_FAST_PREV_CHG_MAX", [8.0, 20.0], + ), + "min_price": _parse_csv_floats( + "BREAKOUT_GRID_FAST_MIN_PRICE", [3000.0], ), - # ★ 익절(%) — R:R 탐색축. 7/10/15. "tp_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_TP_PCT", [7.0, 10.0, 15.0], + "BREAKOUT_GRID_FAST_TP_PCT", [3.0, 12.0], ), - # ★ 손절(%) — PF 직격 축. 2/3/4 (멘토 지적: 고정 3% 단일값 → 탐색). "sl_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_SL_PCT", [2.0, 3.0, 4.0], + "BREAKOUT_GRID_FAST_SL_PCT", [4.0], ), - "shoulder_min_high_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_SHOULDER_SMIN", [1.0, 2.0, 3.0], - ), - # ★ 어깨컷(%) — 익절 미달 구간 고점 되돌림 컷. 0.5/0.8/1.2. - "shoulder_cut_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_SHOULDER_CUT", [0.5, 0.8, 1.2], - ), - # ★ 트레일(%) — 고점 추적 되돌림 폭. arm 은 고정(아래) 유지. - "trail_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_TRAIL_PCT", [1.0, 1.5, 2.0], - ), - "trail_arm_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_TRAIL_ARM", [1.5], - ), - "lookback_min": _parse_csv_ints( - "BREAKOUT_GRID_FAST_LOOKBACK_MIN", [1], - ), - "confirm_margin_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_CONFIRM_MARGIN", [0.0], - ), - "body_min_pct": _parse_csv_floats( - "BREAKOUT_GRID_FAST_BODY_MIN", [0.0], - ), - # ★ 매매종료 시각 비교축 — 골든타임(10:30)만 vs 오전연장(11:30) vs 오후추격(13:00). - # "오전장만 좋다" 가설을 데이터로 검증(오후 추격이 PF 를 갉아먹는지). - "time_end_hm": _parse_csv_ints( - "BREAKOUT_GRID_FAST_TIME_END_HM", [1030, 1130, 1300], - ), - # ── 래칫(단계식 트레일) 축 ───────────────────────────────────── - # ""(OFF·단일 어깨컷, 기준선) vs 2단/3단 티어. fast 는 cap 균등샘플이라 - # 조합이 늘어도 max-combos 안에서 OFF/ON 이 골고루 섞인다. - # ※ 래칫 ON 이면 엔진에서 shoulder_min_high/cut 은 무시(else 분기)된다. - "ratchet_tiers": _parse_csv_tiers( - "BREAKOUT_GRID_FAST_RATCHET_TIERS", - ["", "2:1.5,5:1.0", "3:1.5,6:1.0,10:0.7"], - ), - # ── ATR 동적 손절 비교축 ───────────────────────────────────── - # sl_mode='fixed'(기존 고정 sl_pct%) vs 'atr'(변동성 비례). atr 일 때만 - # atr_sl_mult 적용(손절%=ATR×배수/진입가, atr_sl_min/max_pct 캡). - # fixed 조합에선 atr_sl_mult 가 무의미(중복) → cap 균등샘플이 흡수. "sl_mode": _parse_csv_strs( "BREAKOUT_GRID_FAST_SL_MODE", ["fixed", "atr"], ), "atr_sl_mult": _parse_csv_floats( - "BREAKOUT_GRID_FAST_ATR_SL_MULT", [1.5, 2.0, 2.5], + "BREAKOUT_GRID_FAST_ATR_SL_MULT", [2.5], + ), + "shoulder_min_high_pct": _parse_csv_floats( + "BREAKOUT_GRID_FAST_SHOULDER_SMIN", [2.0, 5.0], + ), + "shoulder_cut_pct": _parse_csv_floats( + "BREAKOUT_GRID_FAST_SHOULDER_CUT", [0.5, 0.8], + ), + "trail_pct": _parse_csv_floats( + "BREAKOUT_GRID_FAST_TRAIL_PCT", [1.5, 3.0], + ), + "trail_arm_pct": _parse_csv_floats( + "BREAKOUT_GRID_FAST_TRAIL_ARM", [2.0, 3.0], + ), + "lookback_min": _parse_csv_ints( + "BREAKOUT_GRID_FAST_LOOKBACK_MIN", [1, 5], + ), + "confirm_margin_pct": _parse_csv_floats( + "BREAKOUT_GRID_FAST_CONFIRM_MARGIN", [0.2, 0.5], + ), + "body_min_pct": _parse_csv_floats( + "BREAKOUT_GRID_FAST_BODY_MIN", [0.0, 0.3], + ), + "time_end_hm": _parse_csv_ints( + "BREAKOUT_GRID_FAST_TIME_END_HM", [1300, 1530], + ), + "ratchet_tiers": _parse_csv_tiers( + "BREAKOUT_GRID_FAST_RATCHET_TIERS", + ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7"], + ), + "max_hold_bars": _parse_csv_ints( + "BREAKOUT_GRID_FAST_MAX_HOLD_BARS", [60, 120], ), - # ── 호가필터(2단계 조인트) 축 — 기본 단일값=동작 불변 ────────── - # max_spread_pct : 스프레드 상한(%) ↑완화 - # min_bid_ask_ratio : 매수/매도 잔량비 하한 ↓완화 - # ask_wall_max_qty : 돌파 저항 근처 매도벽 수량 상한 ↑완화 (돌파 전용) "max_spread_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_MAX_SPREAD_PCT", [0.45], ), "min_bid_ask_ratio": _parse_csv_floats( - "BREAKOUT_GRID_FAST_MIN_BID_ASK_RATIO", [0.85], + "BREAKOUT_GRID_FAST_MIN_BID_ASK_RATIO", [0.85, 1.0], ), "ask_wall_max_qty": _parse_csv_ints( - "BREAKOUT_GRID_FAST_ASK_WALL_MAX_QTY", [5000], + "BREAKOUT_GRID_FAST_ASK_WALL_MAX_QTY", [3000], ), }, - # 5×4×4×5×4×3×3 ≈ 14,400 (무효 prev_chg 조합 제외) + # [COARSE] wide Top 밴드 조금 넓게 (1차 스크리닝) "coarse": { - # 급등주(당일시가 대비) — fast 와 동일 축. env BREAKOUT_GRID_COARSE_MAX_DAILY_CHG "max_daily_chg": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_MAX_DAILY_CHG", [15.0, 20.0, 25.0, 30.0], + "BREAKOUT_GRID_COARSE_MAX_DAILY_CHG", [25.0, 30.0, 40.0, 50.0], ), - # 거래량 배수 — HTS 300%(3.0)보다 코드에서 2~5배 탐색 (핵심 축) "vol_mult": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_VOL_MULT", [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], + "BREAKOUT_GRID_COARSE_VOL_MULT", [2.0, 2.5, 3.0], + ), + "vol_window": _parse_csv_ints( + "BREAKOUT_GRID_COARSE_VOL_WINDOW", [1, 5, 10], + ), + "min_turnover_1m_pct": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_MIN_TURNOVER_1M_PCT", [0.1, 0.15, 0.3], ), "prev_chg_min": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_PREV_CHG_MIN", [0.5, 1.0, 1.5, 2.0], + "BREAKOUT_GRID_COARSE_PREV_CHG_MIN", [0.3, 0.5], ), "prev_chg_max": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_PREV_CHG_MAX", [5.0, 8.0, 10.0, 15.0], + "BREAKOUT_GRID_COARSE_PREV_CHG_MAX", [8.0, 10.0, 15.0, 20.0], + ), + "min_price": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_MIN_PRICE", [2000.0, 3000.0], ), "tp_pct": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_TP_PCT", [3.0, 5.0, 7.0, 10.0, 15.0], + "BREAKOUT_GRID_COARSE_TP_PCT", [3.0, 5.0, 10.0, 12.0], ), "sl_pct": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_SL_PCT", [1.5, 2.0, 3.0, 4.0], + "BREAKOUT_GRID_COARSE_SL_PCT", [3.0, 4.0, 5.0], + ), + "sl_mode": _parse_csv_strs( + "BREAKOUT_GRID_COARSE_SL_MODE", ["fixed", "atr"], + ), + "atr_sl_mult": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_ATR_SL_MULT", [2.0, 2.5, 3.0], ), "shoulder_min_high_pct": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_SHOULDER_SMIN", [1.0, 2.0, 3.0], + "BREAKOUT_GRID_COARSE_SHOULDER_SMIN", [2.0, 3.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_SHOULDER_CUT", [0.5, 0.8, 1.2], + "BREAKOUT_GRID_COARSE_SHOULDER_CUT", [0.5, 0.8, 1.0], + ), + "trail_pct": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_TRAIL_PCT", [1.5, 2.0, 3.0], + ), + "trail_arm_pct": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_TRAIL_ARM", [1.5, 2.0, 3.0], + ), + "ratchet_tiers": _parse_csv_tiers( + "BREAKOUT_GRID_COARSE_RATCHET_TIERS", + ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7", "2:1.5,5:1.0"], ), "lookback_min": _parse_csv_ints( - "BREAKOUT_GRID_COARSE_LOOKBACK_MIN", [1, 10], + "BREAKOUT_GRID_COARSE_LOOKBACK_MIN", [1, 5], ), - # 가짜돌파(휩쏘) 필터 "confirm_margin_pct": _parse_csv_floats( - "BREAKOUT_GRID_COARSE_CONFIRM_MARGIN", [0.0, 0.3], + "BREAKOUT_GRID_COARSE_CONFIRM_MARGIN", [0.0, 0.2, 0.5], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_BODY_MIN", [0.0, 0.3], ), - # 10:30 골든타임 vs 11:30·13:00 (오후 추격 리스크) "time_end_hm": _parse_csv_ints( - "BREAKOUT_GRID_COARSE_TIME_END_HM", [1030], + "BREAKOUT_GRID_COARSE_TIME_END_HM", [1300, 1430, 1530], + ), + "max_hold_bars": _parse_csv_ints( + "BREAKOUT_GRID_COARSE_MAX_HOLD_BARS", [0, 60, 120], + ), + "max_spread_pct": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_MAX_SPREAD_PCT", [0.30, 0.45], + ), + "min_bid_ask_ratio": _parse_csv_floats( + "BREAKOUT_GRID_COARSE_MIN_BID_ASK_RATIO", [0.85, 1.0], + ), + "ask_wall_max_qty": _parse_csv_ints( + "BREAKOUT_GRID_COARSE_ASK_WALL_MAX_QTY", [3000, 5000], ), }, + # [FINE] 2026-07-15 wide Top 재설계 (best~+52k/6건 · mode~+32k/4건) + # 실매 앵커(chg25/vol2/turn0.1/end1300/atr) + Top(#1·최빈) 포함. apply는 확인 후. "fine": { + "max_daily_chg": _parse_csv_floats( + "BREAKOUT_GRID_FINE_MAX_DAILY_CHG", [25.0, 30.0, 40.0, 50.0], + ), "vol_mult": _parse_csv_floats( - "BREAKOUT_GRID_FINE_VOL_MULT", [1.0, 1.5, 2.0, 2.5, 3.0, 4.0], + "BREAKOUT_GRID_FINE_VOL_MULT", [2.0, 2.5, 3.0], + ), + "vol_window": _parse_csv_ints( + "BREAKOUT_GRID_FINE_VOL_WINDOW", [1, 5, 10], + ), + "min_turnover_1m_pct": _parse_csv_floats( + "BREAKOUT_GRID_FINE_MIN_TURNOVER_1M_PCT", [0.1, 0.15, 0.3], ), "prev_chg_min": _parse_csv_floats( - "BREAKOUT_GRID_FINE_PREV_CHG_MIN", [0.3, 0.5, 1.0, 1.5, 2.5], + "BREAKOUT_GRID_FINE_PREV_CHG_MIN", [0.3, 0.5], ), "prev_chg_max": _parse_csv_floats( - "BREAKOUT_GRID_FINE_PREV_CHG_MAX", [4.0, 6.0, 8.0, 12.0, 15.0], + "BREAKOUT_GRID_FINE_PREV_CHG_MAX", [8.0, 10.0, 15.0, 20.0, 25.0], + ), + "min_price": _parse_csv_floats( + "BREAKOUT_GRID_FINE_MIN_PRICE", [2000.0, 3000.0], ), "tp_pct": _parse_csv_floats( - "BREAKOUT_GRID_FINE_TP_PCT", [2.0, 3.0, 5.0, 7.0, 10.0], + "BREAKOUT_GRID_FINE_TP_PCT", [3.0, 5.0, 10.0, 12.0, 15.0], ), "sl_pct": _parse_csv_floats( - "BREAKOUT_GRID_FINE_SL_PCT", [1.0, 1.5, 2.0, 3.0], + "BREAKOUT_GRID_FINE_SL_PCT", [3.0, 4.0, 5.0], + ), + "sl_mode": _parse_csv_strs( + "BREAKOUT_GRID_FINE_SL_MODE", ["fixed", "atr"], + ), + "atr_sl_mult": _parse_csv_floats( + "BREAKOUT_GRID_FINE_ATR_SL_MULT", [2.0, 2.5, 3.0], ), "trail_pct": _parse_csv_floats( - "BREAKOUT_GRID_FINE_TRAIL_PCT", [1.0, 1.5, 2.0], + "BREAKOUT_GRID_FINE_TRAIL_PCT", [1.5, 2.0, 3.0], + ), + "trail_arm_pct": _parse_csv_floats( + "BREAKOUT_GRID_FINE_TRAIL_ARM", [1.5, 2.0, 3.0], ), "shoulder_min_high_pct": _parse_csv_floats( - "BREAKOUT_GRID_FINE_SHOULDER_SMIN", [1.0, 2.0, 3.0, 5.0], + "BREAKOUT_GRID_FINE_SHOULDER_SMIN", [2.0, 3.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( - "BREAKOUT_GRID_FINE_SHOULDER_CUT", [0.5, 0.8, 1.2, 1.5], + "BREAKOUT_GRID_FINE_SHOULDER_CUT", [0.5, 0.8, 1.0], ), - # 래칫(단계식 트레일) 후보 — ""(OFF·단일 어깨컷) vs 2단/3단 티어. - # 래칫 ON 이면 shoulder_* 축은 무시되어 중복 조합이 생기지만 cap 샘플로 흡수됨. "ratchet_tiers": _parse_csv_tiers( "BREAKOUT_GRID_FINE_RATCHET_TIERS", - ["", "2:1.5,5:1.0", "3:1.5,6:1.0,10:0.7"], + ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7", "2:1.5,5:1.0"], ), "lookback_min": _parse_csv_ints( - "BREAKOUT_GRID_FINE_LOOKBACK_MIN", [1, 5, 10, 20], + "BREAKOUT_GRID_FINE_LOOKBACK_MIN", [1, 5], ), - # 가짜돌파(휩쏘) 필터 "confirm_margin_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_CONFIRM_MARGIN", [0.0, 0.2, 0.5], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_BODY_MIN", [0.0, 0.3], ), - # 시간컷(분) — 0=OFF · 옆으로 기는 실패 돌파 정리 "max_hold_bars": _parse_csv_ints( - "BREAKOUT_GRID_FINE_MAX_HOLD_BARS", [0, 60], + "BREAKOUT_GRID_FINE_MAX_HOLD_BARS", [0, 60, 120], ), "time_end_hm": _parse_csv_ints( - "BREAKOUT_GRID_FINE_TIME_END_HM", [1030, 1130, 1300], + "BREAKOUT_GRID_FINE_TIME_END_HM", [1300, 1430, 1530], + ), + "max_spread_pct": _parse_csv_floats( + "BREAKOUT_GRID_FINE_MAX_SPREAD_PCT", [0.30, 0.45, 0.60], + ), + "min_bid_ask_ratio": _parse_csv_floats( + "BREAKOUT_GRID_FINE_MIN_BID_ASK_RATIO", [0.85, 1.0], + ), + "ask_wall_max_qty": _parse_csv_ints( + "BREAKOUT_GRID_FINE_ASK_WALL_MAX_QTY", [3000, 5000], ), }, - "full": { + # [WIDE] 축 스크리닝 — 하루 fine 5건·WR100% 과적합 완화용. + # Optuna 소수 trial로 유효 축·구간 먼저 찾고, 이후 fine 재설계. + # 실매/직전 fine 앵커: chg25 · vol2/w1 · turn0.1 · price3000 · tp3 · sl4 · atr · trail3 · end1300 + # ※ SKIP_HTS 는 사용자 지시 없으면 false 유지(그리드에 True 넣지 않음). + "wide": { + "max_daily_chg": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_MAX_DAILY_CHG", + [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0], + ), "vol_mult": _parse_csv_floats( - "BREAKOUT_GRID_FULL_VOL_MULT", [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], + "BREAKOUT_GRID_WIDE_VOL_MULT", + [0.0, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], + ), + "vol_window": _parse_csv_ints( + "BREAKOUT_GRID_WIDE_VOL_WINDOW", [1, 3, 5, 10], + ), + "min_turnover_1m_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_MIN_TURNOVER_1M_PCT", + [0.03, 0.05, 0.1, 0.15, 0.2, 0.3], ), "prev_chg_min": _parse_csv_floats( - "BREAKOUT_GRID_FULL_PREV_CHG_MIN", [0.3, 0.5, 1.0, 1.5, 2.0, 3.0], + "BREAKOUT_GRID_WIDE_PREV_CHG_MIN", + [0.3, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0], ), "prev_chg_max": _parse_csv_floats( - "BREAKOUT_GRID_FULL_PREV_CHG_MAX", [4.0, 6.0, 8.0, 10.0, 12.0, 15.0, 20.0], + "BREAKOUT_GRID_WIDE_PREV_CHG_MAX", + [6.0, 8.0, 10.0, 12.0, 15.0, 20.0, 25.0], + ), + "min_price": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_MIN_PRICE", [1000.0, 2000.0, 3000.0, 5000.0], ), "tp_pct": _parse_csv_floats( - "BREAKOUT_GRID_FULL_TP_PCT", [2.0, 3.0, 5.0, 7.0, 10.0, 15.0, 20.0], + "BREAKOUT_GRID_WIDE_TP_PCT", + [3.0, 5.0, 7.0, 10.0, 12.0, 15.0, 20.0], ), "sl_pct": _parse_csv_floats( - "BREAKOUT_GRID_FULL_SL_PCT", [1.0, 1.5, 2.0, 3.0, 5.0], + "BREAKOUT_GRID_WIDE_SL_PCT", [1.5, 2.0, 3.0, 4.0, 5.0], + ), + "sl_mode": _parse_csv_strs( + "BREAKOUT_GRID_WIDE_SL_MODE", ["fixed", "atr"], + ), + "atr_sl_mult": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_ATR_SL_MULT", [1.5, 2.0, 2.5, 3.0], + ), + "trail_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_TRAIL_PCT", [0.0, 1.0, 1.5, 2.0, 3.0, 4.0], + ), + "trail_arm_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_TRAIL_ARM", [0.0, 1.0, 1.5, 2.0, 3.0], + ), + "shoulder_min_high_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_SHOULDER_SMIN", [1.0, 2.0, 3.0, 5.0, 7.0], + ), + "shoulder_cut_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_SHOULDER_CUT", [0.5, 0.8, 1.0, 1.2, 1.5, 2.0], + ), + # OFF + 늦은 보호 + 기존 fine 선호 주변 (조기만 단독으로 몰지 않음) + "ratchet_tiers": _parse_csv_tiers( + "BREAKOUT_GRID_WIDE_RATCHET_TIERS", + ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7", "2:1.5,5:1.0"], + ), + "lookback_min": _parse_csv_ints( + "BREAKOUT_GRID_WIDE_LOOKBACK_MIN", [1, 5, 10, 20], + ), + "confirm_margin_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_CONFIRM_MARGIN", [0.0, 0.2, 0.5, 1.0], + ), + "body_min_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_BODY_MIN", [0.0, 0.3, 0.5], + ), + "max_hold_bars": _parse_csv_ints( + "BREAKOUT_GRID_WIDE_MAX_HOLD_BARS", [0, 30, 60, 120], + ), + "time_end_hm": _parse_csv_ints( + "BREAKOUT_GRID_WIDE_TIME_END_HM", [1030, 1130, 1300, 1430, 1530], + ), + "max_spread_pct": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_MAX_SPREAD_PCT", [0.30, 0.45, 0.60], + ), + "min_bid_ask_ratio": _parse_csv_floats( + "BREAKOUT_GRID_WIDE_MIN_BID_ASK_RATIO", [0.70, 0.85, 1.0], + ), + "ask_wall_max_qty": _parse_csv_ints( + "BREAKOUT_GRID_WIDE_ASK_WALL_MAX_QTY", [3000, 5000, 8000], + ), + }, + # [FULL] fine + 최대 폭 (HTS 밴드 바깥까지 탐색) + "full": { + "max_daily_chg": _parse_csv_floats( + "BREAKOUT_GRID_FULL_MAX_DAILY_CHG", + [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0], + ), + "vol_mult": _parse_csv_floats( + "BREAKOUT_GRID_FULL_VOL_MULT", [0.0, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], + ), + "vol_window": _parse_csv_ints( + "BREAKOUT_GRID_FULL_VOL_WINDOW", [1, 3, 5, 10], + ), + "min_turnover_1m_pct": _parse_csv_floats( + "BREAKOUT_GRID_FULL_MIN_TURNOVER_1M_PCT", + [0.03, 0.05, 0.1, 0.15, 0.2, 0.3], + ), + "skip_hts_scan_dupes": _parse_csv_bools( + "BREAKOUT_GRID_FULL_SKIP_HTS_SCAN_DUPES", [False, True], + ), + "prev_chg_min": _parse_csv_floats( + "BREAKOUT_GRID_FULL_PREV_CHG_MIN", + [0.3, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ), + "prev_chg_max": _parse_csv_floats( + "BREAKOUT_GRID_FULL_PREV_CHG_MAX", + [6.0, 8.0, 10.0, 12.0, 15.0, 20.0, 25.0], + ), + "min_price": _parse_csv_floats( + "BREAKOUT_GRID_FULL_MIN_PRICE", [1000.0, 2000.0, 3000.0, 5000.0], + ), + "tp_pct": _parse_csv_floats( + "BREAKOUT_GRID_FULL_TP_PCT", [3.0, 5.0, 7.0, 10.0, 15.0, 20.0], + ), + "sl_pct": _parse_csv_floats( + "BREAKOUT_GRID_FULL_SL_PCT", [1.0, 1.5, 2.0, 3.0, 4.0, 5.0], + ), + "sl_mode": _parse_csv_strs( + "BREAKOUT_GRID_FULL_SL_MODE", ["fixed", "atr"], + ), + "atr_sl_mult": _parse_csv_floats( + "BREAKOUT_GRID_FULL_ATR_SL_MULT", [1.0, 1.5, 2.0, 2.5, 3.0], ), "trail_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_TRAIL_PCT", [1.0, 1.5, 2.0, 3.0], ), + "trail_arm_pct": _parse_csv_floats( + "BREAKOUT_GRID_FULL_TRAIL_ARM", [0.0, 1.0, 1.5, 2.0], + ), "shoulder_min_high_pct": _parse_csv_floats( - "BREAKOUT_GRID_FULL_SHOULDER_SMIN", [1.0, 2.0, 3.0, 5.0], + "BREAKOUT_GRID_FULL_SHOULDER_SMIN", [1.0, 2.0, 3.0, 5.0, 7.0], ), "shoulder_cut_pct": _parse_csv_floats( - "BREAKOUT_GRID_FULL_SHOULDER_CUT", [0.5, 0.8, 1.2, 1.5], + "BREAKOUT_GRID_FULL_SHOULDER_CUT", [0.5, 0.8, 1.0, 1.2, 1.5, 2.0], ), - "max_hold_bars": _parse_csv_ints( - "BREAKOUT_GRID_FULL_MAX_HOLD_BARS", [0, 30, 60], + "ratchet_tiers": _parse_csv_tiers( + "BREAKOUT_GRID_FULL_RATCHET_TIERS", + ["", "2:1.5,5:1.0", "3:1.5,6:1.0,10:0.7", "2:2.0,6:1.2,12:0.8"], ), "lookback_min": _parse_csv_ints( - "BREAKOUT_GRID_FULL_LOOKBACK_MIN", [1, 5, 10, 20], + "BREAKOUT_GRID_FULL_LOOKBACK_MIN", [1, 5, 10, 20, 30], ), - "vol_window": _parse_csv_ints( - "BREAKOUT_GRID_FULL_VOL_WINDOW", [1, 3, 5], - ), - # 가짜돌파(휩쏘) 필터 "confirm_margin_pct": _parse_csv_floats( - "BREAKOUT_GRID_FULL_CONFIRM_MARGIN", [0.0, 0.2, 0.5], + "BREAKOUT_GRID_FULL_CONFIRM_MARGIN", [0.0, 0.2, 0.5, 1.0], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_BODY_MIN", [0.0, 0.3, 0.5], ), + "max_hold_bars": _parse_csv_ints( + "BREAKOUT_GRID_FULL_MAX_HOLD_BARS", [0, 30, 60, 90], + ), "time_end_hm": _parse_csv_ints( - "BREAKOUT_GRID_FULL_TIME_END_HM", [1030, 1130, 1300, 1500], + "BREAKOUT_GRID_FULL_TIME_END_HM", [1030, 1130, 1300, 1500, 1530], + ), + "max_spread_pct": _parse_csv_floats( + "BREAKOUT_GRID_FULL_MAX_SPREAD_PCT", [0.20, 0.30, 0.45, 0.60, 0.80], + ), + "min_bid_ask_ratio": _parse_csv_floats( + "BREAKOUT_GRID_FULL_MIN_BID_ASK_RATIO", [0.50, 0.70, 0.85, 1.0], + ), + "ask_wall_max_qty": _parse_csv_ints( + "BREAKOUT_GRID_FULL_ASK_WALL_MAX_QTY", [2000, 3000, 5000, 8000, 12000], ), }, } @@ -592,6 +791,7 @@ def _get_breakout_field_map() -> Dict[str, Tuple[str, Any]]: "slot_money": ("BREAKOUT_SLOT_MONEY", lambda v: str(int(float(v)))), "entry_mode": ("BREAKOUT_ENTRY_MODE", lambda v: str(v).strip().lower()), "intrabar_slippage_pct": ("BREAKOUT_INTRABAR_SLIPPAGE_PCT", lambda v: str(float(v))), + # skip_hts_scan_dupes: full 그리드 탐색만, apply 제외 } @@ -606,8 +806,9 @@ def _apply_to_db(best_params: dict) -> None: ) patch = _patch_from_breakout_merged(best_params) - patch.update(portfolio_env_patch("BREAKOUT", best_params)) patch.update(session_env_patch("BREAKOUT", best_params)) + # 슬롯·동시보유·총한도 제외 — Optuna/Grid 가 slot×종목수로 한도를 덮지 않음 + patch = strip_portfolio_keys_from_apply_patch(patch, "BREAKOUT") if not patch: print("DB 적용할 파라미터가 없습니다.") return @@ -682,6 +883,10 @@ def evaluate_breakout_param_combo( engine_params["_ob_ask_wall_max_qty"] = float(ui_params["ask_wall_max_qty"]) if ui_params.get("_orderbook_filter_enabled") is not None: engine_params["_orderbook_filter_enabled"] = bool(ui_params["_orderbook_filter_enabled"]) + if "skip_hts_scan_dupes" in ui_params: + engine_params["skip_hts_scan_dupes"] = bool(ui_params["skip_hts_scan_dupes"]) + elif "skip_hts_scan_dupes" not in engine_params: + engine_params["skip_hts_scan_dupes"] = resolve_breakout_skip_hts_scan_dupes() if cache_holder: engine_params.update(cache_holder) engine_params["slot_money"] = float(slot_money) @@ -955,8 +1160,8 @@ def run_search( db = TradeDB() try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env_row = load_portfolio_env_row(db) finally: db.close() fee_rate, sell_tax, slot_from_env = bbc.fee_and_slot_from_env(env_row) @@ -1315,13 +1520,13 @@ def run_search( # ────────────────────────────────────────────────────────────────────────────── def main(): - today = datetime.now().strftime("%Y-%m-%d") - week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + week_ago, today = resolve_param_search_range("BREAKOUT", lookback_days=7) parser = argparse.ArgumentParser(description="돌파매매 백테스트 파라미터 Grid Search (BreakoutStrategy + run_breakout_backtest)") - parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)") - parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)") - parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine", "full"], + parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 거래일 보정)") + parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)") + parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine", "wide", "full"], help="탐색 모드: fast(그리드→균등200·~10–15분) / coarse / fine / full") parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", diff --git a/kis_trader/backtest/param_search_dates.py b/kis_trader/backtest/param_search_dates.py new file mode 100644 index 0000000..b6ee4e3 --- /dev/null +++ b/kis_trader/backtest/param_search_dates.py @@ -0,0 +1,94 @@ +""" +param_search_dates.py — 파라미터 탐색 기본 시작/종료일 (거래일 보정 + 조건식 컷오프) +""" +from __future__ import annotations + +from typing import Optional, Tuple + +from kis_trader.utils.env import get_env_from_db, get_env_int +from kis_trader.utils.kr_trading_day import ( + clamp_to_prev_kr_trading_day, + default_kr_trading_end, + default_kr_trading_start, + resolve_kr_backtest_date_defaults, +) + + +def _normalize_ymd(raw: str) -> str: + s = (raw or "").strip() + if not s: + return "" + if "-" in s and len(s) >= 10: + return s[:10] + digits = s.replace("-", "").replace("/", "")[:8] + if len(digits) == 8 and digits.isdigit(): + return f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}" + return s[:10] + + +def resolve_param_search_end() -> str: + """CLI/웹 기본 --end: 오늘이 주말·휴장이면 이전 장운영일.""" + return default_kr_trading_end() + + +def resolve_param_search_start( + strategy: str = "TAIL", + *, + lookback_days: int = 7, + min_start_env_key: Optional[str] = None, +) -> str: + """ + 파라미터 탐색 CLI 기본 --start. + + - lookback_days 전(거래일 보정)과 env 최소시작일 중 **늦은 날** 사용 + - 조건식 변경 후 ``TAIL_PARAM_SEARCH_MIN_START=2026-07-07`` 처럼 설정 + """ + lb = max(1, int(lookback_days)) + week_ago = default_kr_trading_start(lb) + sid = (strategy or "TAIL").strip().upper() + env_key = min_start_env_key or f"{sid}_PARAM_SEARCH_MIN_START" + if sid == "SHORT": + env_key = "TAIL_PARAM_SEARCH_MIN_START" + min_start = _normalize_ymd(str(get_env_from_db(env_key, "") or "")) + if min_start: + # 최소시작일도 휴장이면 다음이 아니라 **이전** 거래일로 맞추면 컷오프가 + # 당겨질 수 있어, 컷오프는 그대로 두고 week_ago 와 max 만 한다. + return max(week_ago, min_start) + return week_ago + + +def resolve_param_search_range( + strategy: str = "TAIL", + *, + lookback_days: Optional[int] = None, +) -> Tuple[str, str]: + """(start, end) — 주말/휴장 보정된 기본 구간.""" + lb = int( + lookback_days + if lookback_days is not None + else get_env_int("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS", 7) + ) + end = resolve_param_search_end() + start = resolve_param_search_start(strategy, lookback_days=lb) + if start > end: + start = end + return start, end + + +def clamp_date_input(raw: str, *, fallback: Optional[str] = None) -> str: + """인풋 날짜 문자열 → 거래일 보정. 빈 값이면 fallback 또는 기본 end.""" + s = _normalize_ymd(raw) + if not s: + return fallback if fallback is not None else resolve_param_search_end() + return clamp_to_prev_kr_trading_day(s) + + +# 하위 호환 re-export +__all__ = [ + "resolve_param_search_start", + "resolve_param_search_end", + "resolve_param_search_range", + "clamp_date_input", + "resolve_kr_backtest_date_defaults", + "clamp_to_prev_kr_trading_day", +] diff --git a/kis_trader/backtest/param_search_momentum.py b/kis_trader/backtest/param_search_momentum.py index 19b8a25..6ca620b 100644 --- a/kis_trader/backtest/param_search_momentum.py +++ b/kis_trader/backtest/param_search_momentum.py @@ -2,11 +2,12 @@ """ kis_trader/backtest/param_search_momentum.py — 모멘텀 백테스트 파라미터 자동 탐색 (Grid Search) ======================================================================================================= -[전략 = MOMENTUM — momentum_engine 전용, SCALP reversal 과 분리] +[전략 = MOMENTUM A안 — HTS ``momentum`` (E∧F∧H∧I) 일봉 SCAN + E 정합 TRIGGER] -- SCAN: 키움/KIS ``scalp`` 조건검색 → ``target_candidates_history`` -- TRIGGER: ``momentum_engine.check_buy_signal_momentum_live`` (실매 MomentumStrategy 동일) -- 청산: ``check_sell_signal_momentum_live`` — 어깨·트레일 선행, tp_max 상한 익절은 마지막 +- SCAN: 키움 ``momentum`` 조건검색 → ``target_candidates_history`` +- TRIGGER: ``momentum_hts_logic`` — 전일시가(E) 유지 + 양봉·거래량 펄스 +- 청산: ``check_sell_signal_momentum_live`` — 래칫·어깨·트레일·손절·시간컷 +- ``--mode exit``: 진입 DB 고정 × 청산(어깨·래칫·트레일·SL·TP) 광범위 그리드 - 백테·파서치: ``momentum_backtest_common.run_momentum_backtest_web_aligned`` → ``momentum_engine.run_momentum_backtest`` → ``check_sell_signal_momentum_backtest_bar`` @@ -61,12 +62,13 @@ logging.getLogger("TradeDB").setLevel(logging.WARNING) from database import TradeDB from kis_trader.engine import momentum_engine as me +from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params from kis_trader.backtest import momentum_backtest_common as mbc from kis_trader.backtest import scalping_backtest_common as sbc from kis_trader.backtest.backtest_portfolio_common import ( merge_param_search_apply_source, - portfolio_env_patch, + strip_portfolio_keys_from_apply_patch, session_env_patch, ) from kis_trader.backtest.param_search_cli_common import ( @@ -134,6 +136,34 @@ def _parse_csv_ints(env_key: str, fallback: List[int]) -> List[int]: return out if out else list(fallback) +def _parse_csv_strings(env_key: str, fallback: List[str], sep: str = "|") -> List[str]: + """문자열 축(래칫 티어 등) — env 는 ``|`` 구분, 빈 토큰은 OFF.""" + raw = get_env_from_db(env_key, "") + if not raw: + return list(fallback) + s = str(raw).strip() + if sep in s: + parts = [p.strip() for p in s.split(sep)] + else: + parts = [p.strip() for p in s.split(",")] + if not parts: + return list(fallback) + return parts + + +def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]: + raw = str(get_env_from_db(env_key, "") or "").strip() + if not raw: + return list(fallback) + out: List[bool] = [] + for part in raw.replace("|", ",").split(","): + part = part.strip().lower() + if not part: + continue + out.append(part in ("1", "true", "t", "y", "yes", "on")) + return out if out else list(fallback) + + # ────────────────────────────────────────────────────────────────────────────── # 모멘텀 기본값 (DB 우선, 없으면 코드 default) # ────────────────────────────────────────────────────────────────────────────── @@ -205,9 +235,20 @@ def _mom_fixed_defaults() -> Dict[str, Any]: )), "sl_pct": float(rr_base.get("sl_pct", float(_d.get("sl_pct", 0.02)) * 100.0)), "tp_pct": float(rr_base.get("tp_pct", float(_d.get("tp_pct", 0.02)) * 100.0)), - "trail_trigger": get_env_float("MOMENTUM_SEARCH_TRAIL_TRIGGER_PCT", 1.5), - "trail_stop": get_env_float("MOMENTUM_SEARCH_TRAIL_STOP_PCT", 1.0), - "cooldown_min": float(get_env_int("MOMENTUM_SEARCH_COOLDOWN_MIN", 1)), + # 모멘텀 전용 트레일(UI%) — momentum_hts_logic 의 trail_pct/arm (0=OFF) + "trail_pct": float(rr_base.get( + "trail_pct", + float(_d.get("trail_pct", 0.0)) * 100.0, + )), + "trail_arm_pct": float(rr_base.get( + "trail_arm_pct", + float(_d.get("trail_arm_pct", 0.0)) * 100.0, + )), + "cooldown_min": float( + _d.get("cooldown_min") + if _d.get("cooldown_min") is not None + else get_env_int("MOMENTUM_SEARCH_COOLDOWN_MIN", 1) + ), "time_start_hm": int(_pick( "MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START", default=get_env_int("MOMENTUM_SEARCH_TIME_START_HM", int(_d.get("time_start_hm", 900))), @@ -218,11 +259,12 @@ def _mom_fixed_defaults() -> Dict[str, Any]: default=get_env_int("MOMENTUM_SEARCH_TIME_END_HM", int(_d.get("time_end_hm", 1530))), cast=lambda v: int(float(v)), )), - "max_daily": float(get_env_int("MOMENTUM_SEARCH_MAX_DAILY", 10)), + # 실매 앵커 = config_momentum (SEARCH_* 단독 기본값 금지 — fine 그리드 누락 방지) + "max_daily": float(int(float(_d.get("max_daily") or get_env_int("MOMENTUM_SEARCH_MAX_DAILY", 10)))), "fee_rate": _fee_r * 100.0, "sell_tax": _tax_r * 100.0, - "high_chase_thr": get_env_float("MOMENTUM_SEARCH_HIGH_CHASE_THR", 0.99), - "max_daily_chg": get_env_float("MOMENTUM_SEARCH_MAX_DAILY_CHG_PCT", 50.0), + "high_chase_thr": float(_d.get("high_chase_thr") or get_env_float("MOMENTUM_SEARCH_HIGH_CHASE_THR", 0.99)), + "max_daily_chg": float(_d.get("max_daily_chg") or get_env_float("MOMENTUM_SEARCH_MAX_DAILY_CHG_PCT", 50.0)), "min_price": int(_pick( "MOMENTUM_MIN_PRICE", "MIN_STOCK_PRICE", default=get_env_float("MOMENTUM_SEARCH_MIN_PRICE_KRW", float(_d.get("min_price", 1000))), @@ -242,11 +284,11 @@ def _mom_fixed_defaults() -> Dict[str, Any]: ), "mom_vol_win": float(get_env_int( "MOMENTUM_SEARCH_VOL_WIN", - int(_pick("MOMENTUM_VOL_WIN", "SCALP_MOM_VOL_WIN", default=5, cast=lambda v: int(float(v)))), + int(_pick("MOMENTUM_VOL_WIN", default=5, cast=lambda v: int(float(v)))), )), "mom_time_end_hm": int(_pick( - "MOMENTUM_TIME_END_HM", "SCALP_MOM_TIME_END_HM", - default=get_env_int("MOMENTUM_SEARCH_TIME_END_BUY_HM", 1430), + "MOMENTUM_TIME_END_HM", + default=get_env_int("MOMENTUM_SEARCH_TIME_END_BUY_HM", 1530), cast=lambda v: int(float(v)), )), "mom_min_from_open_pct": get_env_float("MOMENTUM_SEARCH_MIN_FROM_OPEN_PCT", -999.0), @@ -265,6 +307,26 @@ def _mom_fixed_defaults() -> Dict[str, Any]: "ema_slow_period": int(_d.get("ema_slow_period", 21)), "eod_enabled": bool(_d.get("eod_enabled", True)), "eod_hm": str(_d.get("eod_hm") or "15:25").strip() or "15:25", + "skip_hts_scan_dupes": bool(_d.get( + "skip_hts_scan_dupes", + resolve_momentum_skip_hts_scan_dupes(env), + )), + # HTS E(전일시가) TRIGGER — 실매 ``MOMENTUM_TRIGGER_E_CONFIRM`` 과 동일 키. + # 전일 1분봉이 DB에 없으면 E가 전원 탈락 → Optuna -1e18. 그때만 env=0 으로 탐색. + "trigger_e_confirm": get_env_bool( + "MOMENTUM_TRIGGER_E_CONFIRM", + bool(_d.get("trigger_e_confirm", True)), + ), + "trigger_require_bull_bar": get_env_bool( + "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", + bool(_d.get("trigger_require_bull_bar", True)), + ), + # 래칫(이익구간 손절선 상향) — 빈칸=OFF. 실매 MOMENTUM_RATCHET_TIERS 앵커. + "ratchet_tiers": str(_pick( + "MOMENTUM_RATCHET_TIERS", + default=str(_d.get("ratchet_tiers") or ""), + cast=lambda v: str(v or "").strip(), + ) or "").strip(), } @@ -431,97 +493,161 @@ def _momentum_grids() -> Dict[str, Dict[str, List]]: 각 축은 DB ``env_config`` 문자열 ``MOMENTUM_GRID_{MODE}_{키대문자}`` 로 덮어쓸 수 있다 (예: ``MOMENTUM_GRID_COARSE_TRAIL_TRIGGER_PCT`` — 키 suffix 는 아래 ``_parse_csv_*`` 호출 첫 인자 참고). - - coarse: **진입·손익 6축만** 그리드 (≈144조합). 트레일·쿨다운·일일한도·급등·min_margin 은 - ``_mom_fixed_defaults`` 고정 (min_margin 은 모멘텀 매수 루프 미연결 → 그리드 무의미). - - fine : coarse 와 동일 축 — RSI·거래량·TP/SL 등 스텝만 촘촘히 - - full : RSI·볼·손익 범위 넓힘 + 트레일 OFF(0%) 혼합 + 동일 운영축 - - 청산 축(어깨·tp_max): 실매와 동일 우선순위. tp_max 는 운영 범위(≈1.8~3%) 그대로 탐색. - ※ fast 는 13축 카테시안 후 ``MOMENTUM_FAST_MAX_COMBOS`` 균등 샘플 (꼬리잡기 fast 와 동일 패턴). - ※ EMA OFF 시 fast/slow 는 9/21 고정만 유효 — ``_momentum_combo_grid_valid`` 로 중복 조합 스킵. + - fast : HTS TRIGGER(거래량) + 청산 광범위(어깨·래칫·트레일·SL·TP) 균등샘플 + - exit : **청산 전용** 광범위 그리드 — 진입은 DB 고정, 어깨·래칫·트레일·SL·TP·시간컷 + - rr : 손익비(어깨·SL·TP) 중심 + - wide : **축 스크리닝** — 축당 ~10값 초광범위, Optuna 소수 trial로 유효 축·구간 탐색용 + (실매 앵커 포함, 적대값 min_margin≥10 / vol≥20 단독 제외) """ + live = _mom_fixed_defaults() + # 실매 래칫 문자열 (빈칸=OFF). 그리드에 없으면 DB 고정값으로만 동작. + _live_ratchet = str(live.get("ratchet_tiers") or "").strip() + # 안전용 래칫 = 늦게 잠금 (이익 충분히 난 뒤 보호). 조기(+1~2%) 익절형 제외. + # OFF + 실매 앵커 + 근처 밴드. env MOMENTUM_GRID_*_RATCHET_TIERS 로 덮어쓰기 가능. + _late_ratchet_cands = [ + "", + "5:2,10:1.5", + "5:2,10:1", + "5:2.5,10:1.5", + "7:2,12:1.5", + ] + + def _fmerge(cands: List[float], live_v: float) -> List[float]: + return sorted(set([float(x) for x in cands] + [float(live_v)])) + + def _imerge(cands: List[int], live_v: int) -> List[int]: + return sorted(set([int(x) for x in cands] + [int(live_v)])) + + def _smerge(cands: List[str], live_v: str) -> List[str]: + """문자열 축 머지 — 순서 유지, 실매 앵커 말미 추가(미포함 시).""" + out: List[str] = [] + seen = set() + for x in list(cands) + [str(live_v or "").strip()]: + s = str(x).strip() + if s not in seen: + seen.add(s) + out.append(s) + return out + return { # ───────────────────────────────────────────────────────────────────── - # [FAST] V2 TRIGGER — 추격 패턴 + RSI하한 + vol + 손익 + 어깨 + 트레일 + EMA - # → env MOMENTUM_FAST_MAX_COMBOS(기본 512) 균등샘플 + # [FAST] wide(7/15) Top 근방 스모크 — tp15·sl5·sh0.8·0.2·trail3/1.5 분지 + # 실매 앵커: rsi55/80 · vol2 · tp3 · sl3.5 · sh3/0.15 · trail0/0.5 · cd5 · daily50 # ───────────────────────────────────────────────────────────────────── "fast": { "mom_rsi_min": _parse_csv_ints( - "MOMENTUM_GRID_FAST_MOM_RSI_MIN", [45, 50, 55], + "MOMENTUM_GRID_FAST_MOM_RSI_MIN", + _imerge([49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))), + ), + "mom_rsi_max": _parse_csv_ints( + "MOMENTUM_GRID_FAST_MOM_RSI_MAX", + _imerge([80, 90], int(float(live.get("mom_rsi_max") or 80))), ), "mom_vol_mult": _parse_csv_floats( - "MOMENTUM_GRID_FAST_MOM_VOL_MULT", [1.5, 2.0, 3.0], + "MOMENTUM_GRID_FAST_MOM_VOL_MULT", + _fmerge([1.0, 2.0, 5.0], float(live.get("mom_vol_mult") or 2.0)), ), - "pattern_breakout": _parse_csv_ints( - "MOMENTUM_GRID_FAST_PATTERN_BREAKOUT", [1], - ), - "pattern_pullback": _parse_csv_ints( - "MOMENTUM_GRID_FAST_PATTERN_PULLBACK", [0, 1], - ), - "chase_lookback_min": _parse_csv_ints( - "MOMENTUM_GRID_FAST_CHASE_LOOKBACK_MIN", [8, 10, 15], - ), - "pullback_lookback_min": _parse_csv_ints( - "MOMENTUM_GRID_FAST_PULLBACK_LOOKBACK_MIN", [10, 15], - ), - "pullback_min_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_PULLBACK_MIN_PCT", [0.2, 0.3], - ), - "pullback_max_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_PULLBACK_MAX_PCT", [2.0, 3.0], + "tp_pct": _parse_csv_floats( + "MOMENTUM_GRID_FAST_TP_PCT", + _fmerge([3.0, 5.0, 15.0], float(live.get("tp_pct") or 3.0)), ), "sl_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_SL_PCT", [1.5, 2.0], + "MOMENTUM_GRID_FAST_SL_PCT", + _fmerge([3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)), ), - # ── 추세추종형 청산 그리드 ──────────────────────────────────── - # 모멘텀은 갭상승·신고가·거래량 폭증 추세를 '끌고 가서' 먹는 전략. - # tp/tp_max 를 2% 로 캡하면 큰 수익을 잘라 손익비가 1:1 로 깔려 PF<1. - # → 고정익절(tp_pct)·하드캡(tp_max)·트레일을 넓혀 추세 수익을 살린다. - # (어깨·트레일이 먼저 발동해 수익을 보존하고, tp_max 는 마지막 상한) - "tp_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_TP_PCT", [2.5, 4.0, 6.0], + "mom_time_end_hm": _parse_csv_ints( + "MOMENTUM_GRID_FAST_MOM_TIME_END_HM", + _imerge([1430, 1520, 1530], int(float(live.get("mom_time_end_hm") or 1530))), ), - "tp_max_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_TP_MAX_PCT", [4.0, 6.0, 10.0], + "mom_max_from_open_pct": _parse_csv_floats( + "MOMENTUM_GRID_FAST_MOM_MAX_FROM_OPEN_PCT", + _fmerge( + [25.0, 40.0], + float(live.get("mom_max_from_open_pct") or 40.0), + ), + ), + "min_margin": _parse_csv_floats( + "MOMENTUM_GRID_FAST_MIN_MARGIN", + _fmerge([0.5, 0.8], float(live.get("min_margin") or 0.5)), ), - # 어깨 발동(진입가 대비 최소 수익%) — 추세는 충분히 오른 뒤 고점추적 시작 "shoulder_min_high": _parse_csv_floats( - "MOMENTUM_GRID_FAST_SHOULDER_MIN_HIGH_PCT", [1.0, 2.0], + "MOMENTUM_GRID_FAST_SHOULDER_MIN_HIGH_PCT", + _fmerge([0.8, 3.0], float(live.get("shoulder_min_high") or 3.0)), ), "shoulder_cut_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_SHOULDER_CUT_PCT", [0.15, 0.20, 0.25], + "MOMENTUM_GRID_FAST_SHOULDER_CUT_PCT", + _fmerge([0.15, 0.2], float(live.get("shoulder_cut_pct") or 0.15)), ), - # 전용 트레일 폭(%) — 추세 추적의 핵심. 0(OFF) 대신 0.5~1.5 로 끌고 간다. "trail_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_TRAIL_PCT", [0.5, 1.0, 1.5], + "MOMENTUM_GRID_FAST_TRAIL_PCT", + _fmerge([0.0, 0.7, 3.0], float(live.get("trail_pct") or 0.0)), ), "trail_arm_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_TRAIL_ARM_PCT", [0.0, 0.8], + "MOMENTUM_GRID_FAST_TRAIL_ARM_PCT", + _fmerge([0.5, 1.5], float(live.get("trail_arm_pct") or 0.5)), ), - "use_ema_filter": _parse_csv_ints( - "MOMENTUM_GRID_FAST_USE_EMA_FILTER", [0, 1], + "cooldown_min": _parse_csv_floats( + "MOMENTUM_GRID_FAST_COOLDOWN_MIN", + _fmerge([5.0, 15.0], float(live.get("cooldown_min") or 5.0)), ), - "ema_fast_period": _parse_csv_ints( - "MOMENTUM_GRID_FAST_EMA_FAST_PERIOD", [5, 9, 12], + "max_daily": _parse_csv_ints( + "MOMENTUM_GRID_FAST_MAX_DAILY", + _imerge([30, 50], int(float(live.get("max_daily") or 50))), ), - "ema_slow_period": _parse_csv_ints( - "MOMENTUM_GRID_FAST_EMA_SLOW_PERIOD", [15, 21, 34], + "max_daily_chg": _parse_csv_floats( + "MOMENTUM_GRID_FAST_MAX_DAILY_CHG_PCT", + _fmerge([25.0, 30.0], float(live.get("max_daily_chg") or 30.0)), ), - # ── 호가필터(2단계 조인트) 축 ────────────────────────────── - # 기본은 모두 단일값(운영값) → 조합 안 늘고 동작 불변. - # 여러 값 주면 스윕 + kiwoom_0d 본체 재계산 자동 활성(필터 ON 일 때). - # (본체는 6/25~ 만 존재 → 그 외 날짜는 효과 없음/판정재생) - # max_spread_pct : 호가 스프레드 상한(%) ↑완화 - # min_bid_ask_ratio : 매수/매도 잔량비 하한 ↓완화 - # ask_max_mult : 매도벽 허용배수(필요수량×N) ↑완화 - "max_spread_pct": _parse_csv_floats( - "MOMENTUM_GRID_FAST_MAX_SPREAD_PCT", [0.45], + "max_hold_bars": _parse_csv_ints( + "MOMENTUM_GRID_FAST_MAX_HOLD_BARS", + _imerge([0, 15], int(float(live.get("max_hold_bars") or 15))), ), - "min_bid_ask_ratio": _parse_csv_floats( - "MOMENTUM_GRID_FAST_MIN_BID_ASK_RATIO", [0.85], + # 래칫: OFF + 실매 근처(늦게 잠금). 조기(+1~2%) 티어 제외 + "ratchet_tiers": _parse_csv_strings( + "MOMENTUM_GRID_FAST_RATCHET_TIERS", + _smerge(_late_ratchet_cands, _live_ratchet), ), - "ask_max_mult": _parse_csv_floats( - "MOMENTUM_GRID_FAST_ASK_MAX_MULT", [3.0], + }, + # exit: 청산 전용 광범위 — TRIGGER/진입은 _mom_fixed_defaults(DB) 고정 + "exit": { + "sl_pct": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_SL_PCT", [1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0], + ), + "tp_pct": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_TP_PCT", [3.0, 5.0, 6.0, 8.0, 10.0, 12.0, 15.0], + ), + "tp_max_pct": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_TP_MAX_PCT", [5.0, 8.0, 10.0, 12.0, 15.0, 20.0], + ), + "shoulder_min_high": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_SHOULDER_MIN_HIGH_PCT", + [0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0], + ), + "shoulder_cut_pct": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_SHOULDER_CUT_PCT", + [0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.80], + ), + "trail_pct": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_TRAIL_PCT", + [0.0, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], + ), + "trail_arm_pct": _parse_csv_floats( + "MOMENTUM_GRID_EXIT_TRAIL_ARM_PCT", [0.0, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0], + ), + "ratchet_tiers": _parse_csv_strings( + "MOMENTUM_GRID_EXIT_RATCHET_TIERS", + [ + "", + "1:0.5,2:0.8,5:1", + "2:1.5,5:1", + "2:1,5:0.8,8:0.6", + "3:2,6:1.2,10:0.8", + "5:2,10:1.5", + ], + ), + "max_hold_bars": _parse_csv_ints( + "MOMENTUM_GRID_EXIT_MAX_HOLD_BARS", + [0, 15, 30, 45, 60, 90, 120, 180, 240], ), }, # rr: 손익비(어깨·SL·TP) 중심 — 진입은 운영값 1점 고정 (~720조합) @@ -554,86 +680,244 @@ def _momentum_grids() -> Dict[str, Dict[str, List]]: "MOMENTUM_GRID_RR_SHOULDER_CUT_PCT", [0.02, 0.15, 0.25], ), }, - # coarse: 2×2×5×3×3×2×2×2 = 960 (RSI min≥max 무효 조합 제외 시 약간 감소) - # 그리드: RSI·거래량(mom_vol_mult→MOMENTUM_VOL_MULT)·TP/SL·시가과열컷 + # coarse — wide Top 밴드 조금 넓게 (1차 스크리닝) "coarse": { "mom_rsi_min": _parse_csv_ints( - "MOMENTUM_GRID_COARSE_MOM_RSI_MIN", [50, 55], + "MOMENTUM_GRID_COARSE_MOM_RSI_MIN", + _imerge([48, 49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))), ), "mom_rsi_max": _parse_csv_ints( - "MOMENTUM_GRID_COARSE_MOM_RSI_MAX", [90, 95], + "MOMENTUM_GRID_COARSE_MOM_RSI_MAX", + _imerge([70, 80, 90, 100], int(float(live.get("mom_rsi_max") or 80))), ), "mom_vol_mult": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_MOM_VOL_MULT", [1.5,3.0], + "MOMENTUM_GRID_COARSE_MOM_VOL_MULT", + _fmerge([1.0, 2.0, 3.0, 5.0], float(live.get("mom_vol_mult") or 2.0)), ), "tp_pct": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_TP_PCT", [3.0, 5.0], + "MOMENTUM_GRID_COARSE_TP_PCT", + _fmerge([3.0, 5.0, 8.0, 15.0], float(live.get("tp_pct") or 3.0)), ), "sl_pct": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_SL_PCT", [1.5, 2.0, 3.0], - ), - "mom_max_from_open_pct": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_MOM_MAX_FROM_OPEN_PCT", [20.0, 30.0], - ), - "mom_max_from_open_pct": _parse_csv_floats( - "MOMENTUM_GRID_FINE_MOM_MAX_FROM_OPEN_PCT", [20.0, 25.0, 30.0], - ), - "shoulder_min_high": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_SHOULDER_MIN_HIGH_PCT", [0.3, 0.5], - ), - "shoulder_cut_pct": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_SHOULDER_CUT_PCT", [0.2, 0.3], - ), - "tp_max_pct": _parse_csv_floats( - "MOMENTUM_GRID_COARSE_TP_MAX_PCT", [1.8, 2.0], - ), - }, - # fine: 동일 13축 — 중간 스텝 확대 (≈ 1만 × 8 ≈ 10만 조합) - "fine": { - "mom_rsi_min": _parse_csv_ints( - "MOMENTUM_GRID_FINE_MOM_RSI_MIN", [49, 50, 55], - ), - "mom_rsi_max": _parse_csv_ints( - "MOMENTUM_GRID_FINE_MOM_RSI_MAX", [90, 92, 95], - ), - "mom_vol_mult": _parse_csv_floats( - "MOMENTUM_GRID_FINE_MOM_VOL_MULT", [5.0, 7.5, 10.0, 15.0, 20.0], - ), - "tp_pct": _parse_csv_floats( - "MOMENTUM_GRID_FINE_TP_PCT", [3.0, 4.5, 5.5, 7.0], - ), - "sl_pct": _parse_csv_floats( - "MOMENTUM_GRID_FINE_SL_PCT", [1.5, 2.0, 2.5, 3.0], + "MOMENTUM_GRID_COARSE_SL_PCT", + _fmerge([3.0, 3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)), ), "mom_time_end_hm": _parse_csv_ints( - "MOMENTUM_GRID_FINE_MOM_TIME_END_HM", [1300, 1400, 1430], + "MOMENTUM_GRID_COARSE_MOM_TIME_END_HM", + _imerge( + [1400, 1430, 1520, 1530], + int(float(live.get("mom_time_end_hm") or 1530)), + ), ), "mom_max_from_open_pct": _parse_csv_floats( - "MOMENTUM_GRID_FINE_MOM_MAX_FROM_OPEN_PCT", [20.0, 25.0, 30.0], + "MOMENTUM_GRID_COARSE_MOM_MAX_FROM_OPEN_PCT", + _fmerge( + [25.0, 40.0, 45.0], + float(live.get("mom_max_from_open_pct") or 40.0), + ), ), "min_margin": _parse_csv_floats( - "MOMENTUM_GRID_FINE_MIN_MARGIN", [0.2, 10.0], + "MOMENTUM_GRID_COARSE_MIN_MARGIN", + _fmerge([0.3, 0.5, 0.8, 2.0], float(live.get("min_margin") or 0.5)), ), "shoulder_min_high": _parse_csv_floats( - "MOMENTUM_GRID_FINE_SHOULDER_MIN_HIGH_PCT", [0.2, 0.3, 0.5], + "MOMENTUM_GRID_COARSE_SHOULDER_MIN_HIGH_PCT", + _fmerge([0.8, 1.5, 3.0, 5.0], float(live.get("shoulder_min_high") or 3.0)), ), "shoulder_cut_pct": _parse_csv_floats( - "MOMENTUM_GRID_FINE_SHOULDER_CUT_PCT", [0.2, 0.3, 0.4], + "MOMENTUM_GRID_COARSE_SHOULDER_CUT_PCT", + _fmerge( + [0.15, 0.2, 0.25, 0.4], + float(live.get("shoulder_cut_pct") or 0.15), + ), ), - "trail_trigger": _parse_csv_floats( - "MOMENTUM_GRID_FINE_TRAIL_TRIGGER_PCT", [1.0, 1.5], + "trail_pct": _parse_csv_floats( + "MOMENTUM_GRID_COARSE_TRAIL_PCT", + _fmerge([0.0, 0.7, 1.0, 3.0], float(live.get("trail_pct") or 0.0)), ), - "trail_stop": _parse_csv_floats( - "MOMENTUM_GRID_FINE_TRAIL_STOP_PCT", [1.0], + "trail_arm_pct": _parse_csv_floats( + "MOMENTUM_GRID_COARSE_TRAIL_ARM_PCT", + _fmerge([0.5, 1.5, 2.0, 3.0], float(live.get("trail_arm_pct") or 0.5)), ), "cooldown_min": _parse_csv_floats( - "MOMENTUM_GRID_FINE_COOLDOWN_MIN", [1.0, 3.0], + "MOMENTUM_GRID_COARSE_COOLDOWN_MIN", + _fmerge([5.0, 8.0, 15.0], float(live.get("cooldown_min") or 5.0)), ), "max_daily": _parse_csv_ints( - "MOMENTUM_GRID_FINE_MAX_DAILY", [5, 10], + "MOMENTUM_GRID_COARSE_MAX_DAILY", + _imerge([5, 30, 50, 80], int(float(live.get("max_daily") or 50))), ), "max_daily_chg": _parse_csv_floats( - "MOMENTUM_GRID_FINE_MAX_DAILY_CHG_PCT", [45.0], + "MOMENTUM_GRID_COARSE_MAX_DAILY_CHG_PCT", + _fmerge( + [25.0, 30.0, 60.0], + float(live.get("max_daily_chg") or 30.0), + ), + ), + "max_hold_bars": _parse_csv_ints( + "MOMENTUM_GRID_COARSE_MAX_HOLD_BARS", + _imerge([0, 15, 60, 120], int(float(live.get("max_hold_bars") or 15))), + ), + "ratchet_tiers": _parse_csv_strings( + "MOMENTUM_GRID_COARSE_RATCHET_TIERS", + _smerge(_late_ratchet_cands, _live_ratchet), + ), + }, + # fine — 2026-07-15 wide Top 재설계 (best~+60k / Top: tp15·sl5·vol2·rsi52/90) + # 실매 앵커 포함. apply는 별도 확인 후. + 늦게잠금 래칫 + "fine": { + "mom_rsi_min": _parse_csv_ints( + "MOMENTUM_GRID_FINE_MOM_RSI_MIN", + _imerge([49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))), + ), + "mom_rsi_max": _parse_csv_ints( + "MOMENTUM_GRID_FINE_MOM_RSI_MAX", + _imerge([80, 90, 100], int(float(live.get("mom_rsi_max") or 80))), + ), + "mom_vol_mult": _parse_csv_floats( + "MOMENTUM_GRID_FINE_MOM_VOL_MULT", + _fmerge([1.0, 2.0, 3.0, 5.0], float(live.get("mom_vol_mult") or 2.0)), + ), + "tp_pct": _parse_csv_floats( + "MOMENTUM_GRID_FINE_TP_PCT", + _fmerge([3.0, 5.0, 8.0, 15.0], float(live.get("tp_pct") or 3.0)), + ), + "sl_pct": _parse_csv_floats( + "MOMENTUM_GRID_FINE_SL_PCT", + _fmerge([3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)), + ), + "mom_time_end_hm": _parse_csv_ints( + "MOMENTUM_GRID_FINE_MOM_TIME_END_HM", + _imerge( + [1430, 1520, 1530], + int(float(live.get("mom_time_end_hm") or 1530)), + ), + ), + "mom_max_from_open_pct": _parse_csv_floats( + "MOMENTUM_GRID_FINE_MOM_MAX_FROM_OPEN_PCT", + _fmerge( + [25.0, 40.0, 45.0], + float(live.get("mom_max_from_open_pct") or 40.0), + ), + ), + "min_margin": _parse_csv_floats( + "MOMENTUM_GRID_FINE_MIN_MARGIN", + _fmerge([0.3, 0.5, 0.8, 2.0], float(live.get("min_margin") or 0.5)), + ), + "shoulder_min_high": _parse_csv_floats( + "MOMENTUM_GRID_FINE_SHOULDER_MIN_HIGH_PCT", + _fmerge([0.8, 1.5, 3.0, 5.0], float(live.get("shoulder_min_high") or 3.0)), + ), + "shoulder_cut_pct": _parse_csv_floats( + "MOMENTUM_GRID_FINE_SHOULDER_CUT_PCT", + _fmerge( + [0.15, 0.2, 0.25, 0.4], + float(live.get("shoulder_cut_pct") or 0.15), + ), + ), + "trail_pct": _parse_csv_floats( + "MOMENTUM_GRID_FINE_TRAIL_PCT", + _fmerge([0.0, 0.7, 1.0, 3.0], float(live.get("trail_pct") or 0.0)), + ), + "trail_arm_pct": _parse_csv_floats( + "MOMENTUM_GRID_FINE_TRAIL_ARM_PCT", + _fmerge([0.5, 1.5, 2.0, 3.0], float(live.get("trail_arm_pct") or 0.5)), + ), + "cooldown_min": _parse_csv_floats( + "MOMENTUM_GRID_FINE_COOLDOWN_MIN", + _fmerge([5.0, 8.0, 15.0], float(live.get("cooldown_min") or 5.0)), + ), + "max_daily": _parse_csv_ints( + "MOMENTUM_GRID_FINE_MAX_DAILY", + _imerge([5, 30, 50, 80], int(float(live.get("max_daily") or 50))), + ), + "max_daily_chg": _parse_csv_floats( + "MOMENTUM_GRID_FINE_MAX_DAILY_CHG_PCT", + _fmerge( + [25.0, 30.0, 60.0], + float(live.get("max_daily_chg") or 30.0), + ), + ), + "max_hold_bars": _parse_csv_ints( + "MOMENTUM_GRID_FINE_MAX_HOLD_BARS", + _imerge([0, 15, 60, 120], int(float(live.get("max_hold_bars") or 15))), + ), + "ratchet_tiers": _parse_csv_strings( + "MOMENTUM_GRID_FINE_RATCHET_TIERS", + _smerge(_late_ratchet_cands, _live_ratchet), + ), + }, + # wide: 축 스크리닝용 초광범위 (축당 ~10값) — fine 승자(+761)가 너무 얇을 때 + # Optuna trials≈50 으로 어떤 축·구간이 PnL/건수에 반응하는지 먼저 찾고, 이후 fine 재세팅 + # 실매 앵커: vol1.05 / sh0.3·0.1 / trail0.7·0.4 / chg40 / hold15 / daily100 / cd1 / end1530 + "wide": { + "mom_rsi_min": _parse_csv_ints( + "MOMENTUM_GRID_WIDE_MOM_RSI_MIN", + [40, 45, 48, 49, 50, 52, 55, 58, 60, 65], + ), + "mom_rsi_max": _parse_csv_ints( + "MOMENTUM_GRID_WIDE_MOM_RSI_MAX", + [70, 75, 80, 85, 88, 90, 92, 95, 98, 100], + ), + "mom_vol_mult": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_MOM_VOL_MULT", + [1.0, 1.05, 1.2, 1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0], + ), + "tp_pct": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_TP_PCT", + [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 12.0, 15.0], + ), + "sl_pct": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_SL_PCT", + [1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], + ), + "mom_time_end_hm": _parse_csv_ints( + "MOMENTUM_GRID_WIDE_MOM_TIME_END_HM", + [1100, 1200, 1300, 1330, 1400, 1430, 1500, 1510, 1520, 1530], + ), + "mom_max_from_open_pct": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_MOM_MAX_FROM_OPEN_PCT", + [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 60.0], + ), + "min_margin": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_MIN_MARGIN", + [0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0], + ), + "shoulder_min_high": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_SHOULDER_MIN_HIGH_PCT", + [0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0], + ), + "shoulder_cut_pct": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_SHOULDER_CUT_PCT", + [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.8, 1.0], + ), + "trail_pct": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_TRAIL_PCT", + [0.0, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], + ), + "trail_arm_pct": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_TRAIL_ARM_PCT", + [0.0, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], + ), + "cooldown_min": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_COOLDOWN_MIN", + [0.0, 1.0, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0], + ), + "max_daily": _parse_csv_ints( + "MOMENTUM_GRID_WIDE_MAX_DAILY", + [5, 10, 15, 20, 30, 50, 80, 100, 150, 200], + ), + "max_daily_chg": _parse_csv_floats( + "MOMENTUM_GRID_WIDE_MAX_DAILY_CHG_PCT", + [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 60.0], + ), + "max_hold_bars": _parse_csv_ints( + "MOMENTUM_GRID_WIDE_MAX_HOLD_BARS", + [0, 10, 15, 20, 30, 45, 60, 90, 120, 180], + ), + "ratchet_tiers": _parse_csv_strings( + "MOMENTUM_GRID_WIDE_RATCHET_TIERS", + _smerge(_late_ratchet_cands, _live_ratchet), ), }, # full: 가설 폭넓힘 (낮은 vol_mult·짧은 TP 가설 포함) + 트레일 0 = TP 상한 위주 청산 @@ -663,11 +947,11 @@ def _momentum_grids() -> Dict[str, Dict[str, List]]: "min_margin": _parse_csv_floats( "MOMENTUM_GRID_FULL_MIN_MARGIN", [0.2, 10.0], ), - "trail_trigger": _parse_csv_floats( - "MOMENTUM_GRID_FULL_TRAIL_TRIGGER_PCT", [0.0, 1.2], + "trail_pct": _parse_csv_floats( + "MOMENTUM_GRID_FULL_TRAIL_PCT", [0.0, 0.8, 1.2], ), - "trail_stop": _parse_csv_floats( - "MOMENTUM_GRID_FULL_TRAIL_STOP_PCT", [0.5, 1.0], + "trail_arm_pct": _parse_csv_floats( + "MOMENTUM_GRID_FULL_TRAIL_ARM_PCT", [0.0, 0.5, 1.0], ), "cooldown_min": _parse_csv_floats( "MOMENTUM_GRID_FULL_COOLDOWN_MIN", [1.0, 5.0], @@ -678,6 +962,10 @@ def _momentum_grids() -> Dict[str, Dict[str, List]]: "max_daily_chg": _parse_csv_floats( "MOMENTUM_GRID_FULL_MAX_DAILY_CHG_PCT", [35.0, 50.0], ), + # full+ 전용 — apply 시 DB 미반영(운영 false 고정) + "skip_hts_scan_dupes": _parse_csv_bools( + "MOMENTUM_GRID_FULL_SKIP_HTS_SCAN_DUPES", [False, True], + ), }, } @@ -685,9 +973,10 @@ def _momentum_grids() -> Dict[str, Dict[str, List]]: MOMENTUM_GRID_AXIS_HINTS_KO: Dict[str, str] = { "mom_rsi_min": "RSI3 하한 — 약세 제외", "mom_rsi_max": "RSI3 상한 — use_rsi_max_filter ON 시만", - "mom_vol_mult": "현재봉 거래량 ≥ N봉평균 × 배수", - "pattern_breakout": "N분 고가 돌파 패턴 (0=OFF, 1=ON)", - "pattern_pullback": "눌림 재돌파 패턴 (0=OFF, 1=ON)", + "mom_vol_mult": "TRIGGER 거래량 펄스: 현재봉 ≥ N봉평균 × 배수", + "trigger_require_bull_bar": "양봉 필수 (0=OFF, 1=ON) — HTS TRIGGER", + "pattern_breakout": "(레거시) N분 고가 돌파 패턴", + "pattern_pullback": "(레거시) 눌림 재돌파 패턴", "chase_lookback_min": "돌파 패턴 관찰 구간(분)", "pullback_lookback_min": "눌림 패턴 스윙고점 탐색 구간(분)", "pullback_min_pct": "눌림 최소 깊이(%)", @@ -720,23 +1009,28 @@ def _momentum_ema_filter_on(raw: Any) -> bool: def _momentum_combo_grid_valid(combo: Dict[str, Any]) -> bool: - """fast 그리드 — 패턴 OR·EMA·눌림폭·RSI 무효 조합 제거.""" + """그리드 무효 조합 제거 — HTS TRIGGER + 레거시 패턴 축 호환.""" if "mom_rsi_min" in combo and "mom_rsi_max" in combo: if float(combo["mom_rsi_min"]) >= float(combo["mom_rsi_max"]): return False - bo = _momentum_ema_filter_on(combo.get("pattern_breakout", 1)) - pb = _momentum_ema_filter_on(combo.get("pattern_pullback", 1)) - if not bo and not pb: - return False + if "pattern_breakout" in combo or "pattern_pullback" in combo: + bo = _momentum_ema_filter_on(combo.get("pattern_breakout", 1)) + pb = _momentum_ema_filter_on(combo.get("pattern_pullback", 1)) + if not bo and not pb: + return False if "pullback_min_pct" in combo and "pullback_max_pct" in combo: if float(combo["pullback_min_pct"]) >= float(combo["pullback_max_pct"]): return False - ema_on = _momentum_ema_filter_on(combo.get("use_ema_filter", 0)) - fast = int(float(combo.get("ema_fast_period", 9))) - slow = int(float(combo.get("ema_slow_period", 21))) - if not ema_on: - return fast == 9 and slow == 21 - return slow > fast + if "use_ema_filter" in combo and ( + "ema_fast_period" in combo or "ema_slow_period" in combo + ): + ema_on = _momentum_ema_filter_on(combo.get("use_ema_filter", 0)) + fast = int(float(combo.get("ema_fast_period", 9))) + slow = int(float(combo.get("ema_slow_period", 21))) + if not ema_on: + return fast == 9 and slow == 21 + return slow > fast + return True # ────────────────────────────────────────────────────────────────────────────── @@ -746,7 +1040,7 @@ def _momentum_combo_grid_valid(combo: Dict[str, Any]) -> bool: def _get_momentum_field_map() -> Dict[str, Tuple[str, Any]]: """모멘텀 파라미터 → env_config 컬럼 매핑. - ※ 새 키 MOMENTUM_* 를 1차로 채움. 기존 SCALP_MOM_*/SCALP_* 도 같이 채울지는 + ※ MOMENTUM_* 키만 저장. --apply 사용자 의도에 따라 향후 옵션 추가 가능. 지금은 새 키만. """ return { @@ -771,19 +1065,27 @@ def _get_momentum_field_map() -> Dict[str, Tuple[str, Any]]: "sl_pct": ("MOMENTUM_STOP_LOSS_PCT", lambda v: str(float(v) / 100)), "tp_pct": ("MOMENTUM_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)), # 청산 보조 — 웹 저장과 동일: UI 퍼센트 → DB 비율 (/100) - "trail_trigger": ("SCALP_ATR_UP_MULT", lambda v: str(abs(float(v)) / 100.0)), - "trail_stop": ("SCALP_ATR_DOWN_MULT", lambda v: str(abs(float(v)) / 100.0)), + # trail_trigger/stop 레거시는 모멘텀 미사용(전용 trail_pct/arm). SCALP ATR 오염 금지. "shoulder_min_high": ("MOMENTUM_SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v) / 100)), "shoulder_cut_pct": ("MOMENTUM_SHOULDER_CUT_PCT", lambda v: str(float(v) / 100)), + "trail_pct": ("MOMENTUM_TRAIL_PCT", lambda v: str(abs(float(v)) / 100.0)), + "trail_arm_pct": ("MOMENTUM_TRAIL_ARM_PCT", lambda v: str(abs(float(v)) / 100.0)), + "max_hold_bars": ("MOMENTUM_MAX_HOLD_BARS", lambda v: str(int(float(v)))), + "ratchet_tiers": ("MOMENTUM_RATCHET_TIERS", lambda v: str(v or "").strip()), + "trigger_require_bull_bar": ( + "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", + lambda v: "true" if _momentum_ema_filter_on(v) else "false", + ), + # skip_hts_scan_dupes: full 그리드 탐색만, apply 제외 "tp_max_pct": ("MOMENTUM_TP_MAX_PCT", lambda v: str(float(v) / 100)), - "cooldown_min": ("SCALP_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)), - # 방어로직 (모멘텀 전용 키가 없으면 SCALP_* 공유) - "high_chase_thr": ("SCALP_HIGH_PRICE_CHASE_THRESHOLD", lambda v: str(float(v))), - "max_daily_chg": ("SCALP_MAX_DAILY_CHANGE_PCT", lambda v: str(float(v))), + # 엔진 get_momentum_defaults 와 동일 — SCALP_* 오염 금지 + "cooldown_min": ("MOMENTUM_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)), + "high_chase_thr": ("MOMENTUM_HIGH_CHASE_THR", lambda v: str(float(v))), + "max_daily_chg": ("MOMENTUM_MAX_DAILY_CHG", lambda v: str(float(v))), "min_price": ("MOMENTUM_MIN_PRICE", lambda v: str(int(float(v)))), "max_loss_krw": ("MOMENTUM_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))), "min_margin": ("MOMENTUM_MIN_PROFIT_PCT", lambda v: str(float(v))), - "use_defense_filters": ("SCALP_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"), + "use_defense_filters": ("MOMENTUM_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"), "max_daily": ("MOMENTUM_MAX_DAILY", lambda v: str(int(float(v)))), "slot_money": ("MOMENTUM_SLOT_MONEY", lambda v: str(int(float(v)))), } @@ -797,8 +1099,9 @@ def _apply_to_db(best_params: dict) -> Optional[int]: ) patch = _patch_from_momentum_merged(best_params) - patch.update(portfolio_env_patch("MOMENTUM", best_params)) patch.update(session_env_patch("MOMENTUM", best_params)) + # 슬롯·동시보유·총한도 제외 — Optuna/Grid 가 slot×종목수로 한도를 덮지 않음 + patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM") if not patch: print("DB 적용할 파라미터가 없습니다.") return None @@ -830,20 +1133,40 @@ def _ui_to_engine_params(ui_params: dict) -> dict: engine_params["tp_max_pct"] = ui_params["tp_max_pct"] / 100 elif "tp_max_pct" not in engine_params: engine_params["tp_max_pct"] = get_env_float("MOMENTUM_TP_MAX_PCT", 0.02) - if "trail_trigger" in ui_params: - engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100 - if "trail_stop" in ui_params: - engine_params["trail_stop"] = ui_params["trail_stop"] / 100 + # 모멘텀 청산은 trail_pct/arm 만 사용 (0=OFF). + # 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 동일하게 전용 트레일 OFF. if "trail_pct" in ui_params: engine_params["trail_pct"] = abs(float(ui_params["trail_pct"])) / 100.0 if "trail_arm_pct" in ui_params: engine_params["trail_arm_pct"] = abs(float(ui_params["trail_arm_pct"])) / 100.0 + if "trail_pct" not in ui_params and "trail_arm_pct" not in ui_params: + if "trail_trigger" in ui_params or "trail_stop" in ui_params: + engine_params["trail_pct"] = 0.0 + engine_params["trail_arm_pct"] = 0.0 + if "trail_trigger" in ui_params: + engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100 + if "trail_stop" in ui_params: + engine_params["trail_stop"] = ui_params["trail_stop"] / 100 if "max_hold_bars" in ui_params: engine_params["max_hold_bars"] = int(float(ui_params["max_hold_bars"] or 0)) if "shoulder_min_high" in ui_params: engine_params["shoulder_min_high"] = ui_params["shoulder_min_high"] / 100 if "shoulder_cut_pct" in ui_params: engine_params["shoulder_cut_pct"] = ui_params["shoulder_cut_pct"] / 100 + if "ratchet_tiers" in ui_params: + engine_params["ratchet_tiers"] = str(ui_params["ratchet_tiers"] or "").strip() + if "trigger_require_bull_bar" in ui_params: + engine_params["trigger_require_bull_bar"] = _momentum_ema_filter_on( + ui_params["trigger_require_bull_bar"], + ) + if "trigger_e_confirm" in ui_params: + engine_params["trigger_e_confirm"] = _momentum_ema_filter_on( + ui_params["trigger_e_confirm"], + ) + if "skip_hts_scan_dupes" in ui_params: + engine_params["skip_hts_scan_dupes"] = bool(ui_params["skip_hts_scan_dupes"]) + elif "skip_hts_scan_dupes" not in engine_params: + engine_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes() engine_params["fee_rate"] = ui_params["fee_rate"] / 100 engine_params["sell_tax"] = ui_params["sell_tax"] / 100 engine_params["min_margin"] = ui_params.get("min_margin", 0.2) / 100 @@ -1210,8 +1533,12 @@ def run_search( axes, mode, strategy_env_prefix="MOMENTUM", - default_fast=150, - default_other=DEFAULT_MAX_COMBOS, + default_fast=get_env_int("MOMENTUM_FAST_MAX_COMBOS", 512), + default_other=( + get_env_int("MOMENTUM_EXIT_MAX_COMBOS", 2000) + if mode == "exit" + else DEFAULT_MAX_COMBOS + ), max_combos_override=max_combos, valid_fn=_momentum_combo_grid_valid, ) @@ -1219,8 +1546,13 @@ def run_search( print(f"\n[MOMENTUM {mode.upper()} 모드] 그리드: {total_grid:,} → 백테: {sampled:,} | 기간: {start} ~ {end}") if mode == "fast": print( - f"📌 [fast] MOMENTUM_GRID_FAST_* · {len(keys)}축 · " - f"{total_grid:,}→{max_combos_cap}균등샘플 · momentum_engine 전용청산" + f"📌 [fast] HTS TRIGGER + 청산 광범위 · {len(keys)}축 · " + f"{total_grid:,}→{max_combos_cap}균등샘플" + ) + elif mode == "exit": + print( + f"📌 [exit] 청산 전용 광범위(어깨·래칫·트레일·SL·TP·시간) · " + f"{total_grid:,}→{max_combos_cap}균등샘플 · 진입=DB고정" ) if dropped_by_cap: print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)") @@ -1279,8 +1611,8 @@ def run_search( db = TradeDB() try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env_row = load_portfolio_env_row(db) finally: db.close() fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") @@ -1724,17 +2056,17 @@ def run_search( # ────────────────────────────────────────────────────────────────────────────── def main(): - today = datetime.now().strftime("%Y-%m-%d") - week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + week_ago, today = resolve_param_search_range("MOMENTUM", lookback_days=7) parser = argparse.ArgumentParser( description="모멘텀 Grid Search (momentum_engine · MomentumStrategy 실매 동일 경로)", ) - parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)") - parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)") - parser.add_argument("--mode", default="fast", - choices=["fast", "rr", "coarse", "fine", "full"], - help="탐색 모드: fast(V2·200균등샘플·~15분) / rr / coarse / fine / full") + parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 거래일 보정)") + parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)") + parser.add_argument("--mode", default="exit", + choices=["fast", "exit", "rr", "coarse", "fine", "wide", "full"], + help="탐색 모드: exit(청산광범위·기본) / fast / rr / coarse / fine / wide(축스크리닝) / full") parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", help="백테 조합 상한 (fast 기본 env MOMENTUM_FAST_MAX_COMBOS=200, coarse 등 5000, 0=무제한)", diff --git a/kis_trader/backtest/param_search_optuna.py b/kis_trader/backtest/param_search_optuna.py index c2e8323..d25135d 100644 --- a/kis_trader/backtest/param_search_optuna.py +++ b/kis_trader/backtest/param_search_optuna.py @@ -4,7 +4,7 @@ kis_trader/backtest/param_search_optuna.py — Optuna TPE 파라미터 탐색 ( ============================================================================== 기존 Grid CLI(tail_param_search.py 등)는 그대로 두고, Bayesian(TPE) add-on. -현재 구현: --strategy tail | momentum | breakout +현재 구현: --strategy tail | momentum | breakout | scalp 실행 예: # 꼬리 @@ -13,6 +13,8 @@ kis_trader/backtest/param_search_optuna.py — Optuna TPE 파라미터 탐색 ( python3 kis_trader/backtest/param_search_optuna.py --strategy momentum --mode fast --trials 200 # 돌파 python3 kis_trader/backtest/param_search_optuna.py --strategy breakout --mode fast --trials 200 + # 스캘핑 RSI V자 (trigger=진입 / exit=청산) + python3 kis_trader/backtest/param_search_optuna.py --strategy scalp --mode trigger --trials 100 Win11 + VM 동시 분산: 같은 study-name · 같은 storage(141/kis_optuna) 로 각각 --trials 실행. @@ -51,10 +53,13 @@ from database import TradeDB from kis_trader.backtest import tail_backtest_common as tbc from kis_trader.backtest.optuna_common import ( OPTUNA_STRATEGIES, + announce_optuna_json_path, optuna_run_lock_name, + release_shared_tick_store, resolve_optuna_storage_url, resolve_study_name, ) +from kis_trader.backtest.optuna_mode_combo import enrich_out_data_with_mode_combo from kis_trader.backtest.optuna_breakout import ( apply_best_breakout_trial, prepare_breakout_search_context, @@ -65,6 +70,11 @@ from kis_trader.backtest.optuna_momentum import ( prepare_momentum_search_context, run_momentum_optuna, ) +from kis_trader.backtest.optuna_scalping import ( + apply_best_scalp_trial, + prepare_scalp_search_context, + run_scalp_optuna, +) from kis_trader.backtest.optuna_search_space import suggest_tail_params, tail_grid_axis_keys from kis_trader.backtest.param_search_cli_common import ( add_portfolio_cli_args, @@ -92,9 +102,10 @@ _FAIL_OBJECTIVE = -1e18 # 전략별 --mode 허용값 (Grid CLI 와 동일) STRATEGY_MODES: Dict[str, List[str]] = { - "tail": ["fast", "coarse", "fine", "full", "massive"], - "momentum": ["fast", "rr", "coarse", "fine", "full"], - "breakout": ["fast", "coarse", "fine", "full"], + "tail": ["fast", "coarse", "fine", "wide", "full", "massive"], + "momentum": ["fast", "exit", "rr", "coarse", "fine", "wide", "full"], + "breakout": ["fast", "coarse", "fine", "wide", "full"], + "scalp": ["fast", "trigger", "exit", "coarse", "fine", "full", "wide"], } @@ -171,8 +182,8 @@ def prepare_tail_search_context( "적용" if ob_filter_on else "스킵 — 코어 파라미터 순수 탐색", ) - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - r = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + r = load_portfolio_env_row(db) fee_rate, sell_tax, _slot_from_fee = tbc.fee_and_slot_from_env_row(r) portfolio = tbc.resolve_tail_portfolio_params( r, @@ -221,10 +232,15 @@ def prepare_tail_search_context( base_params = dict(base_params) base_params["scan_interval_min"] = scan_interval_min base_params["timeframe"] = tail_tf - base_params.setdefault("backtest_tick_fallback_ohlc", True) - from kis_trader.engine.tail_tick_replay import tail_backtest_use_tick_db as _tail_use_tick + from kis_trader.engine.tail_tick_replay import ( + tail_backtest_tick_fallback_ohlc as _tail_tick_fallback, + tail_backtest_use_tick_db as _tail_use_tick, + tail_backtest_use_tick_exit as _tail_use_tick_exit, + ) + base_params.setdefault("backtest_tick_fallback_ohlc", _tail_tick_fallback(None)) base_params.setdefault("backtest_use_tick_db", _tail_use_tick(None)) - if base_params.get("backtest_use_tick_db"): + base_params.setdefault("backtest_use_tick_exit", _tail_use_tick_exit(None)) + if base_params.get("backtest_use_tick_db") or base_params.get("backtest_use_tick_exit"): logger.info("📌 틱재생(ws_ticks): ON — 실매 체결 정합 모드 (OHLC 낙관편향 제거)") logger.info( @@ -452,111 +468,155 @@ def run_tail_optuna( n_jobs=n_jobs, show_progress_bar=show_progress, ) - finally: - # 탐색 종료(또는 예외) 시 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치). - _store = getattr(ctx, "shared_tick_store", None) - if _store is not None: + elapsed = time.time() - t0 + logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials)) + + # JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전) + passing: List[Dict[str, Any]] = [] + for trial in study.trials: + if trial.state != optuna.trial.TrialState.COMPLETE: + continue + if not trial.user_attrs.get("gates_ok"): + continue + params_raw = trial.user_attrs.get("params_json") or "{}" try: - _store.unlink() - except Exception: - pass - ctx.shared_tick_store = None - elapsed = time.time() - t0 - logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials)) + combo = json.loads(params_raw) + except json.JSONDecodeError: + combo = dict(trial.params) + passing.append({ + "params": combo, + "apply_cfg": {**ctx.base_params, **combo}, + "total_trades": int(trial.user_attrs.get("total_trades") or 0), + "win_rate": float(trial.user_attrs.get("win_rate") or 0), + "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), + "pf": float(trial.user_attrs.get("pf") or 0), + "optuna_trial_number": trial.number, + }) + if sort_by == "pnl": + passing.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["win_rate"]))) + else: + passing.sort(key=lambda r: (-float(r["win_rate"]), -float(r["total_pnl"]))) - # JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전) - passing: List[Dict[str, Any]] = [] - for trial in study.trials: - if trial.state != optuna.trial.TrialState.COMPLETE: - continue - if not trial.user_attrs.get("gates_ok"): - continue - params_raw = trial.user_attrs.get("params_json") or "{}" + profitable = [r for r in passing if float(r.get("total_pnl") or 0) > 0] + if profitable: + passing = profitable + + out_data = { + "engine": "optuna", + "mode": ctx.mode, + "start": ctx.start, + "end": ctx.end, + "timeframe": ctx.tail_tf, + "universe_source": ctx.universe_source, + "universe_history_slots": ctx.universe_history_slots, + "slot_money": int(ctx.slot_money), + "max_stocks": ctx.max_stocks, + "total_budget_krw": int(ctx.total_budget_krw), + "portfolio_mode": True, + "budget_warning": ctx.portfolio.get("budget_warning"), + "backtest_days": ctx.period_days, + "min_trades": min_trades, + "min_win_rate": min_win_rate, + "min_pf": min_pf, + "sort_by": sort_by, + "grid_keys": ctx.grid_keys, + "grid_axis_hints": {k: TAIL_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in TAIL_GRID_AXIS_HINTS_KO}, + "holding_peak_in_candles": ctx.has_holding_peak, + "optuna_study_name": study_name, + "optuna_storage": storage_url, + "optuna_n_trials_requested": n_trials, + "optuna_trials_completed": len(study.trials), + "optuna_best_value": study.best_value if study.best_trial else None, + "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, + "elapsed_sec": round(elapsed, 1), + "results": passing[:5000], + } + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out_name = f"optuna_tail_{ctx.mode}_{ts}.json" + out_dir = _results_dir_for_write() + out_path = os.path.join(out_dir, out_name) try: - combo = json.loads(params_raw) - except json.JSONDecodeError: - combo = dict(trial.params) - passing.append({ - "params": combo, - "apply_cfg": {**ctx.base_params, **combo}, - "total_trades": int(trial.user_attrs.get("total_trades") or 0), - "win_rate": float(trial.user_attrs.get("win_rate") or 0), - "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), - "pf": float(trial.user_attrs.get("pf") or 0), - "optuna_trial_number": trial.number, - }) - if sort_by == "pnl": - passing.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["win_rate"]))) - else: - passing.sort(key=lambda r: (-float(r["win_rate"]), -float(r["total_pnl"]))) - - profitable = [r for r in passing if float(r.get("total_pnl") or 0) > 0] - if profitable: - passing = profitable - - out_data = { - "engine": "optuna", - "mode": ctx.mode, - "start": ctx.start, - "end": ctx.end, - "timeframe": ctx.tail_tf, - "universe_source": ctx.universe_source, - "universe_history_slots": ctx.universe_history_slots, - "slot_money": int(ctx.slot_money), - "max_stocks": ctx.max_stocks, - "total_budget_krw": int(ctx.total_budget_krw), - "portfolio_mode": True, - "budget_warning": ctx.portfolio.get("budget_warning"), - "backtest_days": ctx.period_days, - "min_trades": min_trades, - "min_win_rate": min_win_rate, - "min_pf": min_pf, - "sort_by": sort_by, - "grid_keys": ctx.grid_keys, - "grid_axis_hints": {k: TAIL_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in TAIL_GRID_AXIS_HINTS_KO}, - "holding_peak_in_candles": ctx.has_holding_peak, - "optuna_study_name": study_name, - "optuna_storage": storage_url, - "optuna_n_trials_requested": n_trials, - "optuna_trials_completed": len(study.trials), - "optuna_best_value": study.best_value if study.best_trial else None, - "optuna_best_trial_number": study.best_trial.number if study.best_trial else None, - "elapsed_sec": round(elapsed, 1), - "results": passing[:5000], - } - - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - out_name = f"optuna_tail_{ctx.mode}_{ts}.json" - out_dir = _results_dir_for_write() - out_path = os.path.join(out_dir, out_name) - try: - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - except OSError: - fb = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results") - os.makedirs(fb, exist_ok=True) - out_path = os.path.join(fb, out_name) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - logger.warning("⚠️ results/ 쓰기 권한 없음 → 폴백 저장: %s", out_path) - - logger.info("💾 Optuna 결과 저장: %s", out_path) - - if study.best_trial and study.best_value > _FAIL_OBJECTIVE + 1: - bt = study.best_trial - logger.info( - "🏆 Best trial #%d | objective=%.4g | pnl=%s | wr=%.1f%% | trades=%s", - bt.number, - study.best_value, - bt.user_attrs.get("total_pnl"), - float(bt.user_attrs.get("win_rate") or 0), - bt.user_attrs.get("total_trades"), + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + except OSError: + fb = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results") + os.makedirs(fb, exist_ok=True) + out_path = os.path.join(fb, out_name) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + logger.warning("⚠️ results/ 쓰기 권한 없음 → 폴백 저장: %s", out_path) + announce_optuna_json_path( + out_path, strategy="tail", mode=ctx.mode, note="중간저장(mode 전)", log=logger, ) - else: - logger.info("⚠️ 조건 만족 trial 없음 (min_trades·승률·PF 게이트 확인)") - study._kis_export_path = out_path # type: ignore[attr-defined] - return study + def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return evaluate_tail_param_combo( + combo, + base_params=ctx.base_params, + candles_by_code=ctx.candles_by_code, + fee_rate=ctx.fee_rate, + sell_tax=ctx.sell_tax, + min_trades=1, + min_win_rate=0.0, + min_pf=0.0, + universe_by_slot=ctx.universe_by_slot, + slot_money=ctx.slot_money, + max_stocks=ctx.max_stocks, + total_budget_krw=ctx.total_budget_krw, + period_days=ctx.period_days, + cache_holder=ctx.cache_holder, + ticks_by_code=ctx.ticks_by_code, + orderbook_by_code=ctx.orderbook_by_code, + program_by_code=ctx.program_by_code, + log_verdict_by_code=ctx.log_verdict_by_code, + ) + + def _save_partial(_data: Dict[str, Any]) -> None: + try: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(_data, f, indent=2, ensure_ascii=False) + except OSError as exc: + logger.warning("⚠️ mode_combo 부분저장 실패: %s", exc) + return + announce_optuna_json_path( + out_path, strategy="tail", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, + ) + + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + try: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + except OSError as exc: + logger.warning("⚠️ mode_combo 반영 재저장 실패: %s", exc) + announce_optuna_json_path( + out_path, strategy="tail", mode=ctx.mode, note="최종 JSON", log=logger, + ) + + if study.best_trial and study.best_value > _FAIL_OBJECTIVE + 1: + bt = study.best_trial + logger.info( + "🏆 Best trial #%d | objective=%.4g | pnl=%s | wr=%.1f%% | trades=%s", + bt.number, + study.best_value, + bt.user_attrs.get("total_pnl"), + float(bt.user_attrs.get("win_rate") or 0), + bt.user_attrs.get("total_trades"), + ) + else: + logger.info("⚠️ 조건 만족 trial 없음 (min_trades·승률·PF 게이트 확인)") + + study._kis_export_path = out_path # type: ignore[attr-defined] + return study + finally: + # mode_combo 실측이 ticks 공유뷰를 쓰므로 optimize 직후 unlink 금지 + release_shared_tick_store(ctx, log=logger) def apply_best_trial(study: optuna.Study, ctx: TailSearchContext) -> bool: @@ -579,18 +639,18 @@ def apply_best_trial(study: optuna.Study, ctx: TailSearchContext) -> bool: def main() -> None: - today = datetime.now().strftime("%Y-%m-%d") - week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + week_ago, today = resolve_param_search_range("TAIL", lookback_days=7) parser = argparse.ArgumentParser( description="Optuna TPE 파라미터 탐색 (Grid CLI add-on, storage=MariaDB 141 기본)", ) parser.add_argument( "--strategy", default="tail", choices=list(OPTUNA_STRATEGIES), - help="전략: tail | momentum | breakout", + help="전략: tail | momentum | breakout | scalp", ) - parser.add_argument("--start", default=week_ago, help="시작일 YYYY-MM-DD") - parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD") + parser.add_argument("--start", default=week_ago, help="시작일 YYYY-MM-DD (거래일 보정)") + parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD (주말·휴장이면 이전 장운영일)") parser.add_argument("--timeframe", "--tf", default=3, type=int, dest="timeframe", help="ws_candles 분봉 3·5·15·60") add_portfolio_cli_args(parser) @@ -666,7 +726,7 @@ def main() -> None: strategy = (args.strategy or "tail").strip().lower() if strategy not in OPTUNA_STRATEGIES: - logger.error("❌ --strategy 는 tail/momentum/breakout 중 하나") + logger.error("❌ --strategy 는 tail/momentum/breakout/scalp 중 하나") sys.exit(2) allowed_modes = STRATEGY_MODES.get(strategy, []) @@ -677,11 +737,11 @@ def main() -> None: sort_by = (args.sort_by or "").strip().lower() if not sort_by: - sort_by = "score" if strategy == "momentum" else "pnl" + sort_by = "score" if strategy in ("momentum", "scalp") else "pnl" momentum_sort = {"score", "pnl", "win_rate"} basic_sort = {"pnl", "win_rate"} - if strategy == "momentum" and sort_by not in momentum_sort: - logger.error("❌ momentum --sort-by 는 score|pnl|win_rate") + if strategy in ("momentum", "scalp") and sort_by not in momentum_sort: + logger.error("❌ %s --sort-by 는 score|pnl|win_rate", strategy) sys.exit(2) if strategy in ("tail", "breakout") and sort_by not in basic_sort: logger.error("❌ %s --sort-by 는 pnl|win_rate", strategy) @@ -711,6 +771,7 @@ def main() -> None: cli_override=args.study_name, ) + study = None try: if strategy == "tail": ctx = prepare_tail_search_context( @@ -773,6 +834,36 @@ def main() -> None: if args.apply_best: apply_best_momentum_trial(study) + elif strategy == "scalp": + ctx_s = prepare_scalp_search_context( + args.start, args.end, mode, + use_fallback_universe=use_fallback, + time_start_hm=args.time_start, + time_end_hm=args.time_end, + slot_money=args.slot_money, + max_stocks=args.max_stocks, + total_budget_krw=args.total_budget, + orderbook_filter=args.orderbook_filter, + ) + if ctx_s is None: + sys.exit(1) + study = run_scalp_optuna( + ctx_s, + n_trials=n_trials, + storage_url=storage_url, + study_name=study_name, + min_trades=args.min_trades, + min_win_rate=args.min_win_rate, + min_pf=args.min_pf, + sort_by=sort_by, + sampler_name=sampler_name, + seed=args.seed, + n_jobs=n_jobs, + show_progress=not args.no_progress, + ) + if args.apply_best: + apply_best_scalp_trial(study) + else: ctx_b = prepare_breakout_search_context( args.start, args.end, mode, @@ -803,6 +894,17 @@ def main() -> None: if args.apply_best: apply_best_breakout_trial(study) + # 종료 직전 절대경로 한 번 더 (로그 끝에서 바로 복사) + export = getattr(study, "_kis_export_path", None) if study is not None else None + if export: + announce_optuna_json_path( + str(export), + strategy=strategy, + mode=mode, + note="CLI 종료·열기용 경로", + log=logger, + ) + except KeyboardInterrupt as e: print(f"\n⛔ {e} — 중단", flush=True) sys.exit(130) diff --git a/kis_trader/backtest/param_search_range_break.py b/kis_trader/backtest/param_search_range_break.py index 64da7fd..303077c 100644 --- a/kis_trader/backtest/param_search_range_break.py +++ b/kis_trader/backtest/param_search_range_break.py @@ -143,9 +143,11 @@ def _run_one( def main() -> int: + from kis_trader.backtest.param_search_dates import resolve_param_search_range + week_ago, today = resolve_param_search_range("RANGE_BREAK", lookback_days=7) parser = argparse.ArgumentParser(description="박스권 돌파 백테스트 파라미터 Grid Search") - parser.add_argument("--start", default=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")) - parser.add_argument("--end", default=datetime.now().strftime("%Y-%m-%d")) + parser.add_argument("--start", default=week_ago, help="시작일 (거래일 보정)") + parser.add_argument("--end", default=today, help="종료일 (주말·휴장이면 이전 장운영일)") parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine"]) parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", diff --git a/kis_trader/backtest/param_search_scalping.py b/kis_trader/backtest/param_search_scalping.py index 2cb30a5..63812cd 100644 --- a/kis_trader/backtest/param_search_scalping.py +++ b/kis_trader/backtest/param_search_scalping.py @@ -2,13 +2,16 @@ """ kis_trader/backtest/param_search_scalping.py — 스캘핑(Reversal) 백테스트 파라미터 자동 탐색 (Grid Search) ====================================================================================================== -[전략 = SCALP / Reversal] -- RSI(3) 과매도( Dict[str, List[Any]]: + """호가필터 축 — 실매 SCALP_ORDERBOOK_* 앵커 포함. sweep=False 면 운영값 1점.""" + from kis_trader.engine.orderbook_env import ( + OB_DEFAULT_ENTRY_ASK_MAX_MULT, + OB_DEFAULT_MAX_SPREAD_PCT, + OB_DEFAULT_MIN_BID_ASK_RATIO, + get_ob_float, + ) + live_spread = float(get_ob_float("SCALP", "MAX_SPREAD_PCT", OB_DEFAULT_MAX_SPREAD_PCT)) + live_ratio = float(get_ob_float("SCALP", "MIN_BID_ASK_RATIO", OB_DEFAULT_MIN_BID_ASK_RATIO)) + live_ask = float(get_ob_float("SCALP", "ENTRY_ASK_MAX_MULT", OB_DEFAULT_ENTRY_ASK_MAX_MULT)) + if not sweep: + return { + "max_spread_pct": _fmerge([live_spread], live_spread), + "min_bid_ask_ratio": _fmerge([live_ratio], live_ratio), + "ask_max_mult": _fmerge([live_ask], live_ask), + } + return { + "max_spread_pct": _fmerge([0.30, 0.45, 0.80, 1.20], live_spread), + "min_bid_ask_ratio": _fmerge([0.50, 0.70, 0.85, 1.00], live_ratio), + "ask_max_mult": _fmerge([2.0, 3.0, 5.0, 8.0], live_ask), + } + + +def _parse_csv_floats(env_key: str, fallback: List[float]) -> List[float]: + raw = get_env_from_db(env_key, "") + if not raw or str(raw).strip() in ("", "None"): + return list(fallback) + out: List[float] = [] + for chunk in str(raw).replace("|", ",").split(","): + chunk = chunk.strip() + if not chunk: + continue + try: + out.append(float(chunk)) + except (TypeError, ValueError): + continue + return out if out else list(fallback) + + +def _parse_csv_ints(env_key: str, fallback: List[int]) -> List[int]: + raw = get_env_from_db(env_key, "") + if not raw or str(raw).strip() in ("", "None"): + return list(fallback) + out: List[int] = [] + for chunk in str(raw).replace("|", ",").split(","): + chunk = chunk.strip() + if not chunk: + continue + try: + out.append(int(float(chunk))) + except (TypeError, ValueError): + continue + return out if out else list(fallback) + + +def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]: + raw = get_env_from_db(env_key, "") + if not raw or str(raw).strip() in ("", "None"): + return list(fallback) + out: List[bool] = [] + for chunk in str(raw).replace("|", ",").split(","): + chunk = chunk.strip().lower() + if not chunk: + continue + out.append(chunk in ("1", "true", "t", "y", "yes", "on")) + return out if out else list(fallback) # ────────────────────────────────────────────────────────────────────────────── # 스캘핑 기본값 = 엔진에서 DB 로드 (백테스트 API와 동일 단일 소스, 실매매와 동기화) @@ -100,9 +212,15 @@ def _fixed_defaults(): _d = se.get_scalping_defaults_from_db() return { "rsi_period": _d["rsi_period"], + "rsi_oversold": _d.get("rsi_oversold", 25.0), + "sl_pct": abs(float(_d.get("sl_pct", 0.015))) * 100, + "tp_pct": abs(float(_d.get("tp_pct", 0.015))) * 100, + "drop_rate": float(_d.get("drop_rate", 0.015)) * 100, + "require_reversal_candle": bool(_d.get("require_reversal_candle", True)), + "min_hold_sec": float(_d.get("min_hold_sec", 30.0)), "rsi_overbought": _d.get("rsi_overbought", 75.0), # 과열 차단 (그리드 탐색 제외, 고정값) "slot_money": _d["slot_money"], - "vol_mult": _d["vol_mult"], + "vol_mult": _d.get("vol_mult", 0), "trail_trigger": _d["trail_trigger"] * 100, # % 단위 (레거시, 청산 미사용) "trail_stop": _d["trail_stop"] * 100, # % 단위 (레거시, 청산 미사용) "shoulder_min_high": _d.get("shoulder_min_high", 0.005) * 100, @@ -122,6 +240,7 @@ def _fixed_defaults(): "min_margin": _d["min_margin"] * 100, # % 단위 "use_defense_filters": _d.get("use_defense_filters", True), "use_macd_cross": _d.get("use_macd_cross", False), + "skip_hts_scan_dupes": bool(_d.get("skip_hts_scan_dupes", se.resolve_scalp_skip_hts_scan_dupes())), } # RSI_OVERSOLD별 JSON 저장 개수 (한 RSI에 치중되지 않도록 균등 분배) @@ -170,95 +289,388 @@ def _latest_json(prefix: str) -> Optional[str]: # ────────────────────────────────────────────────────────────────────────────── def _min_price_grid(): - """DB get_scalping_defaults 의 min_price 를 포함한 최소가격 스윕 (저·중·고가 후보).""" - mp = int(_fixed_defaults()["min_price"]) - return sorted(set([500, 1000, max(500, mp), min(mp + 5000, 200000), 50000, 100000])) + """최소가격 스윕 — HTS 1000원+ 와 정합. + + - 500/10만 등 조건식 밖·하루 과적합 값은 제외. + - 기본·실매 앵커=1000, 보조=6000(wide Top에서 납득 가능 구간). + """ + mp = int(_fixed_defaults()["min_price"] or 1000) + return sorted(set([1000, 6000, max(1000, mp)])) + + +def _scalp_live_anchors() -> Dict[str, Any]: + """실매 DB 앵커 — 그리드에 반드시 포함 (단위=UI/% 표시).""" + d = _fixed_defaults() + eng = se.get_scalping_defaults_from_db() + return { + "rsi_period": int(eng.get("rsi_period") or d.get("rsi_period") or 3), + "rsi_oversold": float(d.get("rsi_oversold") or 25), + "rsi_overbought": float(d.get("rsi_overbought") or 75), + "sl_pct": float(d.get("sl_pct") or 1.5), + "tp_pct": float(d.get("tp_pct") or 1.5), + "tp_max_pct": float(d.get("tp_max_pct") or 2.0), + "drop_rate": float(d.get("drop_rate") or 1.5), + "shoulder_min_high": float(d.get("shoulder_min_high") or 0.5), + "shoulder_cut_pct": float(d.get("shoulder_cut_pct") or 0.3), + "high_chase_thr": float(d.get("high_chase_thr") or 0.96), + "max_daily_chg": float(d.get("max_daily_chg") or 20.0), + "min_price": int(d.get("min_price") or 1000), + "max_loss_krw": int(d.get("max_loss_krw") or 200000), + "min_drop_pct_for_loss_cut": float(eng.get("min_drop_pct_for_loss_cut") or 0.015), + "min_margin": float(d.get("min_margin") or 0.2), + "vol_mult": float(d.get("vol_mult") or 0.0), + "require_reversal_candle": bool(d.get("require_reversal_candle", True)), + "use_defense_filters": bool(d.get("use_defense_filters", True)), + "use_macd_cross": bool(d.get("use_macd_cross", False)), + "skip_hts_scan_dupes": bool(d.get("skip_hts_scan_dupes", False)), + "min_hold_sec": int(float(d.get("min_hold_sec") or 30)), + "cooldown_min": int(d.get("cooldown_min") or 10), + "max_daily": int(d.get("max_daily") or 3), + "time_start_hm": int(d.get("time_start_hm") or 900), + "time_end_hm": int(d.get("time_end_hm") or 1530), + } + + +def _fmerge(candidates: List[float], live: float) -> List[float]: + return sorted(set([float(x) for x in candidates] + [float(live)])) + + +def _imerge(candidates: List[int], live: int) -> List[int]: + return sorted(set([int(x) for x in candidates] + [int(live)])) + + +def _bmerge(candidates: List[bool], live: bool) -> List[bool]: + out: List[bool] = [] + for v in list(candidates) + [bool(live)]: + if v not in out: + out.append(v) + # 실매 기본(False) 우선 — SKIP_HTS 철학 + if False in out and out[0] is not False: + out = [False] + [x for x in out if x is not False] + return out def _scalp_grids(): - """탐색 모드별 그리드. 호출 시점 DB 기본값으로 min_price 티어가 잡힘.""" + """탐색 모드별 그리드. + + - HTS SCAN(시가→저가 -8~-1.5% 등)은 유니버스 참고 — 그리드 목적이 아님. + - 축 = TRIGGER/청산/방어 (실매 엔진 키). 실매 DB 앵커는 모든 mode에 포함. + - 범위는 꼬리 coarse/full 수준으로 넓힘 (Optuna TPE 샘플링 전제). + """ + live = _scalp_live_anchors() mp_list = _min_price_grid() - # fine: 조합 폭주 완화 — min_price 는 앵커·고가 두 티어 + 전 구간 5만원 - fd_mp = int(_fixed_defaults()["min_price"]) - min_price_fine = sorted(set([1000, fd_mp, 50000])) + # fine/coarse/fast — HTS 1000원+ · wide(7/15) Top: 1000 기본 + 6000 보조 (500/10만 제외) + min_price_ops = _imerge([1000, 6000], int(live["min_price"])) + p = "SCALP_GRID_" # env 오버라이드 prefix (mode별 키는 기존 TRIGGER/EXIT 유지) + # 매수 종료 HHMM — 오전컷(0930~1230) + 장마감(1530). 시작은 0900 고정(×5만, 시작×끝=25 방지) + session_end_hm = _imerge([930, 1030, 1130, 1230, 1530], live["time_end_hm"]) + session_start_hm = _imerge([900], live["time_start_hm"]) + return { # ───────────────────────────────────────────────────────────────────── - # [FAST] ~12–15분 — 어깨 1순위 + tp_max 상한 + reversal 진입 (192조합) - # MACD/방어/최소가격 등은 _fixed_defaults(DB) 고정. use_macd=False 권장 실매 정렬. - # 2×2×3×2×2×2×2 = 192 + # [FAST] wide(7/15) Top 근방 스모크 — sl3.5·tp2.5·sh3·defense OFF 앵커 # ───────────────────────────────────────────────────────────────────── "fast": { - "rsi_oversold": [21, 24], - "sl_pct": [1.2, 1.5], - "tp_pct": [2.0, 2.5, 3.0], - "tp_max_pct": [1.8, 2.0], - "shoulder_min_high": [0.3, 0.5], - "shoulder_cut_pct": [0.2, 0.3], - "drop_rate": [1.0, 1.5], + "rsi_period": _imerge([3, 7], live["rsi_period"]), + "rsi_oversold": _fmerge([19, 21, 23, 25], live["rsi_oversold"]), + "sl_pct": _fmerge([2.5, 3.0, 3.5], live["sl_pct"]), + "tp_pct": _fmerge([2.0, 2.5, 3.0], live["tp_pct"]), + "tp_max_pct": _fmerge([4.0, 5.0], live["tp_max_pct"]), + "shoulder_min_high": _fmerge([0.8, 3.0], live["shoulder_min_high"]), + "shoulder_cut_pct": _fmerge([0.1, 0.2, 0.4], live["shoulder_cut_pct"]), + "drop_rate": _fmerge([1.5, 3.0, 5.0], live["drop_rate"]), + "high_chase_thr": _fmerge([0.98, 1.0], live["high_chase_thr"]), + "vol_mult": _fmerge([0.0, 1.2, 1.5], live["vol_mult"]), + "require_reversal_candle": _bmerge([True, False], live["require_reversal_candle"]), + "cooldown_min": _imerge([1, 5], live["cooldown_min"]), + "min_hold_sec": _imerge([0, 30, 60], live["min_hold_sec"]), + "use_defense_filters": _bmerge([False, True], live["use_defense_filters"]), + "use_macd_cross": _bmerge([False], live["use_macd_cross"]), + "min_price": min_price_ops, + "max_daily_chg": _fmerge([16.0, 20.0, 40.0], live["max_daily_chg"]), + "time_start_hm": session_start_hm, + "time_end_hm": session_end_hm, + **_scalp_ob_grid_axes(sweep=False), }, - # coarse — 1차 스크리닝 (~576조합, history 유니버스 기준 약 1~1.5시간) - # MACD/reversal × 방어 × 손익 × RSI × 낙폭 × 트레일 (구 5,184 대비 축소) - # 조합: 4×3×3×2×2×2×2 = 576 + # trigger: RSI V자 진입 전용 — 청산은 DB 고정 + "trigger": { + "rsi_period": _parse_csv_ints( + f"{p}TRIGGER_RSI_PERIOD", _imerge([3, 7, 14], live["rsi_period"]), + ), + "rsi_oversold": _parse_csv_floats( + f"{p}TRIGGER_RSI_OVERSOLD", + _fmerge([12, 15, 17, 19, 21, 23, 25, 28, 32], live["rsi_oversold"]), + ), + "drop_rate": _parse_csv_floats( + f"{p}TRIGGER_DROP_RATE", + # HTS SCAN 낙폭대(~1.5~8)를 참고로 넓게 — TRIGGER 재필터 탐색용 + _fmerge( + [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], + live["drop_rate"], + ), + ), + "high_chase_thr": _parse_csv_floats( + f"{p}TRIGGER_HIGH_CHASE_THR", + _fmerge([0.92, 0.94, 0.96, 0.98, 0.99, 1.0], live["high_chase_thr"]), + ), + "vol_mult": _parse_csv_floats( + f"{p}TRIGGER_VOL_MULT", + _fmerge([0.0, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], live["vol_mult"]), + ), + "require_reversal_candle": _parse_csv_bools( + f"{p}TRIGGER_REQUIRE_REVERSAL", + _bmerge([True, False], live["require_reversal_candle"]), + ), + "use_macd_cross": _parse_csv_bools( + f"{p}TRIGGER_USE_MACD", + _bmerge([False, True], live["use_macd_cross"]), + ), + "rsi_overbought": _parse_csv_floats( + f"{p}TRIGGER_RSI_OVERBOUGHT", + _fmerge([70, 75, 80, 85], live["rsi_overbought"]), + ), + **_scalp_ob_grid_axes(sweep=False), + }, + # exit: 청산 전용 — TRIGGER(DB) 고정 + "exit": { + "sl_pct": _parse_csv_floats( + f"{p}EXIT_SL_PCT", + _fmerge([0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], live["sl_pct"]), + ), + "tp_pct": _parse_csv_floats( + f"{p}EXIT_TP_PCT", + _fmerge([1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], live["tp_pct"]), + ), + "tp_max_pct": _parse_csv_floats( + f"{p}EXIT_TP_MAX_PCT", + _fmerge([1.5, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0], live["tp_max_pct"]), + ), + "shoulder_min_high": _parse_csv_floats( + f"{p}EXIT_SHOULDER_MIN_HIGH_PCT", + _fmerge( + [0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0], + live["shoulder_min_high"], + ), + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}EXIT_SHOULDER_CUT_PCT", + _fmerge( + [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.7], + live["shoulder_cut_pct"], + ), + ), + "min_hold_sec": _parse_csv_ints( + f"{p}EXIT_MIN_HOLD_SEC", + _imerge([0, 10, 15, 30, 60, 90, 120], live["min_hold_sec"]), + ), + "max_loss_krw": _parse_csv_ints( + f"{p}EXIT_MAX_LOSS_KRW", + _imerge( + [50000, 100000, 150000, 200000, 300000, 500000], + live["max_loss_krw"], + ), + ), + "min_margin": _parse_csv_floats( + f"{p}EXIT_MIN_MARGIN", + _fmerge([0.0, 0.1, 0.2, 0.3, 0.5], live["min_margin"]), + ), + }, + # coarse — wide Top 밴드 조금 넓게 (1차 스크리닝) "coarse": { - "rsi_oversold": [18, 21, 24, 28], # reversal 전용 (MACD ON 일 땐 무시) - "sl_pct": [0.8, 1.2, 1.5], - "tp_pct": [2.0, 2.5, 3.5], - "drop_rate": [1.0, 1.5], - "shoulder_min_high": [0.3, 0.5], - "shoulder_cut_pct": [0.2, 0.3], - "tp_max_pct": [1.8, 2.0], - "high_chase_thr": [0.96], - "max_daily_chg": [28.0], - "min_price": [1000], - "use_defense_filters": [True, False], - "use_macd_cross": [False, True], - "max_loss_krw": [200000], - "min_drop_pct_for_loss_cut": [0.015], + "rsi_period": _imerge([3, 7, 14], live["rsi_period"]), + "rsi_oversold": _fmerge( + [17, 19, 21, 23, 25, 28], live["rsi_oversold"], + ), + "sl_pct": _fmerge([2.0, 2.5, 3.0, 3.5], live["sl_pct"]), + "tp_pct": _fmerge([1.5, 2.0, 2.5, 3.0, 3.5], live["tp_pct"]), + "tp_max_pct": _fmerge( + [3.0, 4.0, 5.0], live["tp_max_pct"], + ), + "drop_rate": _fmerge( + [1.0, 1.5, 2.5, 3.0, 5.0], live["drop_rate"], + ), + "shoulder_min_high": _fmerge( + [0.8, 1.5, 3.0], live["shoulder_min_high"], + ), + "shoulder_cut_pct": _fmerge( + [0.1, 0.15, 0.2, 0.3, 0.4], live["shoulder_cut_pct"], + ), + "high_chase_thr": _fmerge( + [0.96, 0.98, 0.99, 1.0], live["high_chase_thr"], + ), + "max_daily_chg": _fmerge( + [16.0, 20.0, 25.0, 30.0, 40.0], live["max_daily_chg"], + ), + "min_price": min_price_ops, + "vol_mult": _fmerge([0.0, 1.2, 1.5, 2.0], live["vol_mult"]), + "use_defense_filters": _bmerge([False, True], live["use_defense_filters"]), + "use_macd_cross": _bmerge([False, True], live["use_macd_cross"]), + "require_reversal_candle": _bmerge( + [True, False], live["require_reversal_candle"], + ), + "max_loss_krw": _imerge( + [100000, 150000, 200000], live["max_loss_krw"], + ), + "min_drop_pct_for_loss_cut": _fmerge( + [0.01, 0.015, 0.02], live["min_drop_pct_for_loss_cut"], + ), + "min_margin": _fmerge([0.1, 0.2, 0.3], live["min_margin"]), + "cooldown_min": _imerge([1, 3, 5, 10], live["cooldown_min"]), + "min_hold_sec": _imerge([0, 15, 30, 60], live["min_hold_sec"]), + "max_daily": _imerge([10, 20, 50, 100], live["max_daily"]), + "time_start_hm": session_start_hm, + "time_end_hm": session_end_hm, + **_scalp_ob_grid_axes(sweep=False), }, - # 세밀 2차 (트레일·쿨다운 포함 + 최소가격·방어) + # fine — 2026-07-15 wide Top 재설계 (min_price=1000·6000, 500/10만 제외) "fine": { - "rsi_oversold": [15, 17, 19, 21, 25], - "sl_pct": [0.8, 1.0, 1.2, 1.5], - "tp_pct": [1.5, 2.0, 2.5, 3.0, 3.5], - "drop_rate": [1.0, 1.5, 2.0], - "shoulder_min_high": [0.2, 0.3, 0.5], - "shoulder_cut_pct": [0.2, 0.3, 0.4], - "cooldown_min": [5, 10], - "high_chase_thr": [0.96, 0.98], - "max_daily_chg": [15.0, 20.0, 25.0], - "min_price": min_price_fine, - "use_defense_filters": [True, False], - "max_loss_krw": [150000, 200000, 300000], - "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], + "rsi_period": _imerge([3, 7], live["rsi_period"]), + "rsi_oversold": _fmerge( + [19, 21, 23, 25], live["rsi_oversold"], + ), + "sl_pct": _fmerge([2.5, 3.0, 3.5], live["sl_pct"]), + "tp_pct": _fmerge([2.0, 2.5, 3.0], live["tp_pct"]), + "tp_max_pct": _fmerge( + [4.0, 5.0], live["tp_max_pct"], + ), + "drop_rate": _fmerge( + [1.5, 2.5, 3.0, 5.0], live["drop_rate"], + ), + "shoulder_min_high": _fmerge( + [0.8, 3.0], live["shoulder_min_high"], + ), + "shoulder_cut_pct": _fmerge( + [0.1, 0.2, 0.3, 0.4], live["shoulder_cut_pct"], + ), + "cooldown_min": _imerge([1, 5], live["cooldown_min"]), + "high_chase_thr": _fmerge( + [0.98, 0.99, 1.0], live["high_chase_thr"], + ), + "max_daily_chg": _fmerge( + [16.0, 20.0, 30.0, 40.0], live["max_daily_chg"], + ), + "min_price": min_price_ops, + "vol_mult": _fmerge([0.0, 1.2, 1.5], live["vol_mult"]), + "use_defense_filters": _bmerge([False, True], live["use_defense_filters"]), + "use_macd_cross": _bmerge([False], live["use_macd_cross"]), + "require_reversal_candle": _bmerge( + [True, False], live["require_reversal_candle"], + ), + "max_loss_krw": _imerge( + [100000, 150000, 200000], live["max_loss_krw"], + ), + "min_drop_pct_for_loss_cut": _fmerge( + [0.01, 0.015], live["min_drop_pct_for_loss_cut"], + ), + "min_margin": _fmerge([0.1, 0.2, 0.3], live["min_margin"]), + "min_hold_sec": _imerge([0, 30, 60], live["min_hold_sec"]), + "max_daily": _imerge([20, 50, 100], live["max_daily"]), + "rsi_overbought": _fmerge([70, 75], live["rsi_overbought"]), + "time_start_hm": session_start_hm, + "time_end_hm": session_end_hm, + **_scalp_ob_grid_axes(sweep=False), }, - # 전체 탐색 (매우 오래 걸림) + # full — 전축 최대 폭 (꼬리 full 급) "full": { - "rsi_oversold": [15, 17, 20, 25], - "sl_pct": [0.8, 1.0, 1.2, 1.5, 2.0], - "tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0], - "drop_rate": [0.8, 1.0, 1.5, 2.0, 2.5], - "shoulder_min_high": [0.2, 0.3, 0.5, 0.7], - "shoulder_cut_pct": [0.2, 0.3, 0.4, 0.5], - "cooldown_min": [5, 10], - "high_chase_thr": [0.96, 0.98, 1.0], - "max_daily_chg": [15.0, 20.0, 30.0], - "min_price": mp_list, - "use_defense_filters": [True, False], - "max_loss_krw": [100000, 200000, 300000], - "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025], + "rsi_period": _imerge([3, 5, 7, 14, 21], live["rsi_period"]), + "rsi_oversold": _fmerge( + [10, 12, 15, 17, 19, 21, 23, 25, 28, 32, 35], live["rsi_oversold"], + ), + "rsi_overbought": _fmerge( + [65, 70, 75, 80, 85, 90], live["rsi_overbought"], + ), + "sl_pct": _fmerge( + [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0, 4.0], live["sl_pct"], + ), + "tp_pct": _fmerge( + [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0], live["tp_pct"], + ), + "tp_max_pct": _fmerge( + [1.5, 1.8, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], live["tp_max_pct"], + ), + "drop_rate": _fmerge( + [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0], + live["drop_rate"], + ), + "shoulder_min_high": _fmerge( + [0.1, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0], live["shoulder_min_high"], + ), + "shoulder_cut_pct": _fmerge( + [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0], + live["shoulder_cut_pct"], + ), + "cooldown_min": _imerge([0, 1, 3, 5, 10, 15, 20], live["cooldown_min"]), + "high_chase_thr": _fmerge( + [0.90, 0.92, 0.94, 0.96, 0.98, 0.99, 1.0], live["high_chase_thr"], + ), + "max_daily_chg": _fmerge( + [8.0, 12.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0], + live["max_daily_chg"], + ), + "min_price": mp_list, + "vol_mult": _fmerge( + [0.0, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], live["vol_mult"], + ), + "use_defense_filters": _bmerge([False, True], live["use_defense_filters"]), + "use_macd_cross": _bmerge([False, True], live["use_macd_cross"]), + "require_reversal_candle": _bmerge( + [True, False], live["require_reversal_candle"], + ), + "skip_hts_scan_dupes": _bmerge( + [False, True], live["skip_hts_scan_dupes"], + ), + "max_loss_krw": _imerge( + [50000, 100000, 150000, 200000, 300000, 500000], + live["max_loss_krw"], + ), + "min_drop_pct_for_loss_cut": _fmerge( + [0.005, 0.01, 0.015, 0.02, 0.025, 0.03], + live["min_drop_pct_for_loss_cut"], + ), + "min_margin": _fmerge( + [0.0, 0.1, 0.2, 0.3, 0.5, 1.0], live["min_margin"], + ), + "min_hold_sec": _imerge( + [0, 10, 15, 30, 60, 90, 120], live["min_hold_sec"], + ), + "max_daily": _imerge([3, 5, 10, 20, 50, 100], live["max_daily"]), + "time_start_hm": session_start_hm, + "time_end_hm": session_end_hm, + **_scalp_ob_grid_axes(sweep=False), }, - # 방어축 광범위 (RSI·슬립은 coarse 보다 약간 줄이고 급등·min_price·방어를 넓게) + # wide — 축 스크리닝 (min_price=1000/6000, sl에 3.5 포함) "wide": { - "rsi_oversold": [15, 17, 19, 21, 23, 25, 28], - "sl_pct": [0.8, 1.0, 1.2, 1.5], - "tp_pct": [1.5, 2.0, 2.5, 3.0], - "drop_rate": [1.0, 1.5, 2.0, 2.5], - "high_chase_thr": [0.96, 0.98, 1.0], - "max_daily_chg": [8.0, 12.0, 16.0, 20.0, 25.0, 30.0, 40.0], - "min_price": mp_list, - "use_defense_filters": [True, False], - "max_loss_krw": [100000, 200000, 300000], - "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], + "rsi_oversold": _fmerge( + [15, 17, 19, 21, 23, 25, 28], live["rsi_oversold"], + ), + "sl_pct": _fmerge([1.5, 2.0, 2.5, 3.0, 3.5], live["sl_pct"]), + "tp_pct": _fmerge([1.5, 2.0, 2.5, 3.0, 4.0], live["tp_pct"]), + "tp_max_pct": _fmerge([2.0, 3.0, 4.0, 5.0], live["tp_max_pct"]), + "drop_rate": _fmerge( + [1.0, 1.5, 2.0, 2.5, 3.0, 5.0], live["drop_rate"], + ), + "shoulder_min_high": _fmerge( + [0.5, 0.8, 3.0], live["shoulder_min_high"], + ), + "shoulder_cut_pct": _fmerge( + [0.1, 0.2, 0.3, 0.4], live["shoulder_cut_pct"], + ), + "high_chase_thr": _fmerge( + [0.96, 0.98, 0.99, 1.0], live["high_chase_thr"], + ), + "max_daily_chg": _fmerge( + [12.0, 16.0, 20.0, 25.0, 30.0, 40.0], + live["max_daily_chg"], + ), + "min_price": mp_list, + "use_defense_filters": _bmerge([False, True], live["use_defense_filters"]), + "max_loss_krw": _imerge( + [100000, 150000, 200000, 300000], live["max_loss_krw"], + ), + "min_drop_pct_for_loss_cut": _fmerge( + [0.01, 0.015, 0.02], live["min_drop_pct_for_loss_cut"], + ), + **_scalp_ob_grid_axes(sweep=False), }, } @@ -285,20 +697,128 @@ def _get_scalp_field_map(): "min_margin": ("SCALP_MIN_PROFIT_PCT", lambda v: str(float(v))), # % 단위 (0.2 등) "use_defense_filters": ("SCALP_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"), "use_macd_cross": ("SCALP_USE_MACD_CROSS", lambda v: "true" if v else "false"), + "require_reversal_candle": ("SCALP_REQUIRE_REVERSAL_CANDLE", lambda v: "true" if v else "false"), + # skip_hts_scan_dupes: full+ 그리드 탐색만, apply 제외(운영 false 고정) + "vol_mult": ("VOL_MULTIPLIER", lambda v: str(float(v))), + "min_hold_sec": ("SCALP_MIN_HOLD_SEC", lambda v: str(int(float(v)))), + "rsi_period": ("SCALP_RSI_PERIOD", lambda v: str(int(float(v)))), + "max_daily": ("SCALP_MAX_DAILY", lambda v: str(int(float(v)))), + "time_start_hm": ("SCALP_TIME_START", lambda v: str(int(float(v)))), + "time_end_hm": ("SCALP_TIME_END", lambda v: str(int(float(v)))), + # 호가필터 축 → SCALP_ORDERBOOK_* (글로벌 ORDERBOOK_* 폐기) + "max_spread_pct": ("SCALP_ORDERBOOK_MAX_SPREAD_PCT", lambda v: str(float(v))), + "min_bid_ask_ratio": ("SCALP_ORDERBOOK_MIN_BID_ASK_RATIO", lambda v: str(float(v))), + "ask_max_mult": ("SCALP_ORDERBOOK_ENTRY_ASK_MAX_MULT", lambda v: str(float(v))), + } + + +def apply_params_to_db(best_params: dict) -> None: + """Grid/Optuna 1위 → insert_env_snapshot (config_scalp + env_config).""" + _apply_to_db(best_params) + + +def evaluate_scalp_param_combo( + combo: Dict[str, Any], + *, + base_fixed: Dict[str, Any], + grid_keys: List[str], + codes_candles: Dict[str, List[Dict]], + min_trades: int, + min_win_rate: float, + min_pf: float, + universe_by_slot: Optional[Dict[str, List[str]]] = None, + slot_money: float = 3_000_000.0, + max_stocks: int = 3, + total_budget_krw: float = 9_000_000.0, + fee_rate: float = 0.00015, + sell_tax: float = 0.0018, + period_days: int = 1, + cache_holder: Optional[Dict[str, Any]] = None, + ticks_by_code: Any = None, + orderbook_by_code: Any = None, + program_by_code: Any = None, +) -> Optional[Dict[str, Any]]: + """단일 스캘핑(reversal) 조합 백테 — Grid 워커·Optuna objective 공통. + + ticks_by_code: 웹/실매 정합용 ws_ticks (없으면 OHLC만 — 탐색 과대평가 위험). + """ + ui_params = dict(base_fixed) + ui_params.update(combo) + engine_params = _ui_to_engine_params(ui_params) + if cache_holder: + engine_params.update(cache_holder) + engine_params["slot_money"] = float(slot_money) + engine_params["max_stocks"] = int(max_stocks) + engine_params["total_budget_krw"] = float(total_budget_krw) + engine_params["portfolio_mode"] = True + if orderbook_by_code is not None: + engine_params["_bt_orderbook_by_code"] = orderbook_by_code + if program_by_code is not None: + engine_params["_bt_program_by_code"] = program_by_code + + meta: Dict[str, Any] = {} + trades = sbc.run_scalping_backtest_web_aligned( + codes_candles, engine_params, universe_by_slot, + slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax, + max_stocks=max_stocks, total_budget_krw=total_budget_krw, + meta_out=meta, mode="reversal", + ticks_by_code=ticks_by_code, + ) + stats = sbc.summarize_scalp_trades( + trades, total_budget_krw=total_budget_krw, period_days=period_days, + ) + total_trades = stats["total_trades"] + if total_trades < min_trades: + return None + total_pnl = stats["total_pnl"] + win_rate = stats["win_rate"] + pf = float(stats.get("pf") or 0) + if not combo_passes_search_filters( + win_rate=win_rate, pf=pf, + min_win_rate=min_win_rate, min_pf=min_pf, + ): + return None + + peak, mdd, cum = 0.0, 0.0, 0.0 + for t in trades: + cum += t["pnl"] + if cum > peak: + peak = cum + dd = peak - cum + if dd > mdd: + mdd = dd + + merged = dict(ui_params) + merged["slot_money"] = float(slot_money) + merged["max_stocks"] = int(max_stocks) + merged["total_budget_krw"] = float(total_budget_krw) + return { + "params": {k: ui_params[k] for k in grid_keys if k in ui_params}, + "total_pnl": int(total_pnl), + "win_rate": round(win_rate, 2), + "total_trades": total_trades, + "pf": round(pf, 2), + "avg_hold": round(stats["avg_hold_min"], 1), + "mdd": round(mdd), + "bot_pct": stats["bot_pct"], + "daily_avg_pct": stats["daily_avg_pct"], + "merged_params": merged, } def _params_to_db_snapshot(params: dict) -> dict: - """그리드 params(표시 단위) + 포트폴리오 → env_config 컬럼명:값 문자열 dict.""" + """그리드 params(표시 단위) → env_config 컬럼명:값 문자열 dict. + + 슬롯·동시보유·총한도는 apply 제외(운영 한도 보존). 웹「봇에 설정저장」만 포트폴리오 기록. + """ field_map = _get_scalp_field_map() snap = { db_col: fmt(params[param_k]) for param_k, (db_col, fmt) in field_map.items() if param_k in params } - snap.update(portfolio_env_patch("SCALP", params)) snap.update(session_env_patch("SCALP", params)) - return snap + return strip_portfolio_keys_from_apply_patch(snap, "SCALP") def _apply_from_latest_json(rank: int): @@ -350,6 +870,26 @@ def _ui_to_engine_params(ui_params: dict) -> dict: engine_params["use_defense_filters"] = bool(ui_params["use_defense_filters"]) if "use_macd_cross" in ui_params: engine_params["use_macd_cross"] = bool(ui_params["use_macd_cross"]) + if "require_reversal_candle" in ui_params: + engine_params["require_reversal_candle"] = bool(ui_params["require_reversal_candle"]) + if "min_hold_sec" in ui_params: + engine_params["min_hold_sec"] = float(ui_params["min_hold_sec"]) + if "vol_mult" in ui_params: + engine_params["vol_mult"] = float(ui_params["vol_mult"]) + if "skip_hts_scan_dupes" in ui_params: + engine_params["skip_hts_scan_dupes"] = bool(ui_params["skip_hts_scan_dupes"]) + elif "skip_hts_scan_dupes" not in engine_params: + engine_params["skip_hts_scan_dupes"] = se.resolve_scalp_skip_hts_scan_dupes() + + # 호가필터 오버라이드 (파람서치 — 본체/스냅샷 재평가) + if ui_params.get("_orderbook_filter_enabled") is not None: + engine_params["_orderbook_filter_enabled"] = bool(ui_params["_orderbook_filter_enabled"]) + if "max_spread_pct" in ui_params and ui_params.get("max_spread_pct") is not None: + engine_params["_ob_max_spread_pct"] = float(ui_params["max_spread_pct"]) + if "min_bid_ask_ratio" in ui_params and ui_params.get("min_bid_ask_ratio") is not None: + engine_params["_ob_min_bid_ask_ratio"] = float(ui_params["min_bid_ask_ratio"]) + if "ask_max_mult" in ui_params and ui_params.get("ask_max_mult") is not None: + engine_params["_ob_ask_max_mult"] = float(ui_params["ask_max_mult"]) # 👇 [핵심] 손절 퍼센트에 맞춰 1회 투자금(slot_money) 자동 계산 (봇과 동일 공식) # 5억 고정이면 수수료만으로 손절컷 걸려 좋은 조합(RSI 17 등)이 버려짐 → max_loss_krw/sl_pct 로 보정 @@ -379,11 +919,23 @@ def _evaluate_scalp_chunk( ) -> List[Tuple[float, float, int, Dict]]: """워커: 청크 내 조합 평가 — 시각순 포트폴리오·총한도 (scalping_backtest_common).""" shared = worker_shared_get() + ticks_preloaded = None if shared: if codes_candles is None: codes_candles = shared.get("codes_candles") or {} if universe_by_slot is None: universe_by_slot = shared.get("universe_by_slot") + ticks_preloaded = shared.get("ticks_by_code") + # ws_ticks 공유메모리(opt-in): descriptor 로 read-only attach (워커당 1회 재사용). + if not ticks_preloaded: + _desc = shared.get("ticks_shared_descriptor") + if _desc: + _tm = shared.get("_ticks_mapping_cache") + if _tm is None: + from kis_trader.backtest.shared_ticks import SharedTicksMapping + _tm = SharedTicksMapping(_desc) + shared["_ticks_mapping_cache"] = _tm + ticks_preloaded = _tm if codes_candles is None: codes_candles = {} local_heap: List[Tuple[float, float, int, Dict]] = [] @@ -403,6 +955,7 @@ def _evaluate_scalp_chunk( slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=max_stocks, total_budget_krw=total_budget_krw, meta_out=meta, mode="reversal", + ticks_by_code=ticks_preloaded, ) stats = sbc.summarize_scalp_trades( trades, total_budget_krw=total_budget_krw, period_days=period_days, @@ -526,6 +1079,10 @@ def run_search(start: str, end: str, mode: str, top_n: int, print(f"\n[{mode.upper()} 모드] 그리드: {total_grid:,} → 백테: {total:,} | 기간: {start} ~ {end}") if mode == "fast": print(f"📌 [fast] {total_grid:,}→{max_combos_cap}균등샘플 · reversal·어깨·손익 축") + elif mode == "trigger": + print("📌 [trigger] RSI·낙폭·V자·거래량 — 청산은 DB 고정") + elif mode == "exit": + print("📌 [exit] 청산 전용 — TRIGGER(DB) 고정") if dropped_by_cap: print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)") print(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}") @@ -544,8 +1101,8 @@ def run_search(start: str, end: str, mode: str, top_n: int, db = TradeDB() try: - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - env_row = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + env_row = load_portfolio_env_row(db) finally: db.close() fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="SCALP") @@ -572,10 +1129,49 @@ def run_search(start: str, end: str, mode: str, top_n: int, print(f"💰 {portfolio['budget_warning']}") # 캔들 데이터를 메모리에 1회 로드 (워커에 전달) + # rsi_period 그리드 최댓값으로 로드 → 조합별 기간 변경 시 봉 부족 방지 print("⏳ DB에서 캔들 데이터를 메모리로 불러오는 중...") - codes_candles = _load_candles_for_search(start, end, FIXED_DEFAULTS.get("rsi_period", 3)) + _rsi_cands = grid.get("rsi_period") or [FIXED_DEFAULTS.get("rsi_period", 3)] + try: + _rsi_load = max(int(float(x)) for x in _rsi_cands) + except (TypeError, ValueError): + _rsi_load = int(FIXED_DEFAULTS.get("rsi_period") or 3) + codes_candles = _load_candles_for_search(start, end, _rsi_load) print(f"✅ 데이터 로드 완료: {len(codes_candles)}종목") + # ── 틱재생(ws_ticks) — 기본 ON (웹·실매 체결 정합, OHLC 폴백 기본 OFF) ── + start_key = (start.replace("-", "") + "0000") if start else "202601010000" + end_key = (end.replace("-", "") + "2359") if end else "999912312359" + ticks_by_code: Dict[str, Any] = {} + tick_backtest_meta: Dict[str, Any] = {} + tick_rows = 0 + if sbc._scalp_backtest_wants_ticks(_ui_to_engine_params(FIXED_DEFAULTS)): + _tick_db = TradeDB() + try: + ticks_by_code, tick_rows = load_breakout_ticks_by_code( + _tick_db, start_key, end_key, set(codes_candles.keys()), + ) + tick_backtest_meta = tick_coverage_stats(codes_candles, ticks_by_code) + tick_backtest_meta["ws_tick_rows_loaded"] = tick_rows + cov = tick_backtest_meta.get("tick_bar_coverage_pct", 0) + print( + f"✅ ws_ticks {tick_rows:,}건 | 분봉 커버리지 {cov}% " + f"({tick_backtest_meta.get('tick_codes_with_data', 0)}/" + f"{tick_backtest_meta.get('tick_codes_total', 0)}종목)" + ) + if tick_rows <= 0: + print( + "⚠️ ws_ticks 없음 — SCALP 틱 청산/진입 불가 " + "(FALLBACK_OHLC 기본 OFF, 틱 수집 후 재탐색)" + ) + else: + print( + "📌 틱재생(ws_ticks): ON — OHLC 폴백 " + f"{'ON' if get_env_bool('SCALP_BACKTEST_TICK_FALLBACK_OHLC', False) else 'OFF'}" + ) + finally: + _tick_db.close() + # 유니버스: --fallback-universe 이면 이력 무시하고 시뮬레이션만 사용 (조합별 거래 수 확대) # 신봇 기준: # * 실매매는 10초 REST 폴링 + 변동 tick 마다 초단위 event_time 으로 저장. @@ -642,15 +1238,62 @@ def run_search(start: str, end: str, mode: str, top_n: int, # 엔진에 슬롯 단위 주입 (engine._slot_key 가 이 값으로 캔들 시각 정규화) FIXED_DEFAULTS["scan_interval_min"] = engine_scan_interval_min + # ── ws_ticks 공유메모리 — 워커별 사본 대신 1벌 공유 (momentum·breakout 과 동일) ── + shared_tick_store = None + if get_env_bool("SCALP_PARAM_SEARCH_SHARED_TICKS", True) and ticks_by_code: + from kis_trader.backtest.shared_ticks import ( + build_shared_ticks, + shared_ticks_available, + ) + if shared_ticks_available(): + shared_tick_store = build_shared_ticks(ticks_by_code) + if shared_tick_store is not None: + import atexit as _atexit + _atexit.register(shared_tick_store.unlink) + print("📦 ws_ticks 공유메모리 ON — 워커 attach(read-only), 사본 제거") + ticks_by_code = {} + import gc as _gc + _gc.collect() + try: + import ctypes as _ctypes + _ctypes.CDLL("libc.so.6").malloc_trim(0) + except Exception: + pass + else: + print("⚠️ ws_ticks 공유메모리 build 실패 — 기존 경로 폴백") + else: + print("⚠️ numpy/shared_memory 미지원 — 기존 경로 폴백") + # 조합을 딕셔너리 리스트로 변환 후 청크 분할 (dict_combos 는 fast 균등샘플 적용 완료) shared = ParamSearchSharedPayload({ "codes_candles": codes_candles, "universe_by_slot": universe_by_slot, + "ticks_by_code": ticks_by_code, + "ticks_shared_descriptor": (shared_tick_store.descriptor() if shared_tick_store else None), + "tick_backtest_meta": tick_backtest_meta, }) payload_bytes = shared.estimate_bytes() n_cpu = os.cpu_count() or 4 _cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8) max_workers, chunk_size, _ = param_search_chunk_plan(total, payload_bytes) + # 틱 payload 시 워커 상한 (OOM 방지) — 하드코딩 금지, DB/Env + if ticks_by_code or shared_tick_store is not None: + if ticks_by_code: + tick_cap = get_env_int("SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS", 2) + if tick_cap > 0 and max_workers > tick_cap: + max_workers = tick_cap + print( + f"📌 ws_ticks payload — 워커 상한 {max_workers} " + "(SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS)" + ) + if shared_tick_store is not None: + shared_cap = get_env_int("SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS", 0) + if shared_cap > 0 and max_workers > shared_cap: + max_workers = shared_cap + print( + f"📌 ws_ticks 공유메모리 — 워커 상한 {max_workers} " + "(SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS)" + ) chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)] print(param_search_worker_budget_line(payload_bytes)) @@ -915,14 +1558,15 @@ def _apply_to_db(best_params: dict): # ────────────────────────────────────────────────────────────────────────────── def main(): - today = datetime.now().strftime("%Y-%m-%d") - week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + week_ago, today = resolve_param_search_range("SCALP", lookback_days=7) parser = argparse.ArgumentParser(description="스캘핑 백테스트 파라미터 Grid Search") - parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)") - parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)") - parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine", "full", "wide"], - help="탐색 모드: fast(그리드→균등192·~12–15분) / coarse / fine / full / wide") + parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 거래일 보정)") + parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)") + parser.add_argument("--mode", default="fast", + choices=["fast", "trigger", "exit", "coarse", "fine", "full", "wide"], + help="탐색 모드: fast / trigger(진입) / exit(청산) / coarse / fine / full / wide") parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", help="백테 조합 상한 (fast 기본 env SCALP_FAST_MAX_COMBOS 또는 PARAM_SEARCH_FAST_MAX_COMBOS=192, 0=무제한)", diff --git a/kis_trader/backtest/param_search_updow.py b/kis_trader/backtest/param_search_updow.py index bea7dad..3d777d3 100644 --- a/kis_trader/backtest/param_search_updow.py +++ b/kis_trader/backtest/param_search_updow.py @@ -217,13 +217,13 @@ def _apply_from_latest_json( def main(argv: List[str] | None = None) -> int: - today = datetime.now().strftime("%Y-%m-%d") - default_start = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + default_start, today = resolve_param_search_range("UPDOW", lookback_days=365) ap = argparse.ArgumentParser(description="Updow 파라미터 그리드 탐색 (holding_min_candles + env_config)") ap.add_argument("--code", required=True, help="6자리 종목코드 (--from-file 시 최근 JSON 필터용)") - ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD") - ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD") + ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD (거래일 보정)") + ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD (주말·휴장이면 이전 장운영일)") ap.add_argument("--tf", type=int, default=-1, help="분봉 (기본 -1 = env UPDOW_TF_MIN, 없으면 60)") ap.add_argument("--mode", choices=["fast", "full"], default="fast", help="탐색 모드 (fast=경량, full=전체)") ap.add_argument("--max-combos", type=int, default=-1, help="백테 조합 상한 (0=무제한, 기본 fast=1200/full=0)") diff --git a/kis_trader/backtest/param_search_updow_us.py b/kis_trader/backtest/param_search_updow_us.py index c7033c4..0ce3d1f 100644 --- a/kis_trader/backtest/param_search_updow_us.py +++ b/kis_trader/backtest/param_search_updow_us.py @@ -86,8 +86,13 @@ def _parse_symbol_exchange(args: argparse.Namespace) -> Tuple[str, str, str, str def main(argv: List[str] | None = None) -> int: - today = datetime.now().strftime("%Y-%m-%d") - default_start = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + from datetime import date, timedelta + from kis_trader.utils.kr_trading_day import clamp_to_prev_weekday + + today = clamp_to_prev_weekday(date.today()) + default_start = clamp_to_prev_weekday( + date.fromisoformat(today) - timedelta(days=365) + ) ap = argparse.ArgumentParser( description="UPDOW 해외(US) 파라미터 그리드 탐색 (레짐 OFF, holding_min_candles)" @@ -96,8 +101,8 @@ def main(argv: List[str] | None = None) -> int: ap.add_argument("--code", default="", help="--symbol 과 동일 (호환)") ap.add_argument("--exchange", default="NASD", help="거래소 코드 (NASD/NYSE/AMEX)") ap.add_argument("--market-type", default="US", dest="market_type", help="시장 (기본 US)") - ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD") - ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD") + ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD (주말→이전 평일)") + ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD (주말→이전 평일)") ap.add_argument("--tf", type=int, default=-1, help="분봉 (기본 -1 = env UPDOW_TF_MIN)") ap.add_argument("--mode", choices=["fast", "full"], default="fast", help="fast|full") ap.add_argument("--max-combos", type=int, default=-1, help="조합 상한 (0=무제한)") diff --git a/kis_trader/backtest/param_search_updown_box.py b/kis_trader/backtest/param_search_updown_box.py index 7eb1369..49537ad 100644 --- a/kis_trader/backtest/param_search_updown_box.py +++ b/kis_trader/backtest/param_search_updown_box.py @@ -111,14 +111,14 @@ def _apply_env_patch(db: TradeDB, patch: Dict[str, Any]) -> int: def main(argv: Optional[List[str]] = None) -> int: - today = datetime.now().strftime("%Y-%m-%d") - default_start = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + default_start, today = resolve_param_search_range("UPDOWN", lookback_days=365) ap = argparse.ArgumentParser(description="UPDOWN 박스권 글로벌 파라미터 그리드 탐색") ap.add_argument("--codes", default="", help="쉼표 구분 6자리 종목코드") ap.add_argument("--from-watchlist", action="store_true", help="watchlist active 종목 사용") - ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD") - ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD") + ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD (거래일 보정)") + ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD (주말·휴장이면 이전 장운영일)") ap.add_argument("--tf", type=int, default=-1, help="분봉 (기본 env UPDOWN_SCAN_TF_MIN=15)") ap.add_argument("--mode", choices=["fast", "full"], default="fast") ap.add_argument("--max-combos", type=int, default=-1, help="조합 상한 (0=무제한)") diff --git a/kis_trader/backtest/range_break_backtest_common.py b/kis_trader/backtest/range_break_backtest_common.py index 244bdeb..c91490a 100644 --- a/kis_trader/backtest/range_break_backtest_common.py +++ b/kis_trader/backtest/range_break_backtest_common.py @@ -16,11 +16,16 @@ from kis_trader.backtest.backtest_portfolio_common import ( resolve_portfolio_params, summarize_trades, ) +from kis_trader.backtest.breakout_tick_loader import ( + load_breakout_ticks_by_code, + tick_coverage_stats, +) from kis_trader.engine.range_break_engine import ( RANGE_BREAK_STRATEGY_ID, range_break_min_bars_required, run_range_break_backtest, ) +from kis_trader.engine.tick_exit_common import strategy_use_tick_exit RANGE_BREAK_STRATEGY_ID = RANGE_BREAK_STRATEGY_ID # noqa: F811 — re-export @@ -101,6 +106,7 @@ def run_range_break_backtest_web_aligned( max_stocks: Optional[int] = None, total_budget_krw: Optional[float] = None, meta_out: Optional[Dict[str, Any]] = None, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: engine_params = dict(params) engine_params["slot_money"] = float(slot_money) @@ -115,10 +121,40 @@ def run_range_break_backtest_web_aligned( engine_params.setdefault("scan_interval_min", 1) engine_params.setdefault("portfolio_mode", True) + from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params + attach_backtest_env_timeline_to_params(engine_params, meta_out, "RANGE_BREAK") + + loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {}) + tick_meta: Dict[str, Any] = {} + if strategy_use_tick_exit(engine_params, "RANGE_BREAK_BACKTEST_USE_TICK_EXIT", default=True): + if not loaded_ticks and meta_out is not None: + start_key = str(meta_out.get("start_key") or "") + end_key = str(meta_out.get("end_key") or "") + db = meta_out.get("db") + if db and start_key and end_key: + loaded_ticks, tick_rows = load_breakout_ticks_by_code( + db, start_key, end_key, set(candles_by_code.keys()), + ) + tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks) + tick_meta["ws_tick_rows_loaded"] = tick_rows + if tick_rows <= 0: + from kis_trader.utils.logger import get_logger as _get_logger + + _get_logger("kis_trader.range_break_backtest").warning( + "⚠️ ws_ticks 데이터 없음 — RANGE_BREAK 틱 청산 스킵 " + "(틱 수집 후 재백테 권장, FALLBACK_OHLC=1 시 OHLC)", + ) + elif loaded_ticks: + tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks) + tick_meta["ws_tick_rows_loaded"] = sum( + len(lst) for cm in loaded_ticks.values() for lst in cm.values() + ) + trades = run_range_break_backtest( candles_by_code, engine_params, universe_by_slot=universe_by_slot, + ticks_by_code=loaded_ticks or None, ) attach_scalp_trade_pnl( trades, fee_rate=fee_rate, sell_tax=sell_tax, @@ -128,7 +164,18 @@ def run_range_break_backtest_web_aligned( skip_stats = engine_params.get("_portfolio_skip_stats") or {} meta_out["skip_stats"] = dict(skip_stats) meta_out["engine_params"] = engine_params - meta_out["backtest_buy_source"] = "align" + if tick_meta: + from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes + tick_meta = enrich_tick_meta_with_traded_codes( + tick_meta, candles_by_code, loaded_ticks, trades, + ) + meta_out["tick_backtest"] = tick_meta + if tick_meta.get("ws_tick_rows_loaded", 0) > 0: + meta_out["backtest_buy_source"] = "ws_ticks" + else: + meta_out["backtest_buy_source"] = "ohlc_fallback" + else: + meta_out["backtest_buy_source"] = "align" return trades diff --git a/kis_trader/backtest/range_break_portfolio_backtest.py b/kis_trader/backtest/range_break_portfolio_backtest.py index 6d5233e..043a701 100644 --- a/kis_trader/backtest/range_break_portfolio_backtest.py +++ b/kis_trader/backtest/range_break_portfolio_backtest.py @@ -7,6 +7,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from kis_trader.backtest.backtest_portfolio_common import ( + flatten_remaining_portfolio_trades, min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, @@ -17,6 +18,14 @@ from kis_trader.engine.range_break_engine import ( range_break_scan_buy_at_bar, ) from kis_trader.engine.scalping_engine import _t2dt, _to_bool +from kis_trader.engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + collect_minute_ticks, + resolve_backtest_sell, + strategy_tick_fallback_ohlc, + strategy_use_tick_exit, +) from kis_trader.share.stock_share import share_denom_for_code from kis_trader.strategies.breakout import ( _bt_slot_key, @@ -72,17 +81,26 @@ def _total_budget_from_params(params: Dict[str, Any]) -> float: def _resolve_invest_cap(params: Dict[str, Any]) -> float: slot_money = float(params.get("slot_money", 200_000)) - sl_pct = abs(float(params.get("stop_loss_pct", params.get("sl_pct", -0.03)))) + if params.get("stop_loss_pct") not in (None, ""): + sl_pct_ui = abs(float(params["stop_loss_pct"])) * 100.0 + else: + raw = abs(float(params.get("sl_pct", 3.0))) + sl_pct_ui = raw if raw >= 0.5 else raw * 100.0 max_loss_krw = normalize_breakout_max_loss_krw(params.get("max_loss_krw", 200_000)) - return breakout_invest_amount_krw(max_loss_krw, sl_pct * 100.0, slot_money) + return breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_money) def run_range_break_backtest_portfolio( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: - """시각순 포트폴리오 박스권 돌파 백테스트.""" + """ + 시각순 포트폴리오 박스권 돌파 백테스트. + + - 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_range_break_live`` + """ min_bars = range_break_min_bars_required(params) force_eod_exit = _to_bool(params.get("force_eod_exit"), False) cooldown_min = float(params.get("cooldown_min", 30)) @@ -97,6 +115,18 @@ def run_range_break_backtest_portfolio( buy_params = dict(params) skipped_micro_buys = 0 + use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit( + params, "RANGE_BREAK_BACKTEST_USE_TICK_EXIT", default=True, + ) + tick_fallback_ohlc = strategy_tick_fallback_ohlc( + params, "RANGE_BREAK_BACKTEST_TICK_FALLBACK_OHLC", default=False, + ) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="RANGE_BREAK_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct( + params, strategy_env="RANGE_BREAK_BACKTEST_SELL_SLIP_PCT", + ) + tick_exit_count = 0 + ohlc_exit_count = 0 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() @@ -132,7 +162,22 @@ def run_range_break_backtest_portfolio( from kis_trader.engine.scalping_engine import check_sell_signal_backtest_bar + from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at + for t in all_times: + if apply_env_timeline_at(params, t, "RANGE_BREAK"): + max_stocks = _max_stocks_from_params(params) + slot_money = float(params.get("slot_money", 200_000)) + total_budget = _total_budget_from_params(params) + if total_budget <= 0: + total_budget = float(max_stocks * slot_money) + invest_cap = _resolve_invest_cap(params) + cooldown_min = float(params.get("cooldown_min", 30)) + max_daily = int(params.get("max_daily", 1)) + buy_params.update({ + k: params[k] for k in params + if k not in buy_params or buy_params.get(k) != params[k] + }) slot_key = _bt_slot_key(t, int(params.get("scan_interval_min", 1))) pending_codes = [ @@ -202,21 +247,46 @@ def run_range_break_backtest_portfolio( bar = dict(c) if "open" not in bar or bar.get("open") in (None, ""): bar["open"] = float(c.get("open") or cl) - res = check_sell_signal_backtest_bar( - pos, - bar, - params, - is_eod=is_eod, - sell_fn=check_sell_signal_range_break_live, - low_mode="current", - ) - if not res: - continue - reason, exit_price = res + if use_tick_exit: + minute_ticks = collect_minute_ticks(ticks_by_code, code, t) + res5 = resolve_backtest_sell( + pos, + bar, + params, + is_eod=is_eod, + sell_fn=check_sell_signal_range_break_live, + low_mode="current", + ticks=minute_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, + ) + if not res5: + continue + reason, exit_price, sell_time, _hold_min, exit_src = res5 + if exit_src == "ws_ticks": + tick_exit_count += 1 + else: + ohlc_exit_count += 1 + else: + res = check_sell_signal_backtest_bar( + pos, + bar, + params, + is_eod=is_eod, + sell_fn=check_sell_signal_range_break_live, + low_mode="current", + ) + if not res: + continue + reason, exit_price = res + sell_time = t + ohlc_exit_count += 1 all_trades.append({ "code": code, "buy_time": pos["entry_time"], - "sell_time": t, + "sell_time": sell_time or t, "buy_price": pos["entry_price"], "sell_price": round(exit_price, 2), "qty": pos.get("qty", 1), @@ -224,7 +294,7 @@ def run_range_break_backtest_portfolio( "sell_reason": reason, "hold_min": 0, }) - ctx["last_exit_dt"][day] = _t2dt(t) + ctx["last_exit_dt"][day] = _t2dt(sell_time or t) del portfolio[code] if len(portfolio) >= max_stocks: @@ -314,7 +384,19 @@ def run_range_break_backtest_portfolio( _pri, pick_code, pe = candidates[0] ctx_by_code[pick_code]["pending_entry"] = pe + skip_stats: Dict[str, Any] = {} if skipped_micro_buys: - params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys} + skip_stats["skipped_micro_buys"] = skipped_micro_buys + if tick_exit_count or ohlc_exit_count: + skip_stats["tick_exit_count"] = tick_exit_count + skip_stats["ohlc_exit_count"] = ohlc_exit_count + flat_n = flatten_remaining_portfolio_trades( + portfolio, ctx_by_code, all_trades, + params=params, strategy="RANGE_BREAK", + ) + if flat_n: + skip_stats["bt_flatten_count"] = flat_n + if skip_stats: + params["_portfolio_skip_stats"] = skip_stats all_trades.sort(key=lambda x: x["sell_time"]) return all_trades diff --git a/kis_trader/backtest/scalping_backtest_common.py b/kis_trader/backtest/scalping_backtest_common.py index aa89b7f..2b02646 100644 --- a/kis_trader/backtest/scalping_backtest_common.py +++ b/kis_trader/backtest/scalping_backtest_common.py @@ -18,7 +18,13 @@ from kis_trader.backtest.backtest_portfolio_common import ( resolve_portfolio_params, summarize_trades, ) +from kis_trader.backtest.breakout_tick_loader import ( + load_breakout_ticks_by_code, + tick_coverage_stats, +) from kis_trader.engine import scalping_engine as se +from kis_trader.engine.tick_exit_common import strategy_use_tick_exit +from kis_trader.utils.env import get_env_bool SCALP_STRATEGY_ID = "SCALP" MOMENTUM_STRATEGY_ID = "MOMENTUM" @@ -94,6 +100,15 @@ def load_scalp_candles_by_code( return candles_by_code, total_candles +def _scalp_backtest_wants_ticks(params: Optional[Dict[str, Any]] = None) -> bool: + """청산·진입 틱 재생이 필요한지 (기본 ON).""" + if strategy_use_tick_exit(params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True): + return True + if params is not None and params.get("backtest_use_tick_entry") is not None: + return se._to_bool(params.get("backtest_use_tick_entry"), True) + return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True) + + def run_scalping_backtest_web_aligned( candles_by_code: Dict[str, List[Dict]], params: Dict[str, Any], @@ -106,6 +121,7 @@ def run_scalping_backtest_web_aligned( total_budget_krw: Optional[float] = None, meta_out: Optional[Dict[str, Any]] = None, mode: str = "reversal", + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: """엔진 1회 + 웹과 동일 손익 부착 (reversal / momentum).""" engine_params = dict(params) @@ -121,6 +137,9 @@ def run_scalping_backtest_web_aligned( engine_params.setdefault("scan_interval_min", 1) engine_params.setdefault("portfolio_mode", True) + from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params + attach_backtest_env_timeline_to_params(engine_params, meta_out, "SCALP") + if str(mode).strip().lower() == "momentum": from kis_trader.backtest import momentum_backtest_common as mbc trades = mbc.run_momentum_backtest_web_aligned( @@ -133,9 +152,48 @@ def run_scalping_backtest_web_aligned( meta_out=meta_out, ) else: + loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {}) + tick_meta: Dict[str, Any] = {} + if _scalp_backtest_wants_ticks(engine_params): + if not loaded_ticks and meta_out is not None: + start_key = str(meta_out.get("start_key") or "") + end_key = str(meta_out.get("end_key") or "") + db = meta_out.get("db") + if db and start_key and end_key: + loaded_ticks, tick_rows = load_breakout_ticks_by_code( + db, start_key, end_key, set(candles_by_code.keys()), + ) + tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks) + tick_meta["ws_tick_rows_loaded"] = tick_rows + if tick_rows <= 0: + from kis_trader.utils.logger import get_logger as _get_logger + + _get_logger("kis_trader.scalping_backtest").warning( + "⚠️ ws_ticks 데이터 없음 — SCALP 틱 청산/진입 스킵 " + "(틱 수집 후 재백테 권장, FALLBACK_OHLC=1 시 OHLC)", + ) + elif loaded_ticks: + tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks) + tick_meta["ws_tick_rows_loaded"] = sum( + len(lst) for cm in loaded_ticks.values() for lst in cm.values() + ) + trades = se.run_scalping_backtest( candles_by_code, engine_params, universe_by_slot=universe_by_slot, + ticks_by_code=loaded_ticks or None, ) + if meta_out is not None and tick_meta: + from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes + tick_meta = enrich_tick_meta_with_traded_codes( + tick_meta, candles_by_code, loaded_ticks, trades, + ) + meta_out["tick_backtest"] = tick_meta + if tick_meta.get("ws_tick_rows_loaded", 0) > 0: + meta_out["backtest_buy_source"] = "ws_ticks" + elif _scalp_backtest_wants_ticks(engine_params): + meta_out["backtest_buy_source"] = "ohlc_fallback" + else: + meta_out["backtest_buy_source"] = "align" attach_scalp_trade_pnl( trades, fee_rate=fee_rate, sell_tax=sell_tax, diff --git a/kis_trader/backtest/scalping_portfolio_backtest.py b/kis_trader/backtest/scalping_portfolio_backtest.py index 3d5cf2f..6682eb7 100644 --- a/kis_trader/backtest/scalping_portfolio_backtest.py +++ b/kis_trader/backtest/scalping_portfolio_backtest.py @@ -16,16 +16,26 @@ from kis_trader.backtest.backtest_portfolio_common import ( ) from kis_trader.engine.scalping_engine import ( _apply_buy_state_filters, - _eval_momentum_buy_at_index, _eval_scalp_buy_at_index, _macd_lines_from_params, _slot_key, _t2dt, _to_bool, check_sell_signal_backtest_bar, + check_sell_signal_live, compute_rsi_series, effective_tp_pct_from_params, ) +from kis_trader.engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + collect_minute_ticks, + resolve_backtest_sell, + strategy_tick_fallback_ohlc, + strategy_use_tick_exit, +) +from kis_trader.engine.tail_tick_replay import align_entry_price_from_ticks +from kis_trader.utils.env import get_env_bool def _buy_priority_key( @@ -84,28 +94,37 @@ def _resolve_invest_cap_krw(params: Dict[str, Any], slot_money: float) -> float: return invest_amount +def _scalp_use_tick_entry(params: Optional[Dict[str, Any]] = None) -> bool: + """백테 진입: 예약 체결 시 해당 분 첫 틱 가격 (기본 ON — 실매 체결 정합).""" + if params is not None and params.get("backtest_use_tick_entry") is not None: + return _to_bool(params.get("backtest_use_tick_entry"), True) + return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True) + + def run_scalping_backtest_portfolio( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, mode: str = "reversal", + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: """ 시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사. - - ``mode='reversal'``: ``_eval_scalp_buy_at_index`` - - ``mode='momentum'``: ``_eval_momentum_buy_at_index`` + - reversal 전용 (모멘텀은 ``momentum_portfolio_backtest``). + - 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_live`` """ mode = str(mode or "reversal").strip().lower() if mode == "momentum": from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio return run_momentum_backtest_portfolio( codes_candles, params, universe_by_slot=universe_by_slot, + ticks_by_code=ticks_by_code, ) strategy = "SCALP" rsi_period = int(params.get("rsi_period", 3)) - min_bars = max(rsi_period + 5, 6) if mode == "momentum" else rsi_period + 5 + min_bars = rsi_period + 5 force_eod_exit = _to_bool(params.get("force_eod_exit"), False) sl_pct = abs(float(params.get("sl_pct", 0.015))) tp_pct = effective_tp_pct_from_params(params) @@ -119,6 +138,18 @@ def run_scalping_backtest_portfolio( use_macd_cross = _to_bool(params.get("use_macd_cross", False), False) skipped_micro_buys = 0 + use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit( + params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True, + ) + tick_fallback_ohlc = strategy_tick_fallback_ohlc( + params, "SCALP_BACKTEST_TICK_FALLBACK_OHLC", default=False, + ) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="SCALP_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="SCALP_BACKTEST_SELL_SLIP_PCT") + use_tick_entry = bool(ticks_by_code) and _scalp_use_tick_entry(params) + tick_exit_count = 0 + ohlc_exit_count = 0 + tick_entry_count = 0 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() @@ -145,10 +176,21 @@ def run_scalping_backtest_portfolio( portfolio: Dict[str, Dict[str, Any]] = {} all_trades: List[Dict] = [] + from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at + for t in all_times: + if apply_env_timeline_at(params, t, "SCALP"): + max_stocks = _max_stocks_from_params(params) + slot_money = float(params.get("slot_money", 300_000)) + total_budget = _total_budget_from_params(params) + if total_budget <= 0: + total_budget = float(max_stocks * slot_money) + invest_cap = _resolve_invest_cap_krw(params, slot_money) + sl_pct = abs(float(params.get("sl_pct", 0.015))) + tp_pct = effective_tp_pct_from_params(params) slot_key = _slot_key(t, params.get("scan_interval_min", 1)) - # ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가) ── + # ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가 / 첫 틱) ── pending_codes = [ code for code, ctx in ctx_by_code.items() if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t @@ -162,6 +204,17 @@ def run_scalping_backtest_portfolio( if len(portfolio) >= max_stocks: break entry_price = float(pe["entry_price"]) + # [틱 진입] 예약 체결 시각의 첫 체결가 — 실매 시가 근사보다 정합 + if use_tick_entry and entry_price > 0: + minute_ticks = collect_minute_ticks(ticks_by_code, code, t) + aligned, align_src = align_entry_price_from_ticks(minute_ticks, entry_price) + if aligned > 0: + entry_price = float(aligned) + if align_src == "ws_ticks": + tick_entry_count += 1 + # 진입가 변경 시 손절·익절 재계산 (비율 동일) + pe["stop"] = entry_price * (1 - sl_pct) + pe["target"] = entry_price * (1 + tp_pct) if entry_price <= 0: continue exposure = portfolio_exposure_krw(portfolio) @@ -217,9 +270,6 @@ def run_scalping_backtest_portfolio( if t == pos["entry_time"]: continue - max_p = max(float(pos.get("max_price", 0) or 0), hi) - pos["max_price"] = max_p - cur_c_info = { "open": op, "high": hi, @@ -227,14 +277,41 @@ def run_scalping_backtest_portfolio( "close": cl, "candle_time": t, } - res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod) - if not res: - continue - reason, exit_price = res + if use_tick_exit: + minute_ticks = collect_minute_ticks(ticks_by_code, code, t) + res5 = resolve_backtest_sell( + pos, + cur_c_info, + params, + is_eod=is_eod, + sell_fn=check_sell_signal_live, + low_mode="current", + ticks=minute_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, + ) + if not res5: + continue + reason, exit_price, sell_time, _hold_min, exit_src = res5 + if exit_src == "ws_ticks": + tick_exit_count += 1 + else: + ohlc_exit_count += 1 + else: + max_p = max(float(pos.get("max_price", 0) or 0), hi) + pos["max_price"] = max_p + res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod) + if not res: + continue + reason, exit_price = res + sell_time = t + ohlc_exit_count += 1 trade: Dict[str, Any] = { "code": code, "buy_time": pos["entry_time"], - "sell_time": t, + "sell_time": sell_time or t, "buy_price": pos["entry_price"], "sell_price": round(exit_price, 2), "qty": pos.get("qty", 1), @@ -245,7 +322,7 @@ def run_scalping_backtest_portfolio( if pos.get("rsi") is not None: trade["rsi_entry"] = round(float(pos["rsi"]), 1) all_trades.append(trade) - ctx["last_exit_dt"][day] = _t2dt(t) + ctx["last_exit_dt"][day] = _t2dt(sell_time or t) ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 del portfolio[code] @@ -275,33 +352,35 @@ def run_scalping_backtest_portfolio( continue eval_params = dict(params) - if universe_by_slot is not None: - eval_params.setdefault("skip_hts_scan_dupes", True) - else: - eval_params.setdefault("skip_hts_scan_dupes", False) + if "skip_hts_scan_dupes" not in eval_params: + from kis_trader.engine.scalping_engine import resolve_scalp_skip_hts_scan_dupes + eval_params["skip_hts_scan_dupes"] = resolve_scalp_skip_hts_scan_dupes() state = { "daily_cnt": ctx["daily_cnt"].get(day, 0), "last_exit_dt": ctx["last_exit_dt"].get(day), } - if mode == "momentum": - if idx < 5: - continue - reject, _msg, sig = _eval_momentum_buy_at_index( - candles, idx, eval_params, state, - ) - else: - st = _apply_buy_state_filters(candles, idx, eval_params, state) - if st[2] is None: - continue - reject, _msg, sig = _eval_scalp_buy_at_index( - candles, idx, eval_params, macd_combined=ctx.get("macd_combined"), - ) + st = _apply_buy_state_filters(candles, idx, eval_params, state) + if st[2] is None: + continue + from kis_trader.backtest.trigger_snapshot_loader import ( + inject_trigger_snapshots_into_params, + ) + inject_trigger_snapshots_into_params( + eval_params, + orderbook_by_code=params.get("_bt_orderbook_by_code"), + program_by_code=params.get("_bt_program_by_code"), + code=code, + bar_candle_time=str(c.get("candle_time") or t), + ) + reject, _msg, sig = _eval_scalp_buy_at_index( + candles, idx, eval_params, macd_combined=ctx.get("macd_combined"), + ) if reject or not sig: continue rsi = sig.get("rsi") - if rsi is None and mode == "reversal": + if rsi is None: continue if idx + 1 >= len(candles): @@ -332,7 +411,15 @@ def run_scalping_backtest_portfolio( _pri, pick_code, pe = candidates[0] ctx_by_code[pick_code]["pending_entry"] = pe + skip_stats: Dict[str, Any] = {} if skipped_micro_buys: - params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys} + skip_stats["skipped_micro_buys"] = skipped_micro_buys + if tick_exit_count or ohlc_exit_count: + skip_stats["tick_exit_count"] = tick_exit_count + skip_stats["ohlc_exit_count"] = ohlc_exit_count + if tick_entry_count: + skip_stats["tick_entry_count"] = tick_entry_count + if skip_stats: + params["_portfolio_skip_stats"] = skip_stats all_trades.sort(key=lambda x: x["sell_time"]) return all_trades diff --git a/kis_trader/backtest/tail_backtest_common.py b/kis_trader/backtest/tail_backtest_common.py index 80f3b1b..671c064 100644 --- a/kis_trader/backtest/tail_backtest_common.py +++ b/kis_trader/backtest/tail_backtest_common.py @@ -5,8 +5,9 @@ """ from __future__ import annotations +import time from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple import kis_trader.engine.tail_engine as te from kis_trader.backtest.backtest_portfolio_common import resolve_trigger_snapshots_for_backtest @@ -18,6 +19,9 @@ from kis_trader.engine.indicator_cache import ( TAIL_STRATEGY_ID = "SHORT" VALID_TIMEFRAMES = (3, 5, 15, 60) +# 종목×기간일 REST 웜업 캐시 (프로세스 메모리만 — DB 미기록) +_TAIL_REST_WARMUP_PREFIX_CACHE: Dict[Tuple[str, str, int], List[Dict[str, Any]]] = {} + def tail_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool: """백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합). @@ -44,6 +48,236 @@ def date_keys(start: str, end: str) -> Tuple[str, str, str, str]: return start_key, end_key, start_key[:8], end_key[:8] +def tail_backtest_candle_warmup_bars() -> int: + """백테 지표 warm-up — 실매 ``get_candles(..., n=50)`` 과 동일하게 전일 N봉(전략 TF). + + 꼬리 기본 TF=3분 → 50봉 ≈ 실매 RAM 조회 길이. RSI·패턴은 확정봉 ≥20 필요. + """ + from kis_trader.utils.env import get_env_int + return max(0, int(get_env_int("TAIL_BACKTEST_CANDLE_WARMUP_BARS", 50))) + + +def _tail_rows_have_prev_day(rows: List[Dict], period_day: str) -> bool: + pd = str(period_day or "")[:8] + if not pd: + return True + for r in rows or []: + ct = str(r.get("candle_time") or "") + if len(ct) >= 8 and ct[:8] < pd: + return True + return False + + +def prepend_tail_candle_warmup( + db, + candles_by_code: Dict[str, List[Dict]], + period_start_key: str, + *, + timeframe: int = 3, + warmup_bars: Optional[int] = None, + peak_sel: str = "", +) -> int: + """ + ``period_start_key`` 이전 N봉(전략 TF)을 종목별로 prepend. + 지표·패턴용 — 엔진 ``all_times`` 는 기간일만 쓰도록 ``_bt_period_start_ymd`` 로 걸러짐. + """ + wb = ( + tail_backtest_candle_warmup_bars() + if warmup_bars is None + else max(0, int(warmup_bars)) + ) + if wb <= 0 or db is None or not period_start_key: + return 0 + tf = int(timeframe) + if tf not in VALID_TIMEFRAMES: + tf = 3 + ps = str(period_start_key)[:12] + ind_cols = ws_candles_select_indicator_cols(db) + total_prepended = 0 + for code, rows in list(candles_by_code.items()): + if not rows: + continue + # 이미 기간 이전 봉이 있으면 skip + if _tail_rows_have_prev_day(rows, ps[:8]): + continue + first_ct = "" + for r in rows: + ct = str(r.get("candle_time") or "") + if ct >= ps: + first_ct = ct + break + if not first_ct: + continue + warm_rows = db.conn.execute( + f"SELECT candle_time, open, high, low, close, volume{peak_sel}{ind_cols} " + "FROM ws_candles WHERE timeframe=%s AND code=%s " + "AND candle_time < %s AND is_confirmed=1 " + "ORDER BY candle_time DESC LIMIT %s", + [tf, code, first_ct, wb], + ).fetchall() + if not warm_rows: + continue + prefix = [dict(r) for r in reversed(warm_rows)] + candles_by_code[code] = prefix + [dict(r) for r in rows] + total_prepended += len(prefix) + if total_prepended > 0: + materialize_ws_candles_batch(db, candles_by_code, tf) + return total_prepended + + +def inject_tail_rest_warmup_memory( + candles_by_code: Dict[str, List[Dict]], + period_start_key: str, + *, + timeframe: int = 3, + universe_by_slot: Optional[Dict[str, List[str]]] = None, +) -> Dict[str, int]: + """ + DB 전일봉이 없을 때 키움 ka10080 REST 1회 → 1분봉 → TF 롤업 → 메모리 prepend. + + - 실매: ``SHORT_GAP_FILL_LIMIT``(기본 150) 1M/3M 갭보정 + - 백테: ``TAIL_BACKTEST_REST_WARMUP_BARS`` 기본 **150**(1분) → 3분 ≈50봉 + (= 실매 ``get_candles(n=50)`` 근사). 모멘텀 REST 기본 700(1분·HTS E)과 다름. + - DB INSERT 없음. + """ + from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int + from kis_trader.utils.logger import get_logger + from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf + + log = get_logger("kis_trader.backtest.tail") + stats = {"need": 0, "ok": 0, "fail": 0, "cache_hit": 0, "bars": 0, "skipped": 0} + if not get_env_bool("TAIL_BACKTEST_REST_WARMUP", True): + stats["skipped"] = 1 + return stats + ps = str(period_start_key or "")[:12] + if len(ps) < 8 or not candles_by_code: + return stats + period_day = ps[:8] + tf = int(timeframe) if int(timeframe) in VALID_TIMEFRAMES else 3 + + if universe_by_slot: + target: Set[str] = set() + for codes in universe_by_slot.values(): + for c in codes or []: + if c: + target.add(str(c).strip()) + target &= set(candles_by_code.keys()) + else: + target = set(candles_by_code.keys()) + + need_codes = [ + c for c in sorted(target) + if not _tail_rows_have_prev_day(candles_by_code.get(c) or [], period_day) + ] + stats["need"] = len(need_codes) + if not need_codes: + return stats + + max_codes = int(get_env_int("TAIL_BACKTEST_REST_MAX_CODES", 0)) + if max_codes > 0: + need_codes = need_codes[:max_codes] + + # 1분봉 개수 — 전일 세션 커버용 (장중 1분≈380봉). 기본 450. + # (150은 당일 최근만 닿아 prev_day 판정 실패 → REST 무의미. 모멘텀 700보다 작고 + # SHORT_GAP_FILL_LIMIT(150·3분)≈450·1분 과 맞춤.) + n_1m = max( + 50, + int(get_env_int("TAIL_BACKTEST_REST_WARMUP_BARS", 450)), + ) + sleep_sec = float(get_env_float("TAIL_BACKTEST_REST_SLEEP_SEC", 0.25)) + + from kis_trader.backtest.momentum_backtest_common import _kiwoom_gap_credentials + from kis_trader.ws.kis_ws import get_kiwoom_candles_df + + kw_key, kw_secret, is_mock = _kiwoom_gap_credentials() + if not kw_key or not kw_secret: + log.warning("⚠️ 꼬리 REST 웜업 스킵 — 키움 앱키/시크릿 없음") + stats["fail"] = len(need_codes) + return stats + + log.info( + "📡 꼬리 REST 웜업(메모리): 전일봉 부족 %d종목 · 1M n=%d → %dM 롤업 (DB 미기록)", + len(need_codes), n_1m, tf, + ) + for i, code in enumerate(need_codes): + rows = candles_by_code.get(code) or [] + if not rows: + stats["fail"] += 1 + continue + cache_key = (code, period_day, tf) + cached = _TAIL_REST_WARMUP_PREFIX_CACHE.get(cache_key) + if cached is not None: + stats["cache_hit"] += 1 + prefix = [dict(r) for r in cached] + else: + try: + df = get_kiwoom_candles_df( + code, 1, kw_key, kw_secret, is_mock=is_mock, n=n_1m, + ) + except Exception as e: + log.warning("⚠️ 꼬리 REST 웜업 실패 %s: %s", code, e) + stats["fail"] += 1 + continue + if df is None or getattr(df, "empty", True): + stats["fail"] += 1 + continue + first_ct = "" + for r in rows: + ct = str(r.get("candle_time") or "") + if ct >= ps: + first_ct = ct[:12] + break + if not first_ct: + first_ct = ps + existing = {str(r.get("candle_time") or "")[:12] for r in rows} + bars_1m: List[Dict[str, Any]] = [] + try: + for _, rec in df.iterrows(): + t = str(rec.get("time") or "")[:12] + if len(t) < 12 or t >= first_ct: + continue + op = float(rec.get("open") or 0) + if op <= 0: + continue + bars_1m.append({ + "candle_time": t, + "open": op, + "high": float(rec.get("high") or op), + "low": float(rec.get("low") or op), + "close": float(rec.get("close") or op), + "volume": int(float(rec.get("volume") or 0)), + "is_confirmed": 1, + "_rest_warmup": 1, + }) + except Exception as e: + log.warning("⚠️ 꼬리 REST 웜업 파싱 실패 %s: %s", code, e) + stats["fail"] += 1 + continue + bars_1m.sort(key=lambda x: str(x.get("candle_time") or "")) + rolled = rollup_1m_bars_to_tf(bars_1m, tf) if tf != 1 else bars_1m + prefix = [ + dict(r) for r in rolled + if str(r.get("candle_time") or "")[:12] < first_ct + and str(r.get("candle_time") or "")[:12] not in existing + ] + _TAIL_REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix] + if sleep_sec > 0 and i + 1 < len(need_codes): + time.sleep(sleep_sec) + + if not prefix or not _tail_rows_have_prev_day(prefix, period_day): + stats["fail"] += 1 + continue + candles_by_code[code] = [dict(r) for r in prefix] + [dict(r) for r in rows] + stats["ok"] += 1 + stats["bars"] += len(prefix) + + log.info( + "✅ 꼬리 REST 웜업 완료: ok=%d fail=%d cache=%d bars=%d", + stats["ok"], stats["fail"], stats["cache_hit"], stats["bars"], + ) + return stats + + def resolve_tail_universe( start_ymd: str, end_ymd: str, @@ -83,7 +317,12 @@ def load_tail_candles_by_code( """ ws_candles 전 종목 로드 (backtest_web api/backtest/tail 과 동일 쿼리). 유니버스 필터는 엔진 run_tail_backtest 에서 슬롯별 적용. + + ``TAIL_BT_SYNTH_3M_FROM_1M``(기본 true) 이고 timeframe=3 이면 + DB 3분 구멍을 1분봉 롤업으로 보강 (실매 RAM 롤업과 동일 규칙). """ + from kis_trader.utils.env import get_env_bool + tail_tf = int(timeframe) if tail_tf not in VALID_TIMEFRAMES: raise ValueError(f"timeframe 은 {VALID_TIMEFRAMES} 중 하나여야 합니다") @@ -104,9 +343,30 @@ def load_tail_candles_by_code( ).fetchall() codes = [r["code"] for r in codes_raw] + # 3분 합성 시 1분만 있는 종목도 후보에 포함 + synth_on = ( + tail_tf == 3 + and get_env_bool("TAIL_BT_SYNTH_3M_FROM_1M", True) + ) + if synth_on: + try: + codes_1m = db.conn.execute( + "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " + "AND candle_time >= %s AND candle_time <= %s ORDER BY code", + [start_key, end_key], + ).fetchall() + for r in codes_1m: + c = r["code"] + if c not in codes: + codes.append(c) + codes = sorted(set(codes)) + except Exception: + pass + candles_by_code: Dict[str, List[Dict]] = {} total_candles = 0 min_bars = int(rsi_period) + 5 + synth_filled_total = 0 for code in codes: rows = db.conn.execute( @@ -116,18 +376,78 @@ def load_tail_candles_by_code( "ORDER BY candle_time ASC", [tail_tf, code, start_key, end_key], ).fetchall() - if len(rows) < min_bars: + bars = [dict(r) for r in rows] + + if synth_on: + bars, n_fill = _synth_fill_3m_holes_from_1m( + db, code, bars, start_key, end_key, peak_sel=peak_sel, + ) + synth_filled_total += n_fill + + # 기간 내 1봉 이상이면 일단 적재 — 전일 웜업 후 min_bars 재필터 + if len(bars) < 1: continue - candles_by_code[code] = [dict(r) for r in rows] - total_candles += len(rows) + candles_by_code[code] = bars + + # B) 전일 TF봉 DB prepend (실매 get_candles n=50 정합) + warmup_n = prepend_tail_candle_warmup( + db, candles_by_code, start_key, + timeframe=tail_tf, peak_sel=peak_sel, + ) + + # 웜업 후에도 RSI+여유 미달 종목 제거 + for code in list(candles_by_code.keys()): + if len(candles_by_code[code]) < min_bars: + del candles_by_code[code] + total_candles = sum(len(v) for v in candles_by_code.values()) materialize_ws_candles_batch(db, candles_by_code, tail_tf) + if synth_on and synth_filled_total: + import logging + logging.getLogger("kis_trader.backtest.tail").info( + "🔧 [백테-합성] 1M→3M 구멍 보강 %d봉 (종목 %d)", + synth_filled_total, len(candles_by_code), + ) + if warmup_n: + import logging + logging.getLogger("kis_trader.backtest.tail").info( + "🔧 [백테-웜업] 전일 %dM prepend %d봉 (종목 %d, 목표 N=%d)", + tail_tf, warmup_n, len(candles_by_code), tail_backtest_candle_warmup_bars(), + ) return candles_by_code, total_candles, has_holding_peak +def _synth_fill_3m_holes_from_1m( + db, + code: str, + bars_3m: List[Dict], + start_key: str, + end_key: str, + *, + peak_sel: str = "", +) -> Tuple[List[Dict], int]: + """DB 3분 리스트에 없는 시각만 1분→3분 롤업으로 보강.""" + from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf + + try: + rows_1m = db.conn.execute( + f"SELECT candle_time, open, high, low, close, volume{peak_sel} " + "FROM ws_candles WHERE timeframe=1 AND code=%s " + "AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 " + "ORDER BY candle_time ASC", + [code, start_key, end_key], + ).fetchall() + except Exception: + return bars_3m, 0 + if not rows_1m: + return bars_3m, 0 + rolled = rollup_1m_bars_to_tf([dict(r) for r in rows_1m], 3) + return merge_fill_holes(bars_3m, rolled) + + def _t2dt(candle_time: str) -> datetime: - s = str(candle_time) - return datetime.strptime(s[:12], "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(candle_time) def attach_tail_trade_pnl( @@ -314,6 +634,9 @@ def run_tail_backtest_web_aligned( engine_params = dict(params) engine_params["slot_money"] = float(slot_money) + # 종목 일일 손익 게이트가 실매 realized_pnl과 동일 net 기준으로 온라인 누적하도록 전달 + engine_params["fee_rate"] = float(fee_rate) + engine_params["sell_tax"] = float(sell_tax) if max_stocks is not None: engine_params["max_stocks"] = int(max_stocks) if total_budget_krw is not None: @@ -321,8 +644,40 @@ def run_tail_backtest_web_aligned( engine_params["total_budget_krw"] = tb if tb > 0 else float( int(engine_params.get("max_stocks") or 3) * slot_money ) + # 웜업봉은 지표용 — 매매 시계는 백테 기간일만 (전일 all_times 오염 방지) + _sk0 = str((meta_out or {}).get("start_key") or "")[:12] + if len(_sk0) >= 8: + engine_params["_bt_period_start_ymd"] = _sk0[:8] + + # B) DB 전일 부족 시 REST 1회 (유니버스 종목 우선, 모멘텀과 동일 패턴) + _tf_rest = int(engine_params.get("timeframe") or engine_params.get("tf") or 3) + if _tf_rest not in VALID_TIMEFRAMES: + _tf_rest = 3 + if _sk0: + rest_stats = inject_tail_rest_warmup_memory( + candles_by_code, + _sk0, + timeframe=_tf_rest, + universe_by_slot=universe_by_slot, + ) + if meta_out is not None and ( + rest_stats.get("ok") or rest_stats.get("need") or rest_stats.get("skipped") + ): + meta_out.setdefault("skip_stats", {}) + # skip_stats 는 엔진 후 overwrite 되므로 임시 보관 + meta_out["_tail_rest_warmup"] = dict(rest_stats) + meta_out["_tail_candle_warmup_bars"] = tail_backtest_candle_warmup_bars() + if universe_by_slot is not None: engine_params.setdefault("scan_interval_min", 1) + # 실매 정합 — skip_hts_scan_dupes 는 DB(TAIL_SKIP_HTS_SCAN_DUPES) 값을 그대로 쓴다. + # (과거엔 저장 이력 백테 시 이 값을 True 로 강제했으나, 실매와 다른 값을 쓰게 되어 + # 정합성이 깨졌다. 이제는 params 에 이미 들어온 값(= 호출측이 DB/그리드에서 읽은 값)을 + # 그대로 사용 — 웹·파라서치·실매가 항상 같은 소스를 본다.) + engine_params.setdefault("skip_hts_scan_dupes", te.resolve_tail_skip_hts_scan_dupes()) + if meta_out is not None: + meta_out["skip_hts_scan_dupes_effective"] = bool(engine_params.get("skip_hts_scan_dupes")) + meta_out["skip_hts_scan_dupes_requested"] = bool(params.get("skip_hts_scan_dupes")) engine_params.setdefault("portfolio_mode", True) # ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합, 돌파·모멘텀 공통) ────── @@ -341,17 +696,25 @@ def run_tail_backtest_web_aligned( if _days: _sk, _ek = min(_days), max(_days) if len(_sk) >= 8 and len(_ek) >= 8: - from kis_trader.backtest.universe_timeline import build_universe_timeline + from kis_trader.backtest.universe_timeline import ( + build_universe_timeline, + resolve_universe_exit_debounce_sec, + ) + _deb = resolve_universe_exit_debounce_sec( + strategy_env_key="TAIL_UNIVERSE_EXIT_DEBOUNCE_SEC", + default_when_no_grace=0, + ) _tl = build_universe_timeline( strategy_id=TAIL_STRATEGY_ID, start_ymd=_sk[:8], end_ymd=_ek[:8], - debounce_sec=0, strict=False, strict_lag_minutes=0, + debounce_sec=_deb, strict=False, strict_lag_minutes=0, ) if _tl is not None: engine_params["_universe_timeline"] = _tl if meta_out is not None: meta_out["universe_timing"] = "scan_at" meta_out["universe_timeline_snapshots"] = _tl.snapshot_count + meta_out["universe_exit_debounce_sec"] = _deb # ── 체결 검증 게이트 (STRICT_FILL_VERIFY) ────────────────────────────── # 백테 체결모델(슬리피지·체결량 상한)은 '엄격 체결 검증'이 켜졌을 때만 적용한다. @@ -396,6 +759,8 @@ def run_tail_backtest_web_aligned( candles_by_code, engine_params, strategy="TAIL", meta_out=meta_out, orderbook_by_code=orderbook_by_code, program_by_code=program_by_code, ) + from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params + attach_backtest_env_timeline_to_params(engine_params, meta_out, TAIL_STRATEGY_ID) if snap_meta.get("log_verdict_by_code"): engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"] @@ -427,8 +792,18 @@ def run_tail_backtest_web_aligned( if meta_out is not None: skip_stats = engine_params.get("_portfolio_skip_stats") or {} meta_out["skip_stats"] = dict(skip_stats) + if meta_out.get("_tail_rest_warmup"): + meta_out["skip_stats"]["rest_warmup"] = meta_out.pop("_tail_rest_warmup") + if meta_out.get("_tail_candle_warmup_bars") is not None: + meta_out["skip_stats"]["candle_warmup_bars"] = meta_out.pop( + "_tail_candle_warmup_bars" + ) meta_out["engine_params"] = engine_params if tick_meta: + from kis_trader.backtest.tail_tick_loader import enrich_tick_meta_with_traded_codes + tick_meta = enrich_tick_meta_with_traded_codes( + tick_meta, candles_by_code, loaded_ticks, trades, + ) meta_out["tick_backtest"] = tick_meta if int(tick_meta.get("ws_tick_rows_loaded") or 0) > 0: meta_out["backtest_buy_source"] = "ws_ticks" diff --git a/kis_trader/backtest/tail_mfe_analysis.py b/kis_trader/backtest/tail_mfe_analysis.py index 350ad56..2b44312 100644 --- a/kis_trader/backtest/tail_mfe_analysis.py +++ b/kis_trader/backtest/tail_mfe_analysis.py @@ -18,7 +18,8 @@ from kis_trader.engine import tail_engine as te def _t2dt(candle_time: str) -> datetime: - return datetime.strptime(str(candle_time)[:12], "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(candle_time) def _session_peak_after_entry( diff --git a/kis_trader/backtest/tail_param_search.py b/kis_trader/backtest/tail_param_search.py index d3f8114..ec5174e 100644 --- a/kis_trader/backtest/tail_param_search.py +++ b/kis_trader/backtest/tail_param_search.py @@ -16,7 +16,7 @@ tail_engine 을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(en 옵션: --start 시작일 (기본: 오늘-7일) --end 종료일 (기본: 오늘) - --mode 탐색 모드: fast / coarse / fine / full / massive (기본: fast ≈768조합·~10–20분·포트폴리오) + --mode 탐색 모드: fast / coarse / fine / wide / full / massive (기본: fast ≈768조합·~10–20분·포트폴리오) --timeframe ws_candles 분봉 (3·5·15·60, 기본 3 — backtest_web 꼬리 탭과 동일) --slot-money 1회투자금(원). 미지정 시 DB (웹 tl_slot) --max-stocks 동시보유 종목. 미지정 시 DB SHORT_MAX_STOCKS @@ -30,7 +30,7 @@ tail_engine 을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(en --from-file --apply N 과 함께 사용 시, 최근 결과 JSON에서 N번째 적용 (탐색 생략). ⚠️ 그리드 축 env 오버라이드 (콤마 구분, DB/env_config 또는 셸 export): - TAIL_GRID_{FAST|COARSE|FINE|FULL|MASSIVE}_{축이름} + TAIL_GRID_{FAST|COARSE|FINE|WIDE|FULL|MASSIVE}_{축이름} 예) TAIL_GRID_COARSE_MAX_DAILY_CHG=15,20,25,30 예) TAIL_GRID_FAST_LIMIT_ATR_MULT=1.5,2.0 @@ -76,7 +76,7 @@ from kis_trader.engine import tail_engine as te from kis_trader.backtest import tail_backtest_common as tbc from kis_trader.backtest.backtest_portfolio_common import ( merge_param_search_apply_source, - portfolio_env_patch, + strip_portfolio_keys_from_apply_patch, session_env_patch, ) from kis_trader.backtest.param_search_cli_common import ( @@ -291,189 +291,724 @@ def _tail_grids(mode: str) -> Dict[str, List[Any]]: p = f"TAIL_GRID_{m.upper()}_" if m == "fast": - # 16축 — tail_vol_mult×4, atr_tp_max×4 + 청산방식(ratchet)·RSI·반전패턴 (균등샘플 cap) + # [FAST] wide1(7/15) Top 근방 스모크 — plateau +16k 분지 + # 앵커: rec0.1·래칫1.0:0.4… · atr_tp 2.5/5 · drop0.03 · align · trail off + # 실매 앵커(rec0.25·atr_tp2·sl_max6·래칫실매·rsi85·cd5) 포함 return { - "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), - "max_daily_change": _parse_csv_floats(f"{p}MAX_DAILY_CHG", [15.0, 20.0, 25.0, 30.0]), - "min_drop_rate": _parse_csv_floats(f"{p}MIN_DROP_RATE", [0.02, 0.03]), - "min_recovery_ratio": _parse_csv_floats(f"{p}MIN_RECOVERY_RATIO", [0.25, 0.4, 0.5]), - "tail_ratio_min": _parse_csv_floats(f"{p}TAIL_RATIO_MIN", [1.0, 1.5]), - "max_rec_3m": _parse_csv_floats(f"{p}MAX_REC_3M", [0.70, 0.80, 0.90]), - "shoulder_min_high": _parse_csv_floats(f"{p}SHOULDER_MIN_HIGH", [0.003, 0.005]), - "shoulder_cut_pct": _parse_csv_floats(f"{p}SHOULDER_CUT_PCT", [0.002, 0.003]), - "stop_atr_mult": _parse_csv_floats(f"{p}STOP_ATR_MULT", [1.5, 2.0]), - "target_atr_mult": _parse_csv_floats(f"{p}TARGET_ATR_MULT", [1.5, 2.0]), - "atr_tp_max_pct": _parse_csv_floats(f"{p}ATR_TP_MAX_PCT", [1.0, 1.5, 2.0, 3.0]), - "tail_vol_mult": _parse_csv_floats(f"{p}VOL_MULT", [0.0, 1.5, 2.0, 2.5]), - "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [5.0]), - "limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0]), - # ── 청산방식 탐색: ratchet_tiers ────────────────────────────── - # off = 단일 어깨컷(shoulder_*) 사용 / 나머지 = 다단 래칫 트레일. - # "off" 포함으로 shoulder_min_high·shoulder_cut_pct 축이 비로소 - # 의미를 갖는다(래칫 ON 조합에선 어깨컷은 엔진상 무시됨). + "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align"]), + "cand_limit": _parse_csv_ints( + f"{p}CAND_LIMIT", + sorted(set([0, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), + ), + "max_daily_change": _parse_csv_floats( + f"{p}MAX_DAILY_CHG", [30.0, 35.0], + ), + "min_drop_rate": _parse_csv_floats( + f"{p}MIN_DROP_RATE", [0.015, 0.03], + ), + "min_recovery_ratio": _parse_csv_floats( + f"{p}MIN_RECOVERY_RATIO", [0.10, 0.25], + ), + "tail_ratio_min": _parse_csv_floats(f"{p}TAIL_RATIO_MIN", [0.6, 1.0]), + "tail_pct_min": _parse_csv_floats(f"{p}TAIL_PCT_MIN", [0.001, 0.005]), + "max_rec_3m": _parse_csv_floats(f"{p}MAX_REC_3M", [0.85, 0.90]), + "shoulder_min_high": _parse_csv_floats( + f"{p}SHOULDER_MIN_HIGH", [0.003, 0.005, 0.007], + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}SHOULDER_CUT_PCT", [0.001, 0.002], + ), + "stop_atr_mult": _parse_csv_floats(f"{p}STOP_ATR_MULT", [1.0, 1.5, 2.0]), + "target_atr_mult": _parse_csv_floats( + f"{p}TARGET_ATR_MULT", [1.0, 1.5, 2.5], + ), + "atr_sl_min_pct": _parse_csv_floats(f"{p}ATR_SL_MIN_PCT", [0.5, 0.8]), + "atr_sl_max_pct": _parse_csv_floats( + f"{p}ATR_SL_MAX_PCT", [2.0, 6.0], + ), + "atr_tp_min_pct": _parse_csv_floats(f"{p}ATR_TP_MIN_PCT", [0.3, 0.5]), + "atr_tp_max_pct": _parse_csv_floats( + f"{p}ATR_TP_MAX_PCT", [2.0, 2.5, 5.0], + ), + "tail_vol_mult": _parse_csv_floats(f"{p}VOL_MULT", [0.0, 1.5, 3.0]), + "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0]), + "limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 2.0]), "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", - ["off", "0.5:0.3,1.0:0.25,2.0:0.2", "1.0:0.4,2.0:0.3"], + [ + "off", + "0.5:0.3,1.0:0.25,2.0:0.2", # 실매 + "1.0:0.4,2.0:0.3", # wide1 Top + ], + ), + "symbol_daily_loss_limit_pct": _parse_csv_floats( + f"{p}SYMBOL_LOSS_PCT", [1.5, 2.0], + ), + "symbol_daily_loss_limit_krw": _parse_csv_floats( + f"{p}SYMBOL_LOSS_KRW", [30000.0, 50000.0], + ), + "reentry_min_edge_krw": _parse_csv_floats( + f"{p}REENTRY_MIN_EDGE", [0.0, 500.0], + ), + "reentry_require_nonneg": _parse_csv_bools( + f"{p}REENTRY_REQUIRE_NONNEG", [False], + ), + "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [20, 50]), + "cooldown_min": _parse_csv_floats(f"{p}COOLDOWN_MIN", [1.0, 5.0]), + "bar_chg_min_pct": _parse_csv_floats(f"{p}BAR_CHG_MIN", [-10.0, -5.0]), + "bar_chg_max_pct": _parse_csv_floats(f"{p}BAR_CHG_MAX", [-0.5]), + "rsi_threshold": _parse_csv_floats( + f"{p}RSI_THRESHOLD", [75.0, 85.0], + ), + "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False]), + "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [True]), + "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [True]), + "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.30, 0.45]), + "min_bid_ask_ratio": _parse_csv_floats( + f"{p}MIN_BID_ASK_RATIO", [0.85, 1.0], + ), + "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", ["off"]), + "trail_drop_pct": _parse_csv_floats(f"{p}TRAIL_DROP_PCT", [0.0, 35.0]), + "trail_arm_krw": _parse_csv_floats(f"{p}TRAIL_ARM_KRW", [5000.0]), + "max_loss_krw": _parse_csv_floats( + f"{p}MAX_LOSS_KRW", [150000.0, 200000.0], ), - # ── 과매매 제어: 종목당 일일 매수횟수 · 재진입 콜다운(분) ─────── - # 실매 정합 핵심 — 현재 DB(20회·1분)는 같은 종목 연타로 PF 왜곡. - # 파람서치가 "적게 사는 게 PF↑"를 찾으면 DB에 반영된다. - "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [2, 3, 5]), - "cooldown_min": _parse_csv_floats(f"{p}COOLDOWN_MIN", [3.0, 5.0, 15.0]), - # ── 진입 캔들 등락 필터(%) — 신호봉이 이 구간 안일 때만 매수 ───── - # bar_chg_min: 하한(과대 급락 제외) / bar_chg_max: 상한(최소 하락폭) - "bar_chg_min_pct": _parse_csv_floats(f"{p}BAR_CHG_MIN", [-10.0, -7.0]), - "bar_chg_max_pct": _parse_csv_floats(f"{p}BAR_CHG_MAX", [-1.0, -1.5, -2.0]), - # ── RSI 과열 진입 거부 기준(%) ──────────────────────────────── - "rsi_threshold": _parse_csv_floats(f"{p}RSI_THRESHOLD", [78.0, 82.0, 85.0]), - # ── 반전 캔들 패턴 ON/OFF (hammer 는 base 고정, 추가 패턴만 탐색) ─ - # pin: 핀바(긴 아래꼬리 단일봉, 꼬리잡기와 직결) - # engulfing/piercing: 2봉 반전 — 신호 다양성·표본 확대용 - "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), - "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), - "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), - # ── 호가필터(2단계 조인트) 축 — 기본 단일값=동작 불변 ────────── - # max_spread_pct : 스프레드 상한(%) ↑완화 - # min_bid_ask_ratio : 총매수/총매도 잔량비 하한(꼬리지지) ↓완화 - "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.45]), - "min_bid_ask_ratio": _parse_csv_floats(f"{p}MIN_BID_ASK_RATIO", [0.85]), - # ── 당일 누적손익 트레일 익절(%) — 0=OFF(기본·동작 불변) ───────── - # 고점 대비 N% 되돌리면 그날 신규매수 중단(실매 daily_profit_halt 동일). - # 일(日) 단위 이벤트라 표본=거래일 수 → 긴 기간에서만 유의미(강건 구간 검증). - # 스윕하려면 env TAIL_GRID_FAST_TRAIL_DROP_PCT=0,30,40 + DB - # DAILY_PROFIT_TRAIL_ARM_KRW(발동 최소수익) 설정 필요. - # ── 당일 누적손익 다단계 트레일(ratchet 철학) — 벌수록 타이트 ────── - # 'off'=트레일 미사용(기준군) / '수익원:컷%,…'=다단 trail. - # peak 가 높은 구간에 들수록 되돌림 컷%를 좁혀 이익 보존(워런 버핏식). - # tier 가 켜지면 단일 trail_drop_pct·arm 은 무시됨. 일(日)단위 표본. - # env TAIL_GRID_FAST_TRAIL_TIERS 로 세미콜론(;) 구분 override. - "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [ - "off", - "5000:40,10000:30,20000:20", # 보수: 일찍·타이트하게 사수 - "30000:50,100000:35,300000:25", # 중도 - "50000:60,200000:45,500000:30", # 공격: 많이 벌 때만 강하게 사수 - ]), - # 단일 트레일(tier='off' 일 때만 의미) — 하위호환·기준 비교용 단일값. - "trail_drop_pct": _parse_csv_floats(f"{p}TRAIL_DROP_PCT", [0.0]), - "trail_arm_krw": _parse_csv_floats(f"{p}TRAIL_ARM_KRW", [10000.0]), } if m == "coarse": + # [COARSE] wide1 Top 밴드 살짝 넓힘 (1차 스크리닝) return { - "max_daily_change": _parse_csv_floats(f"{p}MAX_DAILY_CHG", [15.0, 20.0, 25.0, 30.0]), - "min_drop_rate": _parse_csv_floats(f"{p}MIN_DROP_RATE", [0.02, 0.03]), - "min_recovery_ratio": _parse_csv_floats(f"{p}MIN_RECOVERY_RATIO", [0.4, 0.45, 0.5]), - "tail_ratio_min": _parse_csv_floats(f"{p}TAIL_RATIO_MIN", [1.0, 1.5]), - "max_rec_3m": _parse_csv_floats(f"{p}MAX_REC_3M", [0.85, 0.9]), - "shoulder_min_high": _parse_csv_floats(f"{p}SHOULDER_MIN_HIGH", [0.002, 0.003, 0.005]), - "shoulder_cut_pct": _parse_csv_floats(f"{p}SHOULDER_CUT_PCT", [0.002, 0.003]), - "stop_atr_mult": _parse_csv_floats(f"{p}STOP_ATR_MULT", [1.5, 2.0]), - "target_atr_mult": _parse_csv_floats(f"{p}TARGET_ATR_MULT", [1.5, 2.0]), - "atr_tp_max_pct": _parse_csv_floats(f"{p}ATR_TP_MAX_PCT", [0.8, 1.0, 1.2]), - "max_loss_krw": _parse_csv_floats(f"{p}MAX_LOSS_KRW", [100000.0, 200000.0]), - "limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0]), + "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), + "cand_limit": _parse_csv_ints( + f"{p}CAND_LIMIT", + sorted(set([0, 10, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), + ), + "max_daily_change": _parse_csv_floats( + f"{p}MAX_DAILY_CHG", [25.0, 30.0, 35.0, 40.0], + ), + "min_drop_rate": _parse_csv_floats( + f"{p}MIN_DROP_RATE", [0.015, 0.025, 0.03, 0.04, 0.06], + ), + "min_recovery_ratio": _parse_csv_floats( + f"{p}MIN_RECOVERY_RATIO", [0.10, 0.15, 0.20, 0.25, 0.30], + ), + "tail_ratio_min": _parse_csv_floats( + f"{p}TAIL_RATIO_MIN", [0.6, 0.8, 1.0, 1.5], + ), + "tail_pct_min": _parse_csv_floats( + f"{p}TAIL_PCT_MIN", [0.001, 0.002, 0.005], + ), + "max_rec_3m": _parse_csv_floats( + f"{p}MAX_REC_3M", [0.80, 0.85, 0.90, 0.95], + ), + "shoulder_min_high": _parse_csv_floats( + f"{p}SHOULDER_MIN_HIGH", [0.002, 0.003, 0.005, 0.007], + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.003], + ), + "stop_atr_mult": _parse_csv_floats( + f"{p}STOP_ATR_MULT", [1.0, 1.5, 2.0, 2.5], + ), + "target_atr_mult": _parse_csv_floats( + f"{p}TARGET_ATR_MULT", [1.0, 1.5, 2.0, 2.5], + ), + "atr_sl_min_pct": _parse_csv_floats(f"{p}ATR_SL_MIN_PCT", [0.5, 0.8]), + "atr_sl_max_pct": _parse_csv_floats( + f"{p}ATR_SL_MAX_PCT", [1.5, 2.0, 3.0, 6.0], + ), + "atr_tp_min_pct": _parse_csv_floats(f"{p}ATR_TP_MIN_PCT", [0.3, 0.5]), + "atr_tp_max_pct": _parse_csv_floats( + f"{p}ATR_TP_MAX_PCT", [2.0, 2.5, 3.0, 5.0], + ), + "tail_vol_mult": _parse_csv_floats( + f"{p}VOL_MULT", [0.0, 1.5, 2.0, 3.0], + ), + "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0]), + "limit_atr_mult": _parse_csv_floats( + f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0], + ), + "ratchet_tiers": _parse_csv_strings( + f"{p}RATCHET_TIERS", + [ + "off", + "0.5:0.3,1.0:0.25,2.0:0.2", + "1.0:0.4,2.0:0.3", + "0.3:0.4,0.8:0.3,1.5:0.2", + ], + ), + "symbol_daily_loss_limit_pct": _parse_csv_floats( + f"{p}SYMBOL_LOSS_PCT", [0.0, 1.5, 2.0], + ), + "symbol_daily_loss_limit_krw": _parse_csv_floats( + f"{p}SYMBOL_LOSS_KRW", [0.0, 30000.0, 50000.0], + ), + "reentry_min_edge_krw": _parse_csv_floats( + f"{p}REENTRY_MIN_EDGE", [0.0, 500.0], + ), + "reentry_require_nonneg": _parse_csv_bools( + f"{p}REENTRY_REQUIRE_NONNEG", [False, True], + ), + "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [10, 20, 50]), + "cooldown_min": _parse_csv_floats( + f"{p}COOLDOWN_MIN", [1.0, 3.0, 5.0, 10.0], + ), + "bar_chg_min_pct": _parse_csv_floats( + f"{p}BAR_CHG_MIN", [-10.0, -7.0, -5.0], + ), + "bar_chg_max_pct": _parse_csv_floats( + f"{p}BAR_CHG_MAX", [-0.5, -1.0, -1.5], + ), + "rsi_threshold": _parse_csv_floats( + f"{p}RSI_THRESHOLD", [75.0, 78.0, 85.0, 90.0], + ), + "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), + "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [True]), + "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [True]), + "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.30, 0.45]), + "min_bid_ask_ratio": _parse_csv_floats( + f"{p}MIN_BID_ASK_RATIO", [0.85, 1.0], + ), + "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [ + "off", + "5000:40,10000:30,20000:20", + ]), + "trail_drop_pct": _parse_csv_floats(f"{p}TRAIL_DROP_PCT", [0.0, 35.0]), + "trail_arm_krw": _parse_csv_floats(f"{p}TRAIL_ARM_KRW", [5000.0, 10000.0]), + "max_loss_krw": _parse_csv_floats( + f"{p}MAX_LOSS_KRW", [100000.0, 150000.0, 200000.0], + ), } - # fine / full / massive — 기존 PARAM_GRIDS 폴백 + env 축만 덮어쓰기 - base = dict(PARAM_GRIDS.get(m) or {}) - if not base: - return _tail_grids("fast") - overrides = { - "max_daily_change": _parse_csv_floats(f"{p}MAX_DAILY_CHG", base.get("max_daily_change", [20.0, 25.0])), - "limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0]), - } - for axis, env_suffix, fb_key in ( - ("min_drop_rate", "MIN_DROP_RATE", "min_drop_rate"), - ("min_recovery_ratio", "MIN_RECOVERY_RATIO", "min_recovery_ratio"), - ("tail_ratio_min", "TAIL_RATIO_MIN", "tail_ratio_min"), - ("max_rec_3m", "MAX_REC_3M", "max_rec_3m"), - ("shoulder_min_high", "SHOULDER_MIN_HIGH", "shoulder_min_high"), - ("shoulder_cut_pct", "SHOULDER_CUT_PCT", "shoulder_cut_pct"), - ("stop_atr_mult", "STOP_ATR_MULT", "stop_atr_mult"), - ("target_atr_mult", "TARGET_ATR_MULT", "target_atr_mult"), - ): - if fb_key in base or env_suffix: - overrides[axis] = _parse_csv_floats( - f"{p}{env_suffix}", - base.get(fb_key, overrides.get(axis, [0.03])), - ) - merged = dict(base) - merged.update(overrides) - return merged + if m == "fine": + # [FINE] 2026-07-15 wide1 Top 분지 재설계 (+16.6k plateau) + # Top: rec0.1 · ratchet 1.0:0.4,2.0:0.3 · atr_tp 2.5~5 · drop0.03/0.015 + # · stop1.0~1.5 · target1.0~2.5 · atr_sl_max2 · trail off · align · cand0 + # 실매 포함: rec0.25 · atr_tp2 · atr_sl_max6 · 실매래칫 · sh0.005·0.002 + # · rsi85 · bar=-10~-0.5 · cd5 · daily20 · max_loss20만 + # wide2 느슨한 래칫·강한 trail 은 제외 (악화 원인) + return { + "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align"]), + "cand_limit": _parse_csv_ints( + f"{p}CAND_LIMIT", + sorted(set([0, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), + ), + "max_daily_change": _parse_csv_floats( + f"{p}MAX_DAILY_CHG", [25.0, 30.0, 35.0, 40.0], + ), + "min_drop_rate": _parse_csv_floats( + f"{p}MIN_DROP_RATE", [0.015, 0.025, 0.03, 0.04, 0.06], + ), + "min_recovery_ratio": _parse_csv_floats( + f"{p}MIN_RECOVERY_RATIO", [0.10, 0.15, 0.20, 0.25, 0.30], + ), + "tail_ratio_min": _parse_csv_floats( + f"{p}TAIL_RATIO_MIN", [0.6, 0.8, 1.0, 1.2], + ), + "tail_pct_min": _parse_csv_floats( + f"{p}TAIL_PCT_MIN", [0.001, 0.002, 0.005], + ), + "max_rec_3m": _parse_csv_floats( + f"{p}MAX_REC_3M", [0.80, 0.85, 0.90, 0.95], + ), + "shoulder_min_high": _parse_csv_floats( + f"{p}SHOULDER_MIN_HIGH", [0.003, 0.005, 0.007, 0.01], + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.003], + ), + "stop_atr_mult": _parse_csv_floats( + f"{p}STOP_ATR_MULT", [1.0, 1.5, 2.0, 2.5], + ), + "target_atr_mult": _parse_csv_floats( + f"{p}TARGET_ATR_MULT", [1.0, 1.5, 2.0, 2.5, 3.0], + ), + "atr_sl_min_pct": _parse_csv_floats( + f"{p}ATR_SL_MIN_PCT", [0.5, 0.8], + ), + "atr_sl_max_pct": _parse_csv_floats( + f"{p}ATR_SL_MAX_PCT", [1.5, 2.0, 3.0, 6.0], + ), + "atr_tp_min_pct": _parse_csv_floats( + f"{p}ATR_TP_MIN_PCT", [0.3, 0.5], + ), + "atr_tp_max_pct": _parse_csv_floats( + f"{p}ATR_TP_MAX_PCT", [2.0, 2.5, 3.0, 4.0, 5.0], + ), + "tail_vol_mult": _parse_csv_floats( + f"{p}VOL_MULT", [0.0, 1.5, 2.0, 3.0], + ), + "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0]), + "limit_atr_mult": _parse_csv_floats( + f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0], + ), + "ratchet_tiers": _parse_csv_strings( + f"{p}RATCHET_TIERS", + [ + "off", + "0.5:0.3,1.0:0.25,2.0:0.2", + "1.0:0.4,2.0:0.3", + "0.3:0.4,0.8:0.3,1.5:0.2", + ], + ), + "symbol_daily_loss_limit_pct": _parse_csv_floats( + f"{p}SYMBOL_LOSS_PCT", [0.0, 1.5, 2.0], + ), + "symbol_daily_loss_limit_krw": _parse_csv_floats( + f"{p}SYMBOL_LOSS_KRW", [0.0, 30000.0, 50000.0], + ), + "reentry_min_edge_krw": _parse_csv_floats( + f"{p}REENTRY_MIN_EDGE", [0.0, 500.0], + ), + "reentry_require_nonneg": _parse_csv_bools( + f"{p}REENTRY_REQUIRE_NONNEG", [False], + ), + "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [10, 20, 50]), + "cooldown_min": _parse_csv_floats( + f"{p}COOLDOWN_MIN", [1.0, 3.0, 5.0, 10.0], + ), + "bar_chg_min_pct": _parse_csv_floats( + f"{p}BAR_CHG_MIN", [-10.0, -5.0], + ), + "bar_chg_max_pct": _parse_csv_floats( + f"{p}BAR_CHG_MAX", [-0.5, -1.0], + ), + "rsi_threshold": _parse_csv_floats( + f"{p}RSI_THRESHOLD", [75.0, 78.0, 85.0, 90.0], + ), + "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), + "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [True]), + "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [True]), + "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.30, 0.45]), + "min_bid_ask_ratio": _parse_csv_floats( + f"{p}MIN_BID_ASK_RATIO", [0.85, 1.0], + ), + "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [ + "off", + "5000:40,10000:30,20000:20", + ]), + "trail_drop_pct": _parse_csv_floats( + f"{p}TRAIL_DROP_PCT", [0.0, 35.0], + ), + "trail_arm_krw": _parse_csv_floats( + f"{p}TRAIL_ARM_KRW", [5000.0, 10000.0], + ), + "max_loss_krw": _parse_csv_floats( + f"{p}MAX_LOSS_KRW", [100000.0, 150000.0, 200000.0], + ), + } + + if m == "full": + # fine 전축 + 더 촘촘·넓·다양 + return { + "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), + # full+ 전용 — apply 시 DB 미반영(운영 false 고정) + "skip_hts_scan_dupes": _parse_csv_bools( + f"{p}SKIP_HTS", [False, True], + ), + "cand_limit": _parse_csv_ints( + f"{p}CAND_LIMIT", [get_env_int("SHORT_CAND_LIMIT", 0)], + ), + "max_daily_change": _parse_csv_floats( + f"{p}MAX_DAILY_CHG", [5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0], + ), + "min_drop_rate": _parse_csv_floats( + f"{p}MIN_DROP_RATE", + [0.01, 0.015, 0.02, 0.025, 0.03, 0.035, 0.04, 0.05, 0.06], + ), + "min_recovery_ratio": _parse_csv_floats( + f"{p}MIN_RECOVERY_RATIO", + [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.60, 0.70], + ), + "tail_ratio_min": _parse_csv_floats( + f"{p}TAIL_RATIO_MIN", [0.5, 0.6, 0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0], + ), + "tail_pct_min": _parse_csv_floats( + f"{p}TAIL_PCT_MIN", [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008], + ), + "max_rec_3m": _parse_csv_floats( + f"{p}MAX_REC_3M", + [0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.98], + ), + "shoulder_min_high": _parse_csv_floats( + f"{p}SHOULDER_MIN_HIGH", + [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007, 0.01], + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}SHOULDER_CUT_PCT", + [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007], + ), + "stop_atr_mult": _parse_csv_floats( + f"{p}STOP_ATR_MULT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + ), + "target_atr_mult": _parse_csv_floats( + f"{p}TARGET_ATR_MULT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], + ), + "atr_sl_min_pct": _parse_csv_floats( + f"{p}ATR_SL_MIN_PCT", [0.3, 0.5, 0.8, 1.0], + ), + "atr_sl_max_pct": _parse_csv_floats( + f"{p}ATR_SL_MAX_PCT", [0.8, 1.0, 1.2, 1.5, 2.0, 3.0], + ), + "atr_tp_min_pct": _parse_csv_floats( + f"{p}ATR_TP_MIN_PCT", [0.2, 0.3, 0.5, 0.8, 1.0], + ), + "atr_tp_max_pct": _parse_csv_floats( + f"{p}ATR_TP_MAX_PCT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0], + ), + "tail_vol_mult": _parse_csv_floats( + f"{p}VOL_MULT", [0.0, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], + ), + "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0, 8.0, 10.0]), + "limit_atr_mult": _parse_csv_floats( + f"{p}LIMIT_ATR_MULT", [0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + ), + "ratchet_tiers": _parse_csv_strings( + f"{p}RATCHET_TIERS", + [ + "off", + "0.5:0.3,1.0:0.25,2.0:0.2", + "1.0:0.4,2.0:0.3", + "0.3:0.4,0.8:0.3,1.5:0.2", + "0.8:0.35,1.5:0.25,3.0:0.15", + ], + ), + "symbol_daily_loss_limit_pct": _parse_csv_floats( + f"{p}SYMBOL_LOSS_PCT", [0.0, 0.5, 1.0, 1.5, 2.0, 3.0], + ), + "symbol_daily_loss_limit_krw": _parse_csv_floats( + f"{p}SYMBOL_LOSS_KRW", [0.0, 10000.0, 15000.0, 30000.0, 50000.0, 80000.0], + ), + "reentry_min_edge_krw": _parse_csv_floats( + f"{p}REENTRY_MIN_EDGE", [0.0, 300.0, 500.0, 1000.0, 2000.0, 3000.0], + ), + "reentry_require_nonneg": _parse_csv_bools( + f"{p}REENTRY_REQUIRE_NONNEG", [False, True], + ), + "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [5, 10, 20, 30, 50]), + "cooldown_min": _parse_csv_floats( + f"{p}COOLDOWN_MIN", [0.0, 1.0, 3.0, 5.0, 10.0, 15.0, 30.0], + ), + "bar_chg_min_pct": _parse_csv_floats( + f"{p}BAR_CHG_MIN", [-15.0, -12.0, -10.0, -7.0, -5.0, -3.0], + ), + "bar_chg_max_pct": _parse_csv_floats( + f"{p}BAR_CHG_MAX", [-0.3, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0], + ), + "rsi_threshold": _parse_csv_floats( + f"{p}RSI_THRESHOLD", [70.0, 72.0, 75.0, 78.0, 82.0, 85.0, 90.0, 95.0], + ), + "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), + "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), + "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), + "max_spread_pct": _parse_csv_floats( + f"{p}MAX_SPREAD_PCT", [0.20, 0.30, 0.45, 0.60, 0.80], + ), + "min_bid_ask_ratio": _parse_csv_floats( + f"{p}MIN_BID_ASK_RATIO", [0.50, 0.70, 0.85, 1.0, 1.2], + ), + "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [ + "off", + "5000:40,10000:30,20000:20", + "30000:50,100000:35,300000:25", + "50000:60,200000:45,500000:30", + "10000:50,50000:40,200000:30", + ]), + "trail_drop_pct": _parse_csv_floats( + f"{p}TRAIL_DROP_PCT", [0.0, 20.0, 25.0, 35.0, 45.0, 55.0], + ), + "trail_arm_krw": _parse_csv_floats( + f"{p}TRAIL_ARM_KRW", [0.0, 5000.0, 10000.0, 20000.0, 30000.0], + ), + "max_loss_krw": _parse_csv_floats( + f"{p}MAX_LOSS_KRW", + [30000.0, 50000.0, 100000.0, 150000.0, 200000.0, 300000.0, 500000.0], + ), + } + + if m == "wide": + # 축 스크리닝 v2 — 1차 wide(+16k/5건 plateau, rec=0.1 고착)가 좁아서 확장 + # Optuna ~100 trial · 7/15 · 실매 앵커 유지 · skip_hts 스윕 안 함 + # 실매: drop0.03 / rec0.25 / atr_tp_max=2 / stop2 / target1.5 / + # atr_sl 0.8~6.0 / ratchet=0.5:0.3,… / sh0.005·0.002 / rsi85 / + # bar=-10~-0.5 / cd5 / daily20 / cand20 / entry=align / max_loss=20만 + return { + "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), + "cand_limit": _parse_csv_ints( + f"{p}CAND_LIMIT", + sorted(set([0, 5, 10, 20, 30, get_env_int("SHORT_CAND_LIMIT", 0)])), + ), + "max_daily_change": _parse_csv_floats( + f"{p}MAX_DAILY_CHG", + [5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0, 60.0], + ), + "min_drop_rate": _parse_csv_floats( + f"{p}MIN_DROP_RATE", + [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.08], + ), + # Top20 전부 rec=0.1 이었음 → 0.05 추가 + 실매 0.25 유지 + 상단 확대 + "min_recovery_ratio": _parse_csv_floats( + f"{p}MIN_RECOVERY_RATIO", + [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.60, 0.70], + ), + "tail_ratio_min": _parse_csv_floats( + f"{p}TAIL_RATIO_MIN", + [0.4, 0.5, 0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], + ), + "tail_pct_min": _parse_csv_floats( + f"{p}TAIL_PCT_MIN", [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008], + ), + "max_rec_3m": _parse_csv_floats( + f"{p}MAX_REC_3M", + [0.50, 0.60, 0.70, 0.80, 0.85, 0.90, 0.95, 0.98], + ), + "shoulder_min_high": _parse_csv_floats( + f"{p}SHOULDER_MIN_HIGH", + [0.001, 0.0015, 0.002, 0.003, 0.005, 0.007, 0.01, 0.015], + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}SHOULDER_CUT_PCT", + [0.001, 0.0015, 0.002, 0.003, 0.004, 0.005, 0.007, 0.01], + ), + "stop_atr_mult": _parse_csv_floats( + f"{p}STOP_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], + ), + "target_atr_mult": _parse_csv_floats( + f"{p}TARGET_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], + ), + "atr_sl_min_pct": _parse_csv_floats( + f"{p}ATR_SL_MIN_PCT", [0.3, 0.5, 0.8, 1.0, 1.2], + ), + # 실매 atr_sl_max=6.0 필수 + "atr_sl_max_pct": _parse_csv_floats( + f"{p}ATR_SL_MAX_PCT", [0.8, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0], + ), + "atr_tp_min_pct": _parse_csv_floats( + f"{p}ATR_TP_MIN_PCT", [0.2, 0.3, 0.5, 0.8, 1.0], + ), + # 1차 Top이 atr_tp=2.5~5 고착 → 천장 더 열어 익절 여유 탐색 + "atr_tp_max_pct": _parse_csv_floats( + f"{p}ATR_TP_MAX_PCT", + [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], + ), + "tail_vol_mult": _parse_csv_floats( + f"{p}VOL_MULT", [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], + ), + "tail_vol_win": _parse_csv_floats( + f"{p}VOL_WIN", [3.0, 5.0, 8.0, 10.0, 15.0], + ), + "limit_atr_mult": _parse_csv_floats( + f"{p}LIMIT_ATR_MULT", [0.3, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + ), + # 느슨한 래칫(이익 더 끌고) + OFF + 실매 tier + "ratchet_tiers": _parse_csv_strings( + f"{p}RATCHET_TIERS", + [ + "off", + "0.5:0.3,1.0:0.25,2.0:0.2", + "1.0:0.4,2.0:0.3", + "0.3:0.4,0.8:0.3,1.5:0.2", + "0.8:0.35,1.5:0.25,3.0:0.15", + "1.5:0.5,3.0:0.35,5.0:0.25", + "2.0:0.5,4.0:0.35,6.0:0.2", + ], + ), + "symbol_daily_loss_limit_pct": _parse_csv_floats( + f"{p}SYMBOL_LOSS_PCT", [0.0, 0.5, 1.0, 1.5, 2.0, 3.0], + ), + "symbol_daily_loss_limit_krw": _parse_csv_floats( + f"{p}SYMBOL_LOSS_KRW", + [0.0, 10000.0, 15000.0, 30000.0, 50000.0, 80000.0], + ), + "reentry_min_edge_krw": _parse_csv_floats( + f"{p}REENTRY_MIN_EDGE", [0.0, 300.0, 500.0, 1000.0, 2000.0, 3000.0], + ), + "reentry_require_nonneg": _parse_csv_bools( + f"{p}REENTRY_REQUIRE_NONNEG", [False, True], + ), + "max_daily": _parse_csv_ints( + f"{p}MAX_DAILY", [5, 10, 20, 30, 50, 80, 100], + ), + "cooldown_min": _parse_csv_floats( + f"{p}COOLDOWN_MIN", [0.0, 1.0, 3.0, 5.0, 10.0, 15.0, 30.0], + ), + "bar_chg_min_pct": _parse_csv_floats( + f"{p}BAR_CHG_MIN", [-20.0, -15.0, -12.0, -10.0, -7.0, -5.0, -3.0], + ), + "bar_chg_max_pct": _parse_csv_floats( + f"{p}BAR_CHG_MAX", [-0.3, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0], + ), + "rsi_threshold": _parse_csv_floats( + f"{p}RSI_THRESHOLD", + [70.0, 72.0, 75.0, 78.0, 82.0, 85.0, 90.0, 95.0], + ), + "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), + "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), + "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), + "max_spread_pct": _parse_csv_floats( + f"{p}MAX_SPREAD_PCT", [0.20, 0.30, 0.45, 0.60, 0.80], + ), + "min_bid_ask_ratio": _parse_csv_floats( + f"{p}MIN_BID_ASK_RATIO", [0.50, 0.70, 0.85, 1.0, 1.2], + ), + "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [ + "off", + "5000:40,10000:30,20000:20", + "30000:50,100000:35,300000:25", + "50000:60,200000:45,500000:30", + ]), + "trail_drop_pct": _parse_csv_floats( + f"{p}TRAIL_DROP_PCT", [0.0, 20.0, 25.0, 35.0, 45.0, 55.0], + ), + "trail_arm_krw": _parse_csv_floats( + f"{p}TRAIL_ARM_KRW", [0.0, 3000.0, 5000.0, 10000.0, 20000.0, 30000.0], + ), + "max_loss_krw": _parse_csv_floats( + f"{p}MAX_LOSS_KRW", + [30000.0, 50000.0, 100000.0, 150000.0, 200000.0, 300000.0, 500000.0], + ), + } + + if m == "massive": + # full 전축 + 최대 폭 (탐색 공간 최대) + return { + "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), + "skip_hts_scan_dupes": _parse_csv_bools( + f"{p}SKIP_HTS", [False, True], + ), + "cand_limit": _parse_csv_ints( + f"{p}CAND_LIMIT", + sorted(set([0, 10, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), + ), + "max_daily_change": _parse_csv_floats( + f"{p}MAX_DAILY_CHG", [5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0], + ), + "min_drop_rate": _parse_csv_floats( + f"{p}MIN_DROP_RATE", + [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.035, 0.04, 0.05, 0.06, 0.08], + ), + "min_recovery_ratio": _parse_csv_floats( + f"{p}MIN_RECOVERY_RATIO", + [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.60, 0.70, 0.80], + ), + "tail_ratio_min": _parse_csv_floats( + f"{p}TAIL_RATIO_MIN", + [0.4, 0.5, 0.6, 0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0], + ), + "tail_pct_min": _parse_csv_floats( + f"{p}TAIL_PCT_MIN", [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008, 0.01], + ), + "max_rec_3m": _parse_csv_floats( + f"{p}MAX_REC_3M", + [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.98], + ), + "shoulder_min_high": _parse_csv_floats( + f"{p}SHOULDER_MIN_HIGH", + [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007, 0.01, 0.015], + ), + "shoulder_cut_pct": _parse_csv_floats( + f"{p}SHOULDER_CUT_PCT", + [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007, 0.01], + ), + "stop_atr_mult": _parse_csv_floats( + f"{p}STOP_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], + ), + "target_atr_mult": _parse_csv_floats( + f"{p}TARGET_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], + ), + "atr_sl_min_pct": _parse_csv_floats( + f"{p}ATR_SL_MIN_PCT", [0.3, 0.5, 0.8, 1.0, 1.2], + ), + "atr_sl_max_pct": _parse_csv_floats( + f"{p}ATR_SL_MAX_PCT", [0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 4.0], + ), + "atr_tp_min_pct": _parse_csv_floats( + f"{p}ATR_TP_MIN_PCT", [0.2, 0.3, 0.5, 0.8, 1.0, 1.5], + ), + "atr_tp_max_pct": _parse_csv_floats( + f"{p}ATR_TP_MAX_PCT", + [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], + ), + "tail_vol_mult": _parse_csv_floats( + f"{p}VOL_MULT", [0.0, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], + ), + "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0, 8.0, 10.0, 15.0]), + "limit_atr_mult": _parse_csv_floats( + f"{p}LIMIT_ATR_MULT", [0.3, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + ), + "ratchet_tiers": _parse_csv_strings( + f"{p}RATCHET_TIERS", + [ + "off", + "0.5:0.3,1.0:0.25,2.0:0.2", + "1.0:0.4,2.0:0.3", + "0.3:0.4,0.8:0.3,1.5:0.2", + "0.8:0.35,1.5:0.25,3.0:0.15", + ], + ), + "symbol_daily_loss_limit_pct": _parse_csv_floats( + f"{p}SYMBOL_LOSS_PCT", [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0], + ), + "symbol_daily_loss_limit_krw": _parse_csv_floats( + f"{p}SYMBOL_LOSS_KRW", + [0.0, 10000.0, 15000.0, 30000.0, 50000.0, 80000.0, 100000.0], + ), + "reentry_min_edge_krw": _parse_csv_floats( + f"{p}REENTRY_MIN_EDGE", [0.0, 300.0, 500.0, 1000.0, 2000.0, 3000.0, 5000.0], + ), + "reentry_require_nonneg": _parse_csv_bools( + f"{p}REENTRY_REQUIRE_NONNEG", [False, True], + ), + "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [5, 10, 20, 30, 50, 100]), + "cooldown_min": _parse_csv_floats( + f"{p}COOLDOWN_MIN", [0.0, 1.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0], + ), + "bar_chg_min_pct": _parse_csv_floats( + f"{p}BAR_CHG_MIN", [-20.0, -15.0, -12.0, -10.0, -7.0, -5.0, -3.0], + ), + "bar_chg_max_pct": _parse_csv_floats( + f"{p}BAR_CHG_MAX", [-0.3, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -5.0], + ), + "rsi_threshold": _parse_csv_floats( + f"{p}RSI_THRESHOLD", [65.0, 70.0, 72.0, 75.0, 78.0, 82.0, 85.0, 90.0, 95.0], + ), + "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), + "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), + "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), + "max_spread_pct": _parse_csv_floats( + f"{p}MAX_SPREAD_PCT", [0.15, 0.20, 0.30, 0.45, 0.60, 0.80, 1.0], + ), + "min_bid_ask_ratio": _parse_csv_floats( + f"{p}MIN_BID_ASK_RATIO", [0.40, 0.50, 0.70, 0.85, 1.0, 1.2, 1.5], + ), + "trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [ + "off", + "5000:40,10000:30,20000:20", + "30000:50,100000:35,300000:25", + "50000:60,200000:45,500000:30", + "10000:50,50000:40,200000:30", + ]), + "trail_drop_pct": _parse_csv_floats( + f"{p}TRAIL_DROP_PCT", [0.0, 15.0, 20.0, 25.0, 35.0, 45.0, 55.0], + ), + "trail_arm_krw": _parse_csv_floats( + f"{p}TRAIL_ARM_KRW", [0.0, 3000.0, 5000.0, 10000.0, 20000.0, 30000.0], + ), + "max_loss_krw": _parse_csv_floats( + f"{p}MAX_LOSS_KRW", + [30000.0, 50000.0, 100000.0, 150000.0, 200000.0, 300000.0, 500000.0], + ), + "ma20_max_above": _parse_csv_floats(f"{p}MA20_MAX_ABOVE", [2.0, 3.0, 5.0, 8.0]), + } + + # 알 수 없는 mode → fast + return _tail_grids("fast") # ───────────────────────────────────────────────────────────────────────────── -# 파라미터 그리드 정의 (fine/full/massive 폴백 — fast/coarse 는 _tail_grids) +# 레거시 PARAM_GRIDS (미사용 — coarse/fine/wide/full/massive 는 _tail_grids 전용) # ───────────────────────────────────────────────────────────────────────────── -PARAM_GRIDS = { - # ───────────────────────────────────────────────────────────────────── - # [FAST] ~10–15분 — 포트폴리오 ON · limit_atr_mult×3 포함 시 ~768조합 (2^8×3) - # 진입 4축 + 청산 4축, atr_tp_max·회복률 3단계는 DB base 고정 (coarse에서 탐색) - # ───────────────────────────────────────────────────────────────────── - "fine": { - "min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04], - "min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6], - "tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0], - "tail_pct_min": [0.001, 0.002, 0.003, 0.005], - "stop_atr_mult": [1.5, 2.0, 2.5], - "target_atr_mult": [1.5, 2.0, 2.5, 3.0], - "atr_sl_min_pct": [0.5, 0.8], - "atr_sl_max_pct": [0.8, 1.0, 1.2], - "atr_tp_min_pct": [0.3, 0.5, 0.8], - "atr_tp_max_pct": [0.8, 1.0, 1.2], - "shoulder_min_high": [0.002, 0.0025, 0.003, 0.004, 0.005], - "shoulder_cut_pct": [0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005], - "max_rec_3m": [0.85, 0.9, 0.95], - "sl_pct": [0.015, 0.02, 0.025], - "tp_pct": [0.03, 0.04, 0.05], - "rsi_threshold": [72, 75, 78, 82], - "max_loss_krw": [150000, 200000], - "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], - }, - "full": { - "min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05], - "min_recovery_ratio": [0.35, 0.4, 0.5, 0.6], - "tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0], - "tail_pct_min": [0.001, 0.002, 0.003], - "stop_atr_mult": [1.5, 2.0, 2.5, 3.0], - "target_atr_mult": [1.5, 2.0, 2.5, 3.0], - "atr_sl_min_pct": [0.5, 0.8], - "atr_sl_max_pct": [0.8, 1.0, 1.2, 1.5], - "atr_tp_min_pct": [0.3, 0.5, 0.8], - "atr_tp_max_pct": [0.8, 1.0, 1.2, 1.5], - "shoulder_min_high": [0.002, 0.003, 0.004, 0.005, 0.007], - "shoulder_cut_pct": [0.0015, 0.002, 0.003, 0.004, 0.005], - "max_rec_3m": [0.85, 0.9, 0.95], - "sl_pct": [0.015, 0.02, 0.025, 0.03], - "tp_pct": [0.03, 0.04, 0.05, 0.06], - "ma20_max_above": [3.0, 5.0], - "max_daily_change": [15.0, 20.0, 25.0], - "max_loss_krw": [100000, 200000], - "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025], - }, - "massive": { - "min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05], - "min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6], - "tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0], - "tail_pct_min": [0.001, 0.002, 0.003, 0.005], - "stop_atr_mult": [1.5, 2.0, 2.5, 3.0], - "target_atr_mult": [1.5, 2.0, 2.5, 3.0], - "atr_sl_min_pct": [0.5, 0.8, 1.0], - "atr_sl_max_pct": [0.8, 1.0, 1.2, 1.5], - "atr_tp_min_pct": [0.3, 0.5, 0.8], - "atr_tp_max_pct": [0.8, 1.0, 1.2, 1.5], - "shoulder_min_high": [0.002, 0.0025, 0.003, 0.004, 0.005, 0.007], - "shoulder_cut_pct": [0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005], - "max_rec_3m": [0.85, 0.9, 0.95], - "ma20_max_above": [2.0, 3.0, 5.0], - "max_daily_change": [15.0, 20.0, 25.0], - "sl_pct": [0.015, 0.02, 0.025, 0.03], - "tp_pct": [0.03, 0.04, 0.05, 0.06], - "risk_pct": [0.005, 0.01, 0.02], - "kelly_mult": [0.15, 0.25, 0.5], - "max_loss_krw": [100000, 200000], - "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], - }, +PARAM_GRIDS: Dict[str, Dict[str, List[Any]]] = { } @@ -497,7 +1032,11 @@ TAIL_GRID_AXIS_HINTS_KO: Dict[str, str] = { "trail_tiers": "당일손익 다단 트레일 'off'/'수익원:컷%,…' — 벌수록 컷% 타이트(이익 보존), 일단위 표본", "trail_drop_pct": "당일손익 단일 트레일(고점대비 %, 0=OFF) — tier='off' 일 때만 의미", "trail_arm_krw": "단일 트레일 발동 최소수익(원) — tier='off' & drop>0 일 때만 의미", - "max_daily": "종목당 일일 매수 횟수 상한 (낮을수록 과매매↓·실매정합↑)", + "max_daily": "종목당 일일 매수 안전판(무한루프 방지, 기본 20)", + "symbol_daily_loss_limit_pct": "종목당 당일 실현손익 손실한도(%·양수, slot 대비) — 재진입 차단", + "symbol_daily_loss_limit_krw": "종목당 당일 실현손익 손실한도(원·양수) — KRW·% 중 먼저 닿는 쪽", + "reentry_min_edge_krw": "재진입 최소 당일 누적순익(원) — 2회째부터, 수수료 감안 엣지", + "reentry_require_nonneg": "재진입 시 당일 누적순익≥0 필수 — OFF=엣지(원)만 검사", "cooldown_min": "청산 후 동일종목 재진입 콜다운(분) — 연타 방지", "bar_chg_min_pct": "진입 신호봉 등락 하한(%) — 과대 급락 제외", "bar_chg_max_pct": "진입 신호봉 등락 상한(%) — 최소 하락폭 요구", @@ -508,6 +1047,8 @@ TAIL_GRID_AXIS_HINTS_KO: Dict[str, str] = { "max_loss_krw": "1회 최대 금액 손실(원, 트레일 미발동 시)", "limit_atr_mult": "ATR 지정가 깊이(anchor−ATR×배수) — limit_atr 전용, 클수록 체결↓", "entry_mode": "진입: align=다음봉 시가 / limit_atr=지정가(틱 체결 재생)", + "skip_hts_scan_dupes": "TRIGGER HTS A(봉등락) 재검사 생략 여부 — 실매 DB와 정합 검증용", + "cand_limit": "실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 매수체크 (0=무제한)", "max_daily_change": "급등주 필터(당일 등락률 상한, %)", "trail_pct": "보조 트레일 폭(고점 대비, 0=OFF). 래칫 ON이면 1단계보다 arm이 커야 효과", "trail_arm_pct": "보조 트레일 무장: 진입×(1+arm) 도달 후 (0=즉시)", @@ -748,8 +1289,8 @@ def run_search( "적용" if _ob_filter_on else "스킵 — 코어 파라미터 순수 탐색", ) - row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() - r = dict(row) if row else {} + from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row + r = load_portfolio_env_row(db) fee_rate, sell_tax, _slot_from_fee = tbc.fee_and_slot_from_env_row(r) portfolio = tbc.resolve_tail_portfolio_params( r, @@ -798,15 +1339,23 @@ def run_search( base_params = dict(base_params) base_params["scan_interval_min"] = scan_interval_min base_params["timeframe"] = tail_tf - base_params.setdefault("backtest_tick_fallback_ohlc", True) + from kis_trader.engine.tail_tick_replay import ( + tail_backtest_tick_fallback_ohlc as _tail_tick_fallback, + tail_backtest_use_tick_db as _tail_use_tick, + tail_backtest_use_tick_exit as _tail_use_tick_exit, + ) + base_params.setdefault("backtest_tick_fallback_ohlc", _tail_tick_fallback(None)) # ── 틱재생(ws_ticks) — 기본 ON (실매 체결 정합) ────────────────────── # 3분봉 OHLC(open→high→low→close 가정)는 손절보다 익절을 먼저 체결하는 # 낙관 편향을 만든다. 모멘텀·돌파와 동일하게 tail_tick_replay 기본값(ON)을 # 그대로 따르되, params/env(TAIL_BACKTEST_USE_TICK_DB)로 오버라이드 가능. - from kis_trader.engine.tail_tick_replay import tail_backtest_use_tick_db as _tail_use_tick base_params.setdefault("backtest_use_tick_db", _tail_use_tick(None)) - if base_params.get("backtest_use_tick_db"): - logger.info("📌 틱재생(ws_ticks): ON — 실매 체결 정합 모드 (OHLC 낙관편향 제거)") + base_params.setdefault("backtest_use_tick_exit", _tail_use_tick_exit(None)) + if base_params.get("backtest_use_tick_db") or base_params.get("backtest_use_tick_exit"): + if base_params.get("backtest_tick_fallback_ohlc"): + logger.info("📌 틱재생(ws_ticks): ON + OHLC 폴백 ON") + else: + logger.info("📌 틱재생(ws_ticks): ON — OHLC 폴백 OFF (틱 없는 구간 스킵)") logger.info( f"📅 데이터 로드: {start} ~ {end} | TF={tail_tf} | " @@ -1256,13 +1805,41 @@ def _count_tail_sell_reasons(trades: List[Dict[str, Any]]) -> Dict[str, int]: def _tail_params_to_env_map(p: Dict[str, Any]) -> Dict[str, str]: - """꼬리 파라서치·웹 save_config 와 동일 — TAIL_* 키만.""" + """꼬리 파라서치·웹 save_config 와 동일 — TAIL_* 키 + 당일손익 트레일(글로벌/SHORT).""" from kis_trader.engine.tail_env_keys import params_to_tail_env_patch - env_map = params_to_tail_env_patch(p) + + src = dict(p or {}) + # skip_hts_scan_dupes: full+ 그리드 탐색만, param_search apply 제외 + src.pop("skip_hts_scan_dupes", None) + env_map = params_to_tail_env_patch(src) if "risk_pct" in p: env_map["RISK_PCT_PER_TRADE"] = str(p["risk_pct"]) if "kelly_mult" in p: env_map["KELLY_MULTIPLIER"] = str(p["kelly_mult"]) + # 당일손익 트레일 — 백테 trail_tiers 축 → 실매 DAILY_PROFIT_* / SHORT_* 동기 + if "trail_tiers" in p: + tier = str(p.get("trail_tiers") or "").strip() + if tier.lower() == "off": + env_map["DAILY_PROFIT_TRAIL_TIERS"] = "" + env_map["SHORT_DAILY_PROFIT_TRAIL_TIERS"] = "" + else: + env_map["DAILY_PROFIT_TRAIL_TIERS"] = tier + env_map["SHORT_DAILY_PROFIT_TRAIL_TIERS"] = tier + env_map["DAILY_PROFIT_MODE"] = "trailing" + env_map["SHORT_DAILY_PROFIT_MODE"] = "trailing" + if "trail_drop_pct" in p and p.get("trail_drop_pct") is not None: + drop = max(0.0, float(p["trail_drop_pct"])) + env_map["DAILY_PROFIT_TRAIL_DROP_PCT"] = str(drop) + env_map["SHORT_DAILY_PROFIT_TRAIL_DROP_PCT"] = str(drop) + if drop > 0: + env_map["DAILY_PROFIT_MODE"] = "trailing" + env_map["SHORT_DAILY_PROFIT_MODE"] = "trailing" + if "trail_arm_krw" in p and p.get("trail_arm_krw") is not None: + arm = max(0.0, float(p["trail_arm_krw"])) + env_map["DAILY_PROFIT_TRAIL_ARM_KRW"] = str(int(arm)) + env_map["SHORT_DAILY_PROFIT_TRAIL_ARM_KRW"] = str(int(arm)) + from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch + env_map.update(orderbook_params_to_env_patch("TAIL", p)) return env_map @@ -1299,6 +1876,8 @@ def apply_from_json(apply_idx: int): merged = _merge_tail_apply_source(target, data) env_map = _tail_params_to_env_map(merged) + # 슬롯·동시보유·총한도 제외 — 파람서치가 운영 한도를 덮지 않음 + env_map = strip_portfolio_keys_from_apply_patch(env_map, "TAIL", "SHORT") db = TradeDB() try: @@ -1321,6 +1900,7 @@ def apply_params_to_db(p: dict): (_merge_tail_apply_source 결과 또는 apply_from_json 과 동일 형태) """ env_map = _tail_params_to_env_map(dict(p or {})) + env_map = strip_portfolio_keys_from_apply_patch(env_map, "TAIL", "SHORT") if not env_map: logger.warning("DB 적용할 env 키가 없습니다.") return @@ -1335,16 +1915,16 @@ def apply_params_to_db(p: dict): def main(): - today = datetime.now().strftime("%Y-%m-%d") - week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + from kis_trader.backtest.param_search_dates import resolve_param_search_range + week_ago, today = resolve_param_search_range("TAIL", lookback_days=7) parser = argparse.ArgumentParser(description="꼬리잡기 V3 백테스트 파라미터 최적화 (Grid Search)") - parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 기본 7일)") - parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)") + parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 기본 7일·거래일 보정)") + parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)") parser.add_argument("--timeframe", "--tf", default=3, type=int, dest="timeframe", help="ws_candles 분봉 3·5·15·60 (backtest_web 꼬리 탭과 동일, 기본 3)") add_portfolio_cli_args(parser) - parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine", "full", "massive"], + parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine", "wide", "full", "massive"], help="탐색 모드 (fast≈768조합·포트폴리오·~10–20분, coarse≈10k)") parser.add_argument("--max-combos", type=int, default=-1, help=f"백테 조합 상한 (기본 {DEFAULT_MAX_COMBOS}, 0=무제한·수억 조합 모드 비추)") diff --git a/kis_trader/backtest/tail_tick_loader.py b/kis_trader/backtest/tail_tick_loader.py index 99d9632..984726a 100644 --- a/kis_trader/backtest/tail_tick_loader.py +++ b/kis_trader/backtest/tail_tick_loader.py @@ -3,10 +3,16 @@ from __future__ import annotations from kis_trader.backtest.breakout_tick_loader import ( + enrich_tick_meta_with_traded_codes, load_breakout_ticks_by_code, tick_coverage_stats, ) -__all__ = ["load_breakout_ticks_by_code", "load_tail_ticks_by_code", "tick_coverage_stats"] +__all__ = [ + "enrich_tick_meta_with_traded_codes", + "load_breakout_ticks_by_code", + "load_tail_ticks_by_code", + "tick_coverage_stats", +] load_tail_ticks_by_code = load_breakout_ticks_by_code diff --git a/kis_trader/backtest/universe_timeline.py b/kis_trader/backtest/universe_timeline.py index b69d5f3..20bc69a 100644 --- a/kis_trader/backtest/universe_timeline.py +++ b/kis_trader/backtest/universe_timeline.py @@ -16,6 +16,31 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +def resolve_universe_exit_debounce_sec( + *, + strategy_env_key: Optional[str] = None, + default_when_no_grace: int = 0, +) -> int: + """실매 ``CONDITION_EXIT_GRACE_SEC`` 와 동일 초로 스냅샷 EXIT 디바운스. + + history 가 장초·overnight 전환에서 raw 축소로 찍혀도, 실매 EXIT grace 처럼 + 빠진 종목을 N초간 유지한다. 전략별 키가 있으면 그 값 우선(0=디바운스 OFF). + """ + from kis_trader.utils.env import get_env_from_db, get_env_int + + if strategy_env_key: + raw = get_env_from_db(strategy_env_key, None) + if raw not in (None, ""): + try: + return max(0, int(float(raw))) + except (TypeError, ValueError): + pass + grace = int(get_env_int("CONDITION_EXIT_GRACE_SEC", 0)) + if grace > 0: + return grace + return max(0, int(default_when_no_grace)) + + def _event_time_to_key(event_time: str) -> str: et = str(event_time or "") if len(et) < 19: diff --git a/kis_trader/database/db_manager.py b/kis_trader/database/db_manager.py index 8f080b1..0724e91 100644 --- a/kis_trader/database/db_manager.py +++ b/kis_trader/database/db_manager.py @@ -65,14 +65,23 @@ class TradeDBExt: submitted_at : 주문 접수 시각 filled_at : 체결 확인 시각 - 중복 차단은 ord_no(PK) 만 사용. + PK: 서로게이트 id(auto_increment). 중복 차단은 + (ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로 한정한다. (구) UNIQUE(strategy,code,side,ord_date) 는 당일 재매수·부분체결 재주문 시 HTS 에는 체결됐는데 orders/active_trades 미기록 버그 유발 → 제거. + + ※ ord_no 단독 PK 였던 과거엔 "다른 영업일·다른 전략" 주문이 같은 ODNO 를 + 받아도(모의투자 서버가 과거 이미 쓴 ODNO 를 재발급하는 사례 확인됨) DB가 + 전역 중복으로 오판 → insert 실패 → 그 매수 시도를 통째로 포기해 + active_trades 미기록(장마감 고아복구 전까지 손절 무방비) 사고가 발생했다. + 복합 UNIQUE 로 좁혀 "진짜 같은 날·같은 전략·같은 종목·같은 방향의 재전송"만 + 차단하고, 그 외 ODNO 재사용은 정상 신규 주문으로 기록되게 한다. """ try: self.conn.execute(""" CREATE TABLE IF NOT EXISTS orders ( - ord_no VARCHAR(30) NOT NULL PRIMARY KEY, + id BIGINT NOT NULL AUTO_INCREMENT, + ord_no VARCHAR(30) NOT NULL, strategy_id VARCHAR(40) NOT NULL, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT '', @@ -88,11 +97,14 @@ class TradeDBExt: submitted_at VARCHAR(30) NOT NULL, filled_at VARCHAR(30) DEFAULT NULL, raw_json MEDIUMTEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date), INDEX idx_strategy_date (strategy_id, ord_date), INDEX idx_code_date (code, ord_date) ) CHARACTER SET utf8mb4 """) self._migrate_orders_drop_daily_side_unique() + self._migrate_orders_pk_scope() logger.info("📊 orders 테이블 확인/생성 완료") except Exception as e: logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e) @@ -115,6 +127,41 @@ class TradeDBExt: except Exception as e: logger.debug("orders UNIQUE 마이그레이션 스킵: %s", e) + def _migrate_orders_pk_scope(self) -> None: + """기존(구버전) 테이블: ord_no 단독 PK → surrogate id PK + 복합 UNIQUE 로 전환. + + 모의투자 서버의 ODNO 재사용 버그로 다른 날짜/전략 주문이 같은 ODNO 를 + 받으면 종전 PK(ord_no 단독)에서는 무조건 "중복"으로 차단됐다. 이미 + 운영 중인 DB(구 스키마)를 새 스키마로 안전 전환한다. + """ + try: + cols = self.conn.get_columns("orders") + if "id" not in cols: + self.conn.execute( + "ALTER TABLE orders " + "ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT FIRST, " + "DROP PRIMARY KEY, " + "ADD PRIMARY KEY (id)" + ) + logger.info( + "✅ orders.id(surrogate PK) 추가 — ord_no 단독 PK 제거" + ) + rows = self.conn.execute( + "SHOW INDEX FROM orders WHERE Key_name = %s", + ("uq_ord_no_ctx",), + ).fetchall() + if not rows: + self.conn.execute( + "ALTER TABLE orders ADD UNIQUE INDEX uq_ord_no_ctx " + "(ord_no, strategy_id, code, side, ord_date)" + ) + logger.info( + "✅ orders.uq_ord_no_ctx 추가 — 중복 판정을 " + "(ord_no, strategy_id, code, side, ord_date) 로 한정" + ) + except Exception as e: + logger.warning("orders PK 범위 마이그레이션 실패(무시·폴백): %s", e) + # ------------------------------------------------------------------ # orders CRUD # ------------------------------------------------------------------ @@ -134,7 +181,10 @@ class TradeDBExt: raw_json: Optional[str] = None, ) -> bool: """ - 주문 기록 INSERT. PK(ord_no) 중복이면 False 반환 (서버단 차단). + 주문 기록 INSERT. + 중복 판정은 (ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로 + 한정 — 다른 날짜·다른 전략·다른 종목이 같은 ODNO 를 받아도(모의투자 + 서버 ODNO 재사용 버그) 오탐 차단하지 않는다. 실패 시 False 반환. side: 'BUY' | 'SELL' """ side = (side or "").upper() @@ -163,7 +213,8 @@ class TradeDBExt: msg = str(e) if "1062" in msg or "Duplicate entry" in msg: logger.warning( - "⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s (이미 DB 존재)", + "⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s " + "(같은 날짜·전략·종목·방향으로 이미 DB 존재)", strategy_id, code, side, ord_no, ) return False @@ -174,21 +225,31 @@ class TradeDBExt: self, *, ord_no: str, + strategy_id: str, + code: str, filled_qty: int, filled_avg_price: float, status: str = "FILLED", + ord_date: Optional[str] = None, ) -> bool: - """주문 체결 확인 후 체결가/체결수량/상태 갱신.""" - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """주문 체결 확인 후 체결가/체결수량/상태 갱신. + + strategy_id·code·ord_date 로 정확히 그 행만 갱신 — ODNO 재사용 시 + 다른 날짜/전략의 동일 ODNO 행을 잘못 덮어쓰는 사고 방지. + """ + now_dt = datetime.datetime.now() + now = now_dt.strftime("%Y-%m-%d %H:%M:%S") + od = ord_date or now_dt.strftime("%Y-%m-%d") try: with self.conn: self.conn.execute( """ UPDATE orders SET filled_qty=%s, filled_avg_price=%s, status=%s, filled_at=%s - WHERE ord_no=%s + WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s """, - (filled_qty, filled_avg_price, status, now, ord_no), + (filled_qty, filled_avg_price, status, now, + ord_no, strategy_id, code, od), ) return True except Exception as e: @@ -196,28 +257,46 @@ class TradeDBExt: return False def mark_order_rejected( - self, *, ord_no: str, msg_cd: str = "", msg1: str = "" + self, + *, + ord_no: str, + strategy_id: str, + code: str, + msg_cd: str = "", + msg1: str = "", + ord_date: Optional[str] = None, ) -> None: - """주문 실패/거부 시 상태 REJECTED 처리.""" + """주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date 로 행 한정).""" + od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d") try: with self.conn: self.conn.execute( """ UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s - WHERE ord_no=%s + WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s """, - (msg_cd, msg1, ord_no), + (msg_cd, msg1, ord_no, strategy_id, code, od), ) except Exception as e: logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e) - def update_order_status(self, *, ord_no: str, status: str) -> bool: - """체결 대기 등 — filled_qty 없이 status 만 갱신.""" + def update_order_status( + self, + *, + ord_no: str, + strategy_id: str, + code: str, + status: str, + ord_date: Optional[str] = None, + ) -> bool: + """체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date 로 행 한정).""" + od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d") try: with self.conn: self.conn.execute( - "UPDATE orders SET status=%s WHERE ord_no=%s", - (status, ord_no), + "UPDATE orders SET status=%s " + "WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s", + (status, ord_no, strategy_id, code, od), ) return True except Exception as e: @@ -301,11 +380,29 @@ class TradeDBExt: logger.error("get_pending_buy_order 실패 (%s/%s): %s", strategy_id, code, e) return None - def get_order_by_odno(self, ord_no: str) -> Optional[Dict]: + def get_order_by_odno( + self, + ord_no: str, + *, + strategy_id: Optional[str] = None, + code: Optional[str] = None, + ) -> Optional[Dict]: + """ODNO 로 주문 조회. ODNO 는 더 이상 전역 유일하지 않으므로 + (모의투자 ODNO 재사용 버그) strategy_id·code 를 함께 주면 정확히 + 그 행만, 안 주면 최신 순 1건을 반환한다.""" try: - row = self.conn.execute( - "SELECT * FROM orders WHERE ord_no=%s", (ord_no,) - ).fetchone() + if strategy_id and code: + row = self.conn.execute( + "SELECT * FROM orders WHERE ord_no=%s AND strategy_id=%s AND code=%s " + "ORDER BY submitted_at DESC LIMIT 1", + (ord_no, strategy_id, code), + ).fetchone() + else: + row = self.conn.execute( + "SELECT * FROM orders WHERE ord_no=%s " + "ORDER BY submitted_at DESC LIMIT 1", + (ord_no,), + ).fetchone() return dict(row) if row else None except Exception as e: logger.error("get_order_by_odno 실패: %s", e) @@ -437,7 +534,16 @@ class TradeDBExt: code = (it.get("code") or "").strip() if not code: continue - name = (it.get("name") or code)[:100] + raw_name = (it.get("name") or code)[:100] + name = raw_name + if not name or name == code: + try: + from kis_trader.utils.stock_name import resolve_stock_display_name + name = resolve_stock_display_name( + self, code, fallback=code, cache_to_meta=True, + )[:100] + except Exception: + name = code try: self.conn.execute( """ diff --git a/kis_trader/engine/candle_rollup.py b/kis_trader/engine/candle_rollup.py new file mode 100644 index 0000000..422da8d --- /dev/null +++ b/kis_trader/engine/candle_rollup.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +1분봉 → N분봉 재합성 (실매 갭보정 RAM · 백테/파람서치 공통). + +키움/집계기 관례: candle_time(YYYYMMDDHHMM) = 봉 **시작** 시각. +예) 3분봉 1315 = 13:15·16·17 1분봉 합산. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + + +def floor_candle_time_to_tf(candle_time: str, tf_min: int) -> str: + """분봉 시각을 tf_min 격자 시작 시각으로 내림.""" + raw = str(candle_time or "").strip()[:12] + if len(raw) < 12: + return "" + try: + dt0 = datetime.strptime(raw, "%Y%m%d%H%M") + except ValueError: + return "" + tf = max(1, int(tf_min)) + total = dt0.hour * 60 + dt0.minute + floored = (total // tf) * tf + nh, nm = divmod(floored, 60) + return f"{dt0.strftime('%Y%m%d')}{nh:02d}{nm:02d}" + + +def add_candle_minutes(candle_time: str, minutes: int) -> str: + """YYYYMMDDHHMM + minutes.""" + raw = str(candle_time or "").strip()[:12] + if len(raw) < 12: + return "" + try: + dt0 = datetime.strptime(raw, "%Y%m%d%H%M") + except ValueError: + return "" + from datetime import timedelta + return (dt0 + timedelta(minutes=int(minutes))).strftime("%Y%m%d%H%M") + + +def minute_diff(a: str, b: str) -> Optional[int]: + """b - a (분). 파싱 실패 시 None.""" + try: + da = datetime.strptime(str(a)[:12], "%Y%m%d%H%M") + db = datetime.strptime(str(b)[:12], "%Y%m%d%H%M") + except ValueError: + return None + return int((db - da).total_seconds() // 60) + + +def rollup_1m_bars_to_tf( + bars_1m: List[Dict[str, Any]], + tf_min: int = 3, +) -> List[Dict[str, Any]]: + """ + 1분봉 리스트(오래된→최신) → tf_min 분봉 OHLC 재합성. + + O=구간 첫 시가, H=max, L=min, C=마지막 종가, V=합. + 반환 candle_time = 구간 시작. source='rollup_1m'. + + **완전 버킷만 반환**: 구간 안 1분봉이 ``tf_min`` 개 모두 있을 때만 포함. + (갭보정 직후 진행 중 버킷을 불완전 volume 으로 넣으면 실매 vol 필터가 + DB·백테와 어긋남 — 2026-07-16 샘표 사례) + """ + tf = max(1, int(tf_min)) + if tf == 1: + out: List[Dict[str, Any]] = [] + for b in bars_1m or []: + ct = str(b.get("candle_time") or b.get("time") or "")[:12] + if len(ct) < 12: + continue + out.append({ + "candle_time": ct, + "open": float(b.get("open") or 0), + "high": float(b.get("high") or 0), + "low": float(b.get("low") or 0), + "close": float(b.get("close") or 0), + "volume": int(float(b.get("volume") or 0)), + "is_confirmed": 1, + "source": str(b.get("source") or "rollup_1m"), + }) + return out + + buckets: Dict[str, Dict[str, Any]] = {} + child_mins: Dict[str, set] = {} + order: List[str] = [] + for b in bars_1m or []: + ct = str(b.get("candle_time") or b.get("time") or "")[:12] + if len(ct) < 12: + continue + key = floor_candle_time_to_tf(ct, tf) + if not key: + continue + o = float(b.get("open") or 0) + h = float(b.get("high") or 0) + lo = float(b.get("low") or 0) + c = float(b.get("close") or 0) + v = int(float(b.get("volume") or 0)) + if c <= 0 and o <= 0: + continue + child_mins.setdefault(key, set()).add(ct) + if key not in buckets: + buckets[key] = { + "candle_time": key, + "open": o if o > 0 else c, + "high": max(h, o, c, lo), + "low": min(x for x in (lo, o, c, h) if x > 0) if any( + x > 0 for x in (lo, o, c, h) + ) else 0.0, + "close": c if c > 0 else o, + "volume": max(0, v), + "is_confirmed": 1, + "source": "rollup_1m", + } + order.append(key) + else: + agg = buckets[key] + if h > 0: + agg["high"] = max(float(agg["high"]), h, o, c) + pos = [x for x in (lo, o, c) if x > 0] + if pos: + agg["low"] = min(float(agg["low"]) if float(agg["low"]) > 0 else pos[0], *pos) + if c > 0: + agg["close"] = c + elif o > 0: + agg["close"] = o + agg["volume"] = int(agg["volume"]) + max(0, v) + + # 자식 1M 이 tf 개 미만이면 미완성 — 실매/백테 공통으로 제외 + return [buckets[k] for k in order if len(child_mins.get(k, ())) >= tf] + + +def merge_fill_holes( + primary: List[Dict[str, Any]], + filler: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], int]: + """ + primary(DB 등)에 없는 candle_time 만 filler(합성)로 보강. + 기존 봉은 덮어쓰지 않음. 반환 (병합 리스트, 보강 개수). + """ + by_t: Dict[str, Dict[str, Any]] = {} + for b in primary or []: + ct = str(b.get("candle_time") or "")[:12] + if len(ct) >= 12: + by_t[ct] = dict(b) + filled = 0 + for b in filler or []: + ct = str(b.get("candle_time") or "")[:12] + if len(ct) < 12: + continue + if ct in by_t: + continue + by_t[ct] = dict(b) + filled += 1 + merged = [by_t[k] for k in sorted(by_t.keys())] + return merged, filled diff --git a/kis_trader/engine/daily_profit_halt.py b/kis_trader/engine/daily_profit_halt.py index 65f60f4..884ad39 100644 --- a/kis_trader/engine/daily_profit_halt.py +++ b/kis_trader/engine/daily_profit_halt.py @@ -86,7 +86,8 @@ def _halt_new_buys_flag(common_key: str, strategy_key: str, default: bool = Fals def load_global_profit_target() -> Dict[str, Any]: return { - "enabled": get_env_bool("DAILY_PROFIT_TARGET_ENABLED", False), + # 글로벌 ENABLED 폐기 — 손익감시는 전략별 *_DAILY_PROFIT_TARGET_ENABLED 만. + "enabled": False, "krw": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_KRW", 0.0))), "pct": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_PCT", 0.0))), "budget_krw": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_BUDGET_KRW", 0.0))), @@ -107,11 +108,8 @@ def load_global_profit_target() -> Dict[str, Any]: def load_strategy_profit_target(strategy_id: str) -> Dict[str, Any]: pfx = _strategy_prefix(strategy_id) return { - "enabled": _enabled_flag( - "DAILY_PROFIT_TARGET_ENABLED", - f"{pfx}_DAILY_PROFIT_TARGET_ENABLED", - False, - ), + # 전략별만 (글로벌 DAILY_PROFIT_TARGET_ENABLED 폴백 없음) + "enabled": get_env_bool(f"{pfx}_DAILY_PROFIT_TARGET_ENABLED", False), "krw": _target_krw( "DAILY_PROFIT_TARGET_KRW", f"{pfx}_DAILY_PROFIT_TARGET_KRW", diff --git a/kis_trader/engine/dbband_engine.py b/kis_trader/engine/dbband_engine.py index 4219285..13f808e 100644 --- a/kis_trader/engine/dbband_engine.py +++ b/kis_trader/engine/dbband_engine.py @@ -102,7 +102,8 @@ def _to_bool(v: Any, default: bool = True) -> bool: def _t2dt(candle_time: str) -> datetime: - return datetime.strptime(str(candle_time)[:12], "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(candle_time) def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: @@ -155,25 +156,28 @@ def compute_bb_series( return mid, upper, lower -def get_dbband_defaults_from_db(db=None) -> Dict[str, Any]: +def get_dbband_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """env_config + config_dbband 병합 → 엔진 params dict.""" own_db = None r: Dict[str, Any] = {} try: - if db is None: - from database import TradeDB - own_db = TradeDB() - db = own_db - if hasattr(db, "get_merged_env_snapshot"): - r = db.get_merged_env_snapshot() - elif hasattr(db, "get_latest_env"): - latest = db.get_latest_env() - r = dict((latest or {}).get("snapshot") or {}) + if env_row is not None: + r = dict(env_row) else: - row = db.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - r = dict(row) if row else {} + if db is None: + from database import TradeDB + own_db = TradeDB() + db = own_db + if hasattr(db, "get_merged_env_snapshot"): + r = db.get_merged_env_snapshot() + elif hasattr(db, "get_latest_env"): + latest = db.get_latest_env() + r = dict((latest or {}).get("snapshot") or {}) + else: + row = db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + r = dict(row) if row else {} bb_period = dbband_env_int(r, "DBBAND_BB_PERIOD", 20) inner_std = dbband_env_float(r, "DBBAND_BB_INNER_STD", 2.0) @@ -704,8 +708,12 @@ def check_buy_signal_live( def run_dbband_backtest_single( candles: List[Dict], params: Dict[str, Any], + ticks_by_minute: Optional[Dict[str, List[Dict]]] = None, ) -> List[Dict]: - """단일 종목 시계열 백테스트.""" + """단일 종목 시계열 백테스트. + + ticks_by_minute: candle_time[:12] → 틱 리스트 (선택). 있으면 틱 우선 청산. + """ if len(candles) < 50: return [] @@ -736,6 +744,22 @@ def run_dbband_backtest_single( last_exit_dt: Optional[datetime] = None cooldown_min = float(params.get("cooldown_min") or 15.0) + from kis_trader.engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + resolve_backtest_sell, + strategy_tick_fallback_ohlc, + strategy_use_tick_exit, + ) + use_tick_exit = bool(ticks_by_minute) and strategy_use_tick_exit( + params, "DBBAND_BACKTEST_USE_TICK_EXIT", default=True, + ) + tick_fallback_ohlc = strategy_tick_fallback_ohlc( + params, "DBBAND_BACKTEST_TICK_FALLBACK_OHLC", default=False, + ) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="DBBAND_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="DBBAND_BACKTEST_SELL_SLIP_PCT") + def _day_key(t: str) -> str: return t[:8] @@ -744,7 +768,20 @@ def run_dbband_backtest_single( return True return _day_key(times[i]) != _day_key(times[i + 1]) + from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at + for i in range(min_i, len(candles)): + if apply_env_timeline_at(params, times[i], "DBBAND"): + period = int(params.get("bb_period") or 20) + inner_std = float(params.get("bb_inner_std") or 2.0) + outer_std = float(params.get("bb_outer_std") or 3.0) + trend_period = int(params.get("trend_ma_period") or 200) + side_mode = str(params.get("side_mode") or "long_only").strip().lower() + valid_bars = int(params.get("entry_valid_bars") or 3) + time_start = int(params.get("time_start_hm") or 930) + time_end = int(params.get("time_end_hm") or 1500) + cooldown_min = float(params.get("cooldown_min") or 15.0) + c = candles[i] hm = _hm_from_candle(times[i]) day = _day_key(times[i]) @@ -753,6 +790,50 @@ def run_dbband_backtest_single( pos = position iu = i_upper[i] il = i_lower[i] + + def _sell_fn(p_pos, candle, p_params, is_eod=False, _iu=iu, _il=il): + return check_sell_signal_live( + p_pos, candle, p_params, + inner_upper=_iu, inner_lower=_il, is_eod=is_eod, + ) + + if use_tick_exit: + mk = str(times[i])[:12] + minute_ticks = list((ticks_by_minute or {}).get(mk) or []) + if minute_ticks: + minute_ticks.sort(key=lambda x: str(x.get("tick_time") or "")) + bar = dict(c) + if "open" not in bar or bar.get("open") in (None, ""): + bar["open"] = float(c.get("open") or c.get("close") or 0) + res5 = resolve_backtest_sell( + pos, + bar, + params, + is_eod=_is_eod_bar(i), + sell_fn=_sell_fn, + low_mode="current", + ticks=minute_ticks or None, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, + ) + if res5: + reason, exit_px, sell_time, _hold, _src = res5 + trades.append({ + "entry_time": pos["entry_time"], + "exit_time": sell_time or times[i], + "entry": round(pos["entry_price"]), + "exit": round(exit_px), + "qty": pos.get("qty", 1), + "side": pos.get("side", "long"), + "reason": reason, + "signal_time": pos.get("signal_time"), + }) + position = None + last_exit_dt = _t2dt(sell_time or times[i]) + continue + sell = check_sell_signal_live( pos, c, params, inner_upper=iu, @@ -863,6 +944,7 @@ def run_dbband_backtest_portfolio( candles_by_code: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: """ 종목별 독립 백테 후 합산 (포트폴리오 동시보유 제약은 1차 단순화). @@ -878,7 +960,10 @@ def run_dbband_backtest_portfolio( for code, rows in candles_by_code.items(): if allowed is not None and code not in allowed: continue - tr = run_dbband_backtest_single(rows, params) + ticks_by_minute = None + if ticks_by_code: + ticks_by_minute = ticks_by_code.get(code) or None + tr = run_dbband_backtest_single(rows, params, ticks_by_minute=ticks_by_minute) for t in tr: t["code"] = code all_trades.append(t) diff --git a/kis_trader/engine/momentum_engine.py b/kis_trader/engine/momentum_engine.py index 0272e66..ab341d9 100644 --- a/kis_trader/engine/momentum_engine.py +++ b/kis_trader/engine/momentum_engine.py @@ -1,30 +1,25 @@ #!/usr/bin/env python3 """ -kis_trader/engine/momentum_engine.py — 모멘텀(1분봉 추세추격) 전용 엔진 -====================================================================== +kis_trader/engine/momentum_engine.py — 모멘텀(HTS momentum 조건식) 전용 엔진 +============================================================================ 스캘핑 reversal(SCALP)과 완전 분리. 백테스트·파라서치·MomentumStrategy 공통. -[전략 컨셉 — SCAN vs TRIGGER] -- SCAN: HTS/KIS ``scalp`` 조건검색 (F/G/H/J) → target_candidates_history -- TRIGGER V2: 추세추격 패턴 OR (단순돌파 / 눌림재돌파) + EMA·거래량·RSI약세컷 - ※ 구 V1(고점추격방지·RSI상한·끝물컷)은 추세추격과 상충 → 기본 OFF - -[청산 우선순위 — 추세추격 전용 (SCALP reversal·돌파와 다름, 실매 MomentumStrategy 동일)] - 래칫(설정 시) → 어깨 → 트레일 → 손절 → 시간컷 → 금액손실컷 → 익절(tp_max 상한) → 장마감청산 - ※ 래칫티어가 있어도 미발동 시 어깨로 폴백. 익절%는 마지막 하드 캡. +[전략 컨셉 — SCAN vs TRIGGER vs 청산] +- SCAN: 키움 ``momentum`` 조건검색 (E∧F∧H∧I) → target_candidates_history +- TRIGGER: ``momentum_hts_logic`` — 전일시가 돌파 유지(E) + 양봉·거래량 펄스 + ※ 구 1분 돌파/눌림재돌파·RSI50·EMA 패턴 TRIGGER 는 폐기 +- 청산: ``momentum_hts_logic`` — 래칫·어깨·트레일·손절·시간컷 """ from __future__ import annotations from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Tuple -from kis_trader.engine.ema_trend_filter import eval_ema_uptrend_reject -from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal -from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry -from kis_trader.engine.program_filter import program_reject_for_entry -from kis_trader.engine.momentum_chase_patterns import ( - chase_pattern_defaults, - eval_momentum_chase_pattern, +from kis_trader.engine.momentum_hts_logic import ( + check_sell_signal_momentum_hts_live, + eval_momentum_hts_buy_at_index, + hts_trigger_defaults_from_row, + resolve_momentum_skip_hts_scan_dupes, ) from kis_trader.engine.momentum_env_keys import ( momentum_env_bool, @@ -34,6 +29,7 @@ from kis_trader.engine.momentum_env_keys import ( ) from kis_trader.engine.strategy_eod import is_strategy_eod_bar from kis_trader.utils.env import get_env_from_db, get_env_int +from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt MOMENTUM_STRATEGY_ID = "MOMENTUM" @@ -51,10 +47,6 @@ def _to_bool(v: Any, default: bool = True) -> bool: return default -def _t2dt(t: str) -> datetime: - return datetime.strptime(str(t)[:12], "%Y%m%d%H%M") - - def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str: date = candle_time[:8] hm = int(candle_time[8:12]) @@ -100,47 +92,40 @@ def effective_tp_pct_from_params(params: Dict[str, Any]) -> float: ) -def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: +def get_momentum_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """env_config + config_momentum 병합 → 엔진 params dict.""" own_db = None r: Dict[str, Any] = {} try: - if db is None: - from database import TradeDB - own_db = TradeDB() - db = own_db - if hasattr(db, "get_merged_env_snapshot"): - r = db.get_merged_env_snapshot() - elif hasattr(db, "get_latest_env"): - latest = db.get_latest_env() - r = dict((latest or {}).get("snapshot") or {}) + if env_row is not None: + r = dict(env_row) else: - row = db.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - r = dict(row) if row else {} + if db is None: + from database import TradeDB + own_db = TradeDB() + db = own_db + if hasattr(db, "get_merged_env_snapshot"): + r = db.get_merged_env_snapshot() + elif hasattr(db, "get_latest_env"): + latest = db.get_latest_env() + r = dict((latest or {}).get("snapshot") or {}) + else: + row = db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + r = dict(row) if row else {} rsi_period = momentum_env_int(r, "MOMENTUM_RSI_PERIOD", 3) - mom_rsi_min = _legacy_float(r, "MOMENTUM_RSI_MIN", ("SCALP_MOM_RSI_MIN",), 50.0) - mom_rsi_max = _legacy_float(r, "MOMENTUM_RSI_MAX", ("SCALP_MOM_RSI_MAX",), 80.0) - mom_vol_mult = _legacy_float(r, "MOMENTUM_VOL_MULT", ("SCALP_MOM_VOL_MULT",), 1.5) + mom_rsi_min = momentum_env_float(r, "MOMENTUM_RSI_MIN", 50.0) + mom_rsi_max = momentum_env_float(r, "MOMENTUM_RSI_MAX", 80.0) + mom_vol_mult = momentum_env_float(r, "MOMENTUM_VOL_MULT", 1.05) mom_vol_win = momentum_env_int(r, "MOMENTUM_VOL_WIN", 5) - if r.get("SCALP_MOM_VOL_WIN") not in (None, "", "None") and "MOMENTUM_VOL_WIN" not in r: - try: - mom_vol_win = int(float(r["SCALP_MOM_VOL_WIN"])) - except (TypeError, ValueError): - pass - mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1430) - if r.get("SCALP_MOM_TIME_END_HM") not in (None, "", "None") and "MOMENTUM_TIME_END_HM" not in r: - try: - mom_time_end = int(float(r["SCALP_MOM_TIME_END_HM"])) - except (TypeError, ValueError): - pass + mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1530) sl_pct = abs(_legacy_float( - r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.015, + r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.03, )) - tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.025) - tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.02) + tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.08) + tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.10) shoulder_high = _legacy_float( r, "MOMENTUM_SHOULDER_MIN_HIGH_PCT", ("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), 0.005, @@ -154,8 +139,8 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: or get_env_from_db("MOMENTUM_RATCHET_TIERS", "") or "" ).strip() - trail_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_PCT", 0.0)) - trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.0)) + trail_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_PCT", 0.02)) + trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.01)) max_hold_bars = momentum_env_int(r, "MOMENTUM_MAX_HOLD_BARS", 0) max_daily = momentum_env_int(r, "MOMENTUM_MAX_DAILY", 5) min_price = momentum_env_float(r, "MOMENTUM_MIN_PRICE", 1000.0) @@ -170,60 +155,53 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: v = float(min_drop_loss) min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v cooldown_sec = momentum_env_int(r, "MOMENTUM_COOLDOWN_SEC", 600) - time_start = momentum_env_int(r, "MOMENTUM_TIME_START", 900) + time_start = momentum_env_int(r, "MOMENTUM_TIME_START", 830) time_end = momentum_env_int(r, "MOMENTUM_TIME_END", mom_time_end) - skip_hts = momentum_env_bool(r, "MOMENTUM_SKIP_HTS_SCAN_DUPES", True) + skip_hts = resolve_momentum_skip_hts_scan_dupes(r) use_defense = momentum_env_bool(r, "MOMENTUM_USE_DEFENSE_FILTERS", True) use_high_chase_f = momentum_env_bool(r, "MOMENTUM_USE_HIGH_CHASE_FILTER", False) use_daily_range_f = momentum_env_bool(r, "MOMENTUM_USE_DAILY_RANGE_FILTER", False) - use_ema_filter = momentum_env_bool(r, "MOMENTUM_USE_EMA_FILTER", True) - use_rsi_max_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_MAX_FILTER", False) - pattern_breakout = momentum_env_bool(r, "MOMENTUM_PATTERN_BREAKOUT", True) - pattern_pullback = momentum_env_bool(r, "MOMENTUM_PATTERN_PULLBACK", True) - chase_lookback_min = momentum_env_int(r, "MOMENTUM_CHASE_LOOKBACK_MIN", 10) - pullback_lookback_min = momentum_env_int(r, "MOMENTUM_PULLBACK_LOOKBACK_MIN", 15) - pullback_min_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MIN_PCT", 0.3) - pullback_max_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MAX_PCT", 3.0) - setup_vol_max_mult = momentum_env_float(r, "MOMENTUM_SETUP_VOL_MAX_MULT", 0.8) - setup_bear_bars_min = momentum_env_int(r, "MOMENTUM_SETUP_BEAR_BARS_MIN", 1) - ema_fast_period = momentum_env_int(r, "MOMENTUM_EMA_FAST_PERIOD", 9) - ema_slow_period = momentum_env_int(r, "MOMENTUM_EMA_SLOW_PERIOD", 21) + use_rsi_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_FILTER", False) + hts_flags = hts_trigger_defaults_from_row(r) slot_money = momentum_env_int(r, "MOMENTUM_SLOT_MONEY", 3_000_000) max_stocks = momentum_env_int(r, "MOMENTUM_MAX_STOCKS", 3) total_budget = momentum_env_int(r, "MOMENTUM_TOTAL_BUDGET_KRW", 0) min_hold_sec = momentum_env_float(r, "MOMENTUM_MIN_HOLD_SEC", 30.0) live_align = momentum_env_bool(r, "MOMENTUM_LIVE_BACKTEST_ALIGN", True) lookback_bars = momentum_env_int(r, "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", 1) + # ALIGN 시 형성 중 봉(T)을 진입봉으로 — BT portfolio 와 동일 (확정봉만 쓰면 1봉 지연) + use_forming = momentum_env_bool(r, "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", True) + # last_exit 분 floor — 신호봉 candle_time 과 시계 정합 (wall-clock 초 단위면 쿨다운 과다) + cd_floor = momentum_env_bool(r, "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", True) + cd_engine_only = momentum_env_bool(r, "MOMENTUM_COOLDOWN_ENGINE_ONLY", True) + skip_pre_sub = momentum_env_bool(r, "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", False) force_eod = momentum_env_bool(r, "MOMENTUM_FORCE_EOD_EXIT", False) eod_enabled = momentum_env_bool(r, "MOMENTUM_EOD_ENABLED", True) if r.get("MOMENTUM_EOD_ENABLED") in (None, "", "None") and r.get("MOMENTUM_FORCE_EOD_EXIT") not in (None, "", "None"): eod_enabled = force_eod - eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:25").strip() or "15:25" + eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:20").strip() or "15:20" portfolio_mode = True except Exception: rsi_period, mom_rsi_min, mom_rsi_max = 3, 50.0, 80.0 - mom_vol_mult, mom_vol_win, mom_time_end = 1.5, 5, 1430 - sl_pct, tp_pct, tp_max = 0.015, 0.025, 0.02 + mom_vol_mult, mom_vol_win, mom_time_end = 1.2, 5, 1530 + sl_pct, tp_pct, tp_max = 0.03, 0.08, 0.10 shoulder_high, shoulder_cut = 0.005, 0.003 - ratchet_tiers, trail_pct, trail_arm_pct = "", 0.0, 0.0 + ratchet_tiers, trail_pct, trail_arm_pct = "", 0.02, 0.01 max_hold_bars, max_daily = 0, 5 min_price, max_daily_chg, high_chase = 1000.0, 20.0, 0.96 mom_max_open, mom_min_open = 999.0, -999.0 max_loss_krw, min_drop_pct_for_loss_cut = 200_000, 0.015 - cooldown_sec, time_start, time_end = 600, 900, 1430 + cooldown_sec, time_start, time_end = 600, 830, 1530 skip_hts, use_defense = True, True use_high_chase_f, use_daily_range_f = False, False - use_ema_filter = True - use_rsi_max_filter = False - pattern_breakout, pattern_pullback = True, True - chase_lookback_min, pullback_lookback_min = 10, 15 - pullback_min_pct, pullback_max_pct = 0.3, 3.0 - setup_vol_max_mult, setup_bear_bars_min = 0.8, 1 - ema_fast_period, ema_slow_period = 9, 21 + use_rsi_filter = False + hts_flags = hts_trigger_defaults_from_row({}) slot_money, max_stocks, total_budget = 3_000_000, 3, 0 min_hold_sec, live_align, lookback_bars = 30.0, True, 1 + use_forming, cd_floor, cd_engine_only = True, True, True + skip_pre_sub = False force_eod, portfolio_mode = False, True - eod_enabled, eod_hm = True, "15:25" + eod_enabled, eod_hm = True, "15:20" finally: if own_db is not None: try: @@ -262,24 +240,18 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: "use_defense_filters": use_defense, "use_high_chase_filter": use_high_chase_f, "use_daily_range_filter": use_daily_range_f, - "use_ema_filter": use_ema_filter, - "use_rsi_max_filter": use_rsi_max_filter, - "pattern_breakout": pattern_breakout, - "pattern_pullback": pattern_pullback, - "chase_lookback_min": chase_lookback_min, - "pullback_lookback_min": pullback_lookback_min, - "pullback_min_pct": pullback_min_pct, - "pullback_max_pct": pullback_max_pct, - "setup_vol_max_mult": setup_vol_max_mult, - "setup_bear_bars_min": setup_bear_bars_min, - "ema_fast_period": ema_fast_period, - "ema_slow_period": ema_slow_period, + "use_rsi_filter": use_rsi_filter, + **hts_flags, "slot_money": float(slot_money), "max_stocks": max_stocks, "total_budget_krw": float(total_budget), "min_hold_sec": min_hold_sec, "live_backtest_align": live_align, "live_signal_lookback_bars": lookback_bars, + "live_align_use_forming_bar": use_forming, + "cooldown_use_candle_floor": cd_floor, + "cooldown_engine_only": cd_engine_only, + "backtest_skip_pre_subscribe": skip_pre_sub, "eod_enabled": eod_enabled, "eod_hm": eod_hm, "force_eod_exit": eod_enabled, @@ -288,200 +260,17 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]: } -def _parse_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]: - raw = params.get("ratchet_tiers") - if raw is None: - raw = get_env_from_db("MOMENTUM_RATCHET_TIERS", "") - if isinstance(raw, (list, tuple)): - pairs = list(raw) - else: - s = str(raw or "").strip() - if not s: - return [] - pairs = [] - for chunk in s.split(","): - chunk = chunk.strip() - if not chunk or ":" not in chunk: - continue - g, c = chunk.split(":", 1) - pairs.append((g, c)) - tiers: List[Tuple[float, float]] = [] - for g, c in pairs: - try: - gain = abs(float(g)) / 100.0 - cut = abs(float(c)) / 100.0 - except (TypeError, ValueError): - continue - if gain <= 0 or cut <= 0: - continue - tiers.append((gain, cut)) - tiers.sort(key=lambda x: x[0]) - return tiers - - -def _shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]: - smh = float(params.get("shoulder_min_high", 0.005)) - sc = float(params.get("shoulder_cut_pct", 0.003)) - return max(0.0, smh), max(0.0, sc) - - -def _minutes_held(position: Dict[str, Any], candle: Dict[str, Any]) -> Optional[int]: - try: - e = _t2dt(position.get("entry_time") or position.get("buy_time", "")) - n = _t2dt(candle.get("candle_time", "")) - return max(0, int((n - e).total_seconds() / 60)) - except Exception: - return None - - -def _day_running_high_low(candles: List[Dict], i: int, day: str) -> Tuple[float, float, float]: - running_low = float(candles[i]["low"]) - running_high = float(candles[i]["high"]) - day_open = float(candles[i]["open"]) - for j in range(i, -1, -1): - if candles[j]["candle_time"][:8] != day: - break - running_low = min(running_low, float(candles[j]["low"])) - running_high = max(running_high, float(candles[j]["high"])) - day_open = float(candles[j]["open"]) - return running_high, running_low, day_open - - def eval_momentum_buy_at_index( candles: List[Dict], i: int, params: Dict[str, Any], state: Dict[str, Any], ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: - """TRIGGER V2: 추세추격 패턴 OR + 공통 가드 (시간·쿨다운·EMA·RSI약세·거래량).""" - if i < 1 or i >= len(candles): - return ("탈락-봉부족", f"인덱스 부적절 (i={i})", None) - - rsi_period = int(params.get("rsi_period", 3)) - rsi_min = float(params.get("mom_rsi_min", 50.0)) - rsi_max = float(params.get("mom_rsi_max", 80.0)) - time_start_hm = int(params.get("time_start_hm", 900)) - time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430))) - cooldown_min = float(params.get("cooldown_min", 10)) - max_daily = int(params.get("max_daily", 5)) - max_daily_chg = float(params.get("max_daily_chg", 20.0)) - min_price = float(params.get("min_price", 1000.0)) - use_defense = _to_bool(params.get("use_defense_filters"), True) - use_high_chase_f = _to_bool(params.get("use_high_chase_filter"), False) - use_daily_range_f = _to_bool(params.get("use_daily_range_filter"), False) - use_ema_filter = _to_bool(params.get("use_ema_filter"), True) - use_rsi_max_filter = _to_bool(params.get("use_rsi_max_filter"), False) - ema_fast_period = int(params.get("ema_fast_period", 9)) - ema_slow_period = int(params.get("ema_slow_period", 21)) - high_chase_thr = float(params.get("high_chase_thr", 0.96)) - - c = candles[i] - day = c["candle_time"][:8] - hm = int(c["candle_time"][8:12]) - cl = float(c["close"]) - - if hm < time_start_hm or hm >= time_end_hm: - return (None, None, None) - - if use_defense and cl < min_price: - return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None) - - last_exit_dt = state.get("last_exit_dt") - if last_exit_dt is not None: - elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60 - if elapsed < cooldown_min: - return (None, None, None) - - if state.get("daily_cnt", 0) >= max_daily: - return (None, None, None) - - closes = [float(x["close"]) for x in candles] - ic = params.get("_indicator_cache") - if ic is not None and hasattr(ic, "rsi_at"): - rsi = ic.rsi_at(i, rsi_period) - else: - rsis = compute_rsi_series(closes, rsi_period) - rsi = rsis[i] if i < len(rsis) else None - if rsi is None: - return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None) - if rsi <= 0.0: - return ("탈락-RSI무효", "RSI=0.0 (봉 부족)", None) - if rsi < rsi_min: - return ("탈락-모멘텀약함", "RSI=%.1f < %.0f" % (rsi, rsi_min), None) - if use_rsi_max_filter and rsi > rsi_max: - return ("탈락-과열끝물", "RSI=%.1f > %.0f" % (rsi, rsi_max), None) - - ema_fast_val = ic.ema_at(i, ema_fast_period) if ic is not None and hasattr(ic, "ema_at") else None - ema_slow_val = ic.ema_at(i, ema_slow_period) if ic is not None and hasattr(ic, "ema_at") else None - ema_rej, ema_msg = eval_ema_uptrend_reject( - closes, i, cl, - use_filter=use_ema_filter, - fast_period=ema_fast_period, - slow_period=ema_slow_period, - ema_fast_val=ema_fast_val, - ema_slow_val=ema_slow_val, + """HTS momentum 조건식 정합 TRIGGER — ``momentum_hts_logic`` 위임.""" + return eval_momentum_hts_buy_at_index( + candles, i, params, state, + compute_rsi_series_fn=compute_rsi_series, ) - if ema_rej: - return (ema_rej, ema_msg, None) - - running_high, running_low, day_open = _day_running_high_low(candles, i, day) - - if use_daily_range_f and running_low > 0: - daily_chg_pct = (running_high - running_low) / running_low * 100 - if daily_chg_pct > max_daily_chg: - return ("탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg), None) - - if use_high_chase_f and running_high > 0 and cl >= running_high * high_chase_thr: - return ( - "탈락-고점추격", - "현재가 %.0f ≥ 고가 %.0f × %.2f" % (cl, running_high, high_chase_thr), - None, - ) - - mom_max_from_open = float(params.get("mom_max_from_open_pct", 999.0)) - mom_min_from_open = float(params.get("mom_min_from_open_pct", -999.0)) - if day_open > 0 and mom_max_from_open < 900: - from_open_pct = (cl / day_open - 1) * 100 - if from_open_pct > mom_max_from_open: - return ("탈락-끝물", "시가+%.1f%% > +%.0f%%" % (from_open_pct, mom_max_from_open), None) - if from_open_pct < mom_min_from_open: - return ("탈락-약세", "시가%+.1f%% < %+.0f%%" % (from_open_pct, mom_min_from_open), None) - - pat_ok, pat_name, pat_metrics = eval_momentum_chase_pattern(candles, i, params) - if not pat_ok: - return ( - "탈락-패턴미충족", - "추격패턴(%s) 미충족" % pat_name, - None, - ) - - sig: Dict[str, Any] = { - "signal": True, - "rsi": rsi, - "mode": "momentum", - "pattern": pat_name, - "signal_candle_time": c.get("candle_time"), - } - if isinstance(pat_metrics, dict): - sig.update(pat_metrics) - ws_rej, ws_msg = whipsaw_reject_for_signal( - params, "MOMENTUM", - signal_bar=c, - current_price=cl, - ) - if ws_rej: - return (ws_rej, ws_msg, None) - ob_rej, ob_msg = orderbook_reject_for_entry( - params, "MOMENTUM", current_price=cl, - ) - if ob_rej: - return (ob_rej, ob_msg, None) - prog_rej, prog_msg = program_reject_for_entry( - params, "MOMENTUM", current_price=cl, - ) - if prog_rej: - return (prog_rej, prog_msg, None) - return (None, None, sig) def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: @@ -500,9 +289,23 @@ def check_buy_signal_momentum_live( params: Dict[str, Any], state: Dict[str, Any], ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: - """실매·백테 공용 모멘텀 진입.""" + """실매·백테 공용 모멘텀 진입. + + live_backtest_align=True: + - 신호봉 = 직전 확정봉 (T-1) + - 진입봉 = 형성 중 봉 T (``live_align_use_forming_bar`` 기본 True) 또는 + forming 없으면 마지막 확정봉 (구버전 폴백) + """ live_align = _to_bool(params.get("live_backtest_align", True), True) lookback = max(1, int(params.get("live_signal_lookback_bars", 1))) + use_forming = _to_bool(params.get("live_align_use_forming_bar", True), True) + + forming: Optional[Dict[str, Any]] = None + if live_align and use_forming and candles: + last = candles[-1] + if last.get("is_confirmed") in (0, False, "0", "false"): + forming = last + confirmed = _confirmed_candles_only(candles) if len(confirmed) < 6: @@ -513,9 +316,16 @@ def check_buy_signal_momentum_live( ) if live_align: - entry_i = len(confirmed) - 1 + # forming 있으면: 신호=confirmed[-1], 진입=forming open (BT idx=T 와 동일) + # forming 없으면: 신호=confirmed[-2], 진입=confirmed[-1] open (구 폴백) + if forming is not None: + entry_bar = forming + signal_base_i = len(confirmed) - 1 + else: + entry_bar = confirmed[-1] + signal_base_i = len(confirmed) - 2 for k in range(lookback): - signal_i = entry_i - 1 - k + signal_i = signal_base_i - k if signal_i < 1: break reject, msg, sig = eval_momentum_buy_at_index( @@ -526,12 +336,11 @@ def check_buy_signal_momentum_live( last_reject = (reject, msg, None) continue if sig: - ent = confirmed[entry_i] - entry_open = float(ent.get("open", 0) or 0) + entry_open = float(entry_bar.get("open", 0) or 0) if entry_open <= 0: - entry_open = float(ent.get("close", 0) or 0) + entry_open = float(entry_bar.get("close", 0) or 0) sig["entry_price"] = entry_open - sig["entry_bar_key"] = ent.get("candle_time") + sig["entry_bar_key"] = entry_bar.get("candle_time") return (None, None, sig) return last_reject @@ -545,101 +354,10 @@ def check_sell_signal_momentum_live( params: Dict[str, Any], is_eod: bool = False, ) -> Optional[Tuple[str, float]]: - """추세추격 전용 청산 — 실매·백테·파서치 공용 (SCALP reversal 과 분리). - - [청산 우선순위 — 스캘핑 V4 어깨 선행과 동일 계열] - 1순위 래칫컷 / 어깨컷 — 고점 대비 되돌림 (상승 보유 → 하락 시 매도) - 2순위 트레일컷 - 3순위 손절 - 4순위 시간컷 - 5순위 금액손실컷 (어깨·래칫 미발동 시) - 6순위 익절 — tp_max 상한 (하드 캡) - 7순위 장마감청산 - """ - sl_pct = -abs(float(params.get("sl_pct", params.get("stop_loss_pct", 0.015)))) - tp_pct = effective_tp_pct_from_params(params) - trail_pct = abs(float(params.get("trail_pct", 0.0) or 0.0)) - trail_arm_pct = abs(float(params.get("trail_arm_pct", 0.0) or 0.0)) - shoulder_min_high, shoulder_cut_pct = _shoulder_ratios(params) - ratchet_tiers = _parse_ratchet_tiers(params) - max_hold_bars = int(params.get("max_hold_bars", 0) or 0) - max_loss_krw = float(params.get("max_loss_krw", 200_000.0)) - min_hold_sec = float(params.get("min_hold_sec", 30.0)) - min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015)) - - try: - hi = float(current_candle.get("high", current_candle["close"])) - lo = float(current_candle.get("low", current_candle["close"])) - cl = float(current_candle["close"]) - except Exception: - return None - - candle_time = current_candle.get("candle_time", "") - if candle_time and position.get("entry_time"): - try: - if (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() < min_hold_sec: - return None - except Exception: - pass - - max_price = max(float(position.get("max_price", position["entry_price"])), hi) - position["max_price"] = max_price - - entry = float(position["entry_price"]) - qty = int(position.get("qty", 1) or 1) - sl_line = entry * (1 + sl_pct) - tp_line = entry * (1 + tp_pct) - - if ratchet_tiers and entry > 0: - peak_gain = (max_price - entry) / entry - cut_ratio = 0.0 - for gain, cut in ratchet_tiers: - if peak_gain >= gain: - cut_ratio = cut - if cut_ratio > 0.0: - ratchet_line = max_price * (1.0 - cut_ratio) - if lo <= ratchet_line: - return ("래칫컷", ratchet_line) - - trail_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high) - shoulder_line = max_price * (1.0 - shoulder_cut_pct) if trail_armed else 0.0 - if trail_armed and lo <= shoulder_line: - return ("어깨컷", shoulder_line) - - if lo <= sl_line: - return ("손절", sl_line) - - if trail_pct > 0 and max_price > entry: - trail_arm_line = entry * (1.0 + trail_arm_pct) - if trail_arm_pct <= 0 or max_price >= trail_arm_line: - trail_line = max_price * (1.0 - trail_pct) - if lo <= trail_line: - return ("트레일컷", trail_line) - - if max_hold_bars > 0: - held = _minutes_held(position, current_candle) - if held is not None and held >= max_hold_bars: - return ("시간컷", cl) - - shoulder_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high) - profit_val = (lo - entry) * qty - drop_pct = (entry - lo) / entry if entry > 0 else 0.0 - if ( - not shoulder_armed - and not ratchet_tiers - and profit_val <= -max_loss_krw - and drop_pct >= min_drop_pct - ): - exit_px = entry - (max_loss_krw / qty) if qty > 0 else lo - return ("금액손실컷", exit_px) - - if hi >= tp_line: - return ("익절", tp_line) - - if is_eod: - return ("장마감청산", cl) - - return None + """HTS momentum 추세추격 청산 — ``momentum_hts_logic`` 위임 (어깨컷 폐기).""" + return check_sell_signal_momentum_hts_live( + position, current_candle, params, is_eod=is_eod, + ) def _intrabar_exit_prices( @@ -910,10 +628,8 @@ def run_momentum_backtest( continue eval_params = dict(params) - if universe_by_slot is not None: - eval_params.setdefault("skip_hts_scan_dupes", True) - else: - eval_params.setdefault("skip_hts_scan_dupes", False) + if "skip_hts_scan_dupes" not in eval_params: + eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes() state = { "daily_cnt": daily_cnt.get(day, 0), "last_exit_dt": last_exit_dt.get(day), diff --git a/kis_trader/engine/momentum_env_keys.py b/kis_trader/engine/momentum_env_keys.py index 9b20305..1b17e9b 100644 --- a/kis_trader/engine/momentum_env_keys.py +++ b/kis_trader/engine/momentum_env_keys.py @@ -8,29 +8,6 @@ from __future__ import annotations from typing import Any, Dict, Optional -# 레거시 → MOMENTUM (DB 마이그레이션·1회 승계용) -LEGACY_TO_MOMENTUM: Dict[str, str] = { - "SCALP_MOM_RSI_MIN": "MOMENTUM_RSI_MIN", - "SCALP_MOM_RSI_MAX": "MOMENTUM_RSI_MAX", - "SCALP_MOM_VOL_MULT": "MOMENTUM_VOL_MULT", - "SCALP_MOM_VOL_WIN": "MOMENTUM_VOL_WIN", - "SCALP_MOM_TIME_END_HM": "MOMENTUM_TIME_END_HM", - "SCALP_STOP_LOSS_PCT": "MOMENTUM_STOP_LOSS_PCT", - "SCALP_TAKE_PROFIT_PCT": "MOMENTUM_TAKE_PROFIT_PCT", - "SCALP_TP_MAX_PCT": "MOMENTUM_TP_MAX_PCT", - "SCALP_SHOULDER_MIN_HIGH_PCT": "MOMENTUM_SHOULDER_MIN_HIGH_PCT", - "SCALP_SHOULDER_CUT_PCT": "MOMENTUM_SHOULDER_CUT_PCT", - "SCALP_MAX_DAILY": "MOMENTUM_MAX_DAILY", - "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT": "MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT", - "SCALP_MIN_PROFIT_PCT": "MOMENTUM_MIN_PROFIT_PCT", - "SCALP_MIN_HOLD_SEC": "MOMENTUM_MIN_HOLD_SEC", - "MAX_LOSS_PER_TRADE_KRW": "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", - "HIGH_CHASE_THR": "MOMENTUM_HIGH_CHASE_THR", - "MAX_DAILY_CHG": "MOMENTUM_MAX_DAILY_CHG", - "MIN_STOCK_PRICE": "MOMENTUM_MIN_PRICE", - "SLOT_MONEY_DEFAULT": "MOMENTUM_SLOT_MONEY", -} - MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_RSI_MIN", "MOMENTUM_RSI_MAX", @@ -61,6 +38,10 @@ MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_EMA_FAST_PERIOD", "MOMENTUM_EMA_SLOW_PERIOD", "MOMENTUM_USE_RSI_MAX_FILTER", + "MOMENTUM_TRIGGER_E_CONFIRM", + "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", + "MOMENTUM_USE_VOL_TRIGGER", + "MOMENTUM_USE_RSI_FILTER", "MOMENTUM_PATTERN_BREAKOUT", "MOMENTUM_PATTERN_PULLBACK", "MOMENTUM_CHASE_LOOKBACK_MIN", @@ -80,6 +61,10 @@ MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_RSI_PERIOD", "MOMENTUM_COOLDOWN_SEC", "MOMENTUM_LIVE_BACKTEST_ALIGN", + "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", + "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", + "MOMENTUM_COOLDOWN_ENGINE_ONLY", + "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", "MOMENTUM_BACKTEST_USE_TICK_EXIT", "MOMENTUM_BACKTEST_USE_TICK_ENTRY", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", @@ -94,6 +79,8 @@ MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", "MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", + "MOMENTUM_LIVE_MIN_CANDLES", + "MOMENTUM_GAP_FILL_LIMIT", "MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", "MOMENTUM_FORCE_EOD_EXIT", diff --git a/kis_trader/engine/momentum_hts_logic.py b/kis_trader/engine/momentum_hts_logic.py new file mode 100644 index 0000000..93a629b --- /dev/null +++ b/kis_trader/engine/momentum_hts_logic.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +""" +momentum_hts_logic.py — HTS momentum 조건식(E∧F∧H∧I) 정합 TRIGGER·청산 +==================================================================== + +[역할 분담 — SCAN vs TRIGGER vs 청산] +- SCAN (키움 WS ``momentum`` 조건검색): E∧F∧H∧I 일봉 필터 → target_candidates_history + · E: 종가 > 전일 시가 + · F: 2천~20만원 + · H: 5일 평균거래량 105% 이상 + · I: 거래량증감률 상위 400 +- TRIGGER (본 모듈): SCAN 후 **진입 타이밍**만 검사 + · ``MOMENTUM_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본): E·양봉·분봉거래량·RSI 중복 생략 + · false: E 유지·양봉·거래량 펄스(선택) — 당일 모멘텀 살아있음 확인 +- 청산 (본 모듈): 래칫·어깨·트레일·손절·시간컷 (A안 돌파 추격) +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from kis_trader.engine.momentum_env_keys import momentum_env_bool, momentum_env_float, momentum_env_int +from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry +from kis_trader.engine.program_filter import program_reject_for_entry +from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal +from kis_trader.utils.env import get_env_from_db +from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt + + +def _to_bool(v: Any, default: bool = True) -> bool: + if v is None: + return default + if isinstance(v, bool): + return v + s = str(v).strip().lower() + if s in ("1", "true", "t", "y", "yes", "on"): + return True + if s in ("0", "false", "f", "n", "no", "off", ""): + return False + return default + + +def resolve_prev_trading_day_open( + candles: List[Dict], + i: int, + day: str, +) -> Optional[float]: + """ + 전일(직전 거래일) 시가 — HTS momentum E 조건 ``close > prev_open`` 확인용. + + 1분봉에서 전일 첫 봉 open = 일봉 시가. + """ + prev_day: Optional[str] = None + prev_open: Optional[float] = None + for j in range(i - 1, -1, -1): + d = str(candles[j].get("candle_time", ""))[:8] + if not d or d >= day: + continue + if prev_day is None: + prev_day = d + if d != prev_day: + break + op = float(candles[j].get("open", 0) or 0) + if op > 0: + prev_open = op + return prev_open + + +def _volume_pulse_ok( + candles: List[Dict], + i: int, + params: Dict[str, Any], +) -> Tuple[bool, str]: + """H/I 정신: 당일 거래량 펄스 — 직전 N봉 평균 × 배수 이상.""" + vol_mult = float(params.get("mom_vol_mult", 1.0)) + vol_win = int(params.get("mom_vol_win", 5)) + if vol_mult <= 0: + return True, "" + vol = float(candles[i].get("volume", 0) or 0) + win = max(1, min(vol_win, i)) + vols = [float(candles[k].get("volume", 0) or 0) for k in range(i - win, i)] + if not vols or sum(vols) <= 0: + return False, "거래량창없음" + avg = sum(vols) / len(vols) + if avg <= 0 or vol < avg * vol_mult: + ratio = vol / avg if avg > 0 else 0.0 + return False, "%.2fx < %.1fx" % (ratio, vol_mult) + return True, "" + + +def _parse_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]: + raw = params.get("ratchet_tiers") + if raw is None: + raw = get_env_from_db("MOMENTUM_RATCHET_TIERS", "") + if isinstance(raw, (list, tuple)): + pairs = list(raw) + else: + s = str(raw or "").strip() + if not s: + return [] + pairs = [] + for chunk in s.split(","): + chunk = chunk.strip() + if not chunk or ":" not in chunk: + continue + g, c = chunk.split(":", 1) + pairs.append((g, c)) + tiers: List[Tuple[float, float]] = [] + for g, c in pairs: + try: + gain = abs(float(g)) / 100.0 + cut = abs(float(c)) / 100.0 + except (TypeError, ValueError): + continue + if gain <= 0 or cut <= 0: + continue + tiers.append((gain, cut)) + tiers.sort(key=lambda x: x[0]) + return tiers + + +def _shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]: + """어깨 발동·컷 비율 — params(비율) 또는 legacy 퍼센트.""" + smh = float(params.get("shoulder_min_high", 0.005)) + sc = float(params.get("shoulder_cut_pct", 0.003)) + return max(0.0, smh), max(0.0, sc) + + +def _minutes_held(position: Dict[str, Any], candle: Dict[str, Any]) -> Optional[int]: + try: + e = _t2dt(position.get("entry_time") or position.get("buy_time", "")) + n = _t2dt(candle.get("candle_time", "")) + return max(0, int((n - e).total_seconds() / 60)) + except Exception: + return None + + +def resolve_effective_tp_pct(tp_pct: float, tp_max_pct: float) -> float: + tp = abs(float(tp_pct)) + cap = abs(float(tp_max_pct)) + if cap > 0: + return min(tp, cap) + return tp + + +def effective_tp_pct_from_params(params: Dict[str, Any]) -> float: + return resolve_effective_tp_pct( + params.get("tp_pct", 0.05), + params.get("tp_max_pct", 0.08), + ) + + +def resolve_momentum_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool: + """ + HTS momentum SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부. + + - ``MOMENTUM_SKIP_HTS_SCAN_DUPES`` 명시 → 그대로 + - 미설정 → ``MOMENTUM_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True + """ + if r is None: + try: + from kis_trader.utils.env import get_strategy_env_dict + r = get_strategy_env_dict("MOMENTUM") or {} + except Exception: + r = {} + raw = r.get("MOMENTUM_SKIP_HTS_SCAN_DUPES") + if raw is not None and str(raw).strip() != "": + return _to_bool(raw, True) + # 엔진 defaults 에 이미 해석된 bool 이 있으면 universe fallback 금지 + if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None: + return _to_bool(r.get("skip_hts_scan_dupes"), False) + src = str(r.get("MOMENTUM_UNIVERSE_SOURCE") or "condition").strip().lower() + return src in ("kiwoom_condition", "condition") + + +def hts_trigger_defaults_from_row(r: Dict[str, Any]) -> Dict[str, Any]: + """env 행에서 HTS TRIGGER 전용 플래그.""" + return { + "trigger_e_confirm": momentum_env_bool(r, "MOMENTUM_TRIGGER_E_CONFIRM", True), + "trigger_require_bull_bar": momentum_env_bool(r, "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", True), + "use_vol_trigger": momentum_env_bool(r, "MOMENTUM_USE_VOL_TRIGGER", True), + "use_rsi_filter": momentum_env_bool(r, "MOMENTUM_USE_RSI_FILTER", False), + } + + +def eval_momentum_hts_buy_at_index( + candles: List[Dict], + i: int, + params: Dict[str, Any], + state: Dict[str, Any], + *, + compute_rsi_series_fn=None, +) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: + """ + HTS momentum 조건식 정합 TRIGGER. + + SCAN(E∧F∧H∧I)은 이미 통과한 종목만 후보 — 여기서는 진입 타이밍만 검사. + """ + if i < 1 or i >= len(candles): + return ("탈락-봉부족", "인덱스 부적절 (i=%d)" % i, None) + + time_start_hm = int(params.get("time_start_hm", 900)) + time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1530))) + cooldown_min = float(params.get("cooldown_min", 10)) + max_daily = int(params.get("max_daily", 5)) + min_price = float(params.get("min_price", 1000.0)) + use_defense = _to_bool(params.get("use_defense_filters"), True) + use_high_chase_f = _to_bool(params.get("use_high_chase_filter"), False) + high_chase_thr = float(params.get("high_chase_thr", 0.96)) + trigger_e_confirm = _to_bool(params.get("trigger_e_confirm"), True) + trigger_bull_bar = _to_bool(params.get("trigger_require_bull_bar"), True) + use_vol_trigger = _to_bool(params.get("use_vol_trigger"), True) + use_rsi_filter = _to_bool(params.get("use_rsi_filter"), False) + rsi_period = int(params.get("rsi_period", 3)) + rsi_min = float(params.get("mom_rsi_min", 50.0)) + rsi_max = float(params.get("mom_rsi_max", 80.0)) + + c = candles[i] + day = c["candle_time"][:8] + hm = int(c["candle_time"][8:12]) + op = float(c.get("open", 0) or 0) + cl = float(c.get("close", 0) or 0) + hi = float(c.get("high", cl) or cl) + + if hm < time_start_hm or hm >= time_end_hm: + return (None, None, None) + + if use_defense and cl < min_price: + return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None) + + last_exit_dt = state.get("last_exit_dt") + if last_exit_dt is not None: + elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60 + if elapsed < cooldown_min: + return (None, None, None) + + if state.get("daily_cnt", 0) >= max_daily: + return (None, None, None) + + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) + prev_open = resolve_prev_trading_day_open(candles, i, day) + + # kiwoom momentum SCAN — E∧F∧H∧I 이미 통과, TRIGGER 는 타이밍·호가·휩쏘만 + if skip_hts: + sig_hts: Dict[str, Any] = { + "signal": True, + "mode": "momentum_hts", + "pattern": "momentum_hts_scan", + "signal_candle_time": c.get("candle_time"), + "prev_day_open": prev_open, + } + ws_rej, ws_msg = whipsaw_reject_for_signal( + params, "MOMENTUM", signal_bar=c, current_price=cl, + ) + if ws_rej: + return (ws_rej, ws_msg, None) + ob_rej, ob_msg = orderbook_reject_for_entry(params, "MOMENTUM", current_price=cl) + if ob_rej: + return (ob_rej, ob_msg, None) + prog_rej, prog_msg = program_reject_for_entry(params, "MOMENTUM", current_price=cl) + if prog_rej: + return (prog_rej, prog_msg, None) + return (None, None, sig_hts) + + # ── E: 전일 시가 돌파 유지 (HTS momentum 핵심) ───────────────────── + if trigger_e_confirm: + if prev_open is None or prev_open <= 0: + return ("탈락-전일시가없음", "전일 시가 미확인", None) + if cl <= prev_open: + return ( + "탈락-E미충족", + "종가 %.0f ≤ 전일시가 %.0f" % (cl, prev_open), + None, + ) + + # ── 양봉: 당일 매수세 확인 (선택) ───────────────────────────────── + if trigger_bull_bar and op > 0 and cl <= op: + return ("탈락-음봉", "양봉 미충족", None) + + # ── 거래량 펄스: H/I 정신 (선택) ─────────────────────────────────── + if use_vol_trigger: + vol_ok, vol_msg = _volume_pulse_ok(candles, i, params) + if not vol_ok: + return ("탈락-거래량", vol_msg, None) + + # ── 고점추격 방지 (선택, 기본 OFF) ─────────────────────────────── + if use_high_chase_f: + running_high = hi + for j in range(i, -1, -1): + if candles[j]["candle_time"][:8] != day: + break + running_high = max(running_high, float(candles[j].get("high", 0) or 0)) + if running_high > 0 and cl >= running_high * high_chase_thr: + return ( + "탈락-고점추격", + "현재가 %.0f ≥ 고가 %.0f × %.2f" % (cl, running_high, high_chase_thr), + None, + ) + + # ── RSI 필터 (선택, 기본 OFF — SCAN 이후 보조만) ─────────────────── + rsi_val: Optional[float] = None + if use_rsi_filter and compute_rsi_series_fn is not None: + closes = [float(x["close"]) for x in candles] + ic = params.get("_indicator_cache") + if ic is not None and hasattr(ic, "rsi_at"): + rsi_val = ic.rsi_at(i, rsi_period) + else: + rsis = compute_rsi_series_fn(closes, rsi_period) + rsi_val = rsis[i] if i < len(rsis) else None + if rsi_val is None: + return ("탈락-RSI없음", "RSI 미계산", None) + if rsi_val < rsi_min: + return ("탈락-RSI약함", "RSI=%.1f < %.0f" % (rsi_val, rsi_min), None) + if rsi_val > rsi_max: + return ("탈락-RSI과열", "RSI=%.1f > %.0f" % (rsi_val, rsi_max), None) + + sig: Dict[str, Any] = { + "signal": True, + "mode": "momentum_hts", + "pattern": "hts_e_confirm", + "signal_candle_time": c.get("candle_time"), + "prev_day_open": prev_open, + } + if rsi_val is not None: + sig["rsi"] = rsi_val + + ws_rej, ws_msg = whipsaw_reject_for_signal( + params, "MOMENTUM", + signal_bar=c, + current_price=cl, + ) + if ws_rej: + return (ws_rej, ws_msg, None) + ob_rej, ob_msg = orderbook_reject_for_entry( + params, "MOMENTUM", current_price=cl, + ) + if ob_rej: + return (ob_rej, ob_msg, None) + prog_rej, prog_msg = program_reject_for_entry( + params, "MOMENTUM", current_price=cl, + ) + if prog_rej: + return (prog_rej, prog_msg, None) + return (None, None, sig) + + +def check_sell_signal_momentum_hts_live( + position: Dict[str, Any], + current_candle: Dict[str, Any], + params: Dict[str, Any], + is_eod: bool = False, +) -> Optional[Tuple[str, float]]: + """ + HTS momentum 추세추격 청산 — 어깨·래칫·트레일 병행. + + [청산 우선순위] + 1순위 래칫컷 (설정 시) + 2순위 어깨컷 (고점 대비 되돌림) + 3순위 손절 (하드 스탑) + 4순위 트레일컷 (추세 이익 보호) + 5순위 시간컷 + 6순위 금액손실컷 (어깨·래칫 미발동 시) + 7순위 익절 (tp_max 상한, 하드 캡) + 8순위 장마감청산 + """ + sl_pct = -abs(float(params.get("sl_pct", params.get("stop_loss_pct", 0.03)))) + tp_pct = effective_tp_pct_from_params(params) + trail_pct = abs(float(params.get("trail_pct", 0.0) or 0.0)) + trail_arm_pct = abs(float(params.get("trail_arm_pct", 0.0) or 0.0)) + shoulder_min_high, shoulder_cut_pct = _shoulder_ratios(params) + ratchet_tiers = _parse_ratchet_tiers(params) + max_hold_bars = int(params.get("max_hold_bars", 0) or 0) + max_loss_krw = float(params.get("max_loss_krw", 200_000.0)) + min_hold_sec = float(params.get("min_hold_sec", 30.0)) + min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015)) + + try: + hi = float(current_candle.get("high", current_candle["close"])) + lo = float(current_candle.get("low", current_candle["close"])) + cl = float(current_candle["close"]) + except Exception: + return None + + candle_time = current_candle.get("candle_time", "") + if candle_time and position.get("entry_time"): + try: + if (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() < min_hold_sec: + return None + except Exception: + pass + + max_price = max(float(position.get("max_price", position["entry_price"])), hi) + position["max_price"] = max_price + + entry = float(position["entry_price"]) + qty = int(position.get("qty", 1) or 1) + sl_line = entry * (1 + sl_pct) + tp_line = entry * (1 + tp_pct) + + # 1순위 래칫 + if ratchet_tiers and entry > 0: + peak_gain = (max_price - entry) / entry + cut_ratio = 0.0 + for gain, cut in ratchet_tiers: + if peak_gain >= gain: + cut_ratio = cut + if cut_ratio > 0.0: + ratchet_line = max_price * (1.0 - cut_ratio) + if lo <= ratchet_line: + return ("래칫컷", ratchet_line) + + # 2순위 어깨 + shoulder_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high) + if shoulder_armed and shoulder_cut_pct > 0: + shoulder_line = max_price * (1.0 - shoulder_cut_pct) + if lo <= shoulder_line: + return ("어깨컷", shoulder_line) + + # 3순위 손절 + if lo <= sl_line: + return ("손절", sl_line) + + # 4순위 트레일 + if trail_pct > 0 and max_price > entry: + trail_arm_line = entry * (1.0 + trail_arm_pct) + if trail_arm_pct <= 0 or max_price >= trail_arm_line: + trail_line = max_price * (1.0 - trail_pct) + if lo <= trail_line: + return ("트레일컷", trail_line) + + # 5순위 시간컷 + if max_hold_bars > 0: + held = _minutes_held(position, current_candle) + if held is not None and held >= max_hold_bars: + return ("시간컷", cl) + + # 6순위 금액손실컷 + profit_val = (lo - entry) * qty + drop_pct = (entry - lo) / entry if entry > 0 else 0.0 + if ( + not shoulder_armed + and not ratchet_tiers + and profit_val <= -max_loss_krw + and drop_pct >= min_drop_pct + ): + exit_px = entry - (max_loss_krw / qty) if qty > 0 else lo + return ("금액손실컷", exit_px) + + # 7순위 익절 (하드 캡) + if hi >= tp_line: + return ("익절", tp_line) + + # 8순위 장마감 + if is_eod: + return ("장마감청산", cl) + + return None diff --git a/kis_trader/engine/momentum_tick_replay.py b/kis_trader/engine/momentum_tick_replay.py index ac6ad75..36cfc65 100644 --- a/kis_trader/engine/momentum_tick_replay.py +++ b/kis_trader/engine/momentum_tick_replay.py @@ -47,7 +47,7 @@ def momentum_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> def momentum_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: """해당 분 틱 없을 때 1분봉 OHLC intrabar 폴백.""" return _param_bool( - params, "backtest_tick_fallback_ohlc", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", True, + params, "backtest_tick_fallback_ohlc", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", False, ) @@ -92,6 +92,16 @@ def momentum_live_align_enabled(params: Optional[Dict[str, Any]] = None) -> bool ) +def momentum_backtest_skip_pre_subscribe(params: Optional[Dict[str, Any]] = None) -> bool: + """정합용: 종목 첫 ws_tick 분 이전 진입봉 매수 제외 (기본 OFF — 파람 탐색 폭 유지).""" + return _param_bool( + params, + "backtest_skip_pre_subscribe", + "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", + False, + ) + + def momentum_backtest_live_scan_queue_enabled(params: Optional[Dict[str, Any]] = None) -> bool: """백테 매수: 실매처럼 N초마다 후보 순회·1건 매수 (기본 ON).""" return _param_bool( @@ -168,13 +178,9 @@ def align_momentum_entry_from_ticks( def parse_backtest_time(t: str) -> datetime: - """YYYYMMDDHHMM 또는 YYYYMMDDHHMMSS → datetime.""" - s = str(t or "").strip().replace("-", "").replace(":", "").replace(" ", "") - if len(s) >= 14: - return datetime.strptime(s[:14], "%Y%m%d%H%M%S") - if len(s) >= 12: - return datetime.strptime(s[:12], "%Y%m%d%H%M") - raise ValueError("invalid backtest time: %r" % (t,)) + """YYYYMMDDHHMM[SS] · 실매 포맷 → datetime (공통 파서).""" + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(t) def _tick_time_to_ms(tick_time: str) -> int: @@ -230,7 +236,8 @@ def _try_momentum_sell_on_ticks_columnar( _price = owner._price _tick_time = owner._tick_time - poll_ms = max(200, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 1500))) + # 실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 — 기본 100ms (하한 50ms) + poll_ms = max(50, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 100))) slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0))) entry_key = str(entry_time or "")[:12] entry_key_epoch = _tick_epoch_sec(entry_key) if entry_key else 0 @@ -324,7 +331,8 @@ def try_momentum_sell_on_ticks( if not ticks: return None - poll_ms = max(200, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 1500))) + # 실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 — 기본 100ms (하한 50ms) + poll_ms = max(50, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 100))) slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0))) entry_key = str(entry_time or "")[:12] try: diff --git a/kis_trader/engine/orderbook_env.py b/kis_trader/engine/orderbook_env.py new file mode 100644 index 0000000..ddf181f --- /dev/null +++ b/kis_trader/engine/orderbook_env.py @@ -0,0 +1,187 @@ +""" +kis_trader/engine/orderbook_env.py — 호가필터 env 키 (전략 전용) +================================================================ +글로벌 ``ORDERBOOK_*`` 임계값은 폐기. 실매·그리드·apply-best 는 +``{SCALP|TAIL|MOMENTUM|BREAKOUT}_ORDERBOOK_*`` 만 사용. + +코드 기본값 = DB 시드·웹 스키마 default 와 동일해야 함. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int + +# 임계값 기본 (하드코딩 금지 규칙: get_env_* 기본 인자로만 사용 · DB 시드와 동기) +OB_DEFAULT_FILTER_ENABLED = False # 현행 실매 OFF 유지 +OB_DEFAULT_MAX_SPREAD_PCT = 0.45 +OB_DEFAULT_ENTRY_BID_LEVELS = 3 +OB_DEFAULT_ENTRY_BID_DEPTH_MULT = 1.2 +OB_DEFAULT_ENTRY_ASK_MAX_MULT = 3.0 +OB_DEFAULT_MIN_BID_ASK_RATIO = 0.85 +OB_DEFAULT_ASK_WALL_MAX_QTY = 5000 + +# 시드·UI 대상 전략 prefix (SHORT 오케스트레이터 → TAIL_* canonical) +OB_STRATEGY_PREFIXES: Tuple[str, ...] = ("SCALP", "TAIL", "MOMENTUM", "BREAKOUT") + + +def orderbook_strategy_prefix(strategy: str) -> str: + s = (strategy or "").strip().upper() + if s in ("BREAKOUT", "BO"): + return "BREAKOUT" + if s in ("MOMENTUM", "MOM"): + return "MOMENTUM" + if s in ("TAIL", "SHORT", "TAIL_CATCH"): + return "TAIL" + if s in ("SCALP", "SCALPING", "REVERSAL"): + return "SCALP" + return s + + +def _key(pfx: str, suffix: str) -> str: + return f"{pfx}_ORDERBOOK_{suffix}" + + +def orderbook_filter_enabled(strategy: str) -> bool: + """전략별 ON/OFF. 글로벌 ORDERBOOK_FILTER_ENABLED 폐기 → 기본 OFF.""" + pfx = orderbook_strategy_prefix(strategy) + if not pfx or pfx == "ORDERBOOK": + return False + return get_env_bool(_key(pfx, "FILTER_ENABLED"), OB_DEFAULT_FILTER_ENABLED) + + +def get_ob_float(strategy: str, suffix: str, default: float) -> float: + pfx = orderbook_strategy_prefix(strategy) + if not pfx: + return float(default) + return float(get_env_float(_key(pfx, suffix), default)) + + +def get_ob_int(strategy: str, suffix: str, default: int) -> int: + pfx = orderbook_strategy_prefix(strategy) + if not pfx: + return int(default) + return int(get_env_int(_key(pfx, suffix), default)) + + +def load_orderbook_threshold_cfg( + strategy: str, + params: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """실매/백테 공통 임계값. params 의 ``_ob_*`` 가 있으면 덮어씀(파람서치).""" + pfx = orderbook_strategy_prefix(strategy) + cfg = { + "max_spread_pct": get_ob_float(pfx, "MAX_SPREAD_PCT", OB_DEFAULT_MAX_SPREAD_PCT), + "entry_bid_levels": get_ob_int(pfx, "ENTRY_BID_LEVELS", OB_DEFAULT_ENTRY_BID_LEVELS), + "entry_bid_depth_mult": get_ob_float( + pfx, "ENTRY_BID_DEPTH_MULT", OB_DEFAULT_ENTRY_BID_DEPTH_MULT, + ), + "entry_ask_max_mult": get_ob_float( + pfx, "ENTRY_ASK_MAX_MULT", OB_DEFAULT_ENTRY_ASK_MAX_MULT, + ), + "min_bid_ask_ratio": get_ob_float( + pfx, "MIN_BID_ASK_RATIO", OB_DEFAULT_MIN_BID_ASK_RATIO, + ), + "breakout_ask_wall_max_qty": get_ob_int( + pfx, "ASK_WALL_MAX_QTY", OB_DEFAULT_ASK_WALL_MAX_QTY, + ), + } + if params: + _overrides = ( + ("_ob_max_spread_pct", "max_spread_pct"), + ("_ob_min_bid_ask_ratio", "min_bid_ask_ratio"), + ("_ob_ask_max_mult", "entry_ask_max_mult"), + ("_ob_ask_wall_max_qty", "breakout_ask_wall_max_qty"), + ("_ob_bid_depth_mult", "entry_bid_depth_mult"), + ("_ob_bid_levels", "entry_bid_levels"), + ) + for src_key, cfg_key in _overrides: + ov = params.get(src_key) + if ov is not None and str(ov).strip() != "": + try: + cfg[cfg_key] = float(ov) + except (TypeError, ValueError): + pass + return cfg + + +def orderbook_params_to_env_patch(strategy: str, params: Dict[str, Any]) -> Dict[str, str]: + """그리드/Optuna merged → 전략별 ORDERBOOK_* 패치 (값 있을 때만).""" + pfx = orderbook_strategy_prefix(strategy) + if not pfx: + return {} + out: Dict[str, str] = {} + if "max_spread_pct" in params and params.get("max_spread_pct") not in (None, ""): + out[_key(pfx, "MAX_SPREAD_PCT")] = str(float(params["max_spread_pct"])) + if "min_bid_ask_ratio" in params and params.get("min_bid_ask_ratio") not in (None, ""): + out[_key(pfx, "MIN_BID_ASK_RATIO")] = str(float(params["min_bid_ask_ratio"])) + if "ask_max_mult" in params and params.get("ask_max_mult") not in (None, ""): + out[_key(pfx, "ENTRY_ASK_MAX_MULT")] = str(float(params["ask_max_mult"])) + if "ask_wall_max_qty" in params and params.get("ask_wall_max_qty") not in (None, ""): + out[_key(pfx, "ASK_WALL_MAX_QTY")] = str(int(float(params["ask_wall_max_qty"]))) + if "bid_depth_mult" in params and params.get("bid_depth_mult") not in (None, ""): + out[_key(pfx, "ENTRY_BID_DEPTH_MULT")] = str(float(params["bid_depth_mult"])) + if "bid_levels" in params and params.get("bid_levels") not in (None, ""): + out[_key(pfx, "ENTRY_BID_LEVELS")] = str(int(float(params["bid_levels"]))) + # 호가축이 섞인 apply 면 필터 ON (탐색 결과를 실매에 쓰려면 필요) + if out and params.get("ob_filter_enabled") not in (None, ""): + out[_key(pfx, "FILTER_ENABLED")] = ( + "true" if str(params.get("ob_filter_enabled")).lower() in ("1", "true", "yes", "on") + else "false" + ) + return out + + +def build_orderbook_seed_patch( + *, + filter_enabled: Optional[bool] = None, + copy_global_if_empty: bool = True, +) -> Dict[str, str]: + """전략별 기본값 시드. 빈 칸만 채움(이미 값 있으면 유지). + + copy_global_if_empty: 구 글로벌 ORDERBOOK_* 가 있으면 1회 이관. + """ + fe = OB_DEFAULT_FILTER_ENABLED if filter_enabled is None else bool(filter_enabled) + # 글로벌 → 전략 이관용 (폐기 전 잔존값) + g_spread = str(get_env_from_db("ORDERBOOK_MAX_SPREAD_PCT", "") or "").strip() + g_levels = str(get_env_from_db("ORDERBOOK_ENTRY_BID_LEVELS", "") or "").strip() + g_depth = str(get_env_from_db("ORDERBOOK_ENTRY_BID_DEPTH_MULT", "") or "").strip() + g_ask = str(get_env_from_db("ORDERBOOK_ENTRY_ASK_MAX_MULT", "") or "").strip() + g_ratio = str(get_env_from_db("ORDERBOOK_MIN_BID_ASK_RATIO", "") or "").strip() + g_wall = str(get_env_from_db("ORDERBOOK_BREAKOUT_ASK_WALL_MAX_QTY", "") or "").strip() + g_filt = str(get_env_from_db("ORDERBOOK_FILTER_ENABLED", "") or "").strip() + + patch: Dict[str, str] = {} + for pfx in OB_STRATEGY_PREFIXES: + pairs: List[Tuple[str, str, str]] = [ + ("FILTER_ENABLED", "false" if not fe else "true", g_filt), + ("MAX_SPREAD_PCT", str(OB_DEFAULT_MAX_SPREAD_PCT), g_spread), + ("ENTRY_BID_LEVELS", str(OB_DEFAULT_ENTRY_BID_LEVELS), g_levels), + ("ENTRY_BID_DEPTH_MULT", str(OB_DEFAULT_ENTRY_BID_DEPTH_MULT), g_depth), + ("ENTRY_ASK_MAX_MULT", str(OB_DEFAULT_ENTRY_ASK_MAX_MULT), g_ask), + ("MIN_BID_ASK_RATIO", str(OB_DEFAULT_MIN_BID_ASK_RATIO), g_ratio), + ] + if pfx == "BREAKOUT": + pairs.append(("ASK_WALL_MAX_QTY", str(OB_DEFAULT_ASK_WALL_MAX_QTY), g_wall)) + for suf, default_s, glob_s in pairs: + k = _key(pfx, suf) + cur = str(get_env_from_db(k, "") or "").strip() + if cur: + continue + if suf == "FILTER_ENABLED": + # 시드 우선순위: 호출자 fe → (이관 시) 글로벌 → 코드 기본 + if filter_enabled is not None: + patch[k] = "true" if fe else "false" + elif copy_global_if_empty and glob_s: + patch[k] = ( + "true" + if glob_s.lower() in ("1", "true", "yes", "on") + else "false" + ) + else: + patch[k] = default_s + elif copy_global_if_empty and glob_s: + patch[k] = glob_s + else: + patch[k] = default_s + return patch diff --git a/kis_trader/engine/orderbook_filter.py b/kis_trader/engine/orderbook_filter.py index d5724d3..03133d4 100644 --- a/kis_trader/engine/orderbook_filter.py +++ b/kis_trader/engine/orderbook_filter.py @@ -4,37 +4,32 @@ kis_trader/engine/orderbook_filter.py — TRIGGER 진입 호가 필터 (키움 0 실매 전용 — ``params['_orderbook_ws']`` / ``_whipsaw_ws`` 의 ``get_orderbook`` 사용. 백테는 호가 없으면 스킵(OFF) 또는 env 로 비활성. -모든 임계값 env/DB — 하드코딩 금지. +필터(주문 차단)와 수집(ws_orderbook 저장)은 분리: + - ``{전략}_ORDERBOOK_FILTER_ENABLED`` — 실매 탈락 적용 (글로벌 ORDERBOOK_* 폐기) + - ``WS_ORDERBOOK_COLLECT_ENABLED`` — TRIGGER 판정 스냅 저장 (필터 OFF여도 가능) + +모든 임계값 env/DB — 하드코딩 금지 (기본값은 orderbook_env 상수). """ from __future__ import annotations from typing import Any, Dict, Optional, Tuple -from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int +from kis_trader.utils.env import get_env_int from kis_trader.ws.orderbook_cache import OrderbookSnapshot from kis_trader.ws.trigger_eval_recorder import get_trigger_eval_recorder +from .orderbook_env import ( + load_orderbook_threshold_cfg, + orderbook_filter_enabled, + orderbook_strategy_prefix, +) +from .trigger_eval_collect import orderbook_collect_enabled + LOG_BACKFILL_SOURCE = "log_backfill" def _strategy_prefix(strategy: str) -> str: - s = (strategy or "").strip().upper() - if s in ("BREAKOUT", "BO"): - return "BREAKOUT" - if s in ("MOMENTUM", "MOM"): - return "MOMENTUM" - if s in ("TAIL", "SHORT", "TAIL_CATCH"): - return "TAIL" - return s or "ORDERBOOK" - - -def orderbook_filter_enabled(strategy: str) -> bool: - prefix = _strategy_prefix(strategy) - sk = f"{prefix}_ORDERBOOK_FILTER_ENABLED" - raw = get_env_from_db(sk, "") - if str(raw).strip() != "": - return get_env_bool(sk, True) - return get_env_bool("ORDERBOOK_FILTER_ENABLED", True) + return orderbook_strategy_prefix(strategy) def _orderbook_filter_enabled_for_entry(params: Dict[str, Any], strategy: str) -> bool: @@ -49,60 +44,8 @@ def _orderbook_filter_enabled_for_entry(params: Dict[str, Any], strategy: str) - return orderbook_filter_enabled(strategy) -def _f(common: str, specific: str, default: float) -> float: - if str(get_env_from_db(specific, "")).strip(): - return get_env_float(specific, default) - return get_env_float(common, default) - - -def _i(common: str, specific: str, default: int) -> int: - sk = specific - if str(get_env_from_db(sk, "")).strip(): - return get_env_int(sk, default) - return get_env_int(common, default) - - def _load_filter_params(strategy: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - pfx = _strategy_prefix(strategy) - cfg = { - "max_spread_pct": _f("ORDERBOOK_MAX_SPREAD_PCT", f"{pfx}_ORDERBOOK_MAX_SPREAD_PCT", 0.45), - "entry_bid_levels": _i("ORDERBOOK_ENTRY_BID_LEVELS", f"{pfx}_ORDERBOOK_ENTRY_BID_LEVELS", 3), - "entry_bid_depth_mult": _f( - "ORDERBOOK_ENTRY_BID_DEPTH_MULT", f"{pfx}_ORDERBOOK_ENTRY_BID_DEPTH_MULT", 1.2, - ), - "entry_ask_max_mult": _f( - "ORDERBOOK_ENTRY_ASK_MAX_MULT", f"{pfx}_ORDERBOOK_ENTRY_ASK_MAX_MULT", 3.0, - ), - "min_bid_ask_ratio": _f( - "ORDERBOOK_MIN_BID_ASK_RATIO", f"{pfx}_ORDERBOOK_MIN_BID_ASK_RATIO", 0.85, - ), - "breakout_ask_wall_max_qty": _i( - "ORDERBOOK_BREAKOUT_ASK_WALL_MAX_QTY", - f"{pfx}_ORDERBOOK_ASK_WALL_MAX_QTY", - 5000, - ), - } - # 백테/파람서치 per-run 오버라이드 — 본체(kiwoom_0d) 재계산 시에만 의미. - # 웹 입력칸·그리드 호가필터 축이 이 키들을 채워 임계값을 바꾼다. - # (실매 루프는 이 키가 없어 env/DB 값 그대로 사용) - # _ob_max_spread_pct → 스프레드 상한(%) (cfg.max_spread_pct) - # _ob_min_bid_ask_ratio → 매수/매도 잔량비 하한 (cfg.min_bid_ask_ratio) - # _ob_ask_max_mult → 매도벽 허용배수(클수록 관대) (cfg.entry_ask_max_mult) - if params: - _overrides = ( - ("_ob_max_spread_pct", "max_spread_pct"), - ("_ob_min_bid_ask_ratio", "min_bid_ask_ratio"), - ("_ob_ask_max_mult", "entry_ask_max_mult"), - ("_ob_ask_wall_max_qty", "breakout_ask_wall_max_qty"), # 돌파 저항 근처 매도벽 수량 - ) - for src_key, cfg_key in _overrides: - ov = params.get(src_key) - if ov is not None and str(ov).strip() != "": - try: - cfg[cfg_key] = float(ov) - except (TypeError, ValueError): - pass - return cfg + return load_orderbook_threshold_cfg(strategy, params) def _resolve_ws(params: Dict[str, Any]): @@ -161,7 +104,7 @@ def _persist_orderbook_eval( reject_msg: Optional[str], current_price: float, ) -> None: - """실매 필터 판정 직후 RAM 스냅샷을 DB에 저장 (백테 재생용).""" + """실매 TRIGGER 판정 직후 RAM 스냅샷을 DB에 저장 (백테 재생용).""" if params.get("_backtest_orderbook_snapshot") is not None: return if params.get("_skip_trigger_eval_persist"): @@ -180,6 +123,77 @@ def _persist_orderbook_eval( ) +def _evaluate_orderbook_verdict( + snap: OrderbookSnapshot, + strategy: str, + params: Dict[str, Any], + *, + current_price: float, + resistance: Optional[float] = None, +) -> Tuple[Optional[str], Optional[str]]: + """호가 규칙만 평가 — (reject_code, msg) 또는 통과 (None, None).""" + cfg = _load_filter_params(strategy, params) + pfx = _strategy_prefix(strategy) + + spread = snap.spread_pct() + max_spread = float(cfg["max_spread_pct"]) + if spread > max_spread: + return ( + "탈락-호가스프레드", + f"스프레드 {spread:.2f}% > {max_spread:.2f}%", + ) + + bid_lv = int(cfg["entry_bid_levels"]) + bid_sum = snap.bid_qty_sum(bid_lv) + ask_sum = snap.ask_qty_sum(bid_lv) + if ask_sum > 0: + ratio = bid_sum / ask_sum + min_ratio = float(cfg["min_bid_ask_ratio"]) + if ratio < min_ratio: + return ( + "탈락-호가수급", + f"매수/매도잔량({bid_lv}호가) {ratio:.2f} < {min_ratio:.2f}", + ) + + qty_need = _estimate_entry_qty(params, current_price) + depth_mult = float(cfg["entry_bid_depth_mult"]) + if qty_need > 0 and bid_sum < int(qty_need * depth_mult): + return ( + "탈락-매수호가얇음", + f"매수{bid_lv}호가 합 {bid_sum}주 < 필요 {int(qty_need * depth_mult)}주", + ) + + ask_max_mult = float(cfg["entry_ask_max_mult"]) + if qty_need > 0 and ask_sum > int(qty_need * ask_max_mult): + return ( + "탈락-매도벽", + f"매도{bid_lv}호가 합 {ask_sum}주 > 허용 {int(qty_need * ask_max_mult)}주", + ) + + if pfx == "BREAKOUT" and resistance and resistance > 0: + wall_max = int(cfg["breakout_ask_wall_max_qty"]) + ask_above = sum( + lv.qty for lv in snap.asks + if lv.price > 0 and lv.price <= resistance * 1.002 + ) + if ask_above > wall_max: + return ( + "탈락-돌파매도벽", + f"저항 {resistance:,.0f} 근처 매도잔량 {ask_above} > {wall_max}", + ) + + if pfx == "TAIL" and snap.total_bid_qty > 0 and snap.total_ask_qty > 0: + tail_ratio = snap.total_bid_qty / snap.total_ask_qty + min_tail = float(cfg["min_bid_ask_ratio"]) + if tail_ratio < min_tail: + return ( + "탈락-꼬리지지부족", + f"총매수/총매도 {tail_ratio:.2f} < {min_tail:.2f}", + ) + + return (None, None) + + def orderbook_reject_for_entry( params: Dict[str, Any], strategy: str, @@ -190,14 +204,18 @@ def orderbook_reject_for_entry( """ TRIGGER 통과 직전 호가 검사. Returns: (reject_code, message) — 통과 시 (None, None) + + 필터 OFF + 수집 ON 이면 규칙은 평가·DB 저장만 하고 주문은 막지 않음. """ - if not _orderbook_filter_enabled_for_entry(params, strategy): + filter_on = _orderbook_filter_enabled_for_entry(params, strategy) + collect_on = orderbook_collect_enabled(params, strategy) + if not filter_on and not collect_on: return (None, None) - # log_backfill 판정 재생 — 호가 본체 없이 실매와 동일 pass/reject + # log_backfill 판정 재생 — 호가 본체 없이 실매와 동일 pass/reject (필터 ON일 때만) log_verdict = params.get("_backtest_log_orderbook_verdict") bt_snap = params.get("_backtest_orderbook_snapshot") - if log_verdict is not None and ( + if filter_on and log_verdict is not None and ( bt_snap is None or str(getattr(bt_snap, "source", "") or "") == LOG_BACKFILL_SOURCE ): rej_code, rej_msg = log_verdict @@ -207,79 +225,21 @@ def orderbook_reject_for_entry( snap = _fetch_snapshot(params) if snap is None: - # 실매 WS 미구독·만료 — 필터 스킵 (REST 부하 없음) + # 실매 WS 미구독·만료 — 필터·수집 모두 스킵 (REST 부하 없음) return (None, None) - cfg = _load_filter_params(strategy, params) - pfx = _strategy_prefix(strategy) + verdict = _evaluate_orderbook_verdict( + snap, strategy, params, + current_price=current_price, + resistance=resistance, + ) + rej_code, rej_msg = verdict - spread = snap.spread_pct() - max_spread = float(cfg["max_spread_pct"]) - if spread > max_spread: - rej = ( - "탈락-호가스프레드", - f"스프레드 {spread:.2f}% > {max_spread:.2f}%", + if collect_on: + _persist_orderbook_eval( + params, strategy, snap, rej_code, rej_msg, current_price, ) - _persist_orderbook_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - bid_lv = int(cfg["entry_bid_levels"]) - bid_sum = snap.bid_qty_sum(bid_lv) - ask_sum = snap.ask_qty_sum(bid_lv) - if ask_sum > 0: - ratio = bid_sum / ask_sum - min_ratio = float(cfg["min_bid_ask_ratio"]) - if ratio < min_ratio: - rej = ( - "탈락-호가수급", - f"매수/매도잔량({bid_lv}호가) {ratio:.2f} < {min_ratio:.2f}", - ) - _persist_orderbook_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - qty_need = _estimate_entry_qty(params, current_price) - depth_mult = float(cfg["entry_bid_depth_mult"]) - if qty_need > 0 and bid_sum < int(qty_need * depth_mult): - rej = ( - "탈락-매수호가얇음", - f"매수{bid_lv}호가 합 {bid_sum}주 < 필요 {int(qty_need * depth_mult)}주", - ) - _persist_orderbook_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - ask_max_mult = float(cfg["entry_ask_max_mult"]) - if qty_need > 0 and ask_sum > int(qty_need * ask_max_mult): - rej = ( - "탈락-매도벽", - f"매도{bid_lv}호가 합 {ask_sum}주 > 허용 {int(qty_need * ask_max_mult)}주", - ) - _persist_orderbook_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - if pfx == "BREAKOUT" and resistance and resistance > 0: - wall_max = int(cfg["breakout_ask_wall_max_qty"]) - ask_above = sum( - lv.qty for lv in snap.asks - if lv.price > 0 and lv.price <= resistance * 1.002 - ) - if ask_above > wall_max: - rej = ( - "탈락-돌파매도벽", - f"저항 {resistance:,.0f} 근처 매도잔량 {ask_above} > {wall_max}", - ) - _persist_orderbook_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - if pfx == "TAIL" and snap.total_bid_qty > 0 and snap.total_ask_qty > 0: - tail_ratio = snap.total_bid_qty / snap.total_ask_qty - min_tail = float(cfg["min_bid_ask_ratio"]) - if tail_ratio < min_tail: - rej = ( - "탈락-꼬리지지부족", - f"총매수/총매도 {tail_ratio:.2f} < {min_tail:.2f}", - ) - _persist_orderbook_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - _persist_orderbook_eval(params, strategy, snap, None, None, current_price) + if filter_on and rej_code: + return verdict return (None, None) diff --git a/kis_trader/engine/post_sell_candle_backfill.py b/kis_trader/engine/post_sell_candle_backfill.py new file mode 100644 index 0000000..710a3bf --- /dev/null +++ b/kis_trader/engine/post_sell_candle_backfill.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +""" +매수~매도(또는 ~now) 구간 1분봉 REST 백필 — 판 뒤 1회 / 보유 중 즉시 백필. + +실매는 벽시계로 청산하지만, ws_candles 가 중간에 끊기면 백테가 +그 종목 포지션을 오후까지 붙잡아 슬롯이 막힌다. +→ 보유 구간의 분봉을 키움 ka10080 으로 채운다 (DB UPSERT). + +- 기본 OFF 아님: ``POST_SELL_CANDLE_BACKFILL`` 기본 true +- 매도 체결 후 백그라운드 1회 (주문 경로 비차단) +- CLI/스크립트로 과거·현재 보유분 즉시 채우기 +""" +from __future__ import annotations + +import random +import threading +import time +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int +from kis_trader.utils.logger import get_logger + +logger = get_logger("kis_trader.post_sell_candle_backfill") + +_INSERT_SQL = """ + INSERT INTO ws_candles + (code, timeframe, candle_time, `open`, high, low, close, + volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + `open`=VALUES(`open`), high=VALUES(high), low=VALUES(low), + close=VALUES(close), + volume=IF(VALUES(volume) > volume, VALUES(volume), volume), + is_confirmed=1, updated_at=VALUES(updated_at), + source=IF(VALUES(volume) > volume, VALUES(source), source) +""" + + +def post_sell_candle_backfill_enabled() -> bool: + return get_env_bool("POST_SELL_CANDLE_BACKFILL", True) + + +def post_sell_candle_rollup_3m_enabled() -> bool: + """1분 채운 뒤 3분 롤업 UPSERT (꼬리 TF 정합).""" + return get_env_bool("POST_SELL_CANDLE_ROLLUP_3M", True) + + +def _dt_to_candle_key(raw: Any) -> str: + """datetime / 'YYYY-MM-DD HH:MM:SS' / 'YYYYMMDDHHMM' → YYYYMMDDHHMM.""" + if raw is None: + return "" + if isinstance(raw, datetime): + return raw.strftime("%Y%m%d%H%M") + s = str(raw).strip() + if len(s) >= 12 and s[:12].isdigit(): + return s[:12] + try: + return datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S").strftime("%Y%m%d%H%M") + except ValueError: + try: + return datetime.strptime(s[:16], "%Y-%m-%d %H:%M").strftime("%Y%m%d%H%M") + except ValueError: + return "" + + +def _bars_needed(start_key: str, end_key: str) -> int: + """ + ka10080 은 **최신→과거** N봉을 주므로, 보유 구간 길이만 요청하면 + 장초·어제 보유분은 아예 안 내려온다. + → ``지금(또는 end)에서 start 까지`` 캘린더 일수 × 장중분 여유로 요청. + """ + cap = max(120, int(get_env_int("POST_SELL_CANDLE_MAX_BARS", 1200))) + try: + start = datetime.strptime(start_key[:12], "%Y%m%d%H%M") + end = datetime.strptime(end_key[:12], "%Y%m%d%H%M") + except ValueError: + return min(cap, 500) + if end < start: + start, end = end, start + # REST 기준점 = max(end, now) — 이미 지난 청산도 ‘지금’에서 거슬러 올라감 + now = datetime.now() + anchor = end if end > now else now + cal_days = max(1, (anchor.date() - start.date()).days + 1) + # 1영업일 ≈ 390분 + 여유. 주말 포함 캘린더일 보정 + n = int(cal_days * 390 * 1.15) + 60 + return min(cap, max(150, n)) + + +def load_kiwoom_credentials(db: Any = None) -> Tuple[str, str, bool]: + """시세(ka10080)용 키움 키 — 기본 **실키** (KIS_MOCK 매매와 분리). + + ``POST_SELL_CANDLE_FORCE_MOCK=true`` 일 때만 모의 키. + """ + from kis_trader.utils.env import get_env_bool as _geb + from kis_trader.utils.env import get_env_from_db + + # 분봉 차트는 시세 → 실키 기본 (주문 모의와 무관) + force_mock = _geb("POST_SELL_CANDLE_FORCE_MOCK", False) + is_mock = bool(force_mock) + if is_mock: + key = str(get_env_from_db("KIWOOM_APP_KEY_MOCK", "") or "").strip() + sec = str(get_env_from_db("KIWOOM_APP_SECRET_MOCK", "") or "").strip() + else: + key = str(get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip() + sec = str(get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip() + if not key or not sec: + key = str(get_env_from_db("KIWOOM_APP_KEY", "") or "").strip() + sec = str(get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip() + if (not key or not sec) and db is not None: + try: + latest = db.get_latest_env() + snap = (latest or {}).get("snapshot") or {} + if is_mock: + key = str(snap.get("KIWOOM_APP_KEY_MOCK") or key).strip() + sec = str(snap.get("KIWOOM_APP_SECRET_MOCK") or sec).strip() + else: + key = str(snap.get("KIWOOM_APP_KEY_REAL") or key).strip() + sec = str(snap.get("KIWOOM_APP_SECRET_REAL") or sec).strip() + if not key or not sec: + key = str(snap.get("KIWOOM_APP_KEY") or key).strip() + sec = str(snap.get("KIWOOM_APP_SECRET") or sec).strip() + except Exception: + pass + return key, sec, bool(is_mock) + + +def _upsert_df_rows(db: Any, code: str, tf_min: int, rows: List[Dict[str, Any]]) -> int: + if not rows: + return 0 + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + payload = [] + for rec in rows: + try: + ct = str(rec.get("candle_time") or rec.get("time") or "")[:12] + if len(ct) < 12: + continue + payload.append(( + code, + int(tf_min), + ct, + float(rec["open"]), + float(rec["high"]), + float(rec["low"]), + float(rec["close"]), + int(float(rec.get("volume") or 0)), + None, None, None, + 1, + str(rec.get("source") or "kw_rest")[:10], + now_str, + )) + except Exception: + continue + if not payload: + return 0 + with db.conn._lock: + db.conn._ensure_connected() + cur = db.conn._conn.cursor() + cur.executemany(_INSERT_SQL, payload) + db.conn._conn.commit() + return len(payload) + + +def count_confirmed_1m(db: Any, code: str, start_key: str, end_key: str) -> int: + row = db.conn.execute( + "SELECT COUNT(*) AS n FROM ws_candles " + "WHERE timeframe=1 AND code=%s AND candle_time >= %s AND candle_time <= %s " + "AND is_confirmed=1", + (code, start_key[:12], end_key[:12]), + ).fetchone() + return int((row or {}).get("n") or 0) + + +def backfill_hold_window( + db: Any, + code: str, + start_raw: Any, + end_raw: Any, + *, + kiwoom_key: str = "", + kiwoom_secret: str = "", + is_mock: Optional[bool] = None, + rollup_3m: Optional[bool] = None, +) -> Dict[str, Any]: + """ + ``[start, end]`` 1분봉을 ka10080 으로 조회해 ws_candles UPSERT. + 반환: before/after/upserted/rollup3m/ok/error + """ + code = str(code or "").strip() + start_key = _dt_to_candle_key(start_raw) + end_key = _dt_to_candle_key(end_raw) + out: Dict[str, Any] = { + "code": code, + "start": start_key, + "end": end_key, + "before": 0, + "after": 0, + "upserted": 0, + "rollup3m": 0, + "ok": False, + "error": "", + } + if not code or len(start_key) < 12 or len(end_key) < 12: + out["error"] = "bad_range" + return out + if end_key < start_key: + start_key, end_key = end_key, start_key + out["start"], out["end"] = start_key, end_key + + before = count_confirmed_1m(db, code, start_key, end_key) + out["before"] = before + + key, sec, mock = kiwoom_key, kiwoom_secret, is_mock + if not key or not sec or mock is None: + k2, s2, m2 = load_kiwoom_credentials(db) + key = key or k2 + sec = sec or s2 + mock = m2 if mock is None else mock + if not key or not sec: + out["error"] = "no_kiwoom_keys" + return out + + n_req = _bars_needed(start_key, end_key) + try: + from kis_trader.ws.kis_ws import get_kiwoom_candles_df + + df = get_kiwoom_candles_df( + code, 1, key, sec, is_mock=bool(mock), n=n_req, + ) + except Exception as e: + out["error"] = f"ka10080:{e}" + logger.warning("📦 보유구간 백필 REST 실패 %s: %s", code, e) + return out + + if df is None or getattr(df, "empty", True): + out["error"] = "empty_df" + out["after"] = before + return out + + rows_1m: List[Dict[str, Any]] = [] + for _, rec in df.iterrows(): + ct = str(rec.get("time") or "")[:12] + if len(ct) < 12 or ct < start_key or ct > end_key: + continue + cl = float(rec.get("close") or 0) + if cl <= 0: + continue + rows_1m.append({ + "candle_time": ct, + "open": float(rec.get("open") or cl), + "high": float(rec.get("high") or cl), + "low": float(rec.get("low") or cl), + "close": cl, + "volume": int(float(rec.get("volume") or 0)), + "source": "kw_rest", + }) + + try: + upserted = _upsert_df_rows(db, code, 1, rows_1m) + except Exception as e: + out["error"] = f"upsert:{e}" + logger.warning("📦 보유구간 백필 UPSERT 실패 %s: %s", code, e) + return out + out["upserted"] = upserted + + do_rollup = post_sell_candle_rollup_3m_enabled() if rollup_3m is None else bool(rollup_3m) + if do_rollup and rows_1m: + try: + from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf + + bars3 = rollup_1m_bars_to_tf(rows_1m, 3) + for b in bars3: + b["source"] = "rollup_1m" + out["rollup3m"] = _upsert_df_rows(db, code, 3, bars3) + except Exception as e: + logger.debug("3M 롤업 스킵 %s: %s", code, e) + + after = count_confirmed_1m(db, code, start_key, end_key) + out["after"] = after + out["ok"] = True + logger.info( + "📦 보유구간 백필 %s %s~%s | 1M %d→%d (upsert %d) 3M+%d", + code, start_key, end_key, before, after, upserted, int(out["rollup3m"]), + ) + return out + + +def schedule_post_sell_backfill( + *, + code: str, + buy_date: Any, + sell_date: Any = None, + strategy: str = "", +) -> None: + """매도 체결 후 비동기 1회 백필 (주문 스레드 비차단).""" + if not post_sell_candle_backfill_enabled(): + return + code = str(code or "").strip() + if not code: + return + end = sell_date or datetime.now() + + def _worker() -> None: + try: + # 서버 부하 완충 — 실매 체결 직후 REST 폭주 방지 + lo = float(get_env_float("POST_SELL_CANDLE_SLEEP_MIN_SEC", 1.0)) + hi = float(get_env_float("POST_SELL_CANDLE_SLEEP_MAX_SEC", 3.0)) + if hi < lo: + hi = lo + time.sleep(random.uniform(lo, hi)) + from database import TradeDB + + db = TradeDB() + try: + backfill_hold_window(db, code, buy_date, end) + finally: + try: + db.close() + except Exception: + pass + except Exception as e: + logger.warning("📦 post-sell 백필 워커 예외 [%s/%s]: %s", strategy, code, e) + + threading.Thread( + target=_worker, + name=f"post_sell_candle_{code}", + daemon=True, + ).start() + + +def backfill_trades_from_db( + db: Any, + *, + buy_date_like: str = "2026-07-16%", + strategies: Optional[Sequence[str]] = None, + include_active: bool = True, + active_max_age_days: int = 5, +) -> List[Dict[str, Any]]: + """ + trade_history(청산) + 최근 active_trades(보유) 보유구간 즉시 백필. + ``buy_date_like`` — pymysql 바인딩용 (예: '2026-07-16%'). + """ + key, sec, mock = load_kiwoom_credentials(db) + results: List[Dict[str, Any]] = [] + sleep_lo = float(get_env_float("POST_SELL_CANDLE_SLEEP_MIN_SEC", 1.0)) + sleep_hi = float(get_env_float("POST_SELL_CANDLE_SLEEP_MAX_SEC", 3.0)) + if sleep_hi < sleep_lo: + sleep_hi = sleep_lo + + sql = ( + "SELECT code, name, strategy, buy_date, sell_date FROM trade_history " + "WHERE buy_date LIKE %s" + ) + params: List[Any] = [buy_date_like] + if strategies: + ph = ",".join(["%s"] * len(strategies)) + sql += f" AND strategy IN ({ph})" + params.extend(list(strategies)) + sql += " ORDER BY buy_date" + closed = db.conn.execute(sql, tuple(params)).fetchall() + + jobs: List[Tuple[str, Any, Any, str]] = [] + for r in closed: + d = dict(r) + jobs.append(( + str(d.get("code") or ""), + d.get("buy_date"), + d.get("sell_date"), + str(d.get("strategy") or ""), + )) + + if include_active: + act_sql = ( + "SELECT code, name, strategy, buy_date FROM active_trades " + "WHERE buy_date >= DATE_SUB(NOW(), INTERVAL %s DAY)" + ) + act_params: List[Any] = [int(active_max_age_days)] + if strategies: + ph = ",".join(["%s"] * len(strategies)) + act_sql += f" AND strategy IN ({ph})" + act_params.extend(list(strategies)) + for r in db.conn.execute(act_sql, tuple(act_params)).fetchall(): + d = dict(r) + jobs.append(( + str(d.get("code") or ""), + d.get("buy_date"), + datetime.now(), + str(d.get("strategy") or ""), + )) + + # 동일 code+구간 중복 제거 (같은 날 재진입은 구간 합치지 않고 각각) + seen = set() + uniq_jobs = [] + for code, b, e, sid in jobs: + if not code: + continue + sk = (_dt_to_candle_key(b), _dt_to_candle_key(e), code, sid) + if sk in seen: + continue + seen.add(sk) + uniq_jobs.append((code, b, e, sid)) + + logger.info( + "📦 즉시 백필 시작: %d건 (closed=%d active포함=%s)", + len(uniq_jobs), len(closed), include_active, + ) + for i, (code, b, e, sid) in enumerate(uniq_jobs, 1): + if i > 1: + time.sleep(random.uniform(sleep_lo, sleep_hi)) + st = backfill_hold_window( + db, code, b, e, + kiwoom_key=key, kiwoom_secret=sec, is_mock=mock, + ) + st["strategy"] = sid + results.append(st) + return results diff --git a/kis_trader/engine/program_filter.py b/kis_trader/engine/program_filter.py index f830ec3..d8184fa 100644 --- a/kis_trader/engine/program_filter.py +++ b/kis_trader/engine/program_filter.py @@ -4,6 +4,10 @@ kis_trader/engine/program_filter.py — TRIGGER 진입 프로그램매매 필터 실매 전용 — ``params['_program_ws']`` / ``_orderbook_ws`` / ``_whipsaw_ws`` 의 ``get_program_snapshot`` 사용. 백테·WS 미구독 시 스킵. +필터(주문 차단)와 수집(ws_program 저장)은 분리: + - ``PROGRAM_FILTER_ENABLED`` — 실매 탈락 적용 + - ``WS_PROGRAM_COLLECT_ENABLED`` — TRIGGER 판정 스냅 저장 (필터 OFF여도 가능) + 모든 임계값 env/DB — 하드코딩 금지. """ from __future__ import annotations @@ -14,6 +18,8 @@ from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, g from kis_trader.ws.program_cache import ProgramSnapshot from kis_trader.ws.trigger_eval_recorder import get_trigger_eval_recorder +from .trigger_eval_collect import program_collect_enabled + def _strategy_prefix(strategy: str) -> str: s = (strategy or "").strip().upper() @@ -127,7 +133,7 @@ def _persist_program_eval( reject_msg: Optional[str], current_price: float, ) -> None: - """실매 필터 판정 직후 RAM 스냅샷을 DB에 저장 (백테 재생용).""" + """실매 TRIGGER 판정 직후 RAM 스냅샷을 DB에 저장 (백테 재생용).""" if params.get("_backtest_program_snapshot") is not None: return if params.get("_skip_trigger_eval_persist"): @@ -146,6 +152,47 @@ def _persist_program_eval( ) +def _evaluate_program_verdict( + snap: ProgramSnapshot, + strategy: str, + params: Dict[str, Any], +) -> Tuple[Optional[str], Optional[str]]: + """프로그램 규칙만 평가 — (reject_code, msg) 또는 통과 (None, None).""" + cfg = _load_filter_params(strategy) + + min_net_qty = int(cfg["min_net_buy_qty"]) + if snap.net_qty < min_net_qty: + return ( + "탈락-프로그램매도세", + f"순매수수량 {snap.net_qty:,} < 최소 {min_net_qty:,}", + ) + + min_net_amt = int(cfg["min_net_buy_amt"]) + if min_net_amt > 0 and snap.net_amt < min_net_amt: + return ( + "탈락-프로그램순매수부족", + f"순매수대금 {snap.net_amt:,} < 최소 {min_net_amt:,}", + ) + + max_ratio = float(cfg["max_sell_buy_qty_ratio"]) + if max_ratio < 900.0 and snap.buy_qty > 0: + ratio = snap.sell_buy_qty_ratio() + if ratio > max_ratio: + return ( + "탈락-프로그램매도우위", + f"매도/매수수량비 {ratio:.2f} > {max_ratio:.2f}", + ) + + min_delta = int(cfg["min_net_delta_qty"]) + if min_delta > 0 and snap.delta_net_qty < min_delta: + return ( + "탈락-프로그램유입부족", + f"순매수증가 {snap.delta_net_qty:,} < 최소 {min_delta:,}", + ) + + return (None, None) + + def program_reject_for_entry( params: Dict[str, Any], strategy: str, @@ -155,9 +202,12 @@ def program_reject_for_entry( """ TRIGGER 통과 직전 프로그램매매 검사. Returns: (reject_code, message) — 통과 시 (None, None) + + 필터 OFF + 수집 ON 이면 규칙은 평가·DB 저장만 하고 주문은 막지 않음. """ - del current_price # 향후 가격 대비 수급 비율 필터용 예약 - if not _program_filter_enabled_for_entry(params, strategy): + filter_on = _program_filter_enabled_for_entry(params, strategy) + collect_on = program_collect_enabled(params, strategy) + if not filter_on and not collect_on: return (None, None) cfg = _load_filter_params(strategy) @@ -166,43 +216,14 @@ def program_reject_for_entry( if snap is None: return (None, None) - min_net_qty = int(cfg["min_net_buy_qty"]) - if snap.net_qty < min_net_qty: - rej = ( - "탈락-프로그램매도세", - f"순매수수량 {snap.net_qty:,} < 최소 {min_net_qty:,}", + verdict = _evaluate_program_verdict(snap, strategy, params) + rej_code, rej_msg = verdict + + if collect_on: + _persist_program_eval( + params, strategy, snap, rej_code, rej_msg, current_price, ) - _persist_program_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - min_net_amt = int(cfg["min_net_buy_amt"]) - if min_net_amt > 0 and snap.net_amt < min_net_amt: - rej = ( - "탈락-프로그램순매수부족", - f"순매수대금 {snap.net_amt:,} < 최소 {min_net_amt:,}", - ) - _persist_program_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - max_ratio = float(cfg["max_sell_buy_qty_ratio"]) - if max_ratio < 900.0 and snap.buy_qty > 0: - ratio = snap.sell_buy_qty_ratio() - if ratio > max_ratio: - rej = ( - "탈락-프로그램매도우위", - f"매도/매수수량비 {ratio:.2f} > {max_ratio:.2f}", - ) - _persist_program_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - min_delta = int(cfg["min_net_delta_qty"]) - if min_delta > 0 and snap.delta_net_qty < min_delta: - rej = ( - "탈락-프로그램유입부족", - f"순매수증가 {snap.delta_net_qty:,} < 최소 {min_delta:,}", - ) - _persist_program_eval(params, strategy, snap, rej[0], rej[1], current_price) - return rej - - _persist_program_eval(params, strategy, snap, None, None, current_price) + if filter_on and rej_code: + return verdict return (None, None) diff --git a/kis_trader/engine/range_break_engine.py b/kis_trader/engine/range_break_engine.py index 0d79d68..ef8897c 100644 --- a/kis_trader/engine/range_break_engine.py +++ b/kis_trader/engine/range_break_engine.py @@ -10,7 +10,6 @@ kis_trader/engine/range_break_engine.py — 박스권 돌파(RANGE_BREAK) 공통 """ from __future__ import annotations -from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from kis_trader.engine.range_break_env_keys import ( @@ -22,6 +21,7 @@ from kis_trader.strategies.breakout import ( check_sell_signal_breakout_live, normalize_breakout_max_loss_krw, ) +from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt RANGE_BREAK_STRATEGY_ID = "RANGE_BREAK" @@ -44,10 +44,6 @@ def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: return confirmed if confirmed else list(candles) -def _t2dt(t: str) -> datetime: - return datetime.strptime(str(t)[:12], "%Y%m%d%H%M") - - def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str: date = candle_time[:8] hm = int(candle_time[8:12]) @@ -281,25 +277,28 @@ def check_sell_signal_range_break_live( return check_sell_signal_breakout_live(position, current_candle, params, is_eod=is_eod) -def get_range_break_defaults_from_db(db=None) -> Dict[str, Any]: +def get_range_break_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """env_config + config_range_break 병합.""" own_db = None r: Dict[str, Any] = {} try: - if db is None: - from database import TradeDB - own_db = TradeDB() - db = own_db - if hasattr(db, "get_merged_env_snapshot"): - r = db.get_merged_env_snapshot() - elif hasattr(db, "get_latest_env"): - latest = db.get_latest_env() - r = dict((latest or {}).get("snapshot") or {}) + if env_row is not None: + r = dict(env_row) else: - row = db.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - r = dict(row) if row else {} + if db is None: + from database import TradeDB + own_db = TradeDB() + db = own_db + if hasattr(db, "get_merged_env_snapshot"): + r = db.get_merged_env_snapshot() + elif hasattr(db, "get_latest_env"): + latest = db.get_latest_env() + r = dict((latest or {}).get("snapshot") or {}) + else: + row = db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + r = dict(row) if row else {} except Exception: r = {} finally: @@ -450,10 +449,12 @@ def run_range_break_backtest( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: from kis_trader.backtest.range_break_portfolio_backtest import ( run_range_break_backtest_portfolio, ) return run_range_break_backtest_portfolio( codes_candles, params, universe_by_slot=universe_by_slot, + ticks_by_code=ticks_by_code, ) diff --git a/kis_trader/engine/scalping_engine.py b/kis_trader/engine/scalping_engine.py index 30841fd..6cfb320 100644 --- a/kis_trader/engine/scalping_engine.py +++ b/kis_trader/engine/scalping_engine.py @@ -6,10 +6,11 @@ SCALP reversal 전용. 모멘텀(MOMENTUM)은 ``momentum_engine.py`` 로 완전 ■ 라이브·백테 검증 원칙 (뇌동 분기 금지) - SCALP 진입/청산: 웹·CLI·실매가 ``check_buy_signal_*`` / ``check_sell_signal_live`` 동일 경로. - - MOMENTUM: ``momentum_engine.check_buy_signal_momentum_live`` / - ``check_sell_signal_momentum_live`` (백테는 ``check_sell_signal_momentum_backtest_bar``). + - MOMENTUM: ``momentum_engine`` 전용 (SCALP 와 분리). -■ SCALP reversal 공통 로직 +■ SCALP reversal 공통 로직 (A안 — RSI V자 + scalp_re HTS SCAN) + - SCAN: 키움 ``CONDITION_SCALP_KIWOOM_NAME`` (기본 scalp_re). + - ``SCALP_SKIP_HTS_SCAN_DUPES=true`` (kiwoom_condition 기본): TRIGGER 에 낙폭·RSI·되돌림 중복 검사 생략. - 매수(reversal): 시간대, 쿨다운, 일일 진입 횟수, RSI 과매도/과매수, 되돌림(음봉->양봉), 낙폭, 거래량. - 매수(macd): HTS C — MACD+Stochastic [12,26,5,3,3] 골든크로스 (``SCALP_USE_MACD_CROSS=true``). - 매수 방어: 고점추격 방지(high_chase_thr), 급등주(max_daily_chg), 최소 가격(min_price). @@ -24,7 +25,7 @@ from typing import List, Dict, Any, Optional, Tuple, Callable from kis_trader.utils.env import get_env_int -# 문자열/숫자/불리언 입력을 안전하게 bool로 변환 + def _frac_from_env_keys(r: Optional[dict], keys: Tuple[str, ...], default: float) -> float: """env_config 행에서 비율(0.003=0.3%) 값 로드. 키 순서대로 fallback.""" if r: @@ -71,24 +72,54 @@ def _to_bool(v: Any, default: bool = True) -> bool: return False return default + +def resolve_scalp_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool: + """ + HTS scalp_re SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부. + + - ``SCALP_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로 + - 미설정 → ``SCALP_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True + - ranking 등 전종목 소스 → False (TRIGGER 에서 HTS A/B/C 재검사) + """ + if r is None: + try: + from kis_trader.utils.env import get_strategy_env_dict + r = get_strategy_env_dict("SCALP") or {} + except Exception: + r = {} + raw = r.get("SCALP_SKIP_HTS_SCAN_DUPES") + if raw is not None and str(raw).strip() != "": + return _to_bool(raw, True) + # 엔진 defaults dict(이미 skip_hts_scan_dupes 해석됨)를 넘긴 경우 — + # env 키 없음 → universe fallback(True) 로 덮어쓰지 않음 (실매 false 고정 재발 방지) + if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None: + return _to_bool(r.get("skip_hts_scan_dupes"), False) + src = str(r.get("SCALP_UNIVERSE_SOURCE") or "condition").strip().lower() + return src in ("kiwoom_condition", "condition") + + # DB 기본값 로드 (백테스트/param_search가 동일한 값 사용하도록 단일 소스) -def get_scalping_defaults_from_db() -> Dict[str, Any]: +def get_scalping_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ config_scalp + env_config 병합에서 스캘핑 관련 기본값 로드. 백테스트 API·param_search·실매(get_env_from_db)가 동일 merged 소스를 사용. + env_row 가 있으면 DB 조회 생략 (웹 백테 env 타임라인용). """ try: - from kis_trader.utils.env import get_strategy_env_dict - r = get_strategy_env_dict("SCALP") - if not r: - from database import TradeDB - db = TradeDB() - if hasattr(db, "get_merged_env_snapshot"): - r = db.get_merged_env_snapshot() - else: - latest = db.get_latest_env() if hasattr(db, "get_latest_env") else None - r = dict((latest or {}).get("snapshot") or {}) - db.close() + if env_row is not None: + r = dict(env_row) + else: + from kis_trader.utils.env import get_strategy_env_dict + r = get_strategy_env_dict("SCALP") + if not r: + from database import TradeDB + db = TradeDB() + if hasattr(db, "get_merged_env_snapshot"): + r = db.get_merged_env_snapshot() + else: + latest = db.get_latest_env() if hasattr(db, "get_latest_env") else None + r = dict((latest or {}).get("snapshot") or {}) + db.close() if r: # SCALP_COOLDOWN_SEC(초) → cooldown_min(분). 실매매와 동일 키 사용 sec = r.get("SCALP_COOLDOWN_SEC") or r.get("REENTRY_COOLDOWN_SEC") or "600" @@ -97,10 +128,11 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: tax_pct = float(r.get("SELL_TAX_RATE_PCT") or 0.18) slot = float(r.get("SLOT_MONEY_DEFAULT") or 3_000_000) - # ──────── 방어 로직 파라미터 (스캘핑 전용 키 우선, 없으면 꼬리잡기 공용 키 fallback) ──────── + # ──────── 방어 로직 (SCALP_* 전용 키 우선 → 공용/타전략 fallback) ──────── + # HIGH_CHASE_THR·MOMENTUM_* 를 먼저 읽으면 Optuna/웹 apply 값이 무시됨. high_chase_thr = float( - r.get("HIGH_CHASE_THR") - or r.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD") + r.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD") + or r.get("HIGH_CHASE_THR") or r.get("HIGH_PRICE_CHASE_THRESHOLD") or 0.96, ) @@ -111,15 +143,15 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: or 20.0, ) min_price = float( - r.get("MOMENTUM_MIN_PRICE") - or r.get("SCALP_MIN_PRICE") + r.get("SCALP_MIN_PRICE") + or r.get("MOMENTUM_MIN_PRICE") or r.get("MIN_PRICE_TAIL") or 1000.0, ) max_loss_krw = int( float( - r.get("MOMENTUM_MAX_LOSS_PER_TRADE_KRW") - or r.get("SCALP_MAX_LOSS_PER_TRADE_KRW") + r.get("SCALP_MAX_LOSS_PER_TRADE_KRW") + or r.get("MOMENTUM_MAX_LOSS_PER_TRADE_KRW") or r.get("MAX_LOSS_PER_TRADE_KRW") or 200000, ), @@ -129,7 +161,11 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: if _min_drop_loss not in (None, ""): v = float(_min_drop_loss) min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v - min_margin = float(r.get("MOMENTUM_MIN_PROFIT_PCT") or r.get("SCALP_MIN_PROFIT_PCT") or 0.2) + min_margin = float( + r.get("SCALP_MIN_PROFIT_PCT") + or r.get("MOMENTUM_MIN_PROFIT_PCT") + or 0.2, + ) rsi_overbought = float(r.get("SCALP_RSI_OVERBOUGHT") or 75.0) use_defense_filters = _to_bool(r.get("SCALP_USE_DEFENSE_FILTERS"), True) use_macd_cross = _to_bool(r.get("SCALP_USE_MACD_CROSS"), False) @@ -139,7 +175,7 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: stoch_k_period = int(float(r.get("SCALP_STOCH_K_PERIOD") or 5)) stoch_d_period = int(float(r.get("SCALP_STOCH_D_PERIOD") or 3)) stoch_slow = int(float(r.get("SCALP_STOCH_SLOW") or 3)) - skip_hts_scan_dupes = _to_bool(r.get("MOMENTUM_SKIP_HTS_SCAN_DUPES"), True) + skip_hts_scan_dupes = resolve_scalp_skip_hts_scan_dupes(r) shoulder_min_high = _frac_from_env_keys( r, ("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), @@ -156,6 +192,19 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: max_stocks = int(float(r.get("SCALP_MAX_STOCKS") or r.get("MAX_STOCKS") or 3)) total_budget_krw = float(r.get("SCALP_TOTAL_BUDGET_KRW") or 0) portfolio_mode = _to_bool(r.get("SCALP_PORTFOLIO_MODE"), True) + rsi_period = int(float( + r.get("SCALP_RSI_PERIOD") or r.get("RSI_PERIOD") or 3, + )) + sl_pct = abs(float(r.get("SCALP_STOP_LOSS_PCT") or 0.015)) + tp_pct = abs(float(r.get("SCALP_TAKE_PROFIT_PCT") or 0.015)) + rsi_oversold = float(r.get("SCALP_RSI_OVERSOLD") or 25.0) + drop_rate = float(r.get("SCALP_MIN_DROP_RATE") or 0.015) + require_reversal_candle = _to_bool(r.get("SCALP_REQUIRE_REVERSAL_CANDLE"), True) + _vm = r.get("VOL_MULTIPLIER") + vol_mult = float(_vm) if _vm not in (None, "") else 0.0 + time_start_hm = int(float(r.get("SCALP_TIME_START") or r.get("TIME_START") or 900)) + time_end_hm = int(float(r.get("SCALP_TIME_END") or r.get("TIME_END") or 1530)) + max_daily = int(float(r.get("SCALP_MAX_DAILY") or r.get("MAX_DAILY") or 3)) else: cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0 high_chase_thr, max_daily_chg, min_price = 0.96, 20.0, 1000.0 @@ -169,6 +218,12 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: shoulder_min_high, shoulder_cut_pct, min_hold_sec = 0.005, 0.003, 30.0 tp_max_pct = 0.02 max_stocks, total_budget_krw, portfolio_mode = 3, 0.0, True + rsi_period = 3 + sl_pct, tp_pct = 0.015, 0.015 + rsi_oversold, drop_rate = 25.0, 0.015 + require_reversal_candle = True + vol_mult = 0.0 + time_start_hm, time_end_hm, max_daily = 900, 1530, 3 except Exception: cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0 @@ -183,21 +238,32 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]: shoulder_min_high, shoulder_cut_pct, min_hold_sec = 0.005, 0.003, 30.0 tp_max_pct = 0.02 max_stocks, total_budget_krw, portfolio_mode = 3, 0.0, True + rsi_period = 3 + sl_pct, tp_pct = 0.015, 0.015 + rsi_oversold, drop_rate = 25.0, 0.015 + require_reversal_candle = True + vol_mult = 0.0 + time_start_hm, time_end_hm, max_daily = 900, 1530, 3 return { "cooldown_min": cooldown_min, - "time_start_hm": 900, - "time_end_hm": 1530, - "time_start": 900, - "time_end": 1530, + "time_start_hm": time_start_hm, + "time_end_hm": time_end_hm, + "time_start": time_start_hm, + "time_end": time_end_hm, "fee_rate": fee_pct / 100, "sell_tax": tax_pct / 100, "slot_money": slot, - "rsi_period": 3, - "vol_mult": 0, + "rsi_period": rsi_period, + "rsi_oversold": rsi_oversold, + "sl_pct": sl_pct, + "tp_pct": tp_pct, + "drop_rate": drop_rate, + "require_reversal_candle": require_reversal_candle, + "vol_mult": vol_mult, "trail_trigger": 0.007, "trail_stop": 0.004, - "max_daily": 3, + "max_daily": max_daily, "high_chase_thr": high_chase_thr, "max_daily_chg": max_daily_chg, "min_price": min_price, @@ -392,6 +458,7 @@ def _apply_scalp_defense_and_vol( max_daily_chg = float(params.get("max_daily_chg", 20.0)) min_price = float(params.get("min_price", 1000.0)) use_defense_filters = _to_bool(params.get("use_defense_filters", True), True) + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) if use_defense_filters and cl < min_price: return ("reject", "탈락-최소가격", "%.0f < %.0f" % (cl, min_price)) @@ -409,7 +476,8 @@ def _apply_scalp_defense_and_vol( if running_open <= 0: return ("skip", None, None) - if use_defense_filters: + # HTS scalp_re SCAN(kiwoom) — B/C/F/D 이미 통과. 낙폭·고점·급등·분봉거래량 중복 생략 + if use_defense_filters and not skip_hts: dr = (running_open - running_low) / running_open if dr < drop_rate: return ("reject", "탈락-낙폭", "%.2f%% < %.1f%%(SCALP_MIN_DROP_RATE)" % (dr * 100, drop_rate * 100)) @@ -420,7 +488,7 @@ def _apply_scalp_defense_and_vol( if daily_chg_pct > max_daily_chg: return ("reject", "탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg)) - if vol_mult > 0: + if not skip_hts and vol_mult > 0: volumes = [float(x.get("volume", 0)) for x in candles] vol = volumes[i] if i < len(volumes) else 0 win = max(1, min(20, i)) @@ -432,8 +500,9 @@ def _apply_scalp_defense_and_vol( def _t2dt(t: str) -> datetime: - """candle_time 문자열 → datetime.""" - return datetime.strptime(t, "%Y%m%d%H%M") + """candle_time / 실매 buy_time → datetime (공통 파서).""" + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(t) def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str: @@ -557,6 +626,7 @@ def run_scalping_backtest( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: """ 종목별 캔들에 대해 스캘핑 백테스트 실행. 실매매와 동일한 규칙(방어로직 포함) 적용. @@ -568,6 +638,7 @@ def run_scalping_backtest( from kis_trader.backtest.scalping_portfolio_backtest import run_scalping_backtest_portfolio return run_scalping_backtest_portfolio( codes_candles, params, universe_by_slot=universe_by_slot, mode="reversal", + ticks_by_code=ticks_by_code, ) rsi_period = int(params.get("rsi_period", 3)) @@ -602,6 +673,23 @@ def run_scalping_backtest( # - False: 실매와 유사하게 포지션 오픈 유지(미청산은 결과 미기록) force_eod_exit = _to_bool(params.get("force_eod_exit"), False) + from kis_trader.engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + collect_minute_ticks, + resolve_backtest_sell, + strategy_tick_fallback_ohlc, + strategy_use_tick_exit, + ) + use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit( + params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True, + ) + tick_fallback_ohlc = strategy_tick_fallback_ohlc( + params, "SCALP_BACKTEST_TICK_FALLBACK_OHLC", default=False, + ) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="SCALP_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="SCALP_BACKTEST_SELL_SLIP_PCT") + all_trades: List[Dict] = [] for code, rows in codes_candles.items(): @@ -649,8 +737,34 @@ def run_scalping_backtest( # 백테스트가 이미 알고 있는 봉의 고/저를 즉시 사용하는 look-ahead를 방지. if c["candle_time"] == position["entry_time"]: continue - # check_sell_signal_backtest_bar — 1분봉 N회 청산(실매 current_price 정렬) - reason, exit_price = check_sell_signal_backtest_bar(position, c, params, is_eod) or (None, cl) + # 틱 있으면 resolve_backtest_sell — 없으면 기존 OHLC N회 청산 + reason = None + exit_price = cl + sell_time = c["candle_time"] + if use_tick_exit: + minute_ticks = collect_minute_ticks( + ticks_by_code, code, c["candle_time"], + ) + res5 = resolve_backtest_sell( + position, + c, + params, + is_eod=is_eod, + sell_fn=check_sell_signal_live, + low_mode="current", + ticks=minute_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, + ) + if res5: + reason, exit_price, sell_time, _hold, _src = res5 + else: + # check_sell_signal_backtest_bar — 1분봉 N회 청산(실매 current_price 정렬) + reason, exit_price = check_sell_signal_backtest_bar( + position, c, params, is_eod, + ) or (None, cl) if reason: qty = position["qty"] @@ -664,12 +778,12 @@ def run_scalping_backtest( - sell_amt * sell_tax ) hold_min = int( - (_t2dt(c["candle_time"]) - _t2dt(position["entry_time"])).total_seconds() / 60 + (_t2dt(sell_time or c["candle_time"]) - _t2dt(position["entry_time"])).total_seconds() / 60 ) all_trades.append({ "code": code, "buy_time": position["entry_time"], - "sell_time": c["candle_time"], + "sell_time": sell_time or c["candle_time"], "buy_price": position["entry_price"], "sell_price": round(exit_price, 2), "qty": qty, @@ -681,7 +795,7 @@ def run_scalping_backtest( "sell_reason": reason, "rsi_entry": round(position["rsi"], 1), }) - last_exit_dt[day] = _t2dt(c["candle_time"]) + last_exit_dt[day] = _t2dt(sell_time or c["candle_time"]) position = None continue @@ -746,24 +860,6 @@ def run_scalping_backtest( return all_trades -# ════════════════════════════════════════════════════════════════════════════ -# ── [레거시] 모멘텀 백테 진입점 — momentum_engine 으로 위임 ───────────────── -# ════════════════════════════════════════════════════════════════════════════ -# 모멘텀(MOMENTUM)은 2026-05 이후 ``momentum_engine`` 전용. -# 진입: ``check_buy_signal_momentum_live`` / 청산: ``check_sell_signal_momentum_live`` -# 백테·파서치: ``momentum_engine.run_momentum_backtest`` / -# ``check_sell_signal_momentum_backtest_bar`` -def run_scalping_backtest_momentum( - codes_candles: Dict[str, List[Dict]], - params: Dict[str, Any], - universe_by_slot: Optional[Dict[str, List[str]]] = None, -) -> List[Dict]: - """[폐기] ``momentum_engine.run_momentum_backtest`` 사용.""" - from kis_trader.engine.momentum_engine import run_momentum_backtest - return run_momentum_backtest(codes_candles, params, universe_by_slot=universe_by_slot) - - - # ── 실시간 봇용: 단일 시점 매수/매도 판단 (백테스트와 100% 동일 규칙) ────────────── def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: @@ -827,10 +923,23 @@ def _eval_scalp_buy_at_index( params: Dict[str, Any], macd_combined: Optional[Tuple[List[Optional[float]], List[Optional[float]]]] = None, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: - """SCALP 진입 모드 분기: MACD 골든크로스 vs RSI reversal.""" + """SCALP 진입 모드 분기: MACD 골든크로스 vs RSI reversal → 호가필터.""" if _to_bool(params.get("use_macd_cross", False), False): - return _eval_macd_golden_buy_at_index(candles, i, params, macd_combined=macd_combined) - return _eval_reversal_buy_at_index(candles, i, params) + reject, msg, sig = _eval_macd_golden_buy_at_index( + candles, i, params, macd_combined=macd_combined, + ) + else: + reject, msg, sig = _eval_reversal_buy_at_index(candles, i, params) + if reject or not sig: + return (reject, msg, sig) + # 호가필터 — ORDERBOOK_FILTER_ENABLED / params['_orderbook_filter_enabled'] + # 실매 기본 OFF 면 차단 없음(수집만). 파람서치 --orderbook-filter on 시 차단. + from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry + cl = float(candles[i].get("close") or 0) + ob_rej, ob_msg = orderbook_reject_for_entry(params, "SCALP", current_price=cl) + if ob_rej: + return (ob_rej, ob_msg, None) + return (None, None, sig) def _eval_macd_golden_buy_at_index( @@ -905,6 +1014,7 @@ def _eval_reversal_buy_at_index( min_price = float(params.get("min_price", 1000.0)) use_defense_filters = _to_bool(params.get("use_defense_filters", True), True) require_reversal_candle = bool(params.get("require_reversal_candle", True)) + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) c = candles[i] day = c["candle_time"][:8] @@ -922,6 +1032,10 @@ def _eval_reversal_buy_at_index( rsis = compute_rsi_series(closes, rsi_period) rsi = rsis[i] if i < len(rsis) else None + # kiwoom scalp_re SCAN — HTS B/C/F/D 통과 후 진입 타이밍만 (RSI V자·되돌림 중복 금지) + if skip_hts: + return (None, None, {"signal": True, "rsi": rsi, "entry_mode": "reversal_hts_scan"}) + if rsi is None: return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None) if rsi <= 0.0: @@ -992,35 +1106,6 @@ def check_buy_signal_live( return _eval_scalp_buy_at_index(confirmed, i, params) -# ── 모멘텀 추격형 진입 (SCALP_MODE=momentum) ──────────────────────────────── -# HTS/KIS ``scalp`` 조건검색(F/G/H/J)이 SCAN → TRIGGER 는 RSI·거래량·양봉·끝물컷만. -# HTS H = close > prev_open. (구버전 prev_close 비교는 HTS 와 불일치 → 수정됨) -# -# 기존 ``check_buy_signal_live`` (RSI 과매도 V자 반전형) 와 정반대 방향이라 -# 별도 함수로 둠. ``SCALP_MODE`` 토글로 어느 쪽이든 즉시 전환 가능. -def _eval_momentum_buy_at_index( - candles: List[Dict], - i: int, - params: Dict[str, Any], - state: Dict[str, Any], -) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: - """[폐기] ``momentum_engine.eval_momentum_buy_at_index`` 사용.""" - from kis_trader.engine.momentum_engine import eval_momentum_buy_at_index - return eval_momentum_buy_at_index(candles, i, params, state) - - - -def check_buy_signal_momentum_live( - candles: List[Dict], - params: Dict[str, Any], - state: Dict[str, Any], -) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: - """[폐기] ``momentum_engine.check_buy_signal_momentum_live`` 사용.""" - from kis_trader.engine.momentum_engine import check_buy_signal_momentum_live as _impl - return _impl(candles, params, state) - - - def _intrabar_exit_prices( open_: float, high: float, low: float, close: float, n_checks: int, ) -> List[float]: diff --git a/kis_trader/engine/strategy_eod.py b/kis_trader/engine/strategy_eod.py index 1ebfe80..f01c19f 100644 --- a/kis_trader/engine/strategy_eod.py +++ b/kis_trader/engine/strategy_eod.py @@ -10,7 +10,7 @@ from datetime import datetime as dt from typing import Any, Dict, Tuple -def parse_eod_hm(raw: str, default: str = "15:25") -> Tuple[int, int]: +def parse_eod_hm(raw: str, default: str = "15:20") -> Tuple[int, int]: """EOD 시각 문자열 → (시, 분). ``1515`` / ``15:15`` 모두 허용.""" s = str(raw or default).strip() if not s or s.lower() == "none": @@ -27,7 +27,7 @@ def parse_eod_hm(raw: str, default: str = "15:25") -> Tuple[int, int]: hh, mm = [int(x) for x in s.split(":")] return hh, mm except Exception: - return 15, 25 + return 15, 20 def is_live_eod_now( @@ -35,7 +35,7 @@ def is_live_eod_now( eod_hm: str, now: dt, *, - default_hm: str = "15:25", + default_hm: str = "15:20", ) -> bool: """실매 EOD 당일청산 시각 도달 여부.""" if not enabled: @@ -47,9 +47,10 @@ def is_live_eod_now( # 전략별 EOD env 키 — 실매·백테·파라서치 공통 _STRATEGY_EOD_SPEC: Dict[str, Tuple[str, str, bool, str, str]] = { "BREAKOUT": ("BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", True, "15:15", ""), - "MOMENTUM": ("MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", True, "15:25", "MOMENTUM_FORCE_EOD_EXIT"), - "TAIL": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"), - "SHORT": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"), + # 정규장 15:30 마감 — 최소 10분 전 강제청산 (순차매도 여유) + "MOMENTUM": ("MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", True, "15:20", "MOMENTUM_FORCE_EOD_EXIT"), + "TAIL": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:20", "force_eod_exit"), + "SHORT": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:20", "force_eod_exit"), } @@ -71,7 +72,7 @@ def resolve_strategy_eod_params( sid = "TAIL" spec = _STRATEGY_EOD_SPEC.get(sid) if spec is None: - return False, "15:25" + return False, "15:20" en_key, hm_key, def_en, def_hm, leg_key = spec if "eod_enabled" in params: @@ -98,7 +99,7 @@ def is_backtest_eod_bar( enabled: bool, eod_hm: str, *, - default_hm: str = "15:25", + default_hm: str = "15:20", ) -> bool: """백테 1분봉/스캔키 — 실매 ``is_live_eod_now`` 와 동일 시각 기준.""" if not enabled: diff --git a/kis_trader/engine/tail_engine.py b/kis_trader/engine/tail_engine.py index c69dc16..cf35f73 100644 --- a/kis_trader/engine/tail_engine.py +++ b/kis_trader/engine/tail_engine.py @@ -8,13 +8,13 @@ kis_trader/engine/tail_engine.py — 꼬리잡기 백테스트·실매매 공통 ■ SCAN vs TRIGGER (돌파 전략과 동일 원칙) [SCAN — HTS 조건검색, 널넬하게] **실매 권장: 키움 ``tail`` (``SHORT_UNIVERSE_SOURCE=kiwoom_condition``)** - A) [일] 시가→종가 -10% ~ -1.5% (당일 약세) + A) [일] 시가→종가 -10% ~ -0.5% (당일 약세) B) 체결강도 85% ~ 400% C) 3봉전 대비 거래량 180% ~ 2000% F) [일] 저가 대비 종가 +1% ~ +8% (꼬리 회복 구간) → ``KiwoomConditionSearchManager`` + ``CONDITION_SHORT_NAME=tail`` **레거시 KIS ``condition`` (REST 폴링)** - A) 1봉 등락률 -10% ~ -1.5% (직전봉 종가 대비) + A) 1봉 등락률 -10% ~ -0.5% (직전봉 종가 대비) B/C 동일 축 — ``ConditionSearchManager`` / ``tail`` → ``target_candidates_history`` (strategy_id=SHORT) 에 스냅샷 저장. @@ -30,17 +30,18 @@ kis_trader/engine/tail_engine.py — 꼬리잡기 백테스트·실매매 공통 1) 트레일(어깨컷) — max_price 갱신 후 되돌림(저가로 터치 판정, 체결=매도선), 발동 수익% 충족 시 **손절·금액손실보다 우선** 2) ATR 캡 적용 익절 / 3) ATR 캡 적용 손절 / 4) 금액손실컷(트레일 미발동 구간만) / 5) 장마감 ATR 목표·손절: 배수 × ATR 후 ``TAIL_ATR_*_MIN/MAX_PCT`` % 상·하한 캡 (잡주 과대 목표가 방지). - 백테 청산: N분 OHLC → N회 intrabar 체크 (``check_sell_signal_backtest_bar``, session_low 모드). + 백테 청산: ws_ticks 시간순 (``resolve_backtest_sell``, session_low) → OHLC 폴백 기본 OFF. 진입·지표는 N분봉 그대로. ■ 진입 모드 (``TAIL_ENTRY_MODE``) - - ``align``: 신호봉 확정 → 다음 3분봉 시가 시장가(레거시) + - ``align``: 신호봉 확정 → **직후 봉**(signal+tf, 1M→3M 합성 포함) 시가·첫 틱 시장가 + (구멍으로 먼 다음 3분 시가에 밀리지 않음) - ``limit_atr``(기본): 신호봉 확정 → anchor−ATR×mult 지정가 → ``TAIL_LIMIT_VALID_BARS`` 봉 내 low 터치 시 체결, 미체결 시 다음 봉부터 취소(실매) / 백테 스킵 """ from datetime import datetime -from typing import List, Dict, Any, Optional, Tuple +from typing import List, Dict, Any, Optional, Set, Tuple from kis_trader.engine.limit_entry_common import ( compute_atr_limit_price, @@ -59,16 +60,29 @@ from kis_trader.engine.whipsaw_filter import inject_whipsaw_ticks_into_params, w from kis_trader.backtest.trigger_snapshot_loader import inject_trigger_snapshots_into_params from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry from kis_trader.engine.program_filter import program_reject_for_entry -from kis_trader.engine.scalping_engine import check_sell_signal_backtest_bar +from kis_trader.engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + collect_minute_ticks, + resolve_backtest_sell, +) from kis_trader.engine.tail_tick_replay import ( + align_entry_execute_time, align_entry_price_from_ticks, collect_bar_ticks, + resolve_align_entry_bar, + tail_backtest_tick_fallback_ohlc, + tail_backtest_use_tick_exit, tail_backtest_wants_tick_replay, tail_timeframe_min, try_limit_fill_on_bar_with_ticks, ) from kis_trader.engine.atr_series import compute_atr_series -from kis_trader.engine.strategy_eod import is_strategy_eod_bar +from kis_trader.engine.strategy_eod import ( + is_strategy_eod_bar, + parse_eod_hm, + resolve_strategy_eod_params, +) from kis_trader.engine.tail_env_keys import ( tail_env_bool, tail_env_float, @@ -95,6 +109,87 @@ def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: return confirmed if confirmed else list(candles) +def resolve_tail_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool: + """ + HTS tail SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부. + + - ``TAIL_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로 + - 미설정 → ``SHORT_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True + """ + if r is None: + try: + from kis_trader.utils.env import get_strategy_env_dict + r = get_strategy_env_dict("TAIL") or {} + except Exception: + r = {} + raw = r.get("TAIL_SKIP_HTS_SCAN_DUPES") + if raw is not None and str(raw).strip() != "": + return tail_env_bool(r, "TAIL_SKIP_HTS_SCAN_DUPES", True) + # 엔진 defaults 에 이미 해석된 bool 이 있으면 universe fallback 금지 + if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None: + return _to_bool(r.get("skip_hts_scan_dupes"), False) + src = str(r.get("SHORT_UNIVERSE_SOURCE") or "kiwoom_condition").strip().lower() + return src in ("kiwoom_condition", "condition") + + +def _tail_symbol_reentry_gate( + daily_cnt: int, + daily_pnl_krw: float, + slot_money: float, + params: Dict[str, Any], +) -> Tuple[bool, str]: + """종목당 당일 재진입 게이트 — 무지성 횟수 제한 대신 손실예산·최소엣지로 판정. + + ``TAIL_MAX_DAILY`` (기본 3, 안전판)는 그대로 두되, 실질적인 과매매 통제는 + 이 게이트가 담당한다: + - 오늘 그 종목 **첫 진입**(daily_cnt==0)은 항상 통과. + - **재진입**(daily_cnt>=1)부터는: + 1) 종목 당일 실현손익이 손실한도(KRW·PCT 중 먼저 닿는 쪽=더 작은 금액) 이하면 차단. + → "3번까지만" 대신 "-3만원까지만" 처럼 손실 크기로 제어. + 2) ``TAIL_REENTRY_REQUIRE_NONNEG``/``TAIL_REENTRY_MIN_EDGE_KRW`` 미충족이면 차단. + → 수수료·세금(왕복 약 0.21%)도 못 건진 재진입을 걸러 회전매매 방지. + 반환: (allowed, reject_detail) — allowed=False 면 detail 에 사유 메시지. + """ + if daily_cnt <= 0: + return True, "" + loss_limit_krw = abs(float(params.get("symbol_daily_loss_limit_krw", 0) or 0)) + loss_limit_pct = abs(float(params.get("symbol_daily_loss_limit_pct", 0) or 0)) + candidate_limits: List[float] = [] + if loss_limit_krw > 0: + candidate_limits.append(loss_limit_krw) + if loss_limit_pct > 0 and slot_money > 0: + candidate_limits.append(slot_money * loss_limit_pct / 100.0) + if candidate_limits: + active_limit = min(candidate_limits) + if daily_pnl_krw <= -active_limit: + return False, f"당일 실현 {daily_pnl_krw:+,.0f}원 ≤ -{active_limit:,.0f}원" + require_nonneg = _to_bool(params.get("reentry_require_nonneg"), False) + if require_nonneg and daily_pnl_krw < 0: + return False, f"당일 실현 {daily_pnl_krw:+,.0f}원 < 0 (재진입 최소수익 미달)" + min_edge = float(params.get("reentry_min_edge_krw", 0) or 0) + if min_edge > 0 and daily_pnl_krw < min_edge: + return False, f"당일 실현 {daily_pnl_krw:+,.0f}원 < {min_edge:,.0f}원 (재진입 최소엣지 미달)" + return True, "" + + +def resolve_tail_cand_limit(params: Optional[Dict[str, Any]] = None) -> int: + """ + ``SHORT_CAND_LIMIT`` — 실매 ``BaseStrategy._post_filter_candidates`` 의 후보 하드캡과 + 동일한 값. 실매는 유니버스 편입 순서 상위 N개만 매수 체크 대상으로 본다(0=무제한). + 백테도 그 시각 유니버스에서 동일하게 상위 N개만 남겨야 실매와 정합된다. + + ``params["cand_limit"]`` 명시 시 그대로(파라서치 그리드용) → 없으면 DB + ``SHORT_CAND_LIMIT`` 값을 그대로 읽는다. + """ + if params is not None and params.get("cand_limit") is not None: + try: + return max(0, int(float(params.get("cand_limit")))) + except (TypeError, ValueError): + pass + from kis_trader.utils.env import get_env_int + return max(0, get_env_int("SHORT_CAND_LIMIT", 0)) + + def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str: """봉 시각을 N분 단위 슬롯 키로 변환. @@ -111,28 +206,32 @@ def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str: return date + str(slot_hm).zfill(4) -def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: +def get_tail_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ env_config 최신 행에서 꼬리잡기 관련 값과 고급 방어 로직 값을 전부 로드. 백테스트·파라미터서치·실매매가 동일 DB 값을 쓰도록 단일 소스. + env_row 가 있으면 DB 조회 생략 (웹 백테 env 타임라인용). """ own_db = None r: Dict[str, Any] = {} try: - if db is None: - from database import TradeDB - own_db = TradeDB() - db = own_db - if hasattr(db, "get_merged_env_snapshot"): - r = db.get_merged_env_snapshot() - elif hasattr(db, "get_latest_env"): - latest = db.get_latest_env() - r = dict((latest or {}).get("snapshot") or {}) + if env_row is not None: + r = dict(env_row) else: - row = db.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - r = dict(row) if row else {} + if db is None: + from database import TradeDB + own_db = TradeDB() + db = own_db + if hasattr(db, "get_merged_env_snapshot"): + r = db.get_merged_env_snapshot() + elif hasattr(db, "get_latest_env"): + latest = db.get_latest_env() + r = dict((latest or {}).get("snapshot") or {}) + else: + row = db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + r = dict(row) if row else {} if r: # TAIL_* 키만 사용 (레거시 MIN_DROP_RATE 등 폴백 없음) min_drop = tail_env_float(r, "TAIL_MIN_DROP_RATE", 0.03) @@ -150,7 +249,12 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: high_chase = tail_env_float(r, "TAIL_HIGH_CHASE_THR", 0.96) time_start = tail_env_int(r, "TAIL_TIME_START", 930) time_end = tail_env_int(r, "TAIL_TIME_END", 1500) - max_daily = tail_env_int(r, "TAIL_MAX_DAILY", 3) + max_daily = tail_env_int(r, "TAIL_MAX_DAILY", 20) + # 종목당 일일 재진입 게이트 — 무지성 횟수 대신 손실예산·최소엣지 (양수=절댓값) + symbol_daily_loss_limit_krw = tail_env_float(r, "TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW", 30000.0) + symbol_daily_loss_limit_pct = tail_env_float(r, "TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT", 1.5) + reentry_require_nonneg = tail_env_bool(r, "TAIL_REENTRY_REQUIRE_NONNEG", False) + reentry_min_edge_krw = tail_env_float(r, "TAIL_REENTRY_MIN_EDGE_KRW", 0.0) min_price = tail_env_float(r, "TAIL_MIN_PRICE", 1000.0) max_daily_change = tail_env_float(r, "TAIL_MAX_DAILY_CHG", 20.0) @@ -184,15 +288,18 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: max_stocks = tail_env_int(r, "TAIL_MAX_STOCKS", 3) total_budget_krw = tail_env_int(r, "TAIL_TOTAL_BUDGET_KRW", 0) slot_money = tail_env_int(r, "TAIL_SLOT_MONEY", 3_000_000) + # 실매 BaseStrategy._post_filter_candidates 후보 하드캡과 동일 (0=무제한) + cand_limit = tail_env_int(r, "SHORT_CAND_LIMIT", 0) short_max_buy = tail_env_int(r, "TAIL_MAX_BUY_AMOUNT", 0) from kis_trader.utils.env import get_env_from_db ratchet_tiers = str(r.get("TAIL_RATCHET_TIERS") or get_env_from_db("TAIL_RATCHET_TIERS", "") or "").strip() max_hold_bars = tail_env_int(r, "TAIL_MAX_HOLD_BARS", 0) eod_enabled = tail_env_bool(r, "TAIL_EOD_ENABLED", True) - eod_hm = str(r.get("TAIL_EOD_HM") or "15:25").strip() or "15:25" + eod_hm = str(r.get("TAIL_EOD_HM") or "15:20").strip() or "15:20" # ws_ticks 진입가 재생 기본 ON — tail_tick_replay·모멘텀·돌파와 동일 (실매 체결 정합, env=0 일 때만 OFF) backtest_use_tick_db = tail_env_bool(r, "TAIL_BACKTEST_USE_TICK_DB", True) - backtest_tick_fallback_ohlc = tail_env_bool(r, "TAIL_BACKTEST_TICK_FALLBACK_OHLC", True) + backtest_use_tick_exit = tail_env_bool(r, "TAIL_BACKTEST_USE_TICK_EXIT", True) + backtest_tick_fallback_ohlc = tail_env_bool(r, "TAIL_BACKTEST_TICK_FALLBACK_OHLC", False) 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) @@ -203,7 +310,9 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: shoulder_high, shoulder_cut = 0.005, 0.003 cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0 max_rec_3m, high_chase = 0.8, 0.96 - time_start, time_end, max_daily = 930, 1500, 3 + time_start, time_end, max_daily = 930, 1500, 20 + symbol_daily_loss_limit_krw, symbol_daily_loss_limit_pct = 30000.0, 1.5 + reentry_require_nonneg, reentry_min_edge_krw = False, 0.0 min_price, max_daily_change, ma20_max_above = 1000.0, 20.0, 3.0 stop_atr_mult, target_atr_mult = 2.0, 2.5 atr_sl_min_pct, atr_sl_max_pct = 0.8, 6.0 @@ -217,9 +326,11 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: bar_chg_min_pct, bar_chg_max_pct = -10.0, -1.5 tail_vol_mult, tail_vol_win = 0.0, 5 max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0 + cand_limit = 0 ratchet_tiers, max_hold_bars = "", 0 - eod_enabled, eod_hm = True, "15:25" - backtest_use_tick_db, backtest_tick_fallback_ohlc = False, True + eod_enabled, eod_hm = True, "15:20" + backtest_use_tick_db, backtest_use_tick_exit = True, True + backtest_tick_fallback_ohlc = False trail_pct, trail_arm_pct = 0.0, 0.0 _pat = _load_tail_pattern_params_from_row({}) except Exception: @@ -229,7 +340,9 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: shoulder_high, shoulder_cut = 0.015, 0.03 cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0 max_rec_3m, high_chase = 0.8, 0.96 - time_start, time_end, max_daily = 930, 1500, 3 + time_start, time_end, max_daily = 930, 1500, 20 + symbol_daily_loss_limit_krw, symbol_daily_loss_limit_pct = 30000.0, 1.5 + reentry_require_nonneg, reentry_min_edge_krw = False, 0.0 min_price, max_daily_change, ma20_max_above = 1000.0, 20.0, 3.0 stop_atr_mult, target_atr_mult = 2.5, 5.0 atr_sl_min_pct, atr_sl_max_pct = 0.8, 6.0 @@ -243,9 +356,11 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: bar_chg_min_pct, bar_chg_max_pct = -10.0, -1.5 tail_vol_mult, tail_vol_win = 0.0, 5 max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0 + cand_limit = 0 ratchet_tiers, max_hold_bars = "", 0 - eod_enabled, eod_hm = True, "15:25" - backtest_use_tick_db, backtest_tick_fallback_ohlc = False, True + eod_enabled, eod_hm = True, "15:20" + backtest_use_tick_db, backtest_use_tick_exit = True, True + backtest_tick_fallback_ohlc = False trail_pct, trail_arm_pct = 0.0, 0.0 _pat = _load_tail_pattern_params_from_row({}) finally: @@ -273,6 +388,11 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: "time_start_hm": time_start, "time_end_hm": time_end, "max_daily": max_daily, + # 종목당 일일 재진입 게이트 (기본 0/false=OFF, 동작 불변) + "symbol_daily_loss_limit_krw": symbol_daily_loss_limit_krw, + "symbol_daily_loss_limit_pct": symbol_daily_loss_limit_pct, + "reentry_require_nonneg": reentry_require_nonneg, + "reentry_min_edge_krw": reentry_min_edge_krw, # 고급 방어 파라미터 반환 "min_price": min_price, "max_daily_change": max_daily_change, @@ -304,6 +424,7 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: "total_budget_krw": total_budget_krw, "slot_money": slot_money, "short_max_buy_amount": short_max_buy, + "cand_limit": cand_limit, "portfolio_mode": True, "entry_mode": short_entry_mode( {"entry_mode": r.get("TAIL_ENTRY_MODE")} if r else None @@ -329,6 +450,7 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]: {"limit_fill_slip_pct": tail_env_float(r, "TAIL_LIMIT_FILL_SLIP_PCT", 0.0) if r else None} ).get("fill_slip_pct", 0.0)), "backtest_use_tick_db": backtest_use_tick_db, + "backtest_use_tick_exit": backtest_use_tick_exit, "backtest_tick_fallback_ohlc": backtest_tick_fallback_ohlc, # 체결량 상한(진입봉 거래량×N%) — 0=OFF. 실매 IOC 미체결 근사 (DB·웹·CLI 공통). "backtest_vol_fill_cap_pct": tail_env_float(r, "TAIL_BACKTEST_VOL_FILL_CAP_PCT", 0.0) if r else 0.0, @@ -376,8 +498,9 @@ def compute_sma_series(closes: List[float], period: int = 20) -> List[Optional[f def _t2dt(t: str) -> datetime: - """candle_time 문자열 → datetime.""" - return datetime.strptime(t, "%Y%m%d%H%M") + """candle_time / 실매 buy_time → datetime (공통 파서).""" + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(t) def _pct_to_frac(v: Any, default_pct: float) -> float: @@ -800,7 +923,10 @@ def _eval_tail_buy_at_index( ma20_max_above = float(params.get("ma20_max_above", 3.0)) # SCAN/TRIGGER 분리 — 조건검색 유니버스 사용 시 HTS A/B/C 중복 필터 생략 - skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), True) + if params.get("skip_hts_scan_dupes") is None: + skip_hts = resolve_tail_skip_hts_scan_dupes() + else: + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), resolve_tail_skip_hts_scan_dupes()) use_intraday_drop = _to_bool(params.get("use_intraday_drop"), False) use_ma20 = _to_bool(params.get("use_ma20_filter"), False) use_rsi = _to_bool(params.get("use_rsi_filter"), True) @@ -812,7 +938,19 @@ def _eval_tail_buy_at_index( if hm < time_start_hm or hm > time_end_hm: return (None, None, None) # 시간대 탈락 if state.get("daily_cnt", 0) >= max_daily: - return (None, None, None) + return ( + "탈락-일일한도", + f"daily={state.get('daily_cnt', 0)}/{max_daily}", + None, + ) + _reentry_ok, _reentry_detail = _tail_symbol_reentry_gate( + int(state.get("daily_cnt", 0) or 0), + float(state.get("daily_pnl_krw", 0.0) or 0.0), + float(params.get("slot_money", 0) or 0), + params, + ) + if not _reentry_ok: + return ("탈락-종목일일손익게이트", _reentry_detail, None) last_exit_dt = state.get("last_exit_dt") if last_exit_dt is not None: elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60 @@ -985,7 +1123,7 @@ def check_buy_signal_live( 실시간 꼬리잡기 매수 신호. - ``live_backtest_align=True`` (기본): 백테와 동일 — **직전 확정봉=신호봉**, - **현재 확정봉=진입봉** (신호봉 조건 충족 후 다음 3분봉 시가 진입). + **현재 확정봉=진입봉** (신호 직후 봉 시가·실매 시장가 정합). - ``live_backtest_align=False``: 구버전 — 마지막 확정봉 1개만 검사. candles: 3분봉 (candle_time, open, high, low, close, volume) @@ -1004,31 +1142,159 @@ def check_buy_signal_live( if live_align: entry_i = len(confirmed) - 1 - for k in range(lookback): - signal_i = entry_i - 1 - k - if signal_i < 19: - break - reject, msg, sig = _eval_tail_buy_at_index( - confirmed, signal_i, params, state, - ) - if reject: - if k == 0: - last_reject = (reject, msg, None) - continue - if sig: - ent = confirmed[entry_i] - entry_open = float(ent.get("open", 0) or 0) - if entry_open <= 0: - entry_open = float(ent.get("close", 0) or 0) - sig["entry_price"] = entry_open - sig["entry_bar_key"] = ent.get("candle_time") - return (None, None, sig) - return last_reject + return _eval_live_align_lookback( + confirmed, entry_i, params, state, lookback=lookback, + ) i = len(confirmed) - 1 return _eval_tail_buy_at_index(confirmed, i, params, state) +def _eval_live_align_lookback( + candles: List[Dict], + entry_i: int, + params: Dict[str, Any], + state: Dict[str, Any], + *, + lookback: int = 1, +) -> tuple: + """실매 align: entry_i=진입봉, entry_i-1-k=신호봉 (lookback개까지).""" + lb = max(1, int(lookback)) + last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = ( + None, None, None, + ) + for k in range(lb): + signal_i = entry_i - 1 - k + if signal_i < 19: + break + reject, msg, sig = _eval_tail_buy_at_index( + candles, signal_i, params, state, + ) + if reject: + if k == 0: + last_reject = (reject, msg, None) + continue + if sig: + ent = candles[entry_i] + entry_open = float(ent.get("open", 0) or 0) + if entry_open <= 0: + entry_open = float(ent.get("close", 0) or 0) + sig["entry_price"] = entry_open + sig["entry_bar_key"] = ent.get("candle_time") + sig["signal_bar_key"] = candles[signal_i].get("candle_time") + sig["signal_i"] = signal_i + sig["entry_i"] = entry_i + return (None, None, sig) + return last_reject + + +def _last_closed_bar_index( + candles: List[Dict], + as_of_hhmm: str, + tf_min: int, +) -> int: + """as_of(YYYYMMDDHHMM) 시점까지 **이미 마감**된 마지막 봉 인덱스. 없으면 -1. + + 봉 시작 + tf_min ≤ as_of 이면 확정(실매: 진행 중 봉은 신호/진입봉으로 안 씀). + """ + from kis_trader.engine.candle_rollup import add_candle_minutes + + as_of = str(as_of_hhmm or "")[:12] + if len(as_of) < 12 or not candles: + return -1 + best = -1 + for i, c in enumerate(candles): + ct = str(c.get("candle_time") or "")[:12] + if len(ct) < 12: + continue + end_t = add_candle_minutes(ct, int(tf_min)) + if end_t and end_t <= as_of: + best = i + elif ct > as_of: + break + return best + + +def _first_universe_minute_by_code( + universe_by_slot: Optional[Dict[str, List[str]]], + universe_timeline: Optional[Any], + codes: Optional[set] = None, +) -> Dict[str, str]: + """종목별 최초 편입 분키(YYYYMMDDHHMM).""" + out: Dict[str, str] = {} + want = codes + if universe_timeline is not None and hasattr(universe_timeline, "_keys"): + try: + keys = list(getattr(universe_timeline, "_keys") or []) + code_lists = list(getattr(universe_timeline, "_codes") or []) + for sk, snap_codes in zip(keys, code_lists): + minute = str(sk)[:12] + for c in snap_codes or []: + cs = str(c).strip() + if not cs or (want is not None and cs not in want): + continue + if cs not in out: + out[cs] = minute + if out: + return out + except Exception: + pass + if not universe_by_slot: + return out + for slot in sorted(universe_by_slot.keys()): + minute = str(slot)[:12] + for c in universe_by_slot.get(slot) or []: + cs = str(c).strip() + if not cs or (want is not None and cs not in want): + continue + if cs not in out: + out[cs] = minute + return out + + +def _universe_enter_minutes( + universe_by_slot: Optional[Dict[str, List[str]]], + universe_timeline: Optional[Any], + codes: Optional[set] = None, +) -> Set[str]: + """유니버스 ENTER 분키 집합 (재편입 포함) — 실매 REAL I(편입) 시각 근사. + + 최초 편입만 쓰는 ``_first_universe_minute_by_code`` 와 달리, + EXIT 후 다시 들어온 분도 시계에 넣어 Phase 0-incl 이 재검사한다. + """ + out: Set[str] = set() + want = codes + prev: Set[str] = set() + + def _iter_snaps(): + if universe_timeline is not None and hasattr(universe_timeline, "_keys"): + try: + keys = list(getattr(universe_timeline, "_keys") or []) + code_lists = list(getattr(universe_timeline, "_codes") or []) + for sk, snap_codes in zip(keys, code_lists): + yield str(sk)[:12], { + str(c).strip() for c in (snap_codes or []) if str(c).strip() + } + return + except Exception: + pass + if not universe_by_slot: + return + for slot in sorted(universe_by_slot.keys()): + yield str(slot)[:12], { + str(c).strip() for c in (universe_by_slot.get(slot) or []) if str(c).strip() + } + + for minute, cur in _iter_snaps(): + if want is not None: + cur = {c for c in cur if c in want} + entered = cur - prev + if entered and minute: + out.add(minute) + prev = cur + return out + + def _tail_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]: """래칫 단계 트레일 — 돌파 ``_breakout_ratchet_tiers`` 와 동일 형식. @@ -1085,8 +1351,9 @@ def _tail_minutes_held(position: Dict[str, Any], current_candle: Dict[str, Any]) if not et or not ct: return None try: - e_dt = _t2dt(str(et)[:12]) - c_dt = _t2dt(str(ct)[:12]) + # 실매 buy_time 은 "YYYY-MM-DD HH:MM:SS" — [:12] 자르면 파싱 실패 + e_dt = _t2dt(str(et)) + c_dt = _t2dt(str(ct)) return max(0, int((c_dt - e_dt).total_seconds() // 60)) except Exception: return None @@ -1239,6 +1506,56 @@ def _portfolio_exposure_krw(portfolio: Dict[str, Dict[str, Any]]) -> float: return total +def _last_close_at_or_before( + candles: List[Dict], + t: str, + default: float, +) -> float: + """시계 ``t`` 이하 마지막 확정 종가 — EOD 틱 없을 때 실매 장마감가 근사.""" + key = str(t or "")[:12] + last = float(default or 0) + if not key: + return last + for c in candles: + ct = str(c.get("candle_time") or "")[:12] + if not ct: + continue + if ct > key: + break + px = float(c.get("close") or 0) + if px > 0: + last = px + return last + + +def _inject_tail_eod_timeline_minutes( + all_times_set: Set[str], + params: Dict[str, Any], + period_ymd: str, +) -> None: + """ + 실매 DB ``TAIL_EOD_ENABLED`` / ``TAIL_EOD_HM`` (params eod_*) 토글을 백테 시계에 반영. + 3분봉 격자에 EOD 분이 없어도, 실매 벽시계처럼 해당 HHMM에 청산 판정 가능. + """ + enabled, eod_hm = resolve_strategy_eod_params(params, "TAIL") + if not enabled: + return + hh, mm = parse_eod_hm(eod_hm) + days: Set[str] = set() + py = str(period_ymd or "")[:8] + if len(py) == 8 and py.isdigit(): + days.add(py) + for t in all_times_set: + d = str(t or "")[:8] + if len(d) == 8 and d.isdigit(): + if py and d < py: + continue + days.add(d) + for d in days: + all_times_set.add(f"{d}{hh:02d}{mm:02d}") + + + def _tail_max_stocks_from_params(params: Dict[str, Any]) -> int: for key in ("max_stocks", "short_max_stocks"): v = params.get(key) @@ -1342,6 +1659,83 @@ def _universe_codes_at( return None +def _fill_portfolio_align_entry( + *, + portfolio: Dict[str, Dict[str, Any]], + ctx: Dict[str, Any], + code: str, + pe: Dict[str, Any], + t: str, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], + tick_tf: int, + use_ticks: bool, + max_stocks: int, + slot_money: float, + total_budget: float, + min_invest_ratio: float, + vol_fill_cap_pct: float, + tick_fill_stats: Dict[str, int], +) -> Tuple[bool, int, int]: + """align 예약 진입 체결 — 실매: 진입봉 확정 직후 시장가.""" + skipped_micro = 0 + skipped_vol = 0 + if code in portfolio or not pe: + return False, skipped_micro, skipped_vol + if len(portfolio) >= max_stocks: + return False, skipped_micro, skipped_vol + + entry_price = float(pe.get("entry_price") or 0) + if use_ticks: + bar_key = str(pe.get("entry_bar_key") or t)[:12] + bar_ticks = collect_bar_ticks(ticks_by_code, code, bar_key, tick_tf) + if bar_ticks: + entry_price, align_src = align_entry_price_from_ticks(bar_ticks, entry_price) + if align_src in tick_fill_stats: + tick_fill_stats[align_src] += 1 + if entry_price <= 0: + return False, skipped_micro, skipped_vol + + exposure = _portfolio_exposure_krw(portfolio) + remaining = max(0.0, total_budget - exposure) + target_qty, target_cost = _tail_target_qty_and_cost(entry_price, float(slot_money)) + min_required = target_cost * min_invest_ratio + if target_qty < 1 or remaining < min_required: + return False, 1, 0 + + invest = min(float(slot_money), remaining, target_cost) + qty = int(invest / entry_price) + if qty < 1: + return False, 1, 0 + + if vol_fill_cap_pct > 0: + _eidx = ctx["time_index"].get(str(pe.get("entry_bar_key") or t)[:12]) + if _eidx is None: + _eidx = ctx["time_index"].get(t) + _ebar = ctx["candles"][_eidx] if _eidx is not None else {} + capped = _vol_capped_qty(qty, _ebar, vol_fill_cap_pct) + if capped < qty: + if capped < 1: + return False, 0, 1 + qty = capped + + cost = qty * entry_price + if cost < min_required: + return False, 1, 0 + if exposure + cost > total_budget + 1e-6: + return False, 1, 0 + + portfolio[code] = { + "entry_price": entry_price, + "entry_time": t, + "stop": pe["stop"], + "target": pe["target"], + "max_price": entry_price, + "session_low": entry_price, + "qty": qty, + } + return True, 0, 0 + + def run_tail_backtest_portfolio( candles_by_code: Dict[str, List[Dict]], params: Dict[str, Any], @@ -1367,6 +1761,9 @@ def run_tail_backtest_portfolio( if total_budget <= 0: total_budget = float(max_stocks * slot_money) min_invest_ratio = _tail_min_invest_ratio_of_slot(params) + # 종목 일일 손익 게이트용 — 실매 realized_pnl 과 동일 net 기준으로 온라인 누적 (수수료/세금 반영) + gate_fee_rate = float(params.get("fee_rate", 0.00015) or 0.00015) + gate_sell_tax = float(params.get("sell_tax", 0.0018) or 0.0018) skipped_micro_buys = 0 # 체결량 상한(유동성 제약 근사) — 진입봉 거래량×N% 까지만 체결. 0=OFF(동작 불변). # 실매 시장가 IOC 가 호가에 있는 만큼만 체결되는 것을 봉단위로 근사 (소형주 미체결↑). @@ -1374,12 +1771,20 @@ def run_tail_backtest_portfolio( skipped_vol_unfilled = 0 tick_tf = tail_timeframe_min(params) use_ticks = bool(ticks_by_code) and tail_backtest_wants_tick_replay(params) + use_tick_exit = bool(ticks_by_code) and tail_backtest_use_tick_exit(params) + tick_fallback_ohlc = tail_backtest_tick_fallback_ohlc(params) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="TAIL_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="TAIL_BACKTEST_SELL_SLIP_PCT") tick_fill_stats = {"ws_ticks": 0, "ohlc_low": 0, "ohlc_open": 0} + tick_exit_count = 0 + ohlc_exit_count = 0 attach_indicator_caches_to_params(params, candles_by_code) # 종목별 컨텍스트 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() + period_ymd = str(params.get("_bt_period_start_ymd") or "")[:8] + universe_timeline = params.get("_universe_timeline") for code, raw_rows in candles_by_code.items(): if len(raw_rows) < min_bars: continue @@ -1395,24 +1800,162 @@ def run_tail_backtest_portfolio( "time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)}, "last_exit_dt": {}, "daily_cnt": {}, + "daily_pnl": {}, "pending_entry": None, "pending_limit": None, } for c in candles: - all_times_set.add(c["candle_time"]) + ct = c["candle_time"] + # 웜업(전일) 봉은 지표용 — 매매 시계에는 기간일만 + if period_ymd and str(ct)[:8] < period_ymd: + continue + all_times_set.add(ct) + # align: 진입 체결은 봉 **마감** 시각(실매 확정봉 직후) — 시계에 마감분 추가 + _bar_close = align_entry_execute_time(ct, tick_tf) + if _bar_close: + if not period_ymd or str(_bar_close)[:8] >= period_ymd: + all_times_set.add(_bar_close) + + # 편입·재편입 시각을 시계에 넣어, 봉 경계가 아니어도 실매처럼 즉시 매수 판정 + enter_mins = _universe_enter_minutes( + universe_by_slot, universe_timeline, set(ctx_by_code.keys()), + ) + for _m in enter_mins: + if _m and (not period_ymd or str(_m)[:8] >= period_ymd): + all_times_set.add(_m) + + # 실매 EOD 토글·시각(DB) → 백테 공유 시계에 EOD 분 주입 (종목별 3분봉 키 유무와 무관) + _inject_tail_eod_timeline_minutes(all_times_set, params, period_ymd) all_times = sorted(all_times_set) portfolio: Dict[str, Dict[str, Any]] = {} all_trades: List[Dict] = [] # 초단위 유니버스 타임라인 (실매 get_universe_at 정합, 돌파·모멘텀 공통). 없으면 1분 슬롯 폴백. - universe_timeline = params.get("_universe_timeline") + # 실매 후보 하드캡(SHORT_CAND_LIMIT) — 백테도 그 시각 유니버스 상위 N개만 검사해야 + # "실매는 20개만 보는데 백테는 그 시각 풀 전체를 본다" 는 정합 오차가 사라진다. + cand_limit = resolve_tail_cand_limit(params) + inclusion_lookback_on = _to_bool( + params.get("backtest_inclusion_lookback", True), True, + ) + if "backtest_inclusion_lookback" not in params: + from kis_trader.utils.env import get_env_bool + inclusion_lookback_on = get_env_bool("TAIL_BT_INCLUSION_LOOKBACK", True) + live_sig_lookback = max(1, int(params.get("live_signal_lookback_bars", 1))) + inclusion_entries = 0 + prev_uni_set: Optional[Set[str]] = None + + from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at for t in all_times: + if apply_env_timeline_at(params, t, "SHORT"): + max_stocks = _tail_max_stocks_from_params(params) + slot_money = resolve_tail_invest_amount_krw(params) + total_budget = _tail_total_budget_from_params(params) + if total_budget <= 0: + total_budget = float(max_stocks * slot_money) + cand_limit = resolve_tail_cand_limit(params) + slot_key = _slot_key(t, params.get("scan_interval_min", 1)) uni_codes = _universe_codes_at(t, slot_key, universe_timeline, universe_by_slot) + if uni_codes is not None and cand_limit > 0 and len(uni_codes) > cand_limit: + uni_codes = uni_codes[:cand_limit] uni_set = set(uni_codes) if uni_codes is not None else None + # ── Phase 0-incl: 유니버스 신규·재편입 직후 lookback (실매 check_buy_signal_live) ── + if ( + inclusion_lookback_on + and uni_set is not None + and not is_limit_atr_entry(short_entry_mode(params)) + ): + # 실매: EXIT 후 RE-ENTER 때도 즉시 매수 검사. 최초 편입만 보면 샘표류 누락. + if prev_uni_set is None: + newly = set(uni_set) # 첫 스냅샷 = 전원 신규 편입으로 취급 + else: + newly = uni_set - prev_uni_set + if newly and len(portfolio) < max_stocks: + incl_cands: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = [] + for code in newly: + if code in portfolio or code not in ctx_by_code: + continue + ctx = ctx_by_code[code] + if ctx.get("pending_entry") or ctx.get("pending_limit"): + continue + entry_i = _last_closed_bar_index(ctx["candles"], t, tick_tf) + if entry_i < 19: + continue + day = str(t)[:8] + if ctx["daily_cnt"].get(day, 0) >= int(params.get("max_daily", 3)): + continue + eval_params = dict(params) + ic = get_indicator_cache_from_params(params, code) + if ic is not None: + eval_params["_indicator_cache"] = ic + state = { + "daily_cnt": ctx["daily_cnt"].get(day, 0), + "last_exit_dt": ctx["last_exit_dt"].get(day), + "daily_pnl_krw": ctx["daily_pnl"].get(day, 0.0), + } + reject, _msg, sig = _eval_live_align_lookback( + ctx["candles"], entry_i, eval_params, state, + lookback=live_sig_lookback, + ) + if reject or not sig: + continue + ent = ctx["candles"][entry_i] + entry_price = float(sig.get("entry_price") or ent.get("open") or 0) + if entry_price <= 0: + continue + atr = ( + ctx["atrs"][entry_i] + if entry_i < len(ctx["atrs"]) and ctx["atrs"][entry_i] is not None + else entry_price * 0.01 + ) + stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params) + exec_t = align_entry_execute_time(ent.get("candle_time"), tick_tf) or t + pri = _buy_priority_key(code, uni_codes) + incl_cands.append((pri, code, { + "entry_time": exec_t, + "entry_price": entry_price, + "stop": stop_p, + "target": target_p, + "entry_bar_key": ent.get("candle_time"), + "from_inclusion": True, + })) + if incl_cands: + incl_cands.sort(key=lambda x: x[0]) + _pri, pick_code, pe = incl_cands[0] + # 편입 직후 = 실매 즉시 시장가 (진입봉 이미 확정된 경우 같은 시각 체결) + _ctx_pick = ctx_by_code[pick_code] + if str(pe.get("entry_time") or "")[:12] == str(t)[:12]: + _filled, _sm, _sv = _fill_portfolio_align_entry( + portfolio=portfolio, + ctx=_ctx_pick, + code=pick_code, + pe=pe, + t=t, + ticks_by_code=ticks_by_code, + tick_tf=tick_tf, + use_ticks=use_ticks, + max_stocks=max_stocks, + slot_money=float(slot_money), + total_budget=float(total_budget), + min_invest_ratio=min_invest_ratio, + vol_fill_cap_pct=vol_fill_cap_pct, + tick_fill_stats=tick_fill_stats, + ) + skipped_micro_buys += _sm + skipped_vol_unfilled += _sv + if _filled: + inclusion_entries += 1 + else: + _ctx_pick["pending_entry"] = pe + else: + _ctx_pick["pending_entry"] = pe + inclusion_entries += 1 + + if uni_set is not None: + prev_uni_set = set(uni_set) # ── Phase 0a: ATR 지정가 체결 (유효 봉 low ≤ limit) ── for code, ctx in ctx_by_code.items(): pl = ctx.get("pending_limit") @@ -1480,7 +2023,7 @@ def run_tail_backtest_portfolio( ctx["pending_limit"] = None break - # ── Phase 0b: 예약 진입 (align — 다음 봉 시가) ── + # ── Phase 0b: 예약 진입 (align — 진입봉 확정 직후 첫 틱 / 편입 lookback) ── pending_codes = [ code for code, ctx in ctx_by_code.items() if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t @@ -1495,53 +2038,26 @@ def run_tail_backtest_portfolio( continue if len(portfolio) >= max_stocks: break - entry_price = float(pe["entry_price"]) - if use_ticks: - bar_ticks = collect_bar_ticks(ticks_by_code, code, t, tick_tf) - entry_price, align_src = align_entry_price_from_ticks(bar_ticks, entry_price) - if align_src in tick_fill_stats: - tick_fill_stats[align_src] += 1 - if entry_price <= 0: - continue - exposure = _portfolio_exposure_krw(portfolio) - remaining = max(0.0, total_budget - exposure) - target_qty, target_cost = _tail_target_qty_and_cost(entry_price, float(slot_money)) - min_required = target_cost * min_invest_ratio - if target_qty < 1 or remaining < min_required: - skipped_micro_buys += 1 - continue - invest = min(float(slot_money), remaining, target_cost) - qty = int(invest / entry_price) - if qty < 1: - skipped_micro_buys += 1 - continue - # 유동성 제약: 진입봉 거래량×cap% 까지만 체결 (실매 IOC 미체결 근사) - if vol_fill_cap_pct > 0: - _eidx = ctx["time_index"].get(t) - _ebar = ctx["candles"][_eidx] if _eidx is not None else {} - capped = _vol_capped_qty(qty, _ebar, vol_fill_cap_pct) - if capped < qty: - if capped < 1: - skipped_vol_unfilled += 1 - continue - qty = capped - cost = qty * entry_price - if cost < min_required: - skipped_micro_buys += 1 - continue - if exposure + cost > total_budget + 1e-6: - skipped_micro_buys += 1 - continue - portfolio[code] = { - "entry_price": entry_price, - "entry_time": t, - "stop": pe["stop"], - "target": pe["target"], - "max_price": entry_price, - "session_low": entry_price, - "qty": qty, - } - break # 1시각 1매수 + _filled, _sm, _sv = _fill_portfolio_align_entry( + portfolio=portfolio, + ctx=ctx, + code=code, + pe=pe, + t=t, + ticks_by_code=ticks_by_code, + tick_tf=tick_tf, + use_ticks=use_ticks, + max_stocks=max_stocks, + slot_money=float(slot_money), + total_budget=float(total_budget), + min_invest_ratio=min_invest_ratio, + vol_fill_cap_pct=vol_fill_cap_pct, + tick_fill_stats=tick_fill_stats, + ) + skipped_micro_buys += _sm + skipped_vol_unfilled += _sv + if _filled: + break # 1시각 1매수 # ── Phase 1: 보유 종목 청산 ── for code in list(portfolio.keys()): @@ -1549,47 +2065,93 @@ def run_tail_backtest_portfolio( if ctx is None: continue idx = ctx["time_index"].get(t) - if idx is None: - continue candles = ctx["candles"] - c = candles[idx] day = t[:8] - hi = float(c["high"]) - lo = float(c["low"]) - cl = float(c["close"]) - op = float(c["open"]) - - is_eod = is_strategy_eod_bar(candles[idx]["candle_time"], params, "TAIL") - pos = portfolio[code] - max_p = max(float(pos.get("max_price", 0) or 0), hi) - hp = float(c.get("holding_peak") or 0) - if hp > 0: - max_p = max(max_p, hp) - pos["max_price"] = max_p - cur_c_info = { - "open": op, - "high": hi, - "low": lo, - "close": cl, - "candle_time": t, - } - res = check_sell_signal_backtest_bar( + # 편입 lookback 등으로 타임라인에 3분봉 키가 아닌 분(예: 13:23)이 + # 들어오면 time_index miss. 실매는 WS 틱마다 청산하므로, 해당 분 + # 틱만으로 청산 검사(봉 OHLC 폴백 없음 — 유령 청산 방지). + # 단 EOD는 실매 벽시계와 동일하게 ``t`` 기준(DB eod_enabled/eod_hm)으로 추적. + is_eod = is_strategy_eod_bar(t, params, "TAIL") + if idx is None: + if not use_tick_exit and not is_eod: + continue + bar_ticks = ( + collect_minute_ticks(ticks_by_code, code, t) + if use_tick_exit else None + ) + if not bar_ticks and not is_eod: + continue + ep0 = float(pos.get("entry_price") or 0) + # EOD·분키 miss: 직전 종가로 장마감 체결 근사 (틱 있으면 틱 경로 우선) + last_px = _last_close_at_or_before(candles, t, ep0) + if last_px <= 0: + last_px = ep0 + max_p = max(float(pos.get("max_price") or ep0), last_px) + sess_lo = float(pos.get("session_low") or ep0) + if last_px > 0: + sess_lo = min(sess_lo, last_px) if sess_lo > 0 else last_px + pos["max_price"] = max_p + if sess_lo > 0: + pos["session_low"] = sess_lo + cur_c_info = { + "open": last_px, + "high": max_p, + "low": sess_lo if sess_lo > 0 else last_px, + "close": last_px, + "candle_time": t, + } + else: + c = candles[idx] + hi = float(c["high"]) + lo = float(c["low"]) + cl = float(c["close"]) + op = float(c["open"]) + + max_p = max(float(pos.get("max_price", 0) or 0), hi) + hp = float(c.get("holding_peak") or 0) + if hp > 0: + max_p = max(max_p, hp) + pos["max_price"] = max_p + + cur_c_info = { + "open": op, + "high": hi, + "low": lo, + "close": cl, + "candle_time": t, + } + bar_ticks = ( + collect_bar_ticks(ticks_by_code, code, t, tick_tf) + if use_tick_exit else None + ) + # 분키 miss: 일반 청산은 틱만(유령 OHLC 방지). EOD만 시계·직전종가 폴백 허용. + _tick_fb = bool(is_eod) if idx is None else bool(tick_fallback_ohlc or is_eod) + res = resolve_backtest_sell( pos, cur_c_info, params, is_eod=is_eod, sell_fn=check_sell_signal_live, low_mode="session_low", + ticks=bar_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=_tick_fb, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, ) if not res: continue - reason, exit_price = res + reason, exit_price, sell_time, _hold_min, exit_src = res + if exit_src == "ws_ticks": + tick_exit_count += 1 + else: + ohlc_exit_count += 1 all_trades.append({ "code": code, "entry_time": pos["entry_time"], - "exit_time": t, + "exit_time": sell_time or t, "entry": round(pos["entry_price"]), "exit": round(exit_price), "pnl": 0, @@ -1598,11 +2160,20 @@ def run_tail_backtest_portfolio( "peak_price": round(max_p), "qty": pos.get("qty", 1), }) - ctx["last_exit_dt"][day] = _t2dt(t) + ctx["last_exit_dt"][day] = _t2dt(sell_time or t) ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 + # 종목 일일 손익 게이트용 net pnl 누적 — attach_tail_trade_pnl 과 동일 공식(수수료/세금) + _qty = int(pos.get("qty", 1) or 1) + _ep, _xp = float(pos["entry_price"]), float(exit_price) + _net_pnl = ( + (_xp - _ep) * _qty + - (_ep + _xp) * _qty * gate_fee_rate + - _xp * _qty * gate_sell_tax + ) + ctx["daily_pnl"][day] = ctx["daily_pnl"].get(day, 0.0) + _net_pnl del portfolio[code] - # ── Phase 2: 신규 매수 신호 (다음 봉 시가 진입 예약) ── + # ── Phase 2: 신규 매수 신호 (align=진입봉 확정 직후 / limit=신호봉 이후 지정가) ── if len(portfolio) >= max_stocks: continue exposure = _portfolio_exposure_krw(portfolio) @@ -1617,18 +2188,13 @@ def run_tail_backtest_portfolio( for code, ctx in ctx_by_code.items(): if code in portfolio or ctx.get("pending_entry") or ctx.get("pending_limit"): continue - idx = ctx["time_index"].get(t) - if idx is None: - continue candles = ctx["candles"] - c = candles[idx] day = t[:8] - cl = float(c["close"]) if uni_set is not None: if code not in uni_set: continue - if cl <= 0 or hm < time_start_hm or hm > time_end_hm: + if hm < time_start_hm or hm > time_end_hm: continue if ctx["daily_cnt"].get(day, 0) >= int(params.get("max_daily", 3)): continue @@ -1641,36 +2207,42 @@ def run_tail_backtest_portfolio( ic = get_indicator_cache_from_params(params, code) if ic is not None: eval_params["_indicator_cache"] = ic - inject_whipsaw_ticks_into_params( - eval_params, - ticks_by_code=ticks_by_code, - code=code, - bar_candle_time=t, - strategy="TAIL", - tf_min=tick_tf, - ) - inject_trigger_snapshots_into_params( - eval_params, - orderbook_by_code=orderbook_by_code, - program_by_code=program_by_code, - code=code, - bar_candle_time=t, - ) - if universe_by_slot is not None: - eval_params.setdefault("skip_hts_scan_dupes", True) - else: - eval_params.setdefault("skip_hts_scan_dupes", False) + if "skip_hts_scan_dupes" not in eval_params: + eval_params["skip_hts_scan_dupes"] = resolve_tail_skip_hts_scan_dupes() state = { "daily_cnt": ctx["daily_cnt"].get(day, 0), "last_exit_dt": ctx["last_exit_dt"].get(day), + "daily_pnl_krw": ctx["daily_pnl"].get(day, 0.0), } - reject, _msg, sig = _eval_tail_buy_at_index(candles, idx, eval_params, state) - if reject or not sig: - continue - atr = ctx["atrs"][idx] if ctx["atrs"][idx] is not None else cl * 0.01 - pri = _buy_priority_key(code, uni_codes) if is_limit_atr_entry(short_entry_mode(params)): + idx = ctx["time_index"].get(t) + if idx is None: + continue + c = candles[idx] + cl = float(c["close"]) + if cl <= 0: + continue + inject_whipsaw_ticks_into_params( + eval_params, + ticks_by_code=ticks_by_code, + code=code, + bar_candle_time=t, + strategy="TAIL", + tf_min=tick_tf, + ) + inject_trigger_snapshots_into_params( + eval_params, + orderbook_by_code=orderbook_by_code, + program_by_code=program_by_code, + code=code, + bar_candle_time=t, + ) + reject, _msg, sig = _eval_tail_buy_at_index(candles, idx, eval_params, state) + if reject or not sig: + continue + atr = ctx["atrs"][idx] if ctx["atrs"][idx] is not None else cl * 0.01 + pri = _buy_priority_key(code, uni_codes) lp_cfg = tail_limit_params(params) sig_bar = candles[idx] anchor_px = resolve_limit_anchor_price( @@ -1695,20 +2267,53 @@ def run_tail_backtest_portfolio( })) continue - if idx + 1 >= len(candles): + # align — 진입봉 **마감** 시각에만 검사 (실매: 확정봉 직후 시장가) + entry_i = _last_closed_bar_index(candles, t, tick_tf) + if entry_i < 19: continue - next_c = candles[idx + 1] - if next_c["candle_time"][:8] != day: + ent = candles[entry_i] + close_t = align_entry_execute_time(ent.get("candle_time"), tick_tf) + if not close_t or str(t)[:12] != str(close_t)[:12]: continue - entry_price = float(next_c["open"]) + ent_ct = str(ent.get("candle_time") or "") + inject_whipsaw_ticks_into_params( + eval_params, + ticks_by_code=ticks_by_code, + code=code, + bar_candle_time=ent_ct, + strategy="TAIL", + tf_min=tick_tf, + ) + inject_trigger_snapshots_into_params( + eval_params, + orderbook_by_code=orderbook_by_code, + program_by_code=program_by_code, + code=code, + bar_candle_time=ent_ct, + ) + reject, _msg, sig = _eval_live_align_lookback( + candles, entry_i, eval_params, state, + lookback=live_sig_lookback, + ) + if reject or not sig: + continue + cl = float(ent.get("close") or 0) + atr = ( + ctx["atrs"][entry_i] + if entry_i < len(ctx["atrs"]) and ctx["atrs"][entry_i] is not None + else cl * 0.01 + ) + pri = _buy_priority_key(code, uni_codes) + entry_price = float(sig.get("entry_price") or ent.get("open") or cl) if entry_price <= 0: - entry_price = cl + continue stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params) candidates.append((pri, code, { - "entry_time": next_c["candle_time"], + "entry_time": close_t, "entry_price": entry_price, "stop": stop_p, "target": target_p, + "entry_bar_key": ent.get("candle_time"), })) if not candidates: @@ -1718,7 +2323,30 @@ def run_tail_backtest_portfolio( if pe.get("pending_limit"): ctx_by_code[pick_code]["pending_limit"] = pe else: - ctx_by_code[pick_code]["pending_entry"] = pe + _ctx_pick = ctx_by_code[pick_code] + if str(pe.get("entry_time") or "")[:12] == str(t)[:12]: + _filled, _sm, _sv = _fill_portfolio_align_entry( + portfolio=portfolio, + ctx=_ctx_pick, + code=pick_code, + pe=pe, + t=t, + ticks_by_code=ticks_by_code, + tick_tf=tick_tf, + use_ticks=use_ticks, + max_stocks=max_stocks, + slot_money=float(slot_money), + total_budget=float(total_budget), + min_invest_ratio=min_invest_ratio, + vol_fill_cap_pct=vol_fill_cap_pct, + tick_fill_stats=tick_fill_stats, + ) + skipped_micro_buys += _sm + skipped_vol_unfilled += _sv + if not _filled: + _ctx_pick["pending_entry"] = pe + else: + _ctx_pick["pending_entry"] = pe skip_stats: Dict[str, Any] = {} if skipped_micro_buys: @@ -1727,6 +2355,11 @@ def run_tail_backtest_portfolio( skip_stats["skipped_vol_unfilled"] = skipped_vol_unfilled if use_ticks: skip_stats["tick_entry_sources"] = dict(tick_fill_stats) + if tick_exit_count or ohlc_exit_count: + skip_stats["tick_exit_count"] = tick_exit_count + skip_stats["ohlc_exit_count"] = ohlc_exit_count + if inclusion_entries: + skip_stats["inclusion_lookback_entries"] = inclusion_entries if skip_stats: params["_portfolio_skip_stats"] = skip_stats return all_trades @@ -1757,6 +2390,10 @@ def run_tail_backtest( # ── 레거시: 종목별 독립 시뮬 ── tick_tf = tail_timeframe_min(params) use_ticks = bool(ticks_by_code) and tail_backtest_wants_tick_replay(params) + use_tick_exit = bool(ticks_by_code) and tail_backtest_use_tick_exit(params) + tick_fallback_ohlc = tail_backtest_tick_fallback_ohlc(params) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="TAIL_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="TAIL_BACKTEST_SELL_SLIP_PCT") # 파라미터 준비 min_drop_rate = float(params.get("min_drop_rate", 0.03)) @@ -1782,6 +2419,8 @@ def run_tail_backtest( kelly_mult = float(params.get("kelly_mult", 0.25)) capital = float(params.get("capital", 100000000.0)) static_sl_pct = abs(float(params.get("sl_pct", 0.03))) + gate_fee_rate = float(params.get("fee_rate", 0.00015) or 0.00015) + gate_sell_tax = float(params.get("sell_tax", 0.0018) or 0.0018) all_trades: List[Dict] = [] @@ -1798,6 +2437,7 @@ def run_tail_backtest( position = None last_exit_dt: Dict[str, datetime] = {} daily_cnt: Dict[str, int] = {} + daily_pnl: Dict[str, float] = {} cur_day = None running_open, running_high, running_low = 0.0, 0.0, 0.0 @@ -1839,21 +2479,31 @@ def run_tail_backtest( "close": cl, "candle_time": c["candle_time"], } - res = check_sell_signal_backtest_bar( + bar_ticks = ( + collect_bar_ticks( + ticks_by_code, code, c["candle_time"], tick_tf, + ) if use_tick_exit else None + ) + res = resolve_backtest_sell( position, cur_c_info, params, is_eod=is_eod, sell_fn=check_sell_signal_live, low_mode="session_low", + ticks=bar_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, ) if res: - reason, exit_price = res + reason, exit_price, sell_time, _hold_min, _exit_src = res all_trades.append({ "code": code, "entry_time": position["entry_time"], - "exit_time": c["candle_time"], + "exit_time": sell_time or c["candle_time"], "entry": round(position["entry_price"]), "exit": round(exit_price), "pnl": 0, @@ -1862,8 +2512,17 @@ def run_tail_backtest( "peak_price": round(max_p), "qty": position.get("qty", 1), }) - last_exit_dt[day] = _t2dt(c["candle_time"]) + last_exit_dt[day] = _t2dt(sell_time or c["candle_time"]) daily_cnt[day] = daily_cnt.get(day, 0) + 1 + # 종목 일일 손익 게이트용 net pnl 누적 (포트폴리오 루프와 동일 공식) + _qty = int(position.get("qty", 1) or 1) + _ep, _xp = float(position["entry_price"]), float(exit_price) + _net_pnl = ( + (_xp - _ep) * _qty + - (_ep + _xp) * _qty * gate_fee_rate + - _xp * _qty * gate_sell_tax + ) + daily_pnl[day] = daily_pnl.get(day, 0.0) + _net_pnl position = None i += 1 continue @@ -1888,23 +2547,22 @@ def run_tail_backtest( continue eval_params = dict(params) - if universe_by_slot is not None: - eval_params.setdefault("skip_hts_scan_dupes", True) - else: - eval_params.setdefault("skip_hts_scan_dupes", False) + if "skip_hts_scan_dupes" not in eval_params: + eval_params["skip_hts_scan_dupes"] = resolve_tail_skip_hts_scan_dupes() state = { "daily_cnt": daily_cnt.get(day, 0), "last_exit_dt": last_exit_dt.get(day), + "daily_pnl_krw": daily_pnl.get(day, 0.0), } - reject, _msg, sig = _eval_tail_buy_at_index(candles, i, eval_params, state) - if reject or not sig: - i += 1 - continue + live_sig_lb = max(1, int(params.get("live_signal_lookback_bars", 1))) - atr = atrs[i] if atrs[i] is not None else cl * 0.01 - - # ── 3. 매수 — limit_atr: 유효 봉 내 지정가 터치 / align: 다음 봉 시가 ── + # ── 3. 매수 — limit_atr: 신호봉 i / align: 진입봉 확정(다음 봉 시각) ── if is_limit_atr_entry(short_entry_mode(params)): + reject, _msg, sig = _eval_tail_buy_at_index(candles, i, eval_params, state) + if reject or not sig: + i += 1 + continue + atr = atrs[i] if atrs[i] is not None else cl * 0.01 lp_cfg = tail_limit_params(params) sig_bar = candles[i] anchor_px = resolve_limit_anchor_price( @@ -1956,23 +2614,41 @@ def run_tail_backtest( i += 1 continue - if i + 1 >= len(candles): + # align — 진입봉 i-1 마감 시각 = 현재 봉 i 시작 (연속 봉 가정) + if i < 20: i += 1 continue - next_c = candles[i + 1] - if next_c["candle_time"][:8] != day: + entry_i = i - 1 + ent = candles[entry_i] + if ( + align_entry_execute_time(ent.get("candle_time"), tick_tf)[:12] + != str(c.get("candle_time") or "")[:12] + ): + i += 1 + continue + reject, _msg, sig = _eval_live_align_lookback( + candles, entry_i, eval_params, state, lookback=live_sig_lb, + ) + if reject or not sig: i += 1 continue - entry_price = float(next_c["open"]) + atr = ( + atrs[entry_i] + if atrs[entry_i] is not None + else float(ent.get("close") or cl) * 0.01 + ) + entry_price = float(sig.get("entry_price") or ent.get("open") or cl) if entry_price <= 0: - entry_price = cl + i += 1 + continue if use_ticks: bar_ticks = collect_bar_ticks( - ticks_by_code, code, next_c["candle_time"], tick_tf, + ticks_by_code, code, ent["candle_time"], tick_tf, ) entry_price, _align_src = align_entry_price_from_ticks(bar_ticks, entry_price) + entry_time = str(c["candle_time"])[:12] stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params) # 포지션 사이징 로직 (Risk % 및 Max Loss 반영) @@ -1983,14 +2659,14 @@ def run_tail_backtest( position = { "entry_price": entry_price, - "entry_time": next_c["candle_time"], + "entry_time": entry_time, "stop": stop_p, "target": target_p, "max_price": entry_price, "session_low": entry_price, "qty": calc_qty, } - i += 1 # 진입 봉 건너뜀 + i += 1 continue return all_trades \ No newline at end of file diff --git a/kis_trader/engine/tail_env_keys.py b/kis_trader/engine/tail_env_keys.py index c687dfd..35d49e2 100644 --- a/kis_trader/engine/tail_env_keys.py +++ b/kis_trader/engine/tail_env_keys.py @@ -67,6 +67,10 @@ TAIL_CONFIG_KEYS = frozenset({ "TAIL_RSI_PERIOD", "TAIL_COOLDOWN_SEC", "TAIL_MAX_DAILY", + "TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW", + "TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT", + "TAIL_REENTRY_MIN_EDGE_KRW", + "TAIL_REENTRY_REQUIRE_NONNEG", "TAIL_HIGH_CHASE_THR", "TAIL_TIME_START", "TAIL_TIME_END", @@ -199,6 +203,7 @@ def params_to_tail_env_patch(p: Dict[str, Any]) -> Dict[str, str]: _set("TAIL_MAX_HOLD_BARS", int(float(p["max_hold_bars"]))) 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_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC"), ): if js_k in p: @@ -238,6 +243,14 @@ def params_to_tail_env_patch(p: Dict[str, Any]) -> Dict[str, str]: _set("TAIL_TIME_END", int(float(p["time_end_hm"]))) if "max_daily" in p and p["max_daily"] is not None: _set("TAIL_MAX_DAILY", int(float(p["max_daily"]))) + if "symbol_daily_loss_limit_krw" in p and p["symbol_daily_loss_limit_krw"] is not None: + _set("TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW", abs(float(p["symbol_daily_loss_limit_krw"]))) + if "symbol_daily_loss_limit_pct" in p and p["symbol_daily_loss_limit_pct"] is not None: + _set("TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT", abs(float(p["symbol_daily_loss_limit_pct"]))) + if "reentry_min_edge_krw" in p and p["reentry_min_edge_krw"] is not None: + _set("TAIL_REENTRY_MIN_EDGE_KRW", abs(float(p["reentry_min_edge_krw"]))) + if "reentry_require_nonneg" in p: + _set("TAIL_REENTRY_REQUIRE_NONNEG", str(p["reentry_require_nonneg"]).lower()) if "eod_enabled" in p: v = p.get("eod_enabled") patch["TAIL_EOD_ENABLED"] = ( @@ -362,7 +375,9 @@ def web_body_to_tail_env_patch(body: Dict[str, Any]) -> Dict[str, str]: p["high_chase_thr"] = x if 0 < x <= 1 else x / 100.0 for k in ( "cooldown_min", "rsi_threshold", "rsi_period", "time_start", "time_end", - "max_daily", "min_price", "max_daily_change", "ma20_max_above", + "max_daily", "symbol_daily_loss_limit_krw", "symbol_daily_loss_limit_pct", + "reentry_min_edge_krw", "reentry_require_nonneg", + "min_price", "max_daily_change", "ma20_max_above", "max_loss_krw", "stop_atr_mult", "target_atr_mult", "atr_sl_min_pct", "atr_sl_max_pct", "atr_tp_min_pct", "atr_tp_max_pct", "slot_money", "max_stocks", "total_budget_krw", "entry_mode", @@ -371,7 +386,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_tick_fallback_ohlc", + "backtest_use_tick_db", "backtest_use_tick_exit", "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 b45d5e8..196c6da 100644 --- a/kis_trader/engine/tail_tick_replay.py +++ b/kis_trader/engine/tail_tick_replay.py @@ -37,13 +37,20 @@ def tail_backtest_use_tick_db(params: Optional[Dict[str, Any]] = None) -> bool: return _param_bool(params, "backtest_use_tick_db", "TAIL_BACKTEST_USE_TICK_DB", True) +def tail_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool: + """백테 청산에 ws_ticks 사용 (기본 ON — OHLC intrabar 낙관 편향 제거).""" + return _param_bool(params, "backtest_use_tick_exit", "TAIL_BACKTEST_USE_TICK_EXIT", True) + + def tail_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: - """해당 구간 틱 없을 때 3분봉 OHLC 폴백.""" - return _param_bool(params, "backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", True) + """해당 구간 틱 없을 때 3분봉 OHLC 폴백 (기본 OFF — 유령거래 방지).""" + return _param_bool(params, "backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", False) def tail_backtest_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool: - """limit_atr 또는 align 백테에서 틱 DB 로드·재생이 필요한지.""" + """진입(align/limit) 또는 청산 틱 재생이 필요한지.""" + if tail_backtest_use_tick_exit(params): + return True if not tail_backtest_use_tick_db(params): return False mode = short_entry_mode(params) @@ -137,7 +144,7 @@ def align_entry_price_from_ticks( ticks: List[Dict[str, Any]], fallback_open: float, ) -> Tuple[float, str]: - """다음 봉 첫 체결 틱 가격 (없으면 시가).""" + """신호 직후(진입봉) 첫 체결 틱 가격 (없으면 시가).""" fo = float(fallback_open or 0) if not ticks: return fo, "ohlc_open" @@ -160,6 +167,51 @@ def align_entry_price_from_ticks( return fo, "ohlc_open" +def resolve_align_entry_bar( + candles: List[Dict[str, Any]], + signal_idx: int, + tf_min: int, +) -> Optional[Dict[str, Any]]: + """ + align 진입봉 — 신호봉 직후 **기대 다음 봉**(signal+tf)을 우선. + + DB 3분 구멍으로 멀리 떨어진 다음 시가에 밀리지 않도록, + ``candle_time == signal_time+tf`` 봉이 있으면 그걸 쓴다 + (1M→3M 합성으로 메워진 경우 포함). 없으면 기존처럼 다음 당일 봉. + """ + from kis_trader.engine.candle_rollup import add_candle_minutes + + if signal_idx < 0 or signal_idx >= len(candles): + return None + sig = candles[signal_idx] + day = str(sig.get("candle_time") or "")[:8] + if len(day) < 8: + return None + expected = add_candle_minutes(str(sig.get("candle_time") or ""), int(tf_min)) + if not expected: + return None + for j in range(signal_idx + 1, len(candles)): + ct = str(candles[j].get("candle_time") or "") + if ct[:8] != day: + return None + if ct[:12] == expected[:12]: + return candles[j] + # 기대 봉 없음 → 레거시: 바로 다음 당일 봉 + if signal_idx + 1 >= len(candles): + return None + next_c = candles[signal_idx + 1] + if str(next_c.get("candle_time") or "")[:8] != day: + return None + return next_c + + +def align_entry_execute_time(bar_candle_time: str, tf_min: int) -> str: + """align 체결 시각 = 진입봉 시작 + tf (봉 확정 직후, 실매 check_buy_signal_live 와 동일).""" + from kis_trader.engine.candle_rollup import add_candle_minutes + + return add_candle_minutes(str(bar_candle_time or "")[:12], int(tf_min)) + + def try_limit_fill_on_bar_with_ticks( bar: Dict[str, Any], limit_price: float, diff --git a/kis_trader/engine/tick_exit_common.py b/kis_trader/engine/tick_exit_common.py new file mode 100644 index 0000000..3f9298f --- /dev/null +++ b/kis_trader/engine/tick_exit_common.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +백테 틱 청산 공통 — 실매 폴링(STRATEGY_LOOP_SLEEP≈0.1초) 근사. + +1분·N분봉 OHLC intrabar(open→high→low→close)는 익절/어깨를 손절보다 먼저 +체결하는 낙관 편향을 만든다. 전 전략 백테·파람서치는 ws_ticks 시간순 재생을 +기본으로 하고, 틱 없는 구간은 OHLC 폴백을 쓰지 않는다(기본 OFF). +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple + +from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int + + +SellFn = Callable[..., Optional[tuple]] + + +def _param_bool( + params: Optional[Dict[str, Any]], + param_key: str, + env_key: str, + default: bool, +) -> bool: + if params is not None and params.get(param_key) is not None: + s = str(params.get(param_key)).strip().lower() + if s in ("1", "true", "t", "y", "yes", "on"): + return True + if s in ("0", "false", "f", "n", "no", "off", ""): + return False + return get_env_bool(env_key, default) + + +def parse_backtest_time(t: str) -> datetime: + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(t) + + +def _tick_time_to_ms(tick_time: str) -> int: + dt = parse_backtest_time(tick_time) + return int(dt.timestamp() * 1000) + + +def backtest_tick_poll_ms( + params: Optional[Dict[str, Any]] = None, + *, + strategy_env: str = "", + default: int = 100, +) -> int: + """틱 청산 폴링 간격(ms). 실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 하한 50.""" + if params is not None and params.get("backtest_tick_poll_ms") is not None: + try: + return max(50, int(float(params["backtest_tick_poll_ms"]))) + except (TypeError, ValueError): + pass + if strategy_env: + v = get_env_int(strategy_env, 0) + if v > 0: + return max(50, int(v)) + return max(50, int(get_env_int("BACKTEST_TICK_POLL_MS", default))) + + +def backtest_sell_slip_pct( + params: Optional[Dict[str, Any]] = None, + *, + strategy_env: str = "", +) -> float: + if params is not None and params.get("backtest_sell_slip_pct") is not None: + try: + return abs(float(params["backtest_sell_slip_pct"])) + except (TypeError, ValueError): + pass + if strategy_env: + return abs(float(get_env_float(strategy_env, 0.0))) + return abs(float(get_env_float("BACKTEST_SELL_SLIP_PCT", 0.0))) + + +def strategy_use_tick_exit( + params: Optional[Dict[str, Any]], + env_key: str, + *, + default: bool = True, +) -> bool: + return _param_bool(params, "backtest_use_tick_exit", env_key, default) + + +def strategy_tick_fallback_ohlc( + params: Optional[Dict[str, Any]], + env_key: str, + *, + default: bool = False, +) -> bool: + return _param_bool(params, "backtest_tick_fallback_ohlc", env_key, default) + + +def collect_minute_ticks( + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], + code: str, + minute_key: str, +) -> List[Dict[str, Any]]: + if not ticks_by_code: + return [] + bucket = ticks_by_code.get(code) or {} + try: + from kis_trader.backtest.shared_ticks import SharedBucketMapping + if isinstance(bucket, SharedBucketMapping): + return bucket.column_view_minute(minute_key) + except Exception: + pass + ticks = list(bucket.get(str(minute_key)[:12]) or []) + ticks.sort(key=lambda x: str(x.get("tick_time") or "")) + return ticks + + +def try_sell_on_ticks( + position: Dict[str, Any], + ticks: List[Dict[str, Any]], + params: Dict[str, Any], + sell_fn: SellFn, + *, + is_eod: bool = False, + entry_time: str = "", + poll_ms: int = 100, + slip_pct: float = 0.0, + low_mode: str = "current", +) -> Optional[Tuple[str, float, str, float]]: + """ + 틱 시간순 청산 검사. + + Returns: + (reason, fill_price, sell_time, hold_min) 또는 None + """ + try: + from kis_trader.backtest.shared_ticks import TickColumnView + except ImportError: + TickColumnView = None # type: ignore[misc, assignment] + if TickColumnView is not None and isinstance(ticks, TickColumnView): + return _try_sell_on_ticks_columnar( + position, ticks, params, sell_fn, + is_eod=is_eod, entry_time=entry_time, + poll_ms=poll_ms, slip_pct=slip_pct, low_mode=low_mode, + ) + + if not ticks: + return None + + entry_key = str(entry_time or "")[:12] + try: + entry_dt = parse_backtest_time(entry_time or ticks[0].get("tick_time", entry_key)) + except ValueError: + entry_dt = parse_backtest_time(entry_key) + + session_low: Optional[float] = None + if str(low_mode).strip().lower() == "session_low": + ep = float(position.get("entry_price", 0) or 0) + session_low = float(position.get("session_low", ep) or ep) + + last_check_ms = -10**15 + n = len(ticks) + poll = max(50, int(poll_ms)) + slip = abs(float(slip_pct)) + + for idx, tick in enumerate(ticks): + tt = str(tick.get("tick_time") or "") + if len(tt) < 12: + continue + if entry_key and tt[:12] < entry_key: + continue + + try: + tick_ms = _tick_time_to_ms(tt) + except ValueError: + continue + + px = float(tick.get("price") or 0) + if px <= 0: + continue + + mp = max(float(position.get("max_price", position["entry_price"])), px) + position["max_price"] = mp + + if session_low is not None: + session_low = min(session_low, px) + position["session_low"] = session_low + lo_sim = session_low + else: + lo_sim = px + + if tick_ms - last_check_ms < poll: + continue + last_check_ms = tick_ms + + candle = { + "high": mp, + "low": lo_sim, + "close": px, + "candle_time": tt[:12], + } + eod_here = bool(is_eod and idx == n - 1) + res = sell_fn(position, candle, params, is_eod=eod_here) + if not res: + continue + + reason, _theoretical = res + fill_px = px + if slip > 0: + fill_px = px * (1.0 - slip / 100.0) + + try: + sell_dt = parse_backtest_time(tt) + except ValueError: + sell_dt = parse_backtest_time(tt[:12]) + hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) + sell_time = tt[:14] if len(tt) >= 14 else tt[:12] + return reason, fill_px, sell_time, hold_min + + return None + + +def _try_sell_on_ticks_columnar( + position: Dict[str, Any], + view: Any, + params: Dict[str, Any], + sell_fn: SellFn, + *, + is_eod: bool = False, + entry_time: str = "", + poll_ms: int = 100, + slip_pct: float = 0.0, + low_mode: str = "current", +) -> Optional[Tuple[str, float, str, float]]: + if len(view) == 0: + return None + from kis_trader.engine.whipsaw_filter import _tick_epoch_sec + + owner = view.owner + _epoch = owner._epoch + _price = owner._price + _tick_time = owner._tick_time + + poll = max(50, int(poll_ms)) + slip = abs(float(slip_pct)) + entry_key = str(entry_time or "")[:12] + entry_key_epoch = _tick_epoch_sec(entry_key) if entry_key else 0 + + if entry_time: + _entry_src = entry_time + else: + _fi = view.first_idx() + _entry_src = _tick_time[_fi].decode("utf-8") if _fi >= 0 else entry_key + try: + entry_dt = parse_backtest_time(_entry_src) + except ValueError: + entry_dt = parse_backtest_time(entry_key) + + session_low: Optional[float] = None + if str(low_mode).strip().lower() == "session_low": + ep = float(position.get("entry_price", 0) or 0) + session_low = float(position.get("session_low", ep) or ep) + + last_check_ms = -10**15 + n = len(view) + idx = -1 + for i in view.iter_idx(): + idx += 1 + ts = int(_epoch[i]) + if ts <= 0: + continue + if entry_key and ts < entry_key_epoch: + continue + px = float(_price[i]) + if px <= 0: + continue + + mp = max(float(position.get("max_price", position["entry_price"])), px) + position["max_price"] = mp + + if session_low is not None: + session_low = min(session_low, px) + position["session_low"] = session_low + lo_sim = session_low + else: + lo_sim = px + + tick_ms = ts * 1000 + if tick_ms - last_check_ms < poll: + continue + last_check_ms = tick_ms + + tt = _tick_time[i].decode("utf-8") + candle = { + "high": mp, + "low": lo_sim, + "close": px, + "candle_time": tt[:12], + } + eod_here = bool(is_eod and idx == n - 1) + res = sell_fn(position, candle, params, is_eod=eod_here) + if not res: + continue + + reason, _theoretical = res + fill_px = px + if slip > 0: + fill_px = px * (1.0 - slip / 100.0) + + try: + sell_dt = parse_backtest_time(tt) + except ValueError: + sell_dt = parse_backtest_time(tt[:12]) + hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) + sell_time = tt[:14] if len(tt) >= 14 else tt[:12] + return reason, fill_px, sell_time, hold_min + + return None + + +def resolve_backtest_sell( + position: Dict[str, Any], + bar: Dict[str, Any], + params: Dict[str, Any], + *, + is_eod: bool = False, + sell_fn: Optional[SellFn] = None, + low_mode: str = "current", + ticks: Optional[List[Dict[str, Any]]] = None, + use_tick_exit: bool = True, + tick_fallback_ohlc: bool = False, + poll_ms: int = 100, + slip_pct: float = 0.0, +) -> Optional[Tuple[str, float, str, float, str]]: + """ + 한 봉 청산 — 틱 우선, 없으면 OHLC intrabar 폴백(기본 OFF). + + Returns: + (reason, fill_price, sell_time, hold_min, exit_source) + exit_source: ws_ticks | ohlc_bar + """ + from kis_trader.engine.scalping_engine import check_sell_signal_backtest_bar + + if sell_fn is None: + from kis_trader.engine.scalping_engine import check_sell_signal_live as sell_fn + + ct = str(bar.get("candle_time") or "") + entry_time = str(position.get("entry_time") or "") + + if use_tick_exit and ticks: + tick_res = try_sell_on_ticks( + position, ticks, params, sell_fn, + is_eod=is_eod, entry_time=entry_time, + poll_ms=poll_ms, slip_pct=slip_pct, low_mode=low_mode, + ) + if tick_res: + reason, fill_px, sell_time, hold_min = tick_res + return reason, fill_px, sell_time, hold_min, "ws_ticks" + + if not tick_fallback_ohlc: + return None + + res = check_sell_signal_backtest_bar( + position, bar, params, is_eod=is_eod, sell_fn=sell_fn, low_mode=low_mode, + ) + if not res: + return None + reason, exit_price = res + try: + entry_dt = parse_backtest_time(entry_time) + sell_dt = parse_backtest_time(ct) + hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) + except ValueError: + hold_min = 0.0 + return reason, float(exit_price), ct, hold_min, "ohlc_bar" diff --git a/kis_trader/engine/trigger_eval_collect.py b/kis_trader/engine/trigger_eval_collect.py new file mode 100644 index 0000000..a2bfa3e --- /dev/null +++ b/kis_trader/engine/trigger_eval_collect.py @@ -0,0 +1,76 @@ +""" +trigger_eval_collect.py — TRIGGER 스냅샷 수집 vs 필터 적용 분리 +================================================================ +- 필터 OFF: 실매 주문은 막지 않음 +- 수집 ON: TRIGGER 시점 호가·프로그램 스냅 + 판정 결과를 DB에 저장 (백테·파람서치용) +""" +from __future__ import annotations + +from typing import Any, Dict + +from kis_trader.utils.env import get_env_bool, get_env_from_db + + +def _strategy_prefix_orderbook(strategy: str) -> str: + s = (strategy or "").strip().upper() + if s in ("BREAKOUT", "BO"): + return "BREAKOUT" + if s in ("MOMENTUM", "MOM"): + return "MOMENTUM" + if s in ("TAIL", "SHORT", "TAIL_CATCH"): + return "TAIL" + return s or "ORDERBOOK" + + +def _strategy_prefix_program(strategy: str) -> str: + s = (strategy or "").strip().upper() + if s in ("BREAKOUT", "BO"): + return "BREAKOUT" + if s in ("MOMENTUM", "MOM"): + return "MOMENTUM" + if s in ("TAIL", "SHORT", "TAIL_CATCH"): + return "TAIL" + return s or "PROGRAM" + + +def trigger_eval_save_master_enabled() -> bool: + """TriggerEvalRecorder 마스터 스위치.""" + return get_env_bool("WS_TRIGGER_EVAL_SAVE_ENABLED", True) + + +def orderbook_collect_enabled(params: Dict[str, Any], strategy: str) -> bool: + """TRIGGER 호가 스냅 DB 수집 여부 (필터 ON/OFF 와 무관).""" + if params.get("_skip_trigger_eval_persist"): + return False + ov_all = params.get("_trigger_eval_collect_enabled") + if ov_all is not None and not bool(ov_all): + return False + if not trigger_eval_save_master_enabled(): + return False + ov = params.get("_orderbook_collect_enabled") + if ov is not None: + return bool(ov) + pfx = _strategy_prefix_orderbook(strategy) + sk = f"{pfx}_ORDERBOOK_COLLECT_ENABLED" + if str(get_env_from_db(sk, "")).strip(): + return get_env_bool(sk, True) + return get_env_bool("WS_ORDERBOOK_COLLECT_ENABLED", True) + + +def program_collect_enabled(params: Dict[str, Any], strategy: str) -> bool: + """TRIGGER 프로그램 스냅 DB 수집 여부 (필터 ON/OFF 와 무관).""" + if params.get("_skip_trigger_eval_persist"): + return False + ov_all = params.get("_trigger_eval_collect_enabled") + if ov_all is not None and not bool(ov_all): + return False + if not trigger_eval_save_master_enabled(): + return False + ov = params.get("_program_collect_enabled") + if ov is not None: + return bool(ov) + pfx = _strategy_prefix_program(strategy) + sk = f"{pfx}_PROGRAM_COLLECT_ENABLED" + if str(get_env_from_db(sk, "")).strip(): + return get_env_bool(sk, True) + return get_env_bool("WS_PROGRAM_COLLECT_ENABLED", True) diff --git a/kis_trader/execution/kis_client.py b/kis_trader/execution/kis_client.py index 022ec71..064c29b 100644 --- a/kis_trader/execution/kis_client.py +++ b/kis_trader/execution/kis_client.py @@ -445,27 +445,15 @@ class KISClient(SafeRequest): # 3=거래금액순, 4=등락률(상승), 5=등락률(하락), 6=체결강도순 @staticmethod def _is_valid_stock_for_rank(name: str, code: str) -> bool: - """스팩/ETN/레버리지/인버스/우선주 등 비본주 제외 (기존 kiwoom 스캐너 규칙과 동일).""" - if not code or not name: + """스팩/ETN/레버리지/인버스/우선주 등 비본주 제외 (utils.non_stock 공용).""" + if not code: return False code = code.strip() - name = name.strip() + name = (name or "").strip() if len(code) != 6: return False - nm = name.upper() - # ETN / ETF / 레버리지 / 인버스 - bad_kw = ("ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물", "KODEX", "TIGER", - "KBSTAR", "ARIRANG", "HANARO", "SOL ", "KOSEF", "ACE ", "KINDEX", - "파워", "히어로", "SMART", "TREX", "WON", "KOACT", "TIMEFOLIO") - if any(k in nm for k in bad_kw): - return False - # 스팩 (기업인수목적회사) - if "스팩" in name or "SPAC" in nm: - return False - # 우선주 (뒤에 우/우B 등) - if name.endswith("우") or name.endswith("우B") or name.endswith("(전환)"): - return False - return True + from kis_trader.utils.non_stock import is_non_stock + return not is_non_stock(name, code) def _filter_rank_rows(self, rows: list) -> list: if not rows: diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py index c9c2dd8..5b550e0 100644 --- a/kis_trader/execution/order_manager.py +++ b/kis_trader/execution/order_manager.py @@ -26,6 +26,9 @@ kis_trader/execution/order_manager.py — Master Executor ``PENDING_SELL_MAX_AGE_SEC`` / ``PENDING_SELL_STOP_MAX_AGE_SEC`` — 미체결 재조회·만료. * ``PENDING_POLL_BATCH_FETCH`` (기본 True): heartbeat ``poll_pending_fills`` 에서 당일 체결을 1 REST 로 일괄 조회 (실매 전용 · 백테 무관). + * ``DUPLICATE_ORDER_FILL_RECOVERY_ENABLED`` (기본 True): insert 실패(주문DB중복) 시 + ``inquire-daily-ccld`` 1회로 체결 복구 → active_trades 반영. + * ``DUPLICATE_ORDER_RECOVERY_WAIT_SEC`` (기본 ORDER_FILL_WAIT_SEC): 중복복구 체결 대기. * ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문. * ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만 ``ORDER_CASH_PCT`` 로 수량 축소 (매수체크 루프에서는 REST 미호출). @@ -43,6 +46,7 @@ from typing import Callable, Dict, Optional from ..database.db_manager import TradeDBExt from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int +from ..utils.stock_name import resolve_stock_display_name from ..utils.logger import ( LOG_CYAN, LOG_GREEN, @@ -194,6 +198,25 @@ class OrderManager: self._holdings_cache = None self._holdings_cache_ts = 0.0 + def _resolve_order_display_name(self, req: OrderRequest) -> str: + """MM·DB·로그용 종목명 — code=이름이면 DB/잔고에서 보완.""" + fb = str(req.name or req.code or "").strip() + if fb and fb != req.code: + return fb + try: + holdings = self.get_broker_holdings(force=False) + except Exception: + holdings = None + resolved = resolve_stock_display_name( + self.db, + req.code, + fb, + holdings_map=holdings, + ) + if resolved and resolved != req.code: + req.name = resolved + return req.name or req.code + # ------------------------------------------------------------------ # 체결 검증 (실전 항상 엄격 / 모의는 STRICT_FILL_VERIFY) # ------------------------------------------------------------------ @@ -337,7 +360,10 @@ class OrderManager: sell_qty, ) if self._strict_fill_required(): - self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL") + self.db.update_order_status( + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + status="PENDING_FILL", + ) return OrderResult( False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) @@ -399,6 +425,100 @@ class OrderManager: except Exception as e: logger.warning("매수 잔량 취소 실패 %s ord_no=%s: %s", code, ord_no, e) + def _try_recover_duplicate_buy_fill( + self, + req: OrderRequest, + ord_no: str, + buy_qty: int, + ) -> Optional[OrderResult]: + """ + insert_order 실패(주문DB중복) 시 브로커 체결 1회 조회 → active_trades 복구. + DUPLICATE_ORDER_FILL_RECOVERY_ENABLED=false 이면 None (호출부에서 실패 반환). + """ + if not get_env_bool("DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", True): + return None + + from ..utils.strategy_ids import canonical_strategy_id + + sid = canonical_strategy_id(req.strategy_id) + existing = self.db.get_order_by_odno( + ord_no, strategy_id=req.strategy_id, code=req.code, + ) + if existing: + ex_filled = int(existing.get("filled_qty") or 0) + ex_status = str(existing.get("status") or "").upper() + if ex_filled > 0 and ex_status in ("FILLED", "PARTIAL", "SUBMITTED"): + at_qty = 0 + try: + row = self.db.conn.execute( + "SELECT current_qty FROM active_trades " + "WHERE code=%s AND strategy=%s", + (req.code, sid), + ).fetchone() + if row: + at_qty = int(float( + row.get("current_qty") + if isinstance(row, dict) + else row[0] + ) or 0) + except Exception as e: + logger.debug("체결복구 active_trades 조회 실패 %s: %s", req.code, e) + if at_qty > 0: + logger.info( + "🔄 [체결복구스킵] %s %s — orders/active_trades 이미 반영 (qty=%d)", + req.code, ord_no, at_qty, + ) + return OrderResult( + True, + ord_no=ord_no, + filled_qty=ex_filled, + filled_avg_price=float( + existing.get("filled_avg_price") or req.price_ref + ), + reason="duplicate_already_finalized", + request=req, + ) + + if req.use_limit_buy: + wait_sec = float(get_env_int("LIMIT_ORDER_FILL_WAIT_SEC", 1)) + else: + wait_sec = float(get_env_int( + "DUPLICATE_ORDER_RECOVERY_WAIT_SEC", + get_env_int("ORDER_FILL_WAIT_SEC", 2), + )) + fill = self.client.get_execution_by_odno( + ord_no, code=req.code, wait_sec=wait_sec, + ) + if not fill or int(fill.get("filled_qty", 0) or 0) <= 0: + logger.warning( + "%s⚠️ [체결복구실패] insert 실패 + 브로커 미체결 %s %s ODNO=%s%s", + LOG_YELLOW, req.name, req.code, ord_no, LOG_RESET, + ) + return None + + filled_qty = int(fill["filled_qty"]) + filled_price = float(fill["avg_price"]) + if 0 < filled_qty < buy_qty: + miss = buy_qty - filled_qty + logger.warning( + "%s⚠️ [체결복구·부분체결] [%s] %s %s: %d/%d주%s", + LOG_YELLOW, req.strategy_id, req.name, req.code, + filled_qty, buy_qty, LOG_RESET, + ) + self._try_cancel_buy_remainder( + ord_no, req.code, miss, use_limit=req.use_limit_buy, + ) + + logger.warning( + "%s🔄 [체결복구] insert 실패했으나 브로커 체결 확인 — " + "[%s] %s %s × %d주%s", + LOG_CYAN, req.strategy_id, req.name, req.code, filled_qty, LOG_RESET, + ) + return self._finalize_buy_fill( + req, ord_no, filled_qty, filled_price, buy_qty, + log_tag="체결복구", + ) + def _finalize_buy_fill( self, req: OrderRequest, @@ -413,9 +533,13 @@ class OrderManager: if filled_qty <= 0 or filled_price <= 0: return OrderResult(False, ord_no=ord_no, reason="zero_fill", request=req) + self._resolve_order_display_name(req) + status = "FILLED" if filled_qty >= order_qty else "PARTIAL" self.db.update_order_fill( ord_no=ord_no, + strategy_id=req.strategy_id, + code=req.code, filled_qty=filled_qty, filled_avg_price=filled_price, status=status, @@ -496,9 +620,13 @@ class OrderManager: if filled_qty <= 0 or sell_price <= 0: return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req) + self._resolve_order_display_name(req) + status = "FILLED" if filled_qty >= order_qty else "PARTIAL" self.db.update_order_fill( ord_no=ord_no, + strategy_id=req.strategy_id, + code=req.code, filled_qty=filled_qty, filled_avg_price=sell_price, status=status, @@ -516,6 +644,34 @@ class OrderManager: else: realized_pnl = None + if req.name and req.name != req.code: + try: + from ..utils.strategy_ids import canonical_strategy_id + + sid = canonical_strategy_id(req.strategy_id) + with self.db.conn: + self.db.conn.execute( + "UPDATE active_trades SET name=%s WHERE code=%s AND strategy=%s", + (req.name, req.code, sid), + ) + except Exception as exc: + logger.debug("active_trades name 보정 실패(%s): %s", req.code, exc) + + # close_trade 전에 매수시각 확보 (보유구간 봉 백필용) + buy_date_for_bf = None + try: + from ..utils.strategy_ids import canonical_strategy_id + + _sid_bf = canonical_strategy_id(req.strategy_id) + _ar = self.db.conn.execute( + "SELECT buy_date FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1", + (req.code, _sid_bf), + ).fetchone() + if _ar: + buy_date_for_bf = dict(_ar).get("buy_date") + except Exception: + buy_date_for_bf = None + self.db.close_trade( code=req.code, sell_price=sell_price, @@ -523,6 +679,20 @@ class OrderManager: strategy=req.strategy_id, realized_pnl_override=realized_pnl, ) + # 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회 + if buy_date_for_bf: + try: + from kis_trader.engine.post_sell_candle_backfill import ( + schedule_post_sell_backfill, + ) + schedule_post_sell_backfill( + code=req.code, + buy_date=buy_date_for_bf, + sell_date=None, + strategy=str(req.strategy_id or ""), + ) + except Exception as _bf_e: + logger.debug("post-sell candle backfill schedule 스킵: %s", _bf_e) self.invalidate_holdings_cache() if self.cash_ledger is not None: gross = filled_qty * sell_price @@ -692,7 +862,10 @@ class OrderManager: except Exception as e: logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e) if prev_filled <= 0: - self.db.update_order_status(ord_no=ord_no, status="CANCELLED") + self.db.update_order_status( + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + status="CANCELLED", + ) logger.warning( "%s⏱ [체결만료] %s %s ODNO=%s — 미체결 취소 (%.0fs)%s", LOG_YELLOW, req.name, req.code, ord_no, age, LOG_RESET, @@ -703,7 +876,10 @@ class OrderManager: req, order_qty, tag="만료재손절", ) elif side == "BUY" and prev_filled > 0: - self.db.update_order_status(ord_no=ord_no, status="PARTIAL") + self.db.update_order_status( + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + status="PARTIAL", + ) logger.warning( "%s⏱ [부분체결만료] %s %s ODNO=%s — 잔량 %d주 취소%s", LOG_YELLOW, req.name, req.code, ord_no, remain, LOG_RESET, @@ -902,6 +1078,11 @@ class OrderManager: "브로커 체결 여부 확인 필요 (active_trades 미반영 가능)%s", LOG_RED, req.strategy_id, req.code, ord_no, LOG_RESET, ) + recovered = self._try_recover_duplicate_buy_fill( + req, ord_no, buy_qty, + ) + if recovered is not None: + return recovered return OrderResult( False, ord_no=ord_no, reason="duplicate_order_record", request=req, ) @@ -939,7 +1120,10 @@ class OrderManager: request=req, ) elif self._strict_fill_required(): - self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL") + self.db.update_order_status( + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + status="PENDING_FILL", + ) logger.warning( "%s⏳ [매수체결대기] [%s] %s %s ODNO=%s — fill 미확인, heartbeat 재조회%s", LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET, @@ -956,6 +1140,8 @@ class OrderManager: filled_price = req.price_ref self.db.update_order_fill( ord_no=ord_no, + strategy_id=req.strategy_id, + code=req.code, filled_qty=filled_qty, filled_avg_price=filled_price, status="SUBMITTED", @@ -1138,7 +1324,10 @@ class OrderManager: filled_qty = int(fill["filled_qty"]) sell_price = float(fill["avg_price"]) elif self._strict_fill_required(): - self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL") + self.db.update_order_status( + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + status="PENDING_FILL", + ) logger.warning( "%s⏳ [매도체결대기] [%s] %s %s ODNO=%s — fill 미확인%s", LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET, @@ -1151,7 +1340,8 @@ class OrderManager: sell_price = req.price_ref or req.buy_price filled_qty = sell_qty self.db.update_order_fill( - ord_no=ord_no, filled_qty=filled_qty, + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + filled_qty=filled_qty, filled_avg_price=sell_price, status="SUBMITTED", ) @@ -1181,6 +1371,8 @@ class OrderManager: fill = {"filled_qty": filled_qty, "avg_price": sell_price} self.db.update_order_fill( ord_no=ord_no, + strategy_id=req.strategy_id, + code=req.code, filled_qty=filled_qty, filled_avg_price=sell_price, status="FILLED" if filled_qty >= sell_qty else "PARTIAL", @@ -1201,7 +1393,10 @@ class OrderManager: if filled_qty <= 0 or sell_price <= 0: if self._strict_fill_required(): - self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL") + self.db.update_order_status( + ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, + status="PENDING_FILL", + ) return OrderResult( False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) diff --git a/kis_trader/execution/orphan_reconcile.py b/kis_trader/execution/orphan_reconcile.py index df872ff..0fbc3dd 100644 --- a/kis_trader/execution/orphan_reconcile.py +++ b/kis_trader/execution/orphan_reconcile.py @@ -1,23 +1,88 @@ """ -kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 후 배치) +kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 전·후 배치) ================================================================================ 실계좌 잔고(inquire-balance, 페이징)와 active_trades·orders 를 대조해 「봇이 매수했는데 active_trades 미기록」 고아만 복구한다. +이어서 GHOST_PURGE_ON_RECONCILE 시 「DB有·브로커0」 유령 행을 삭제한다. -- 수동매수(orders BUY 없음) · MANUAL_HOLD_CODES 지정분은 건드리지 않음. +- Pre-EOD: 활성 전략 중 가장 이른 EOD 시각 − N분 (기본 7분) 1회 +- Post-EOD: 15:36~ 장마감 후 1회 (기존) +- 수동매수(orders BUY 없음) · MANUAL_HOLD_CODES · strategy=HOLDING 은 건드리지 않음. - 매매 알림은 보내지 않음(로그 + 선택적 요약 MM). """ from __future__ import annotations +import datetime import json import logging from typing import Any, Dict, List, Optional, Set, Tuple -from ..utils.env import get_env_bool, get_env_from_db +from ..engine.strategy_eod import _STRATEGY_EOD_SPEC, parse_eod_hm, resolve_strategy_eod_params +from ..utils.env import get_env_bool, get_env_from_db, get_env_int from ..utils.strategy_ids import canonical_strategy_id logger = logging.getLogger("kis_trader.orphan_reconcile") +# Pre-EOD 시각 산출 — (전략 ON 플래그, strategy_eod spec 키) +_PRE_EOD_STRATEGY_FLAGS: Tuple[Tuple[str, str, bool], ...] = ( + ("STRATEGY_BREAKOUT_ENABLED", "BREAKOUT", False), + ("STRATEGY_MOMENTUM_ENABLED", "MOMENTUM", False), + ("STRATEGY_SHORT_ENABLED", "TAIL", True), +) + + +def resolve_pre_eod_reconcile_hm() -> Tuple[int, int]: + """ + 활성 전략 EOD 시각 중 가장 이른 시각 − ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN 분. + 활성 EOD 없으면 BREAKOUT 기본 15:15 기준. + """ + lead = max(1, get_env_int("ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", 7)) + candidates: List[int] = [] + + for flag, sid, def_on in _PRE_EOD_STRATEGY_FLAGS: + if not get_env_bool(flag, def_on): + continue + spec = _STRATEGY_EOD_SPEC.get(sid) + if spec is None: + continue + en_key, hm_key, def_en, def_hm, leg_key = spec + params: Dict[str, Any] = { + en_key: get_env_from_db(en_key, str(def_en)), + hm_key: get_env_from_db(hm_key, def_hm), + } + if leg_key: + params[leg_key] = get_env_from_db(leg_key, str(def_en)) + enabled, eod_hm = resolve_strategy_eod_params(params, sid) + if not enabled: + continue + hh, mm = parse_eod_hm(eod_hm, def_hm) + candidates.append(hh * 60 + mm) + + if not candidates: + base_min = 15 * 60 + 15 + else: + base_min = min(candidates) + + pre_min = max(9 * 60, base_min - lead) + return pre_min // 60, pre_min % 60 + + +def is_pre_eod_reconcile_window( + now: Optional[datetime.datetime] = None, + *, + window_min: int = 3, +) -> bool: + """Pre-EOD 고아복구 실행 윈도우 (기본 3분).""" + if not get_env_bool("ORPHAN_RECONCILE_PRE_EOD_ENABLED", True): + return False + if not get_env_bool("ORPHAN_RECONCILE_ENABLED", True): + return False + t = now or datetime.datetime.now() + pre_h, pre_m = resolve_pre_eod_reconcile_hm() + cur = t.hour * 60 + t.minute + start = pre_h * 60 + pre_m + return start <= cur < start + max(1, window_min) + def parse_manual_hold_codes() -> Set[str]: """MANUAL_HOLD_CODES env — 쉼표/세미콜론 구분 종목코드.""" @@ -101,12 +166,95 @@ def _num(v, default: float = 0.0) -> float: return default +def _broker_qty(broker: Dict[str, Any], code: str) -> int: + br = (broker or {}).get(code) or (broker or {}).get(str(code).strip()) + if not br: + return 0 + try: + return int((br or {}).get("qty") or 0) + except (TypeError, ValueError): + return 0 + + +def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + DB(active_trades) 有 · 브로커 0주 → 유령 행 삭제. + + - MANUAL_HOLD_CODES · strategy=HOLDING(장기) 은 보호(삭제 안 함). + - 잔고 API 실패 시 삭제하지 않음(오판 방지). + """ + out: Dict[str, Any] = {"purged": [], "skipped_manual": [], "skipped_holding": [], "failed": []} + if not get_env_bool("GHOST_PURGE_ON_RECONCILE", True): + out["msg"] = "GHOST_PURGE_ON_RECONCILE=false" + return out + + db = order_mgr.db + if broker is None: + broker = order_mgr.get_broker_holdings(force=True) + if not getattr(order_mgr, "_holdings_last_fetch_ok", False): + out["error"] = "실계좌 잔고 조회 실패" + return out + + manual_hold = parse_manual_hold_codes() + try: + rows = db.conn.execute( + "SELECT code, name, strategy, current_qty FROM active_trades" + ).fetchall() or [] + except Exception as e: + out["error"] = str(e) + logger.warning("유령잔고 조회 실패: %s", e) + return out + + for row in rows: + d = dict(row) + code = str(d.get("code") or "").strip() + if not code: + continue + name = str(d.get("name") or code) + strategy = str(d.get("strategy") or "").strip() + if code in manual_hold: + out["skipped_manual"].append({"code": code, "name": name, "strategy": strategy}) + continue + # 장기 홀딩봇 포지션은 당일 EOD 대상이 아님 — 브로커 0이어도 수동 확인 전 보존 + if strategy.upper() == "HOLDING": + out["skipped_holding"].append({"code": code, "name": name}) + continue + if _broker_qty(broker, code) > 0: + continue + try: + db.delete_active_trade(code=code, strategy=strategy or None) + out["purged"].append({ + "code": code, + "name": name, + "strategy": strategy, + "qty": int(d.get("current_qty") or 0), + }) + logger.warning( + "🧹 [유령잔고삭제] [%s] %s %s — 브로커 0주 → active_trades 삭제", + strategy, name, code, + ) + except Exception as e: + out["failed"].append({"code": code, "name": name, "error": str(e)}) + logger.exception("유령잔고 삭제 실패 %s", code) + + out["purged_count"] = len(out["purged"]) + out["failed_count"] = len(out["failed"]) + if out["purged"]: + try: + order_mgr.invalidate_holdings_cache() + except Exception: + pass + return out + + def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: """ 장마감 후 1회 호출 — 봇 고아만 active_trades 에 upsert. + 이어서 GHOST_PURGE_ON_RECONCILE 시 유령(DB有·브로커0) 삭제. Returns: - reconciled, skipped_tracked, skipped_manual, skipped_no_order, failed + reconciled, skipped_tracked, skipped_manual, skipped_no_order, failed, + ghost_purged, ghost_purged_count """ result: Dict[str, Any] = { "reconciled": [], @@ -114,6 +262,7 @@ def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: "skipped_manual": [], "skipped_no_order": [], "failed": [], + "ghost_purged": [], } if not get_env_bool("ORPHAN_RECONCILE_ENABLED", True): @@ -214,11 +363,21 @@ def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: except Exception: pass + # 유령(DB有·브로커0) — 동일 잔고 스냅샷으로 삭제 (추가 REST 없음) + ghost = purge_ghost_active_trades(order_mgr, broker=broker) + result["ghost_purged"] = list(ghost.get("purged") or []) + result["ghost_purged_count"] = int(ghost.get("purged_count") or 0) + if ghost.get("error"): + result["ghost_error"] = ghost["error"] + for f in ghost.get("failed") or []: + result["failed"].append(f) + result["reconciled_count"] = len(result["reconciled"]) result["failed_count"] = len(result["failed"]) logger.info( - "🧩 [고아복구] 완료 — 복구 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d", + "🧩 [고아복구] 완료 — 복구 %d / 유령삭제 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d", len(result["reconciled"]), + result["ghost_purged_count"], len(result["failed"]), len(result["skipped_manual"]), len(result["skipped_tracked"]), diff --git a/kis_trader/main.py b/kis_trader/main.py index c841f95..ea15acf 100644 --- a/kis_trader/main.py +++ b/kis_trader/main.py @@ -107,6 +107,13 @@ _DEFAULT_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = { "UPDOW": ("reversal", "2"), "DBBAND": ("dbband", "5"), } +# 키움 HTS 전용 조건식 — KIS NAME 과 다를 때만 별도 지정 (비면 CONDITION_{SID}_NAME 폴백) +_DEFAULT_KIWOOM_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = { + "SCALP": ("scalp_re", ""), + "SHORT": ("tail", ""), + "MOMENTUM": ("momentum", ""), + "BREAKOUT": ("breakout", ""), +} _CONDITION_ENV_KEYS: Dict[str, Tuple[str, str]] = { "SCALP": ("CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ"), "SHORT": ("CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ"), @@ -116,8 +123,8 @@ _CONDITION_ENV_KEYS: Dict[str, Tuple[str, str]] = { "UPDOW": ("CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ"), "DBBAND": ("CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ"), } -# 키움 전용 조건식 seq (선택). 이름은 CONDITION_{SID}_NAME 공통 사용 — KIS/키움 HTS 이름이 같으면 NAME 하나로 충분. -# UNIVERSE_SOURCE=kiwoom_condition 일 때만 KIWOOM_SEQ 가 쓰임 (비어 있으면 CNSRLST 로 name→seq 자동 해결). +# 키움 전용 조건식 — ``CONDITION_{SID}_KIWOOM_NAME`` / ``CONDITION_{SID}_KIWOOM_SEQ`` +# UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용 (seq 비면 CNSRLST 로 name→seq 자동 해결). _STARTUP_NOTIFY_STRATEGY_ORDER: Tuple[str, ...] = ( "SCALP", "SHORT", "MOMENTUM", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND", ) @@ -137,7 +144,7 @@ class TradingOrchestrator: # 전략별 UNIVERSE_SOURCE 기본값 (BaseStrategy 와 동기화) _DEFAULT_SOURCE = { - "SCALP": "condition", + "SCALP": "kiwoom_condition", "SHORT": "kiwoom_condition", "BREAKOUT": "kiwoom_condition", "RANGE_BREAK": "condition", @@ -210,6 +217,8 @@ class TradingOrchestrator: self._market_open_report_date: str = "" # 15:36~ 장마감 고아복구 중복 실행 가드 (당일 1회) self._orphan_reconcile_date: str = "" + # Pre-EOD 고아복구 중복 실행 가드 (당일 1회) + self._orphan_pre_eod_reconcile_date: str = "" # start_day_asset 조회가 모의 서버 500 등으로 실패할 때 무한 재시도 방지. # 다음 시도 가능 epoch (0 = 즉시 가능). 실패 시 N초 백오프. # 한투 모의 inquire-balance 가 간헐 500 → 20초 hb 마다 폭주하던 이슈 방지. @@ -399,12 +408,21 @@ class TradingOrchestrator: # 무조건 condition 으로 리셋하면 스위치가 무의미해지므로). cur_src = (snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip().lower() if cur_src not in ("ranking", "condition", "kiwoom_condition"): - patch[f"{sid}_UNIVERSE_SOURCE"] = "condition" + patch[f"{sid}_UNIVERSE_SOURCE"] = self._DEFAULT_SOURCE.get(sid, "condition") nk, sk = _CONDITION_ENV_KEYS.get(sid, ("", "")) if nk and not (snap.get(nk) or "").strip(): patch[nk] = dname if sk and not (snap.get(sk) or "").strip(): patch[sk] = str(dseq) + kw_nk = f"CONDITION_{sid}_KIWOOM_NAME" + kw_sk = f"CONDITION_{sid}_KIWOOM_SEQ" + kd = _DEFAULT_KIWOOM_CONDITION_BY_STRATEGY.get(sid) + if kd: + kdname, kdseq = kd + if not (snap.get(kw_nk) or "").strip() and kdname: + patch[kw_nk] = kdname + if not (snap.get(kw_sk) or "").strip() and kdseq: + patch[kw_sk] = str(kdseq) if not patch: return merged = dict(snap) @@ -1516,31 +1534,45 @@ class TradingOrchestrator: except Exception as e: logger.error("장마감 최종 리포트 실패: %s", e) + # Pre-EOD 고아복구 — 가장 이른 EOD − N분 (하루 1회, EOD 청산 전) + if self._orphan_pre_eod_reconcile_date != today: + try: + from .execution.orphan_reconcile import is_pre_eod_reconcile_window + if is_pre_eod_reconcile_window(now): + self._run_orphan_reconcile(today, phase="pre") + self._orphan_pre_eod_reconcile_date = today + except Exception as e: + logger.error("Pre-EOD 고아복구 실패: %s", e) + # 15:36~16:00 장마감 고아복구 (하루 1회 — 매매 없을 때 REST 잔고↔DB 대조) if self._orphan_reconcile_date != today: in_reconcile_window = (h == 15 and 36 <= m <= 59) or (h == 16 and m < 30) if in_reconcile_window: try: - self._run_orphan_reconcile(today) + self._run_orphan_reconcile(today, phase="post") + self._orphan_reconcile_date = today except Exception as e: logger.error("장마감 고아복구 실패: %s", e) - def _run_orphan_reconcile(self, today: str) -> None: - """장마감 후 봇 고아(active_trades 미기록) 1회 복구.""" + def _run_orphan_reconcile(self, today: str, *, phase: str = "post") -> None: + """봇 고아(active_trades 미기록) 1회 복구. phase=pre|post.""" from .execution.orphan_reconcile import reconcile_orphan_positions result = reconcile_orphan_positions(self.order_mgr) - self._orphan_reconcile_date = today n = int(result.get("reconciled_count") or 0) - if n <= 0 and not result.get("error"): - logger.debug("🧩 [고아복구] 복구 대상 없음") + g = int(result.get("ghost_purged_count") or 0) + if n <= 0 and g <= 0 and not result.get("error"): + label = "Pre-EOD" if phase == "pre" else "장마감" + logger.debug("🧩 [%s 고아복구] 복구·유령삭제 대상 없음", label) return if result.get("error"): logger.warning("🧩 [고아복구] 중단: %s", result["error"]) return + title = "Pre-EOD 고아복구" if phase == "pre" else "장마감 고아복구" lines = [ - "🧩 **[장마감 고아복구]**", + f"🧩 **[{title}]**", f"- 복구: {n}종목", + f"- 유령삭제: {g}종목", f"- 실패: {int(result.get('failed_count') or 0)}건", ] for it in (result.get("reconciled") or [])[:8]: @@ -1550,13 +1582,17 @@ class TradingOrchestrator: ) if n > 8: lines.append(f" …외 {n - 8}종목") + for it in (result.get("ghost_purged") or [])[:5]: + lines.append( + f" ✕유령 {it.get('name')}({it.get('code')}) [{it.get('strategy')}]" + ) body = "\n".join(lines) try: from .utils.logger import msg_mm msg_mm(body, channel_alias=self._system_mm_channel(), jitter=False) except Exception as e: logger.debug("고아복구 MM 전송 실패: %s", e) - logger.info("🧩 [고아복구] %d종목 active_trades 반영", n) + logger.info("🧩 [고아복구] 복구 %d · 유령삭제 %d → active_trades 반영", n, g) def _build_market_open_report(self, today_ymd: str) -> str: """09:00 장 시작 알림 메시지 — 보유종목·예수금·주문가능금액 현황.""" diff --git a/kis_trader/network/kiwoom_condition_manager.py b/kis_trader/network/kiwoom_condition_manager.py index 83553e6..40351f6 100644 --- a/kis_trader/network/kiwoom_condition_manager.py +++ b/kis_trader/network/kiwoom_condition_manager.py @@ -8,9 +8,14 @@ kis_trader/network/kiwoom_condition_manager.py — 키움 조건검색 기반 - CNSRLST : 서버 저장 조건식 목록 (name → seq 해결) - CNSRREQ : 실시간 조건검색 등록 (search_type="1") → 초기 매칭 + 이후 REAL push - REAL : 편입(843="I") / 이탈(843="D"), 종목코드는 9001 - - CNSRCLR : 실시간 해제 + - CNSRCLR : 실시간 해제 (재LOGIN/재등록 전 선행 — Bye 후 유령등록·900003 방지) (이 경로는 ``_test_kiwoom_condition_realtime.py`` 에서 실계정으로 검증 완료) +재접속 복구: + * 키움 WS ``Bye`` 후에도 계정에 조건검색이 남을 수 있음 → ``KIWOOM_CNSRCLR_BEFORE_REQ`` + (기본 true) 로 CNSRREQ 전에 CNSRCLR. + * rc=900003 은 seq 당 1회만 CLR→REQ (무한 재시도 금지). + 설계 (최소 침습): * ``ConditionSearchManager`` 를 **서브클래싱** 하여 - 결과 반영 로직(``_apply_result``), 순서 계산(``_build_ordered_universe``), @@ -36,17 +41,21 @@ from __future__ import annotations import json import random +import re import threading import time from typing import Any, Dict, List, Optional, Set from .condition_manager import ConditionSearchManager -from ..utils.env import get_env_float, get_env_from_db, get_env_int +from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger from ..ws.kis_ws import _get_kiwoom_token_cached logger = get_logger("kis_trader.kwcond") +# 키움 CNSRREQ rc=900003 — WS Bye 후에도 계정에 조건검색 등록이 남는 경우 +_CNSR_ALREADY_REGISTERED_RC = "900003" + def _normalize_code(raw) -> str: """키움 종목코드 정규화: 'A005930' → '005930' (KIS 6자리 코드계와 정합).""" @@ -59,6 +68,22 @@ def _normalize_code(raw) -> str: return c +def _extract_cnsr_seq(data: Dict) -> str: + """CNSRREQ/CNSRCLR 응답에서 seq 추출. + + 실패 응답은 top-level ``seq`` 가 비고 ``return_msg`` 에만 + ``...(seq=1)`` 형태로 오는 경우가 있음 (문서·실제 필드 불일치). + """ + seq = str(data.get("seq") or "").strip() + if seq: + return seq + msg = str(data.get("return_msg") or "") + m = re.search(r"seq\s*=\s*(\d+)", msg, flags=re.IGNORECASE) + if m: + return m.group(1).strip() + return "" + + class KiwoomConditionSearchManager(ConditionSearchManager): """키움 웹소켓 실시간 조건검색 매니저. @@ -133,6 +158,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._cnsrreq_pending: Set[str] = set() self._cnsrreq_confirmed: Set[str] = set() self._cnsrreq_retry_timer: Optional[threading.Timer] = None + # 900003(이미 등록) 에 대한 CNSRCLR→재요청은 seq당 사이클 1회만 (무한 재시도 금지) + self._cnsr_dup_cleared: Set[str] = set() + # CNSRCLR→REQ 파이프라인에서 send 대상 WS 캐시 + self._cnsr_send_ws: Any = None # ------------------------------------------------------------------ # Public API (오버라이드) — 부모 start() 는 REST 폴링이므로 사용 안 함 @@ -258,11 +287,20 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._shared_ws.send_json({"trnm": "CNSRLST"}) def _on_shared_login(self, ws) -> None: - """시세 WS 재접속마다 조건식 목록 재조회 → CNSRREQ 재등록.""" + """시세 WS 재접속마다 조건식 목록 재조회 → (CLR 후) CNSRREQ 재등록. + + 키움은 새벽 등 WS ``Bye`` 후에도 계정 단위로 조건검색 등록이 남을 수 있음. + 재LOGIN 시에는 기존 pending 재시도 타이머를 끊고, CNSRLST→CLR→REQ 로 맞춘다. + """ if not self._running: return + self._cancel_cnsrreq_retry() self._ready.clear() self._start_ok = False + with self._kw_lock: + self._cnsr_dup_cleared.clear() + self._cnsrreq_pending.clear() + self._cnsrreq_confirmed.clear() try: ws.send(json.dumps({"trnm": "CNSRLST"})) logger.debug("키움 조건검색 [공유WS] LOGIN → CNSRLST") @@ -276,7 +314,12 @@ class KiwoomConditionSearchManager(ConditionSearchManager): elif trnm == "CNSRREQ": self._handle_cnsrreq(msg) elif trnm == "CNSRCLR": - logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", msg.get("return_code")) + logger.debug( + "키움 조건검색 CNSRCLR 응답: seq=%s rc=%s msg=%s", + _extract_cnsr_seq(msg), + msg.get("return_code"), + msg.get("return_msg"), + ) def _on_shared_real(self, ws, msg: dict) -> None: """조건검색 편입/이탈 REAL — 843 필드 있는 항목만 처리.""" @@ -369,7 +412,12 @@ class KiwoomConditionSearchManager(ConditionSearchManager): return if trnm == "CNSRCLR": - logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", data.get("return_code")) + logger.debug( + "키움 조건검색 CNSRCLR 응답: seq=%s rc=%s msg=%s", + _extract_cnsr_seq(data), + data.get("return_code"), + data.get("return_msg"), + ) return # ------------------------------------------------------------------ @@ -387,7 +435,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager): return "", "" def _handle_condition_list(self, ws, rows: List) -> None: - """CNSRLST 응답으로 name→seq 해결 후, 전략별 seq 확정 + CNSRREQ 발송.""" + """CNSRLST 응답으로 name→seq 해결 후, 전략별 seq 확정 + CLR→CNSRREQ.""" name_to_seq: Dict[str, str] = {} for it in rows: seq, name = self._seq_name(it) @@ -429,9 +477,55 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._sid_by_seq = sid_by_seq self._active_seqs = list(sid_by_seq.keys()) - # 실시간(search_type=1) 등록 — seq 별 순차 발송 (레이트리밋·응답 누락 방지) + # 실시간(search_type=1) 등록 — 유령등록 방지로 CLR 후 순차 REQ self._send_cnsrreq_all(ws) + def _cancel_cnsrreq_retry(self) -> None: + if self._cnsrreq_retry_timer: + try: + self._cnsrreq_retry_timer.cancel() + except Exception: + pass + self._cnsrreq_retry_timer = None + + def _send_one_cnsrclr(self, ws, seq: str) -> bool: + """단일 seq CNSRCLR(실시간 해제) 발송 — Bye 후 유령 등록 제거용.""" + payload = {"trnm": "CNSRCLR", "seq": seq} + try: + if self._shared_mode and self._shared_ws: + return bool(self._shared_ws.send_json(payload)) + target = ws if ws is not None else self._ws + if target is None: + return False + target.send(json.dumps(payload)) + return True + except Exception as e: + logger.warning("키움 CNSRCLR 발송 실패 (seq=%s): %s", seq, e) + return False + + def _clear_active_seqs(self, ws, seqs: Optional[List[str]] = None) -> None: + """active(또는 주어진) seq 전부 CNSRCLR — 간격은 env (하드코딩 금지).""" + if seqs is None: + with self._kw_lock: + seqs = list(self._active_seqs) + else: + seqs = list(seqs) + try: + seqs.sort(key=lambda x: int(x) if str(x).isdigit() else 0) + except Exception: + pass + if not seqs: + return + gap_lo = float(get_env_float("KIWOOM_CNSRCLR_GAP_MIN_SEC", 0.3)) + gap_hi = float(get_env_float("KIWOOM_CNSRCLR_GAP_MAX_SEC", 0.6)) + if gap_hi < gap_lo: + gap_lo, gap_hi = gap_hi, gap_lo + logger.info("🧹 키움 조건검색 CNSRCLR 선행 (seq=%s)", seqs) + for i, seq in enumerate(seqs): + if i > 0: + time.sleep(random.uniform(gap_lo, gap_hi)) + self._send_one_cnsrclr(ws, seq) + def _send_one_cnsrreq(self, ws, seq: str) -> bool: """단일 seq CNSRREQ 발송.""" payload = { @@ -450,14 +544,24 @@ class KiwoomConditionSearchManager(ConditionSearchManager): return False def _send_cnsrreq_all(self, ws) -> None: - """active seq 목록을 간격 두고 순차 CNSRREQ — 미응답 seq 는 타이머로 재발송.""" + """active seq: (옵션) CNSRCLR → settle → 순차 CNSRREQ. 미응답만 유한 재시도.""" + self._cancel_cnsrreq_retry() + self._cnsr_send_ws = ws with self._kw_lock: seqs = list(self._active_seqs) + self._cnsr_dup_cleared.clear() try: seqs.sort(key=lambda x: int(x)) except Exception: pass + # Bye 후 유령 등록(900003) 방지: 재등록 전에 항상 클리어 (기본 ON) + if get_env_bool("KIWOOM_CNSRCLR_BEFORE_REQ", True): + self._clear_active_seqs(ws, seqs) + settle = float(get_env_float("KIWOOM_CNSRCLR_SETTLE_SEC", 1.0)) + if settle > 0: + time.sleep(settle) + gap_lo = float(get_env_float("KIWOOM_CNSRREQ_GAP_MIN_SEC", 0.8)) gap_hi = float(get_env_float("KIWOOM_CNSRREQ_GAP_MAX_SEC", 1.5)) if gap_hi < gap_lo: @@ -480,7 +584,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._schedule_cnsrreq_retry(ws, attempt=1) def _schedule_cnsrreq_retry(self, ws, *, attempt: int) -> None: - """CNSRREQ 응답이 안 온 seq 만 간격 두고 재발송.""" + """CNSRREQ 응답이 안 온 seq 만 간격 두고 재발송 (상한 초과 시 중단 · 무한 금지).""" max_retries = get_env_int("KIWOOM_CNSRREQ_MAX_RETRIES", 3) retry_delay = float(get_env_float("KIWOOM_CNSRREQ_RETRY_SEC", 5.0)) gap_lo = float(get_env_float("KIWOOM_CNSRREQ_GAP_MIN_SEC", 0.8)) @@ -488,12 +592,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager): if gap_hi < gap_lo: gap_lo, gap_hi = gap_hi, gap_lo - if self._cnsrreq_retry_timer: - try: - self._cnsrreq_retry_timer.cancel() - except Exception: - pass - self._cnsrreq_retry_timer = None + self._cancel_cnsrreq_retry() def _retry() -> None: if not self._running: @@ -507,9 +606,13 @@ class KiwoomConditionSearchManager(ConditionSearchManager): return if attempt > max_retries: logger.warning( - "⚠️ 키움 CNSRREQ 미응답 seq=%s — 최대 재시도 초과", + "⚠️ 키움 CNSRREQ 미응답 seq=%s — 최대 재시도 초과 (추가 재시도 없음)", missing, ) + # pending 비워 이후 타이머/재발송이 남지 않게 함 + with self._kw_lock: + for s in missing: + self._cnsrreq_pending.discard(s) return logger.warning( "⚠️ 키움 CNSRREQ 미응답 seq=%s → %.0fs 후 재발송 (%d/%d)", @@ -525,21 +628,63 @@ class KiwoomConditionSearchManager(ConditionSearchManager): self._cnsrreq_retry_timer.daemon = True self._cnsrreq_retry_timer.start() + def _handle_cnsr_already_registered(self, seq: str, data: Dict) -> None: + """rc=900003: seq당 1회만 CNSRCLR→CNSRREQ. 두 번째면 포기(무한 금지).""" + with self._kw_lock: + already = seq in self._cnsr_dup_cleared + if not already: + self._cnsr_dup_cleared.add(seq) + if already: + logger.warning( + "⚠️ 키움 CNSRREQ 900003 재발 (seq=%s) — CLR 후에도 충돌, 추가 재시도 없음 msg=%s", + seq, data.get("return_msg"), + ) + with self._kw_lock: + self._cnsrreq_pending.discard(seq) + return + + ws = self._cnsr_send_ws + if ws is None and self._shared_mode: + ws = self._shared_ws + if ws is None: + ws = self._ws + logger.warning( + "⚠️ 키움 CNSRREQ 이미등록(900003) seq=%s → CNSRCLR 1회 후 재요청", + seq, + ) + self._send_one_cnsrclr(ws, seq) + settle = float(get_env_float("KIWOOM_CNSRCLR_SETTLE_SEC", 1.0)) + if settle > 0: + time.sleep(settle) + self._send_one_cnsrreq(ws, seq) + def _handle_cnsrreq(self, data: Dict) -> None: """CNSRREQ 초기 응답: 현재 매칭 종목 리스트로 seq universe 초기화.""" rc = str(data.get("return_code")) - seq = str(data.get("seq") or "").strip() - if rc not in ("0", "0.0"): - logger.warning( - "키움 CNSRREQ 실패 (seq=%s) rc=%s msg=%s", - seq, rc, data.get("return_msg"), - ) - return + seq = _extract_cnsr_seq(data) # seq 가 응답에 없을 수 있음 → active_seqs 가 1개면 그걸로 간주 if not seq: with self._kw_lock: if len(self._active_seqs) == 1: seq = self._active_seqs[0] + if rc not in ("0", "0.0"): + msg = str(data.get("return_msg") or "") + is_dup = ( + rc == _CNSR_ALREADY_REGISTERED_RC + or "이미 등록" in msg + ) + logger.warning( + "키움 CNSRREQ 실패 (seq=%s) rc=%s msg=%s", + seq, rc, msg, + ) + if is_dup and seq: + self._handle_cnsr_already_registered(seq, data) + return + # 그 외 에러: pending 에서 제거 → '미응답'으로 오인·무한 재발송 방지 + if seq: + with self._kw_lock: + self._cnsrreq_pending.discard(seq) + return if not seq: return @@ -569,13 +714,20 @@ class KiwoomConditionSearchManager(ConditionSearchManager): if not code: continue ins_del = str(vals.get("843") or "").strip().upper() - # seq 는 item 또는 상위에서 옴. item 에 없으면 유일 seq 로 폴백. - seq = str(it.get("item") or it.get("seq") or "").strip() if isinstance(it, dict) else "" + # 조건식 번호는 values.841. item 은 종목코드(9001 과 동일)인 경우가 많아 seq 로 쓰면 안 됨. + seq = str(vals.get("841") or "").strip() + if not seq and isinstance(it, dict): + raw_item = str(it.get("seq") or "").strip() + if raw_item and raw_item in self._sid_by_seq: + seq = raw_item if not seq: if len(self._active_seqs) == 1: seq = self._active_seqs[0] else: - # seq 불명 + 다중 조건 → 어느 유니버스인지 특정 불가, skip + logger.debug( + "키움 조건 REAL skip — seq 미식별 code=%s 843=%s active=%s", + code, ins_del, self._active_seqs, + ) continue bucket = self._seq_codes.setdefault(seq, {}) if ins_del == "D": @@ -609,7 +761,16 @@ class KiwoomConditionSearchManager(ConditionSearchManager): bucket = dict(self._seq_codes.get(seq, {})) sids = list(self._sid_by_seq.get(seq, [])) # rows: 삽입순(HTS 응답 순서) 유지 → _build_ordered_universe 가 그대로 사용 - rows = [{"code": c, "name": n} for c, n in bucket.items()] + rows = [] + for c, n in bucket.items(): + disp = str(n or c).strip() or c + if disp == c and self.db is not None: + try: + from ..utils.stock_name import resolve_stock_display_name + disp = resolve_stock_display_name(self.db, c, fallback=c) + except Exception: + disp = c + rows.append({"code": c, "name": disp}) from ..utils.universe_source import universe_source_active for sid in sids: if not universe_source_active(sid, "kiwoom_condition"): diff --git a/kis_trader/network/ws_manager.py b/kis_trader/network/ws_manager.py index 57a7b6f..6923a81 100644 --- a/kis_trader/network/ws_manager.py +++ b/kis_trader/network/ws_manager.py @@ -107,6 +107,11 @@ class WSManager: self._owner_holdings: Dict[str, Set[str]] = defaultdict(set) # 영구 구독(시장방향 ETF 등) self._permanent_codes: Set[str] = set() + self._permanent_reload_ts: float = 0.0 + # 후보/보유 이탈 후 키움 틱 구독 유지 (만료 epoch) — KIS 41 영구구독과 분리 + self._grace_until: Dict[str, float] = {} + # grace 1회 소진 후 재연장 방지 (재진입 시 discard) + self._grace_exhausted: Set[str] = set() self._lock = threading.Lock() # 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체 self._gap_refill_codes: Set[str] = set() @@ -116,7 +121,7 @@ class WSManager: # 수 분간 블로킹됨 → 백그라운드 워커 큐로 이관) self._gap_q: "queue.Queue[str]" = queue.Queue(maxsize=1024) self._gap_prio_q: "queue.Queue[str]" = queue.Queue(maxsize=512) - self._gap_mode: Dict[str, str] = {} # code → "1m" | "full" + self._gap_mode: Dict[str, str] = {} # code → "1m" | "3m" | "full" self._gap_filled: Set[str] = set() # 이미 갭보정 완료한 코드 self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드 self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터 @@ -217,7 +222,7 @@ class WSManager: logger.info( "✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)", tfs, len(self._permanent_codes), - max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)), + max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4)), ) return True except Exception as e: @@ -277,6 +282,11 @@ class WSManager: """KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화.""" if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws): return + # permanent_subscriptions 테이블 갱신 반영 (보유 해제 후에도 영구구독 틱 유지) + now = time.time() + if now - self._permanent_reload_ts >= 300.0: + self._load_permanent_codes() + self._permanent_reload_ts = now with self._lock: cand_u: Set[str] = set() for s in self._owner_candidates.values(): @@ -289,6 +299,16 @@ class WSManager: kw_want = cand_u | hold_u | perm tick_to_agg = set(cand_u - hold_u) self._gap_refill_codes = set(kis_want) | set(kw_want) + # 재진입 시 grace 재사용 가능하도록 소진 플래그 해제 + active_want = cand_u | hold_u | perm + for code in active_want: + self._grace_exhausted.discard(code) + self._grace_until.pop(code, None) + + # 이탈 후 틱 grace — 키움 구독만 연장 (KIS 41 슬롯 보호) + grace_active = self._purge_and_get_grace_codes() + if grace_active: + kw_want = set(kw_want) | grace_active try: self._kiwoom_ws.set_candle_tick_codes(tick_to_agg) @@ -307,6 +327,8 @@ class WSManager: to_kw = sorted(kw_want - kw_now) if to_kw: + # 한도 여유(headroom) 확보: grace 만료·오래된 것부터 해제 후 신규 REG + self._ensure_kiwoom_headroom_for_new(len(to_kw), kw_want) try: added_kw = self._kiwoom_ws.subscribe_many(to_kw) except Exception: @@ -314,18 +336,25 @@ class WSManager: for code in to_kw: if self._kiwoom_ws.subscribe(code): added_kw.append(code) + with self._lock: + owner_cands = { + str(owner): set(codes) + for owner, codes in self._owner_candidates.items() + } for code in added_kw: if code in self._permanent_codes: self._enqueue_gap_fill(code) else: - # 후보 종목: 1M 우선 갭보정을 큐 앞쪽에 — BREAKOUT 매수체크 즉시 가능 - self._enqueue_gap_fill(code, priority=True, mode="1m") + # 전 후보 1M 우선 — REST 1회 후 RAM 3M 롤업(꼬리 트리거 웜업) + gap_mode = self._candidate_gap_fill_mode(code, owner_cands) + self._enqueue_gap_fill(code, priority=True, mode=gap_mode) for code in sorted(kis_want - kis_now): self.ws_cache.subscribe(code) self._enqueue_gap_fill(code) for code in sorted(kis_now - kis_want): + # KIS 는 grace 미적용 (영구+보유만) — 즉시 해제 self.ws_cache.unsubscribe(code) if code not in kw_want and self.candle_agg: self.candle_agg.remove_code(code) @@ -333,16 +362,115 @@ class WSManager: with self._kiwoom_ws._sub_lock: kw_now2 = set(self._kiwoom_ws._subscribed) for code in sorted(kw_now2 - kw_want): + # want 밖이면 grace 등록 또는 즉시 해제 + if self._note_leave_for_grace(code): + continue self._kiwoom_ws.unsubscribe(code) if code not in kis_want and self.candle_agg: self.candle_agg.remove_code(code) - if self.tick_recorder and code not in kw_want: + with self._lock: + is_perm = code in self._permanent_codes + if self.tick_recorder and code not in kw_want and not is_perm: self.tick_recorder.remove_code(code) - if self.trigger_snapshot_recorder and code not in kw_want: + if self.trigger_snapshot_recorder and code not in kw_want and not is_perm: self.trigger_snapshot_recorder.remove_code(code) self._sync_tick_record_codes() + def _ws_grace_sec(self) -> int: + # 후보/보유 이탈 후 틱 조금 더 쌓기 — 길면 파람이 이탈 구간 기회에 과적합되기 쉬워 30초 기본 + return max(0, get_env_int("WS_TICK_GRACE_SEC", 30)) + + def _ws_grace_headroom(self) -> int: + return max(0, get_env_int("WS_TICK_GRACE_HEADROOM", 5)) + + def _purge_and_get_grace_codes(self) -> Set[str]: + """만료 grace 제거 후 활성 코드 집합 반환.""" + now = time.time() + with self._lock: + dead = [c for c, exp in self._grace_until.items() if exp <= now] + for c in dead: + self._grace_until.pop(c, None) + self._grace_exhausted.add(c) + return set(self._grace_until.keys()) + + def _note_leave_for_grace(self, code: str) -> bool: + """이탈 종목을 grace 기간 구독 유지. True=지금은 unsubscribe 하지 않음.""" + grace = self._ws_grace_sec() + if grace <= 0 or not code: + return False + with self._lock: + if code in self._permanent_codes: + return False + if code in self._grace_exhausted: + return False + # 이미 grace 중이면 유지 (만료 전 재등록으로 타이머 리셋 금지) + if code in self._grace_until: + return True + for s in self._owner_holdings.values(): + if code in s: + return False + for s in self._owner_candidates.values(): + if code in s: + return False + self._grace_until[code] = time.time() + float(grace) + return True + + def _ensure_kiwoom_headroom_for_new(self, need: int, kw_want: Set[str]) -> None: + """신규 구독 전 grace 슬롯을 비워 키움 한도(기본 100)−headroom 을 확보.""" + if need <= 0 or not self._kiwoom_ws: + return + try: + limit = int(get_env_int("KIWOOM_WS_MAX_SUBSCRIPTIONS", 100)) + except Exception: + limit = 100 + headroom = self._ws_grace_headroom() + soft_cap = max(1, limit - headroom) + with self._kiwoom_ws._sub_lock: + n_now = len(self._kiwoom_ws._subscribed) + free = soft_cap - n_now + if free >= need: + return + drop_n = need - max(0, free) + with self._lock: + grace_items = sorted(self._grace_until.items(), key=lambda x: x[1]) + dropped = 0 + for code, _exp in grace_items: + if dropped >= drop_n: + break + if code in kw_want: + continue + with self._lock: + self._grace_until.pop(code, None) + self._grace_exhausted.add(code) + try: + self._kiwoom_ws.unsubscribe(code) + except Exception: + pass + if self.tick_recorder: + try: + self.tick_recorder.remove_code(code) + except Exception: + pass + dropped += 1 + + def _active_ws_subscribed_codes(self) -> Set[str]: + """현재 KIS·키움 WS 에 실제 구독 중인 종목 (틱 수신 가능 집합).""" + out: Set[str] = set() + try: + if self._kiwoom_ws: + with self._kiwoom_ws._sub_lock: + out |= set(self._kiwoom_ws._subscribed) + except Exception: + pass + try: + if self.ws_cache: + with self.ws_cache._sub_lock: + out |= set(self.ws_cache._subscribed) + except Exception: + pass + return out + def _sync_tick_record_codes(self) -> None: """``WS_TICK_RECORD_SCOPE`` 에 따라 TickRecorder 저장 대상 종목 갱신.""" if not self.tick_recorder: @@ -358,6 +486,8 @@ class WSManager: for s in self._owner_holdings.values(): hold_u |= s subscribed = perm | cand_u | hold_u + # WS 구독이 아직 유지되는 종목(영구·후보 이탈 직후 등)도 틱 저장 대상에 포함 + subscribed |= self._active_ws_subscribed_codes() if scope in ("subscribed", "all", "full"): want = subscribed else: @@ -365,7 +495,7 @@ class WSManager: # 매수 후 종목이 후보 유니버스에서 이탈하면 보유 구간 틱이 끊겨 # 백테 '틱청산' 재현이 불가(진입틱만 있고 청산틱 없음)해진다. # 실 체결(손절/익절) 정합을 위해 보유분 틱은 반드시 수집한다. - want = cand_u | perm | hold_u + want = cand_u | perm | hold_u | (subscribed - cand_u - hold_u) else: subscribed = set(perm) for refs in self._code_refs.values(): @@ -534,7 +664,7 @@ class WSManager: # ------------------------------------------------------------------ def _start_gap_worker(self) -> None: """갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진.""" - want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)) + want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4)) alive = [t for t in self._gap_worker_threads if t.is_alive()] if len(alive) >= want: return @@ -553,6 +683,29 @@ class WSManager: want, want, ) + @staticmethod + def _candidate_gap_fill_mode( + code: str, + owner_candidates: Optional[Dict[str, Set[str]]] = None, + ) -> str: + """후보 종목 갭보정 1차 TF — 기본 1M (3M은 REST 생략·1M 롤업). + + ``WS_GAP_FILL_CANDIDATE_MODE`` = ``1m``(기본) | ``3m`` | ``legacy`` + (legacy: SHORT만 3M 우선 — 구동작). + """ + mode = ( + get_env_from_db("WS_GAP_FILL_CANDIDATE_MODE", "1m") or "1m" + ).strip().lower() + if mode in ("3m", "3"): + return "3m" + if mode in ("legacy", "short_3m"): + owners = owner_candidates or {} + short_codes = owners.get("SHORT") or set() + if code in short_codes: + return "3m" + return "1m" + return "1m" + def _enqueue_gap_fill( self, code: str, @@ -568,12 +721,13 @@ class WSManager: - 이미 큐/처리 중(`_gap_inflight`) → 스킵 Args: - priority: True 이면 우선 큐(후보 종목 1M 웜업 등) - mode: ``"1m"`` = 1분봉만 먼저, ``"full"`` = 설정된 전 TF + priority: True 이면 우선 큐(후보 종목 1M/3M 웜업 등) + mode: ``"1m"`` / ``"3m"`` = 해당 TF만 먼저, ``"full"`` = 설정된 전 TF """ if not code: return - fill_mode = "1m" if str(mode).strip().lower() == "1m" else "full" + mode_key = str(mode).strip().lower() + fill_mode = mode_key if mode_key in ("1m", "3m") else "full" with self._gap_lock: if code in self._gap_inflight: return @@ -754,7 +908,9 @@ class WSManager: with self._gap_lock: gap_mode = self._gap_mode.get(code, "full") - only_1m = gap_mode == "1m" + partial_tf: Optional[int] = ( + 1 if gap_mode == "1m" else (3 if gap_mode == "3m" else None) + ) # 장중만 실행 (장외면 완료 마커 찍고 다음) if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False): @@ -783,7 +939,7 @@ class WSManager: else: kw_status = "❌" n_workers = max( - 1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4), + 1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4), ) logger.info( "🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s", @@ -794,12 +950,15 @@ class WSManager: self._gap_worker_boot_logged = True try: - only_tfs = {1} if only_1m else None + only_tfs = {partial_tf} if partial_tf is not None else None ok = self._fill_gap_for_code( code, kw_key=kw_key, kw_secret=kw_secret, kw_mock=kw_mock, only_tfs=only_tfs, ) + # lock 밖 — 1M 성공 시 3M 롤업 (_gap_lock 비재진입) + if partial_tf == 1: + self._maybe_rollup_3m_from_1m(code) except Exception as e: logger.debug("갭보정 워커 예외 (%s): %s", code, e) ok = False @@ -808,10 +967,10 @@ class WSManager: self._gap_inflight.discard(code) self._gap_mode.pop(code, None) - if only_1m: - # 1M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서 - have_1m = 1 in self._gap_tf_ok.get(code, set()) - if have_1m: + if partial_tf is not None: + # 1M/3M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서 + have_primary = partial_tf in self._gap_tf_ok.get(code, set()) + if have_primary: need = set(self.candle_agg.timeframes) have = self._gap_tf_ok.get(code, set()) if not need.issubset(have): @@ -828,23 +987,25 @@ class WSManager: retries = self._gap_retry_count.get(code, 0) + 1 max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3) self._gap_retry_count[code] = retries + retry_mode = f"{partial_tf}m" if retries < max_retries: delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8)) logger.warning( - "⚠️ [갭보정] %s 1M 실패 → %ds 후 우선 재시도 (%d/%d)", - code, int(delay), retries, max_retries, + "⚠️ [갭보정] %s %dM 실패 → %ds 후 우선 재시도 (%d/%d)", + code, partial_tf, int(delay), retries, max_retries, ) threading.Timer( delay, - lambda c=code: self._enqueue_gap_fill( - c, force=True, priority=True, mode="1m", + lambda c=code, m=retry_mode: self._enqueue_gap_fill( + c, force=True, priority=True, mode=m, ), ).start() else: logger.warning( - "⚠️ [갭보정] %s 1M 최대 재시도 초과 — WS 틱 누적으로 대체", - code, + "⚠️ [갭보정] %s %dM 최대 재시도 초과 — WS 틱 누적으로 대체", + code, partial_tf, ) + self._gap_filled.add(code) elif ok: self._gap_filled.add(code) self._gap_retry_count.pop(code, None) @@ -982,16 +1143,16 @@ class WSManager: return [int(x) for x in fallback.split(",")] def _gap_priority_tfs(self) -> Set[int]: - """갭보정 1차 우선 TF — 기본 1M·3M (BREAKOUT·SHORT 핵심).""" - raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3") - return set(self._parse_tf_csv(raw, "1,3")) + """갭보정 1차 우선 TF — 기본 1M (3M은 1M 롤업).""" + raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1") + return set(self._parse_tf_csv(raw, "1")) def _resolve_gap_fill_tf_order(self) -> List[int]: - """우선 TF(1M·3M) 먼저, 이후 15M/60M — 레이트리밋 시 핵심 봉 선확보.""" + """우선 TF(1M) 먼저, 이후 15M/60M — 3M REST는 롤업 시 스킵.""" all_tfs = list(self.candle_agg.timeframes) priority = self._gap_priority_tfs() ordered: List[int] = [tf for tf in self._parse_tf_csv( - get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3"), "1,3", + get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1"), "1", ) if tf in all_tfs] for tf in all_tfs: if tf not in priority: @@ -1014,9 +1175,98 @@ class WSManager: lo, hi = hi, lo time.sleep(random.uniform(lo, hi)) + def _gap_fill_limit_for_tf(self, tf: int, code: Optional[str] = None) -> int: + """TF별 갭보정 REST 조회량 — 1M: SHORT 150 / MOMENTUM 등 500.""" + base = get_env_int("WS_GAP_FILL_LIMIT", 120) + if tf == 1: + short_lim = get_env_int("SHORT_GAP_FILL_LIMIT", 150) + mom_lim = get_env_int("MOMENTUM_GAP_FILL_LIMIT", 500) + need_deep = True + if code: + with self._lock: + owners = { + str(o): set(cs) for o, cs in self._owner_candidates.items() + } + deep_owners = set() + for oid in ("MOMENTUM", "BREAKOUT", "SCALP", "RANGE_BREAK"): + deep_owners |= owners.get(oid) or set() + short_only = ( + code in (owners.get("SHORT") or set()) + and code not in deep_owners + ) + need_deep = not short_only + base = max(base, short_lim) + if need_deep: + base = max(base, mom_lim) + if tf == 3: + # 레거시 3M REST 경로 (롤업 OFF·legacy 모드) + base = max(base, get_env_int("SHORT_GAP_FILL_LIMIT", 150)) + return base + + def _code_needs_deep_1m(self, code: str) -> bool: + """모멘텀·돌파 등 1M 심층(500)이 필요한 종목인지.""" + with self._lock: + owners = { + str(o): set(cs) for o, cs in self._owner_candidates.items() + } + deep_owners: Set[str] = set() + for oid in ("MOMENTUM", "BREAKOUT", "SCALP", "RANGE_BREAK"): + deep_owners |= owners.get(oid) or set() + return code in deep_owners + + def _maybe_rollup_3m_from_1m(self, code: str) -> bool: + """1M RAM → 3M 롤업. 성공 시 _gap_tf_ok 에 3 마킹. 반환: 3M 준비 여부. + + 주의: ``_gap_tf_already_ok(1)`` 을 쓰면 SHORT 150봉이 모멘텀 500 미달로 + 1M ok 가 지워져 롤업이 스킵된다 → 여기서는 RAM 1M 존재 여부만 본다. + """ + if not get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True): + return False + if not self.candle_agg or 3 not in getattr(self.candle_agg, "timeframes", [1, 3]): + with self._gap_lock: + return 3 in self._gap_tf_ok.get(code, set()) + have_1m = 0 + try: + have_1m = int(self.candle_agg.get_confirmed_count(code, 1) or 0) + except Exception: + have_1m = 0 + with self._gap_lock: + marked_1 = 1 in self._gap_tf_ok.get(code, set()) + if have_1m <= 0 and not marked_1: + with self._gap_lock: + return 3 in self._gap_tf_ok.get(code, set()) + try: + n = int(self.candle_agg.rollup_tf_from_1m(code, 3) or 0) + have = self.candle_agg.get_confirmed_count(code, 3) + if have >= 1 or n > 0: + self._mark_gap_tf_ok(code, 3) + if n > 0: + logger.info( + "✅ [갭보정-롤업] %s 1M→3M %d봉 보강 (확정=%d)", + code, n, have, + ) + return True + except Exception as e: + logger.warning("⚠️ [갭보정-롤업] %s 1M→3M 실패: %s", code, e) + with self._gap_lock: + return 3 in self._gap_tf_ok.get(code, set()) + + def _momentum_min_candles(self) -> int: + return max(50, get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500)) + def _gap_tf_already_ok(self, code: str, tf: int) -> bool: with self._gap_lock: - return tf in self._gap_tf_ok.get(code, set()) + if tf not in self._gap_tf_ok.get(code, set()): + return False + # 1M 심층: 모멘텀 등만 500봉 미달 시 ok 해제(재갭). SHORT-only 150은 유지. + if tf == 1 and self.candle_agg and self._code_needs_deep_1m(code): + need = self._momentum_min_candles() + have = self.candle_agg.get_confirmed_count(code, 1) + if have < need: + with self._gap_lock: + self._gap_tf_ok.get(code, set()).discard(1) + return False + return True def _mark_gap_tf_ok(self, code: str, tf: int) -> None: with self._gap_lock: @@ -1054,7 +1304,6 @@ class WSManager: if self._all_gap_tfs_ok(code): return True - limit = get_env_int("WS_GAP_FILL_LIMIT", 120) use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None) kis_fallback_on = get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) priority = self._gap_priority_tfs() @@ -1069,6 +1318,26 @@ class WSManager: prev_tf = tf continue + # 1M→3M 롤업 ON: 3M REST 생략 (키움 1회·구멍 방지) + if ( + tf == 3 + and get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True) + and self._gap_tf_already_ok(code, 1) + ): + self._maybe_rollup_3m_from_1m(code) + prev_tf = tf + continue + if ( + tf == 3 + and get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True) + and not self._gap_tf_already_ok(code, 1) + and (only_tfs is None or 1 in only_tfs or 3 in only_tfs) + ): + # 1M 미확보 시 3M REST 대신 1M 먼저 (only_tfs에 1 없으면 스킵) + if only_tfs is not None and 1 not in only_tfs: + prev_tf = tf + continue + # 우선(1M·3M) → 장기(15M·60M) 전환 전 추가 휴식 if ( prev_tf is not None @@ -1083,12 +1352,13 @@ class WSManager: time.sleep(phase_pause) df = None + tf_limit = self._gap_fill_limit_for_tf(tf, code=code) if use_kiwoom: try: df = get_kiwoom_candles_df( code, tf, kw_key, kw_secret, - is_mock=kw_mock, n=limit, + is_mock=kw_mock, n=tf_limit, ) except Exception as e: logger.warning("⚠️ [갭보정] 키움 실패 (%s %dM): %s", code, tf, e) @@ -1097,7 +1367,7 @@ class WSManager: if (df is None or df.empty) and kis_fallback_on and tf <= 3: try: df = self.kis_client.get_minute_chart( - code, period=str(tf), limit=limit, + code, period=str(tf), limit=tf_limit, ) except Exception as e: logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e) @@ -1105,14 +1375,24 @@ class WSManager: if df is not None and not df.empty: self.candle_agg.fill_gap_from_rest(code, tf, df) self._mark_gap_tf_ok(code, tf) + if tf == 1: + self._maybe_rollup_3m_from_1m(code) else: logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf) prev_tf = tf self._gap_tf_sleep() + # only_tfs={1} 만 요청해도 롤업으로 3 준비됐을 수 있음 + if only_tfs is not None and 1 in only_tfs: + self._maybe_rollup_3m_from_1m(code) + return self._all_gap_tfs_ok(code) if only_tfs is None else ( - all(tf in self._gap_tf_ok.get(code, set()) for tf in only_tfs) + all( + tf in self._gap_tf_ok.get(code, set()) + or (tf == 3 and self._gap_tf_already_ok(code, 3)) + for tf in only_tfs + ) ) def get_share_denom(self, code: str) -> float: diff --git a/kis_trader/scripts/test_kis_ws_diagnostic.py b/kis_trader/scripts/test_kis_ws_diagnostic.py new file mode 100644 index 0000000..c098971 --- /dev/null +++ b/kis_trader/scripts/test_kis_ws_diagnostic.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +KIS WebSocket(H0STCNT0) 연결·구독 진단 스크립트 +================================================ +봇 로그의 "장외 서버 거부 추정" / 즉시 끊김이 **진짜 KIS 차단**인지, +**구독 폭주·중복 세션·구독 오류**인지 단계별로 검증한다. + +⚠️ kis_trader_main.service 가 이미 KIS WS 를 쓰는 중이면 + 동일 appkey 로 2번째 WS 를 열 때 기존 세션이 끊길 수 있다. + --skip-if-service-running (기본) 으로 서비스 기동 중이면 Phase 2+ 를 스킵한다. + +사용: + cd /home/hoon/kis_bot + python3 kis_trader/scripts/test_kis_ws_diagnostic.py + python3 kis_trader/scripts/test_kis_ws_diagnostic.py --force --codes 005930,069500 + python3 kis_trader/scripts/test_kis_ws_diagnostic.py --phase duplicate_sub + +로그 파일: + logs/test_kis_ws_diagnostic.log (동시에 stdout 출력) +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import subprocess +import sys +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +HERE = Path(__file__).resolve() +ROOT = HERE.parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import requests # noqa: E402 + +from kis_trader.execution.kis_client import KISClient # noqa: E402 +from kis_trader.utils.env import get_env_bool, get_env_from_db # noqa: E402 + +LOG_DIR = ROOT / "logs" +LOG_DIR.mkdir(exist_ok=True) +LOG_PATH = LOG_DIR / "test_kis_ws_diagnostic.log" + +DEFAULT_CODES = ["000660", "005380", "005930", "069500", "229200", "379810", "466930"] + + +def _setup_logging() -> logging.Logger: + lg = logging.getLogger("test_kis_ws") + lg.setLevel(logging.DEBUG) + lg.handlers.clear() + fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S") + for h in (logging.StreamHandler(sys.stdout), logging.FileHandler(LOG_PATH, encoding="utf-8")): + h.setFormatter(fmt) + lg.addHandler(h) + return lg + + +def _is_market_hours(is_mock: bool) -> bool: + now = datetime.now() + if now.weekday() >= 5: + return False + open_h, open_m = (9, 0) if is_mock else (8, 25) + from datetime import time as dtime + return dtime(open_h, open_m) <= now.time() <= dtime(16, 5) + + +def _service_running(name: str = "kis_trader_main.service") -> bool: + try: + r = subprocess.run( + ["systemctl", "is-active", name], + capture_output=True, + text=True, + timeout=5, + ) + return r.stdout.strip() == "active" + except Exception: + return False + + +def _fetch_approval_key(base_url: str, app_key: str, app_secret: str) -> Tuple[Optional[str], Dict[str, Any]]: + url = f"{base_url}/oauth2/Approval" + body = { + "grant_type": "client_credentials", + "appkey": app_key, + "secretkey": app_secret, + } + try: + r = requests.post(url, json=body, timeout=10) + data = r.json() + return data.get("approval_key"), data + except Exception as e: + return None, {"error": str(e)} + + +class WsProbe: + """단일 WS 연결 프로브 — 수신 메시지·끊김 시각 기록.""" + + def __init__( + self, + ws_url: str, + approval_key: str, + logger: logging.Logger, + gap_sec: float = 0.0, + ): + self.ws_url = ws_url + self.approval_key = approval_key + self.logger = logger + self.gap_sec = gap_sec + self.messages: List[str] = [] + self.json_responses: List[Dict[str, Any]] = [] + self.ticks = 0 + self.connected_at: float = 0.0 + self.closed_at: float = 0.0 + self.close_code: Any = None + self.close_msg: str = "" + self.error: str = "" + self._ws = None + self._thread: Optional[threading.Thread] = None + self._done = threading.Event() + + def _build_sub(self, code: str, subscribe: bool = True) -> str: + return json.dumps({ + "header": { + "approval_key": self.approval_key, + "custtype": "P", + "tr_type": "1" if subscribe else "2", + "content-type": "utf-8", + }, + "body": { + "input": { + "tr_id": "H0STCNT0", + "tr_key": code, + } + }, + }) + + def _on_open(self, ws) -> None: + self.connected_at = time.time() + self.logger.info(" [WS] on_open OK (t=0)") + + def _on_message(self, ws, message: str) -> None: + self.messages.append(message) + raw = (message or "").strip() + if raw == "PINGPONG": + try: + ws.send("PINGPONG") + except Exception: + pass + self.logger.debug(" [WS] PINGPONG echo") + return + if raw.startswith("{"): + try: + j = json.loads(raw) + self.json_responses.append(j) + hdr = j.get("header") or {} + body = j.get("body") or {} + rt = str(body.get("rt_cd", "")) + msg1 = str(body.get("msg1", "")) + tr_id = hdr.get("tr_id", "") + self.logger.info( + " [WS] JSON tr_id=%s rt_cd=%s msg1=%s", + tr_id, rt, msg1, + ) + if rt and rt != "0": + self.logger.warning(" [WS] ★ 구독/서버 오류 rt_cd=%s: %s", rt, msg1) + except Exception as e: + self.logger.warning(" [WS] JSON 파싱 실패: %s | raw=%s", e, raw[:200]) + return + parts = raw.split("|") + if len(parts) >= 2 and parts[1] == "H0STCNT0": + self.ticks += 1 + if self.ticks <= 3: + self.logger.info(" [WS] H0STCNT0 tick #%d (샘플)", self.ticks) + + def _on_error(self, ws, error) -> None: + self.error = str(error) + self.logger.warning(" [WS] on_error: %s", error) + + def _on_close(self, ws, code, msg) -> None: + self.closed_at = time.time() + self.close_code = code + self.close_msg = str(msg or "") + dur = self.closed_at - self.connected_at if self.connected_at else 0.0 + self.logger.info( + " [WS] on_close code=%s dur=%.2fs msg=%s", + code, dur, self.close_msg or "-", + ) + self._done.set() + + def run( + self, + codes: List[str], + *, + duplicate_subscribe: bool = False, + hold_sec: float = 8.0, + ) -> Dict[str, Any]: + try: + import websocket as ws_lib + except ImportError: + return {"ok": False, "reason": "websocket-client 미설치"} + + def _runner(): + app = ws_lib.WebSocketApp( + self.ws_url, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + ) + self._ws = app + + def _subscribe_all(): + time.sleep(0.3) + for i, code in enumerate(codes): + if self.gap_sec > 0 and i > 0: + time.sleep(self.gap_sec) + try: + app.send(self._build_sub(code, True)) + self.logger.info(" [WS] subscribe sent: %s", code) + except Exception as e: + self.logger.warning(" [WS] subscribe fail %s: %s", code, e) + if duplicate_subscribe and codes: + self.logger.info(" [WS] duplicate subscribe burst (봇 _on_open 버그 재현)") + for code in codes: + try: + app.send(self._build_sub(code, True)) + except Exception: + pass + + if codes: + threading.Thread(target=_subscribe_all, daemon=True).start() + + app.run_forever(ping_interval=20, ping_timeout=10) + + self._thread = threading.Thread(target=_runner, daemon=True) + self._thread.start() + self._done.wait(timeout=hold_sec + 15.0) + try: + if self._ws: + self._ws.close() + except Exception: + pass + if self._thread.is_alive(): + self._thread.join(timeout=3) + + dur = (self.closed_at or time.time()) - self.connected_at if self.connected_at else 0.0 + instant = dur > 0 and dur < 3.0 + err_json = [j for j in self.json_responses if str((j.get("body") or {}).get("rt_cd", "0")) != "0"] + return { + "ok": self.connected_at > 0 and not instant and self.error == "", + "connected": self.connected_at > 0, + "duration_sec": round(dur, 2), + "instant_drop": instant, + "error": self.error, + "close_code": self.close_code, + "ticks": self.ticks, + "json_errors": len(err_json), + "json_total": len(self.json_responses), + "messages": len(self.messages), + } + + +def _verdict(phase: str, result: Dict[str, Any], market_open: bool) -> str: + if result.get("reason"): + return f"FAIL — {result['reason']}" + if not result.get("connected"): + return "FAIL — TCP/WS 핸드셰이크 실패 (approval_key·URL·방화벽 확인)" + if result.get("json_errors", 0) > 0: + return "FAIL — KIS JSON rt_cd≠0 (구독 거부·잘못된 tr_key·한도 초과)" + if result.get("instant_drop"): + if not market_open: + return "EXPECTED — 장외 즉시 끊김 (KIS WS 서비스 시간 외)" + if phase == "duplicate_sub": + return "LIKELY — 중복 구독 폭주 후 즉시 끊김 (봇 _on_open 이중 전송 의심)" + return "FAIL — 장중인데 즉시 끊김 (동시 세션·서버 장애·rate limit 의심)" + if result.get("ticks", 0) > 0: + return "OK — 연결 유지 + H0STCNT0 틱 수신" + if result.get("duration_sec", 0) >= 5: + return "OK — 연결 유지 (장중 틱 없음=거래 없는 종목·장외 가능)" + return "WARN — 연결 짧음, 추가 확인 필요" + + +def main() -> int: + parser = argparse.ArgumentParser(description="KIS WebSocket 진단") + parser.add_argument( + "--codes", + default=",".join(DEFAULT_CODES), + help="구독 테스트 종목 (쉼표구분)", + ) + parser.add_argument( + "--phase", + choices=("all", "approval", "connect_only", "single", "multi", "duplicate_sub"), + default="all", + ) + parser.add_argument( + "--force", + action="store_true", + help="kis_trader_main.service 기동 중에도 WS 테스트 실행", + ) + parser.add_argument( + "--gap-sec", + type=float, + default=0.0, + help="종목별 구독 간격(초). 0=봇과 동일 즉시 연속", + ) + parser.add_argument( + "--hold-sec", + type=float, + default=8.0, + help="연결 유지 관측 시간(초)", + ) + args = parser.parse_args() + codes = [c.strip() for c in args.codes.split(",") if c.strip()] + + log = _setup_logging() + log.info("=" * 60) + log.info("KIS WebSocket 진단 시작 → log: %s", LOG_PATH) + log.info("=" * 60) + + mock = get_env_bool("KIS_MOCK", True) + client = KISClient(mock=mock) + ws_url = ( + get_env_from_db("KIS_WS_URL_MOCK", "ws://ops.koreainvestment.com:31000") + if mock + else get_env_from_db("KIS_WS_URL_REAL", "ws://ops.koreainvestment.com:21000") + ) + market_open = _is_market_hours(mock) + svc = _service_running() + + log.info("KIS_MOCK=%s | WS_URL=%s", mock, ws_url) + log.info("장 서비스 시간=%s (mock기준 open %s)", market_open, "09:00" if mock else "08:25") + log.info("kis_trader_main.service active=%s", svc) + if svc and not args.force: + log.warning( + "⚠️ 메인 봇이 WS 사용 중 → 2번째 연결은 기존 세션을 끊을 수 있음. " + "Phase connect/subscribe 는 스킵 (--force 로 강행)", + ) + + # Phase 1: approval_key + if args.phase in ("all", "approval"): + log.info("--- Phase 1: approval_key REST ---") + key, raw = _fetch_approval_key(client.base_url, client.app_key, client.app_secret) + if key: + log.info(" approval_key OK (앞8자 %s…)", key[:8]) + else: + log.error(" approval_key FAIL: %s", raw) + return 1 + + if svc and not args.force and args.phase != "approval": + log.info("진단 종료 (서비스 기동 중, WS Phase 스킵). --force 로 재실행하세요.") + return 0 + + key, _ = _fetch_approval_key(client.base_url, client.app_key, client.app_secret) + if not key: + log.error("approval_key 없음 — 종료") + return 1 + + phases: List[Tuple[str, List[str], bool]] = [] + if args.phase in ("all", "connect_only"): + phases.append(("connect_only", [], False)) + if args.phase in ("all", "single"): + phases.append(("single", [codes[0] if codes else "005930"], False)) + if args.phase in ("all", "multi"): + phases.append(("multi", codes, False)) + if args.phase in ("all", "duplicate_sub"): + phases.append(("duplicate_sub", codes[:3] if codes else ["005930"], True)) + + if args.phase == "duplicate_sub": + phases = [("duplicate_sub", codes[:3] if codes else ["005930"], True)] + + summary: List[str] = [] + for name, sub_codes, dup in phases: + log.info("--- Phase: %s (codes=%d dup=%s gap=%.1fs hold=%.0fs) ---", + name, len(sub_codes), dup, args.gap_sec, args.hold_sec) + probe = WsProbe(ws_url, key, log, gap_sec=args.gap_sec) + res = probe.run(sub_codes, duplicate_subscribe=dup, hold_sec=args.hold_sec) + v = _verdict(name, res, market_open) + log.info(" 결과: %s | detail=%s", v, res) + summary.append(f"{name}: {v}") + + log.info("=" * 60) + log.info("요약") + for line in summary: + log.info(" • %s", line) + log.info("") + log.info("해석 가이드:") + log.info(" • 장중 instant_drop + 서비스 active → 동시 WS 세션 충돌 가능성 큼") + log.info(" • json_errors>0 → KIS 가 구독 거부 (rt_cd/msg1 로그 확인)") + log.info(" • duplicate_sub 만 instant_drop → kis_ws._on_open 이중 subscribe 버그") + log.info(" • 장외 instant_drop → '장외 서버 거부' 메시지는 이 경우 정상") + log.info("=" * 60) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/kis_trader/scripts/verify_three_paths.py b/kis_trader/scripts/verify_three_paths.py index b697191..2e6222c 100644 --- a/kis_trader/scripts/verify_three_paths.py +++ b/kis_trader/scripts/verify_three_paths.py @@ -169,9 +169,49 @@ def verify_tail() -> int: return _cmp_rows(rows) +def verify_scalping() -> int: + import backtest_web as bw + import kis_trader.engine.scalping_engine as se + from kis_trader.backtest.param_search_scalping import _fixed_defaults, _ui_to_engine_params + + print("\n=== 스캘핑 (SCALP) ===") + can = se.get_scalping_defaults_from_db() # 실매 + param_search base (비율) + ui = bw._scalp_ui_defaults_from_db() # 웹 표시값 (%) + + # [A] 웹 표시(%) → ÷100 왕복 == 실매 비율 + rows = [ + ("sl_pct(손절)", can["sl_pct"], _ui_pct_to_ratio(ui["sl_pct"]), "비율"), + ("tp_pct(익절)", can["tp_pct"], _ui_pct_to_ratio(ui["tp_pct"]), "비율"), + ("tp_max_pct(익절상한)", can["tp_max_pct"], _ui_pct_to_ratio(ui["tp_max_pct"]), "비율"), + ("drop_rate(낙폭)", can["drop_rate"], _ui_pct_to_ratio(ui["drop_rate"]), "비율"), + ("shoulder_min_high", can["shoulder_min_high"], _ui_pct_to_ratio(ui["shoulder_min_high"]), "비율"), + ("shoulder_cut_pct", can["shoulder_cut_pct"], _ui_pct_to_ratio(ui["shoulder_cut_pct"]), "비율"), + ("rsi_oversold", can["rsi_oversold"], float(ui["rsi_oversold"]), "그대로"), + ("rsi_period", can["rsi_period"], int(float(ui["rsi_period"])), "정수"), + ("max_daily", can["max_daily"], int(float(ui["max_daily"])), "정수"), + ("use_macd_cross", bool(can.get("use_macd_cross")), bool(ui.get("use_macd_cross")), "bool"), + ("skip_hts_scan_dupes", bool(can.get("skip_hts_scan_dupes")), + bool(ui.get("skip_hts_scan_dupes")), "bool"), + ] + bad = _cmp_rows(rows) + + # [B] param_search _ui_to_engine == 실매 비율 + print(" [B] param_search _ui_to_engine vs 실매 비율") + ps_ui = _fixed_defaults() + eng = _ui_to_engine_params(dict(ps_ui)) + rows_b = [] + for k in ( + "sl_pct", "tp_pct", "tp_max_pct", "drop_rate", + "shoulder_min_high", "shoulder_cut_pct", + ): + rows_b.append((k, can.get(k), eng.get(k), "비율")) + bad += _cmp_rows(rows_b) + return bad + + def main() -> int: total_bad = 0 - for fn in (verify_momentum, verify_breakout, verify_tail): + for fn in (verify_momentum, verify_breakout, verify_tail, verify_scalping): try: total_bad += fn() except Exception as e: # noqa: BLE001 diff --git a/kis_trader/strategies/base.py b/kis_trader/strategies/base.py index 7ee58b7..95d6c62 100644 --- a/kis_trader/strategies/base.py +++ b/kis_trader/strategies/base.py @@ -28,37 +28,9 @@ from ..utils.env import get_env_bool, get_env_from_db, get_env_int from ..utils.logger import get_logger -# ETN/ETF/레버리지/인버스/스팩/우선주 자동 필터 키워드 -# (KISClient._is_valid_stock_for_rank 와 동기화 — 한곳만 수정해도 양쪽 적용되도록 모듈 상수화) -_NON_STOCK_KEYWORDS = ( - "ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물", - "KODEX", "TIGER", "KBSTAR", "ARIRANG", "HANARO", "SOL ", - "KOSEF", "ACE ", "KINDEX", "RISE ", "PLUS ", "TIMEFOLIO", - "파워", "히어로", "SMART", "TREX", "WON", "KOACT", -) - - -def _is_non_stock(name: str, code: str) -> bool: - """ETN/ETF/스팩/우선주 등 비본주 여부. - - 조건검색·랭킹 매니저 양쪽에서 들어오는 후보를 동일 규칙으로 거르기 위해 - BaseStrategy 단계에서 한번 더 차단한다. False 면 매매 가능 종목. - """ - if not name or not code: - # 이름이 비어있으면 보수적으로 통과 (이후 KIS 응답으로 자연 거름) - return False - nm = name.upper() - if any(k in nm for k in _NON_STOCK_KEYWORDS): - return True - if "스팩" in name or "SPAC" in nm: - return True - if name.endswith("우") or name.endswith("우B") or name.endswith("(전환)"): - return True - # KIS 코드 체계: 7로 시작하는 6자리는 ETN (예: 760006) - code = code.strip() - if len(code) == 6 and code[0] == "7": - return True - return False +# 비본주 판별 — kis_trader.utils.non_stock 공용 (KISClient 랭킹 필터와 동일) +from ..utils.non_stock import is_non_stock as _is_non_stock +from ..utils.non_stock import is_unmanageable_holding as _is_unmanageable_holding from ..engine.strategy_eod import ( @@ -82,12 +54,12 @@ class BaseStrategy(ABC, threading.Thread): """ strategy_id: str = "BASE" - loop_min_sleep: float = 1.0 - loop_max_sleep: float = 2.0 + loop_min_sleep: float = 0.1 + loop_max_sleep: float = 0.1 # 전략별 유니버스 소스 기본값 (env 미설정 시) — HTS 조건검색 단일 정책 DEFAULT_UNIVERSE_SOURCES: Dict[str, str] = { - "SCALP": "condition", + "SCALP": "kiwoom_condition", "SHORT": "kiwoom_condition", # 키움 tail(A 시가대비+F 저가회복) WS 실시간 "BREAKOUT": "kiwoom_condition", "MOMENTUM": "kiwoom_condition", # 키움 WS 실시간 조건 (KIS REST 폴링 대비 유니버스 품질↑) @@ -424,6 +396,13 @@ class BaseStrategy(ABC, threading.Thread): return code = c.get("code") or c.get("stk_cd", "") name = c.get("name") or c.get("stk_nm", code) + if name == code or not str(name or "").strip(): + try: + from ..utils.stock_name import resolve_stock_display_name + + name = resolve_stock_display_name(self.db, code, name) + except Exception: + name = code if not code or code in self.holdings: continue if code in self.untradable_skip: @@ -685,7 +664,8 @@ class BaseStrategy(ABC, threading.Thread): - DB에 없는 종목은 holdings·_runtime 에서 제거 (양방향 정합) - poll_pending 매수 체결·재시작 후에도 다음 루프에 자동 반영 - - ETN/ETF/스팩 등 비본주는 가격 API 불가 → 자동 제외 + - ETF/ETN/스팩 등 시세 불가 종목만 보유 루프에서 제외. + 우선주는 매수 차단 대상이지만, 이미 산 경우 매도 가능하도록 유지. """ try: prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id @@ -697,7 +677,9 @@ class BaseStrategy(ABC, threading.Thread): continue if get_env_bool("EXCLUDE_NON_STOCK", True): name = (t.get("name") or "").strip() - if _is_non_stock(name, code): + # 우선주(is_non_stock)는 매도 정리 위해 holdings 유지. + # ETF/ETN/스팩만 자동 제외. + if _is_unmanageable_holding(name, code): skipped_non_stock.append(f"{code}({name})") continue avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0) @@ -753,7 +735,7 @@ class BaseStrategy(ABC, threading.Thread): if skipped_non_stock: self.logger.warning( - "⚠️ ETN/ETF 보유 자동 제외(매수/매도 모두 봇이 안 건드림 — 한투 HTS에서 직접 처분 권장): %s", + "⚠️ ETF/ETN/스팩 보유 자동 제외(시세불가 — 한투 HTS에서 직접 처분 권장): %s", ", ".join(skipped_non_stock), ) except Exception as e: @@ -826,7 +808,7 @@ class BaseStrategy(ABC, threading.Thread): """매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡. - ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외. - KISClient.filter_rank_rows 와 동일 규칙 (`_is_non_stock`). + ``kis_trader.utils.non_stock.is_non_stock`` (코드 끝 5/7/9·알파벳 + 이름). - ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용. WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장. """ diff --git a/kis_trader/strategies/breakout.py b/kis_trader/strategies/breakout.py index a480039..ed059cd 100644 --- a/kis_trader/strategies/breakout.py +++ b/kis_trader/strategies/breakout.py @@ -4,9 +4,10 @@ kis_trader/strategies/breakout.py — 돌파 매매 (Breakout) 전제 (SCAN vs TRIGGER): * [SCAN] HTS 조건식은 **널넬하게** — 후보 풀만 확보 (등락·거래량·돌파를 HTS에서 약하게 걸거나 일부 조건 제거). ETF/관리종목 제외 등 구조 필터만 HTS에 둠. - * [TRIGGER] 본 파일 매수체크는 **엄격하게** — 등락·고가돌파·회전율(유통주식 대비) - ·선택 vol_mult(직전봉 평균 대비 배수)·골든타임·이격·양봉 등은 **항상 코드**에서 검사. - HTS 와 수치를 맞출 필요 없음 — HTS 는 후보만 넓게. + * [TRIGGER] ``BREAKOUT_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본): 일봉 돌파·등락·거래량 + 중복 생략 — 진입 타이밍·호가·휩쏘만 검사. + * [TRIGGER] ``BREAKOUT_SKIP_HTS_SCAN_DUPES=false``: 등락·고가돌파·회전율·vol_mult 등 + 1분봉 TRIGGER 전체 검사. * 진입은 '장 초반 골든타임' 한정 (09:00 ~ BREAKOUT_GOLDEN_END_HM). 오후장 돌파는 거의 속임수(휩쏘) → 봇이 쫓지 않는다. @@ -24,7 +25,7 @@ kis_trader/strategies/breakout.py — 돌파 매매 (Breakout) * 3순위 손절 : 진입가 × (1 + BREAKOUT_STOP_LOSS_PCT) (기본 -2%) * 4순위 트레일링 : 최고가 대비 BREAKOUT_TRAIL_PCT 하락 (기본 1.5%, 어깨 미무장 시 보조) * EOD 강제청산 (15:15 이후 전량) - * 백테 청산: 1분 OHLC → N회 intrabar (``check_sell_signal_backtest_bar`` + ``check_sell_signal_breakout_live``) + * 백테 청산: ws_ticks 시간순 (``resolve_backtest_sell``) → OHLC 폴백 기본 OFF 주문 집행은 모두 ``OrderManager.place()`` 경유 → ODNO·실잔고검증·종목락 공유. """ @@ -40,7 +41,12 @@ from ..utils.logger import get_logger _bo_logger = get_logger("kis_trader.breakout") from ..utils.position_sizing import invest_qty_for_price from ..engine.ema_trend_filter import eval_ema_uptrend_reject -from ..engine.scalping_engine import check_sell_signal_backtest_bar +from ..engine.tick_exit_common import ( + backtest_sell_slip_pct, + backtest_tick_poll_ms, + collect_minute_ticks, + resolve_backtest_sell, +) from ..engine.atr_series import compute_atr_series from ..engine.whipsaw_filter import whipsaw_reject_for_signal from ..engine.orderbook_filter import orderbook_reject_for_entry @@ -62,6 +68,29 @@ def _to_bool(v: Any, default: bool = True) -> bool: return default +def resolve_breakout_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool: + """ + HTS breakout SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부. + + - ``BREAKOUT_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로 + - 미설정 → ``BREAKOUT_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True + """ + if r is None: + try: + from kis_trader.utils.env import get_strategy_env_dict + r = get_strategy_env_dict("BREAKOUT") or {} + except Exception: + r = {} + raw = r.get("BREAKOUT_SKIP_HTS_SCAN_DUPES") + if raw is not None and str(raw).strip() != "": + return _to_bool(raw, True) + # 엔진 defaults 에 이미 해석된 bool 이 있으면 universe fallback 금지 + if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None: + return _to_bool(r.get("skip_hts_scan_dupes"), False) + src = str(r.get("BREAKOUT_UNIVERSE_SOURCE") or "condition").strip().lower() + return src in ("kiwoom_condition", "condition") + + def _confirmed_candles_only(candles: List[Dict[str, Any]]) -> List[Dict[str, Any]]: confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] return confirmed if confirmed else list(candles) @@ -214,22 +243,15 @@ def _breakout_max_hold_bars(params: Dict[str, Any]) -> int: def _bo_dt_to_minutes(s: Any) -> Optional[int]: - """캔들시각/매수시각 → epoch 분(minute) 변환. 백테(YYYYMMDDHHMM)·실매(YYYY-MM-DD HH:MM:SS) 모두 허용.""" + """캔들시각/매수시각 → epoch 분(minute). 실매·백테 공통 파서.""" if s is None: return None txt = str(s).strip() if not txt: return None try: - if "-" in txt or ":" in txt: - # 실매 포맷 "YYYY-MM-DD HH:MM:SS" - d = dt.strptime(txt[:19], "%Y-%m-%d %H:%M:%S") - else: - # 백테 candle_time "YYYYMMDDHHMM[SS]" - digits = "".join(ch for ch in txt if ch.isdigit()) - if len(digits) < 12: - return None - d = dt.strptime(digits[:12], "%Y%m%d%H%M") + from kis_trader.utils.trade_time import parse_trade_datetime + d = parse_trade_datetime(txt) return int(d.timestamp() // 60) except Exception: return None @@ -310,14 +332,16 @@ def _eval_breakout_buy_at_index( ema_fast_period = int(params.get("ema_fast_period", 9)) ema_slow_period = int(params.get("ema_slow_period", 21)) - need_len = max(lookback_min, vol_window) + 2 - if i < 2 or i + 1 > len(candles) or (i + 1) < need_len: + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) + need_len = 1 if skip_hts else max(lookback_min, vol_window) + 2 + min_i = 0 if skip_hts else 2 + if i < min_i or i + 1 > len(candles) or (i + 1) < need_len: return ("탈락-캔들부족", "have=%d need_len=%d i=%d" % (len(candles), need_len, i), None) # ※ sub=candles[:i+1] 복사 없이 인덱스로 직접 접근 (결과 동일) c = candles[i] - prev_c = candles[i - 1] - prev_prev_c = candles[i - 2] + prev_c = candles[i - 1] if i >= 1 else c + prev_prev_c = candles[i - 2] if i >= 2 else prev_c try: cl = float(c.get("close", 0)) op = float(c.get("open", 0)) @@ -341,6 +365,43 @@ def _eval_breakout_buy_at_index( if cl < min_price: return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None) + # kiwoom breakout SCAN — 일봉 돌파·등락·5일거래량 이미 통과, TRIGGER 는 타이밍·호가·휩쏘만 + if skip_hts: + if body_min_pct > 0 and op > 0: + body_pct = (cl - op) / op * 100.0 + if body_pct < body_min_pct: + return ("탈락-몸통부족", "몸통 %.2f%% < %.2f%%" % (body_pct, body_min_pct), None) + sig_hts: Dict[str, Any] = { + "resistance": 0.0, + "vol_ratio": 0.0, + "prev_chg": 0.0, + "close": cl, + "signal_candle_time": ct, + "pattern": "breakout_hts_scan", + "mode": "breakout_hts", + } + ws_rej, ws_msg = whipsaw_reject_for_signal( + params, "BREAKOUT", + signal_bar=c, + current_price=cl, + resistance=0.0, + ) + if ws_rej: + return (ws_rej, ws_msg, None) + ob_rej, ob_msg = orderbook_reject_for_entry( + params, "BREAKOUT", + current_price=cl, + resistance=0.0, + ) + if ob_rej: + return (ob_rej, ob_msg, None) + prog_rej, prog_msg = program_reject_for_entry( + params, "BREAKOUT", current_price=cl, + ) + if prog_rej: + return (prog_rej, prog_msg, None) + return (None, None, sig_hts) + # 2) 직전봉 등락률 (TRIGGER — HTS 는 널넬게, 여기서 1~10% 등으로 조임) if prev_prev_close <= 0: return ("탈락-직전봉(전전봉)없음", "prev_prev_close=0", None) @@ -528,7 +589,8 @@ def check_buy_signal_breakout_intrabar_live( """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) - need_n = max(lookback_min, vol_window) + 2 + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) + need_n = 1 if skip_hts else max(lookback_min, vol_window) + 2 confirmed_only = _confirmed_candles_only(confirmed) if current_price <= 0: @@ -584,8 +646,17 @@ def breakout_intrabar_entry_price( return hi if hi > 0 else op +def breakout_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool: + """백테 청산에 ws_ticks 사용 (기본 ON — OHLC intrabar 낙관 편향 제거).""" + if params is not None and params.get("backtest_use_tick_exit") is not None: + return _to_bool(params.get("backtest_use_tick_exit"), True) + return get_env_bool("BREAKOUT_BACKTEST_USE_TICK_EXIT", True) + + def breakout_backtest_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool: - """B안 + 틱 DB 백테 재생이 필요한지.""" + """B안 진입 틱 또는 청산 틱 재생이 필요한지.""" + if breakout_backtest_use_tick_exit(params): + return True return ( breakout_entry_mode(params) in _INTRABAR_ENTRY_MODES and breakout_backtest_use_tick_db(params) @@ -608,10 +679,10 @@ def breakout_backtest_use_tick_db(params: Optional[Dict[str, Any]] = None) -> bo def breakout_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: - """해당 분에 틱 없을 때 1분봉 high 근사 폴백.""" + """해당 분에 틱 없을 때 1분봉 high 근사 폴백 (기본 OFF — 유령거래 방지).""" if params is not None and params.get("backtest_tick_fallback_ohlc") is not None: - return _to_bool(params.get("backtest_tick_fallback_ohlc"), True) - return get_env_bool("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", True) + return _to_bool(params.get("backtest_tick_fallback_ohlc"), False) + return get_env_bool("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", False) def breakout_scan_buy_intrabar_from_ticks( @@ -649,7 +720,8 @@ def breakout_scan_buy_intrabar_from_ticks( confirmed_only = _confirmed_candles_only(confirmed) lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) - need_n = max(lookback_min, vol_window) + 2 + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) + need_n = 1 if skip_hts else max(lookback_min, vol_window) + 2 if len(confirmed_only) < need_n - 1: return ( "탈락-캔들부족", @@ -702,8 +774,8 @@ def breakout_scan_buy_intrabar_from_ticks( _te = int(params.get("time_end_hm", 2400)) if _hm is not None and (_hm < _ts or _hm >= _te): return (None, None, None, 0.0, "") - # 2) 직전봉 등락 (_eval step2: prev/prev_prev = 확정봉 끝 2개) - if len(confirmed_only) >= 2: + # 2) 직전봉 등락 — skip_hts 시 HTS 일봉 등락과 중복이므로 생략 + if not skip_hts and len(confirmed_only) >= 2: _pc = float(confirmed_only[-1].get("close", 0) or 0) _ppc = float(confirmed_only[-2].get("close", 0) or 0) if _ppc > 0: @@ -714,14 +786,16 @@ def breakout_scan_buy_intrabar_from_ticks( return ("탈락-직전봉약세", "prev=%.2f%% < %.2f%%" % (_prev_chg, _pmin), None, 0.0, "") if _prev_chg > _pmax: return ("탈락-직전봉과열", "prev=%.2f%% > %.2f%%" % (_prev_chg, _pmax), None, 0.0, "") - # 3) 저항선(불변): price ≤ 저항 인 틱은 _eval 도 '탈락-저항미돌파'(sig 없음) - _res_window = ( - [float(x.get("high", 0) or 0) for x in confirmed_only[-lookback_min:]] - if lookback_min > 0 else [] - ) - resistance_gate = max(_res_window) if _res_window else 0.0 - if resistance_gate <= 0: - return ("탈락-저항창없음", "lookback=%d" % lookback_min, None, 0.0, "") + # 3) 저항선(불변) — skip_hts 시 HTS 전일고가 돌파와 중복이므로 생략 + resistance_gate = 0.0 + if not skip_hts: + _res_window = ( + [float(x.get("high", 0) or 0) for x in confirmed_only[-lookback_min:]] + if lookback_min > 0 else [] + ) + resistance_gate = max(_res_window) if _res_window else 0.0 + if resistance_gate <= 0: + return ("탈락-저항창없음", "lookback=%d" % lookback_min, None, 0.0, "") # ── [백테 틱재생·공유메모리] 컬럼 뷰 직행 경로 (dict 재구성/재정렬 제거) ───────── # minute_ticks 가 공유메모리 컬럼 뷰(또는 그 _TickList)면, 매 틱 sorted()+tick_upto @@ -765,7 +839,7 @@ def breakout_scan_buy_intrabar_from_ticks( if price <= 0: continue cum_vol += int(_volume[ai]) - if price <= resistance_gate: + if not skip_hts and price <= resistance_gate: last_reject = ("탈락-저항미돌파", "price=%.0f ≤ 저항=%.0f" % (price, resistance_gate)) continue forming = dict(forming_base) @@ -815,8 +889,8 @@ def breakout_scan_buy_intrabar_from_ticks( if price <= 0: continue cum_vol += int(tick.get("volume") or 0) - # 저항 미돌파 틱 → 비싼 _eval 생략 (결과 동일: sig 없음) - if price <= resistance_gate: + # 저항 미돌파 틱 → 비싼 _eval 생략 (skip_hts 시 저항 게이트 없음) + if not skip_hts and price <= resistance_gate: last_reject = ("탈락-저항미돌파", "price=%.0f ≤ 저항=%.0f" % (price, resistance_gate)) continue forming = dict(forming_base) @@ -867,9 +941,13 @@ def breakout_scan_buy_intrabar_from_ticks( def breakout_min_bars_required(params: Optional[Dict[str, Any]] = None) -> int: """백테/파서치 캔들 최소 개수 — A안(+1 다음봉) vs B안(당일봉 진입).""" p = params or {} - lookback_min = int(p.get("lookback_min", 1)) - vol_window = int(p.get("vol_window", 7)) - need_n = max(lookback_min, vol_window) + 2 + skip_hts = _to_bool(p.get("skip_hts_scan_dupes"), False) + if skip_hts: + need_n = 1 + else: + lookback_min = int(p.get("lookback_min", 1)) + vol_window = int(p.get("vol_window", 7)) + need_n = max(lookback_min, vol_window) + 2 extra = 0 if breakout_entry_mode(p) in _INTRABAR_ENTRY_MODES else 1 return need_n + extra @@ -959,6 +1037,10 @@ def breakout_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]: # EOD — 실매 BREAKOUT_EOD_* 와 동일 (백테·파서치 공용) "eod_enabled": _ui_bool("eod_enabled", True), "eod_hm": str(ui.get("eod_hm") or "15:15").strip() or "15:15", + "skip_hts_scan_dupes": _ui_bool( + "skip_hts_scan_dupes", + resolve_breakout_skip_hts_scan_dupes(), + ), } @@ -1073,7 +1155,8 @@ def check_buy_signal_breakout_live( """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) - need_n = max(lookback_min, vol_window) + 2 + skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) + need_n = 1 if skip_hts else max(lookback_min, vol_window) + 2 live_align = _to_bool(params.get("live_backtest_align", True), True) lookback = max(1, int(params.get("live_signal_lookback_bars", 1))) confirmed = _confirmed_candles_only(candles) @@ -1197,8 +1280,9 @@ def check_sell_signal_breakout_live( # 백테스트 — 매수/매도 모듈 함수 호출 (실매 BreakoutStrategy 와 동일 판정 함수) # ════════════════════════════════════════════════════════════════════════════ def _bt_t2dt(t: str): - """candle_time(YYYYMMDDHHMI) → datetime.""" - return dt.strptime(t, "%Y%m%d%H%M") + """candle_time / buy_time → datetime (공통 파서).""" + from kis_trader.utils.trade_time import parse_trade_datetime + return parse_trade_datetime(t) def _bt_slot_key(candle_time: str, scan_interval_min: int = 1) -> str: @@ -1235,6 +1319,8 @@ def run_breakout_backtest( params = dict(params) if params.get("backtest_use_tick_db") is None: params["backtest_use_tick_db"] = breakout_backtest_use_tick_db(params) + if params.get("backtest_use_tick_exit") is None: + params["backtest_use_tick_exit"] = breakout_backtest_use_tick_exit(params) if params.get("backtest_tick_fallback_ohlc") is None: params["backtest_tick_fallback_ohlc"] = breakout_backtest_tick_fallback_ohlc(params) if params.get("intrabar_slippage_pct") in (None, ""): @@ -1280,6 +1366,10 @@ def run_breakout_backtest( buy_params = dict(params) buy_params["time_start_hm"] = time_start_hm buy_params["time_end_hm"] = time_end_hm + use_tick_exit = bool(ticks_by_code) and breakout_backtest_use_tick_exit(params) + tick_fallback_ohlc = breakout_backtest_tick_fallback_ohlc(params) + tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="BREAKOUT_BACKTEST_POLL_MS") + tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="BREAKOUT_BACKTEST_SELL_SLIP_PCT") all_trades: List[Dict] = [] @@ -1314,16 +1404,25 @@ def run_breakout_backtest( bar = dict(c) if "open" not in bar or bar.get("open") in (None, ""): bar["open"] = float(c.get("open") or cl) - rp = check_sell_signal_backtest_bar( + minute_ticks = ( + collect_minute_ticks(ticks_by_code, code, c["candle_time"]) + if use_tick_exit else None + ) + rp = resolve_backtest_sell( position, bar, params, is_eod=is_eod, sell_fn=check_sell_signal_breakout_live, low_mode="current", + ticks=minute_ticks, + use_tick_exit=use_tick_exit, + tick_fallback_ohlc=tick_fallback_ohlc, + poll_ms=tick_poll_ms, + slip_pct=tick_sell_slip, ) if rp: - reason, exit_price = rp + reason, exit_price, sell_time, hold_min, _exit_src = rp qty = position["qty"] buy_amt = position["entry_price"] * qty sell_amt = exit_price * qty @@ -1333,13 +1432,14 @@ def run_breakout_backtest( - sell_amt * fee_rate - sell_amt * sell_tax ) - hold_min = int( - (_bt_t2dt(c["candle_time"]) - _bt_t2dt(position["entry_time"])).total_seconds() / 60 - ) + if hold_min <= 0: + hold_min = int( + (_bt_t2dt(sell_time or c["candle_time"]) - _bt_t2dt(position["entry_time"])).total_seconds() / 60 + ) all_trades.append({ "code": code, "buy_time": position["entry_time"], - "sell_time": c["candle_time"], + "sell_time": sell_time or c["candle_time"], "buy_price": position["entry_price"], "sell_price": round(exit_price, 2), "qty": qty, @@ -1350,7 +1450,7 @@ def run_breakout_backtest( "hold_min": hold_min, "sell_reason": reason, }) - last_exit_dt[day] = _bt_t2dt(c["candle_time"]) + last_exit_dt[day] = _bt_t2dt(sell_time or c["candle_time"]) position = None continue @@ -1482,6 +1582,7 @@ class BreakoutStrategy(BaseStrategy): self.atr_sl_mult = get_env_float("BREAKOUT_ATR_SL_MULT", 2.0) self.atr_sl_min_pct = get_env_float("BREAKOUT_ATR_SL_MIN_PCT", 0.8) self.atr_sl_max_pct = get_env_float("BREAKOUT_ATR_SL_MAX_PCT", 6.0) + self.skip_hts_scan_dupes = resolve_breakout_skip_hts_scan_dupes() def _breakout_invest_cap_krw(self) -> float: max_loss = normalize_breakout_max_loss_krw( @@ -1539,8 +1640,8 @@ class BreakoutStrategy(BaseStrategy): if not self._is_golden_time(): return None - need_n = max(self.lookback_min, self.vol_window) + 2 - if self.use_ema_filter: + need_n = 1 if self.skip_hts_scan_dupes else max(self.lookback_min, self.vol_window) + 2 + if self.use_ema_filter and not self.skip_hts_scan_dupes: need_n = max(need_n, self.ema_slow_period + 5) confirmed = self.ws.get_candles(code, self.candle_tf, n=need_n + 5) if len(confirmed) < need_n - 1: @@ -1589,6 +1690,7 @@ class BreakoutStrategy(BaseStrategy): "_program_ws": self.ws, "_program_code": code, "slot_money": self.slot_money, + "skip_hts_scan_dupes": self.skip_hts_scan_dupes, } mode = breakout_entry_mode(params) @@ -1775,15 +1877,22 @@ class BreakoutStrategy(BaseStrategy): ) continue - # 최고가 갱신 + # 최고가·세션저점 갱신 (모멘텀·백테 intrabar 와 동일 — 폴링 사이 저점도 손절·트레일에 반영) if current_price > max_price: max_price = current_price holding["max_price"] = max_price + session_low = float(holding.get("session_low", buy_price) or buy_price) + if session_low <= 0: + session_low = buy_price + if current_price < session_low: + session_low = current_price + holding["session_low"] = session_low profit_pct = (current_price - buy_price) / buy_price # ── 청산 판정: 백테와 100% 동일한 공용 함수에 위임 (중복 제거) ── - # 실매는 현재가만 알 수 있으므로 high=low=close=현재가 단일점 봉으로 전달. + # 실매: high=누적고점, low=세션저점(현재가 포함) — 단일점(현재가=고=저)보다 + # 손절·어깨·트레일·시간컷 판정이 백테·모멘텀과 정합. sell_params = { "stop_loss_pct": self.stop_loss_pct, "take_profit_pct": self.take_profit_pct, @@ -1814,8 +1923,8 @@ class BreakoutStrategy(BaseStrategy): "entry_atr": entry_atr, } live_candle = { - "high": current_price, - "low": current_price, + "high": max(max_price, current_price), + "low": session_low, "close": current_price, "candle_time": now.strftime("%Y-%m-%d %H:%M:%S"), } @@ -1828,6 +1937,7 @@ class BreakoutStrategy(BaseStrategy): max_price = new_mp holding["max_price"] = max_price reason = rp[0] if rp else None + exit_price = float(rp[1]) if rp else current_price if not reason: # 보유 중이지만 매도 조건 미충족 — 종목별 60초 1회 상태 로그 @@ -1836,16 +1946,36 @@ class BreakoutStrategy(BaseStrategy): last = self._sell_state_log.get(code, 0) if time.time() - last >= 60: self._sell_state_log[code] = time.time() - sl_line = buy_price * (1 + self.stop_loss_pct) + sl_line = _breakout_sl_line( + buy_price, self.stop_loss_pct, sell_pos, sell_params, + ) tp_line = buy_price * (1 + self.take_profit_pct) - trail_line = max_price * (1 - self.trail_pct) if max_price > buy_price else 0.0 + trail_arm_line = buy_price * (1.0 + self.trail_arm_pct) + trail_armed = ( + max_price > buy_price + and max_price >= trail_arm_line + ) + trail_line = ( + max_price * (1 - self.trail_pct) if trail_armed else 0.0 + ) sh_armed = max_price >= buy_price * (1.0 + self.shoulder_min_high) sh_line = max_price * (1.0 - self.shoulder_cut_pct) if sh_armed else 0.0 + hold_min = _breakout_minutes_held(sell_pos, live_candle) + mh = int(self.max_hold_bars or 0) + mh_left = ( + max(0, mh - hold_min) + if (mh > 0 and hold_min is not None) + else -1 + ) self.logger.info( "🔍 [보유중] %s(%s) 현재=%.0f 매수=%.0f (%.2f%%) " - "손절=%.0f 익절=%.0f 어깨=%.0f 트레일=%.0f [%s]", + "손절=%.0f 익절=%.0f 어깨=%.0f 트레일=%.0f " + "보유=%s분 시간컷잔여=%s [%s]", name, code, current_price, buy_price, profit_pct * 100.0, - sl_line, tp_line, sh_line, trail_line, price_src, + sl_line, tp_line, sh_line, trail_line, + hold_min if hold_min is not None else "?", + f"{mh_left}분" if mh_left >= 0 else "OFF", + price_src, ) continue @@ -1853,7 +1983,7 @@ class BreakoutStrategy(BaseStrategy): "code": code, "name": name, "current_price": current_price, - "price": current_price, + "price": exit_price, "qty": qty, "buy_price": buy_price, "profit_pct": profit_pct, diff --git a/kis_trader/strategies/dbband_strategy.py b/kis_trader/strategies/dbband_strategy.py index 396fd1b..67a6cbb 100644 --- a/kis_trader/strategies/dbband_strategy.py +++ b/kis_trader/strategies/dbband_strategy.py @@ -184,7 +184,7 @@ class DbBandStrategy(BaseStrategy): position = { "entry_price": buy_price, - "entry_time": str(holding.get("buy_time", ""))[:12], + "entry_time": str(holding.get("buy_time", "") or ""), "stop": float(holding.get("stop_price") or buy_price * 0.98), "target": float(holding.get("target_price") or buy_price * 1.03), "max_price": float(holding.get("max_price") or buy_price), diff --git a/kis_trader/strategies/momentum.py b/kis_trader/strategies/momentum.py index 25f6e13..bf2388b 100644 --- a/kis_trader/strategies/momentum.py +++ b/kis_trader/strategies/momentum.py @@ -1,15 +1,16 @@ """ -kis_trader/strategies/momentum.py — 모멘텀 전략 (1분봉 추세추격) +kis_trader/strategies/momentum.py — MOMENTUM A안 (HTS momentum E∧F∧H∧I 돌파·주도주 추격) ================================================================ 스캘핑 reversal(SCALP)과 완전 분리 — ``momentum_engine`` 전용. -[SCAN vs TRIGGER] -- SCAN: HTS/KIS ``scalp`` 조건검색 → target_candidates_history -- TRIGGER: 양봉, RSI 강세, 거래량 spike, 방어필터 (고점추격·급등·시가위치) +[SCAN vs TRIGGER vs 청산] +- SCAN: 키움 ``momentum`` 조건검색 → target_candidates_history +- TRIGGER: ``MOMENTUM_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본) 시 E·양봉·거래량 중복 생략, 진입 타이밍만 +- 청산: 래칫·어깨·트레일·손절·시간컷 (``momentum_hts_logic``) [엔진] - 진입: ``momentum_engine.check_buy_signal_momentum_live`` -- 청산: ``momentum_engine.check_sell_signal_momentum_live`` (어깨·트레일 선행, tp_max 상한 익절 마지막) +- 청산: ``momentum_engine.check_sell_signal_momentum_live`` - 백테: ``momentum_engine.run_momentum_backtest`` / ``check_sell_signal_momentum_backtest_bar`` """ from __future__ import annotations @@ -54,7 +55,7 @@ class MomentumStrategy(BaseStrategy): self.mom_rsi_max = float(base.get("mom_rsi_max", 80.0)) self.max_daily = int(base.get("max_daily", 5)) self.eod_enabled = get_env_bool("MOMENTUM_EOD_ENABLED", True) - self.eod_hm = get_env_from_db("MOMENTUM_EOD_HM", "15:25") + self.eod_hm = get_env_from_db("MOMENTUM_EOD_HM", "15:20") except Exception as e: self.logger.debug("momentum_engine defaults 조회 실패: %s", e) self._engine_params = {} @@ -62,13 +63,41 @@ class MomentumStrategy(BaseStrategy): def _candidate_filter(self, candidate: Dict) -> bool: return bool(candidate.get("scalp_on", True)) + def _reentry_cooldown_sec(self) -> int: + # wall-clock 이중 게이트 제거 — 엔진 cooldown_min(신호봉 시계)만 사용 (BT 정합) + if bool((self._engine_params or {}).get("cooldown_engine_only", True)): + return 0 + return super()._reentry_cooldown_sec() + def check_buy(self, code: str, name: str) -> Optional[Dict]: try: if get_env_bool("FORCE_BUY_TEST", False): return self._force_buy_test(code, name) - candles_raw = self.ws.get_candles(code, self.candle_tf, n=50) - if len(candles_raw) < 5: + # E조건(전일시가) — 당일 50봉만으로는 불가 → 키움 REST 갭보정 RAM (DB 구데이터 미사용) + min_need = get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500) + candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=min_need) or []) + # ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio)와 동일 시점 + use_forming = bool((self._engine_params or {}).get("live_align_use_forming_bar", True)) + if (self._engine_params or {}).get("live_backtest_align", True) and use_forming: + try: + cur = self.ws.get_current_candle(code, self.candle_tf) + except Exception: + cur = None + if cur and float(cur.get("open", 0) or 0) > 0: + ct = str(cur.get("candle_time") or "")[:12] + last_ct = "" + if candles_raw: + last_ct = str(candles_raw[-1].get("candle_time") or "")[:12] + if ct and ct != last_ct: + cur_d = dict(cur) + cur_d["is_confirmed"] = 0 + candles_raw.append(cur_d) + if len(candles_raw) < 6: + try: + self.ws.fill_gap([code]) + except Exception: + pass return None candles = [self._norm_candle(c) for c in candles_raw] @@ -79,6 +108,9 @@ class MomentumStrategy(BaseStrategy): last_exit_dt = dt.fromtimestamp(self.recently_sold[code]) if last_exit_dt.strftime("%Y%m%d") != today: last_exit_dt = None + # 분 단위 floor — 엔진 쿨다운이 신호봉 candle_time 과 같은 시계를 쓰도록 + elif bool((self._engine_params or {}).get("cooldown_use_candle_floor", True)): + last_exit_dt = last_exit_dt.replace(second=0, microsecond=0) except Exception: pass try: @@ -101,6 +133,13 @@ class MomentumStrategy(BaseStrategy): params["slot_money"] = self.slot_money reject, msg, sig = me.check_buy_signal_momentum_live(candles, params, state) if reject: + # 갭보정 워밍업 중 — 전일시가 없음 로그 스팸 방지 + if reject == "탈락-전일시가없음" and len(candles_raw) < min_need: + try: + self.ws.fill_gap([code]) + except Exception: + pass + return None self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: @@ -201,9 +240,9 @@ class MomentumStrategy(BaseStrategy): now = dt.now() is_eod = is_live_eod_now( getattr(self, "eod_enabled", True), - getattr(self, "eod_hm", "15:25"), + getattr(self, "eod_hm", "15:20"), now, - default_hm="15:25", + default_hm="15:20", ) params = dict(self._engine_params or me.get_momentum_defaults_from_db()) diff --git a/kis_trader/strategies/scalping.py b/kis_trader/strategies/scalping.py index 67c523e..c27c89c 100644 --- a/kis_trader/strategies/scalping.py +++ b/kis_trader/strategies/scalping.py @@ -2,10 +2,11 @@ kis_trader/strategies/scalping.py — 스캘핑 전략 (1분봉 RSI 과매도 되돌림 / Reversal 고정) ================================================================================== [전략 컨셉 — SCAN vs TRIGGER] -- **SCAN (HTS/랭킹, 널넬하게)**: K/L/제외 또는 거래량 랭킹 → 감시 대상만 선정. -- **TRIGGER (코드, 엄격하게)** — ``SCALP_USE_MACD_CROSS`` 로 선택: - - **false (기본)**: RSI(3) 과매도(<25) → 양봉 전환 V자 반등 ("바닥잡기"). - - **true**: HTS C — MACD+Stochastic [12,26,5,3,3] 골든크로스 (0봉전 상향돌파). +- **SCAN (HTS ``CONDITION_SCALP_KIWOOM_NAME``=scalp_re)**: 낙폭+회복+거래대금 → ``kiwoom_condition`` WS. +- **TRIGGER (코드)** — ``SCALP_SKIP_HTS_SCAN_DUPES=true`` (kiwoom_condition 기본): + - HTS scalp_re SCAN 통과 후 **진입 타이밍만** (낙폭·RSI·되돌림 중복 생략). + - ``SCALP_SKIP_HTS_SCAN_DUPES=false`` + reversal: RSI(3) 과매도 V자 + 되돌림. + - ``SCALP_USE_MACD_CROSS=true``: MACD+Stochastic 골든크로스 (방어필터는 skip_hts 시 생략). - 청산: TP/SL/EOD/이동평균 이탈 등 (scalping_engine.check_sell_signal_live). - ⚠️ 모멘텀 추종은 MomentumStrategy 분리 (MACD 골든크로스 HTS 는 SCALP TRIGGER 로 이전). @@ -13,11 +14,6 @@ kis_trader/strategies/scalping.py — 스캘핑 전략 (1분봉 RSI 과매도 - 진입 시그널: ``scalping_engine.check_buy_signal_live`` (백테스트 동일 로직) - 청산 시그널: ``scalping_engine.check_sell_signal_live`` (백테스트 동일 로직) -[Deprecated] -- 과거 ``SCALP_MODE`` 토글(reversal/momentum) 은 폐기되었습니다. - ``SCALP_MODE=momentum`` 이 DB 에 남아있으면 시작 시 1회 경고 + reversal 폴백. - 추세추종(모멘텀) 전략은 ``STRATEGY_MOMENTUM_ENABLED=true`` 로 별도 켜세요. - [주문 실행] - ``OrderManager.place(OrderRequest(strategy_id="SCALP", ...))`` → ODNO 저장, 종목 Lock, 실 잔고 검증까지 한 번에 처리. @@ -33,7 +29,7 @@ try: except ImportError: se = None -from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int +from ..utils.env import get_env_bool, get_env_float, get_env_int from .base import BaseStrategy @@ -46,8 +42,6 @@ class ScalpingStrategy(BaseStrategy): super().__init__(**kwargs) self.candle_tf = 1 # 1분봉 self._scan_engine_params: Optional[Dict] = None - # SCALP_MODE deprecate 경고는 시작 시 1회만 (루프마다 reload_config 호출돼 스팸 방지) - self._deprecation_warned: bool = False self.reload_config() # ------------------------------------------------------------------ @@ -70,19 +64,6 @@ class ScalpingStrategy(BaseStrategy): self.rsi_overbought = get_env_float("SCALP_RSI_OVERBOUGHT", 75.0) self.slot_money = get_env_int("SLOT_MONEY_DEFAULT", 3000000) - # ── SCALP_MODE deprecate 가드 ──────────────────────────────── - # 과거에 reversal/momentum 토글이 있었지만, 모멘텀은 MomentumStrategy 로 분리됨. - # SCALP 는 항상 reversal 로 동작. DB 에 'momentum' 이 남아있으면 시작 시 1회 경고. - legacy_mode = (get_env_from_db("SCALP_MODE", "reversal") or "reversal").strip().lower() - if legacy_mode == "momentum" and not self._deprecation_warned: - self.logger.warning( - "⚠️ [DEPRECATED] SCALP_MODE=momentum 은 더 이상 지원되지 않습니다. " - "SCALP 는 reversal 로 고정 동작합니다. " - "추세추종을 원하시면 STRATEGY_MOMENTUM_ENABLED=true 로 MomentumStrategy 를 켜주세요." - ) - self._deprecation_warned = True - self.scalp_mode = "reversal" # 고정 - if se is not None: try: _d = se.get_scalping_defaults_from_db() @@ -115,6 +96,15 @@ class ScalpingStrategy(BaseStrategy): "stoch_k_period": get_env_int("SCALP_STOCH_K_PERIOD", 5), "stoch_d_period": get_env_int("SCALP_STOCH_D_PERIOD", 3), "stoch_slow": get_env_int("SCALP_STOCH_SLOW", 3), + # get_scalping_defaults_from_db() 가 이미 DB SCALP_SKIP_HTS_SCAN_DUPES 를 + # skip_hts_scan_dupes bool 로 해석함. resolve(_d) 금지: + # _d 에는 SCALP_SKIP_* env 키가 없어 universe fallback → 항상 True 가 됨. + "skip_hts_scan_dupes": bool( + _d.get( + "skip_hts_scan_dupes", + se.resolve_scalp_skip_hts_scan_dupes(), + ), + ), } except Exception as e: self.logger.debug("scalping_engine defaults 조회 실패: %s", e) diff --git a/kis_trader/strategies/tail_catch.py b/kis_trader/strategies/tail_catch.py index b2287ca..08cf5a5 100644 --- a/kis_trader/strategies/tail_catch.py +++ b/kis_trader/strategies/tail_catch.py @@ -64,7 +64,7 @@ class TailCatchStrategy(BaseStrategy): except Exception as e: self.logger.debug("tail_engine defaults 조회 실패: %s", e) self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True) - self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:25") + self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:20") def _candidate_filter(self, candidate: Dict) -> bool: """tail_on 이 True 인 후보만 대상 (SCALP 과 분리).""" @@ -174,13 +174,22 @@ class TailCatchStrategy(BaseStrategy): pass try: today_trades = self.db.get_trades_by_date(today) - daily_cnt = len([ + code_trades = [ t for t in today_trades if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT") - ]) + ] + daily_cnt = len(code_trades) + daily_pnl_krw = sum( + float(t.get("realized_pnl") or 0) for t in code_trades + ) except Exception: daily_cnt = 0 - state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt} + daily_pnl_krw = 0.0 + state = { + "last_exit_dt": last_exit_dt, + "daily_cnt": daily_cnt, + "daily_pnl_krw": daily_pnl_krw, + } params = dict(self._engine_params or {}) params["_whipsaw_ws"] = self.ws @@ -381,9 +390,9 @@ class TailCatchStrategy(BaseStrategy): now = dt.now() is_eod = is_live_eod_now( getattr(self, "eod_enabled", True), - getattr(self, "eod_hm", "15:25"), + getattr(self, "eod_hm", "15:20"), now, - default_hm="15:25", + default_hm="15:20", ) try: params = te.get_tail_defaults_from_db(self.db) diff --git a/kis_trader/utils/env.py b/kis_trader/utils/env.py index 28a5a9c..acd67ec 100644 --- a/kis_trader/utils/env.py +++ b/kis_trader/utils/env.py @@ -9,14 +9,16 @@ from __future__ import annotations import logging import os +import time from typing import Any, Dict, Optional logger = logging.getLogger("kis_trader.env") # 순환 import 방지용 레이지 TradeDB 참조 _db_instance = None -# get_merged_env_snapshot() — 프로세스당 1회 (백테·파라서치 env 키 반복 조회 가속) +# get_merged_env_snapshot() — TTL 내 재사용 (실매: 웹에서 MAX_DAILY 등 변경 즉시 반영) _merged_env_cache: Optional[Dict[str, str]] = None +_merged_env_cache_ts: float = 0.0 # env 캐시 세대 카운터 — invalidate 시 +1. 파생 캐시(예: whipsaw 파라미터)가 # 이 값으로 무효화를 감지해 안전하게 재계산한다(값은 그대로, 재계산 시점만 동일). _env_generation: int = 0 @@ -43,11 +45,20 @@ def set_db(db_obj) -> None: def invalidate_merged_env_cache() -> None: """env/config 저장 후 스냅샷 캐시 무효화 (insert_env_snapshot 등).""" - global _merged_env_cache, _env_generation + global _merged_env_cache, _merged_env_cache_ts, _env_generation _merged_env_cache = None + _merged_env_cache_ts = 0.0 _env_generation += 1 +def _merged_env_cache_ttl_sec() -> float: + """DB env 스냅샷 메모리 TTL(초). os.environ 만 사용(순환 import 방지).""" + try: + return max(0.0, float(os.environ.get("ENV_MERGED_CACHE_TTL_SEC", "60"))) + except (ValueError, TypeError): + return 60.0 + + def env_cache_generation() -> int: """현재 env 캐시 세대. invalidate 될 때마다 증가. @@ -66,13 +77,15 @@ def _strip_comment(val: Any) -> Any: def get_merged_env_dict() -> dict: """env_config + config_scalp/short/momentum/… 최신 행 병합 flat dict.""" - global _merged_env_cache - if _merged_env_cache is not None: + global _merged_env_cache, _merged_env_cache_ts + ttl = _merged_env_cache_ttl_sec() + now = time.time() + if _merged_env_cache is not None and (ttl <= 0 or (now - _merged_env_cache_ts) < ttl): return _merged_env_cache db = _get_db() if db is None: - return {} + return _merged_env_cache or {} try: if hasattr(db, "get_merged_env_snapshot"): merged = db.get_merged_env_snapshot() @@ -80,10 +93,11 @@ def get_merged_env_dict() -> dict: row = db.get_latest_env() merged = dict((row or {}).get("snapshot") or {}) _merged_env_cache = dict(merged or {}) + _merged_env_cache_ts = now return _merged_env_cache except Exception as e: logger.debug("merged env 조회 실패: %s", e) - return {} + return _merged_env_cache or {} def get_strategy_env_dict(strategy_id: str) -> dict: diff --git a/kis_trader/utils/kr_trading_day.py b/kis_trader/utils/kr_trading_day.py new file mode 100644 index 0000000..e3a85c7 --- /dev/null +++ b/kis_trader/utils/kr_trading_day.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +""" +kis_trader/utils/kr_trading_day.py — 한국 거래일(주말·휴장) 보정 공통 +==================================================================== +백테 웹 / CLI / API 날짜 기본값: 주말·휴장이면 **이전 장운영일**로 맞춤. + +휴장일 목록: env ``KR_MARKET_HOLIDAYS`` (콤마구분 YYYY-MM-DD). +DB에 없으면 기본 CSV를 env_config_ext 에 1회 시드. +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import List, Optional, Set, Tuple, Union + +from kis_trader.utils.env import get_env_from_db, get_env_int + +DateLike = Union[str, date, datetime] + +# 한국거래소 휴장(주말 제외). DB/ env 로 갱신. get_env 기본값용. +_DEFAULT_KR_MARKET_HOLIDAYS = ( + # 2025 + "2025-01-01,2025-01-28,2025-01-29,2025-01-30," + "2025-03-01,2025-05-05,2025-05-06,2025-06-06," + "2025-08-15,2025-10-03,2025-10-06,2025-10-07,2025-10-08,2025-10-09,2025-12-25," + # 2026 + "2026-01-01,2026-02-16,2026-02-17,2026-02-18," + "2026-03-01,2026-05-05,2026-05-24,2026-06-06," + "2026-08-15,2026-09-24,2026-09-25,2026-09-26," + "2026-10-03,2026-10-09,2026-12-25," + # 2027 (주요) + "2027-01-01,2027-02-08,2027-02-09,2027-02-10," + "2027-03-01,2027-05-05,2027-05-13,2027-06-06," + "2027-08-15,2027-09-14,2027-09-15,2027-09-16," + "2027-10-03,2027-10-09,2027-12-25" +) + +_holidays_cache: Optional[Set[date]] = None +_holidays_cache_raw: Optional[str] = None +_seeded: bool = False + + +def _parse_ymd(raw: DateLike) -> date: + if isinstance(raw, datetime): + return raw.date() + if isinstance(raw, date): + return raw + s = str(raw or "").strip() + if not s: + raise ValueError("empty date") + if "-" in s and len(s) >= 10: + return datetime.strptime(s[:10], "%Y-%m-%d").date() + digits = "".join(c for c in s if c.isdigit()) + if len(digits) >= 8: + return datetime.strptime(digits[:8], "%Y%m%d").date() + raise ValueError(f"bad date: {raw!r}") + + +def ymd(d: date) -> str: + return d.strftime("%Y-%m-%d") + + +def parse_holiday_csv(raw: str) -> Set[date]: + out: Set[date] = set() + for part in str(raw or "").replace(";", ",").split(","): + p = part.strip() + if not p or p.startswith("#"): + continue + try: + out.add(_parse_ymd(p)) + except ValueError: + continue + return out + + +def ensure_kr_market_holidays_in_db() -> None: + """KR_MARKET_HOLIDAYS / lookback 기본값을 DB/ext 에 없으면 1회 시드 (기존 값 유지).""" + global _seeded + if _seeded: + return + _seeded = True + try: + from kis_trader.utils.env import _get_db # noqa: PLC2701 + + db = _get_db() + if not db: + return + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + def _seed_if_missing(key: str, value: str) -> None: + cur = str(get_env_from_db(key, "") or "").strip() + if cur: + return + row = db.conn.execute( + "SELECT env_value FROM env_config_ext WHERE env_key=%s LIMIT 1", + (key,), + ).fetchone() + if row and str((row["env_value"] if isinstance(row, dict) else row[0]) or "").strip(): + return + db.conn.execute( + "INSERT INTO env_config_ext (env_key, env_value, updated_at) " + "VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE env_key=env_key", + (key, value, now), + ) + + _seed_if_missing("KR_MARKET_HOLIDAYS", _DEFAULT_KR_MARKET_HOLIDAYS) + _seed_if_missing("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS", "7") + _seed_if_missing("KR_TRADING_DAY_MAX_BACK_DAYS", "14") + try: + from kis_trader.utils.env import invalidate_merged_env_cache + + invalidate_merged_env_cache() + except Exception: + pass + except Exception: + pass + + +def get_kr_market_holiday_set(*, refresh: bool = False) -> Set[date]: + """휴장일 set. env/DB → 없으면 코드 기본 CSV.""" + global _holidays_cache, _holidays_cache_raw + ensure_kr_market_holidays_in_db() + raw = str( + get_env_from_db("KR_MARKET_HOLIDAYS", _DEFAULT_KR_MARKET_HOLIDAYS) or "" + ).strip() + if not raw: + raw = _DEFAULT_KR_MARKET_HOLIDAYS + if ( + not refresh + and _holidays_cache is not None + and _holidays_cache_raw == raw + ): + return _holidays_cache + _holidays_cache = parse_holiday_csv(raw) + _holidays_cache_raw = raw + return _holidays_cache + + +def is_kr_trading_day(d: DateLike, *, holidays: Optional[Set[date]] = None) -> bool: + """월~금 이고 휴장 목록에 없으면 True.""" + day = _parse_ymd(d) + if day.weekday() >= 5: + return False + hol = holidays if holidays is not None else get_kr_market_holiday_set() + return day not in hol + + +def clamp_to_prev_kr_trading_day( + d: DateLike, + *, + holidays: Optional[Set[date]] = None, + max_back_days: Optional[int] = None, +) -> str: + """ + 거래일이 아니면 하루씩 과거로 이동해 **가장 가까운 이전(또는 당일) 장운영일** 반환. + """ + day = _parse_ymd(d) + hol = holidays if holidays is not None else get_kr_market_holiday_set() + limit = int( + max_back_days + if max_back_days is not None + else get_env_int("KR_TRADING_DAY_MAX_BACK_DAYS", 14) + ) + limit = max(1, limit) + for _ in range(limit + 1): + if is_kr_trading_day(day, holidays=hol): + return ymd(day) + day -= timedelta(days=1) + return ymd(day) + + +def default_kr_trading_end(*, as_of: Optional[DateLike] = None) -> str: + """종료일 기본값 = as_of(기본 오늘) 을 이전 거래일로 보정.""" + base = _parse_ymd(as_of) if as_of is not None else date.today() + return clamp_to_prev_kr_trading_day(base) + + +def default_kr_trading_start( + lookback_days: int = 7, + *, + as_of: Optional[DateLike] = None, + end: Optional[DateLike] = None, +) -> str: + """시작일 기본값 = (종료일 − lookback) 을 이전 거래일로 보정.""" + end_d = _parse_ymd(end) if end is not None else _parse_ymd( + default_kr_trading_end(as_of=as_of) + ) + lb = max(1, int(lookback_days)) + start_raw = end_d - timedelta(days=lb) + return clamp_to_prev_kr_trading_day(start_raw) + + +def resolve_kr_backtest_date_defaults( + lookback_days: int = 7, + *, + as_of: Optional[DateLike] = None, +) -> Tuple[str, str]: + """(start, end) 기본 구간. end·start 모두 거래일.""" + end = default_kr_trading_end(as_of=as_of) + start = default_kr_trading_start(lookback_days, end=end) + if start > end: + start = end + return start, end + + +def clamp_to_prev_weekday(d: DateLike, *, max_back_days: int = 14) -> str: + """주말만 보정 (해외장 등 공휴일 목록 없이 토·일만 스킵).""" + day = _parse_ymd(d) + for _ in range(max(1, int(max_back_days)) + 1): + if day.weekday() < 5: + return ymd(day) + day -= timedelta(days=1) + return ymd(day) + + +def holiday_list_ymd() -> List[str]: + return sorted(ymd(d) for d in get_kr_market_holiday_set()) + + +def trading_dates_payload(lookback_days: int = 7) -> dict: + """웹/API용 날짜 기본값 묶음.""" + start, end = resolve_kr_backtest_date_defaults(lookback_days) + today = ymd(date.today()) + return { + "today": today, + "end": end, + "start": start, + "trading_day": end, # 단일일 조회용 (= 보정된 종료일) + "holidays": holiday_list_ymd(), + "lookback_days": int(lookback_days), + "is_today_trading_day": is_kr_trading_day(today), + } diff --git a/kis_trader/utils/non_stock.py b/kis_trader/utils/non_stock.py new file mode 100644 index 0000000..e46e1ea --- /dev/null +++ b/kis_trader/utils/non_stock.py @@ -0,0 +1,142 @@ +""" +비본주(우선주·ETF·ETN·스팩) 판별 — 후보 매수 차단 공용. + +키움 조건검색은 종목명 없이 code 만 주는 경우가 많아(name==code), +이름 끝 '우' 만으로는 우선주를 못 막는다. 코드 끝자리 규칙으로 보완한다. + +KRX 단축코드 끝자리(종목구분): + - 보통주: 대개 '0' + - 우선주: '5','7','9' (구형) + 'K','L','M'… (2013년 이후 알파벳) +""" +from __future__ import annotations + +from typing import FrozenSet + +from .env import get_env_bool, get_env_from_db + +# ETF/ETN 브랜드·유형 (이름 부분일치). '파워' 단독은 뉴파워프라즈마 오탐 → 넣지 않음. +_DEFAULT_NON_STOCK_NAME_KEYWORDS = ( + "ETN", + "ETF", + "레버리지", + "인버스", + "2X", + "3X", + "선물", + "KODEX", + "TIGER", + "KBSTAR", + "ARIRANG", + "HANARO", + "SOL ", + "KOSEF", + "ACE ", + "KINDEX", + "RISE ", + "PLUS ", + "TIMEFOLIO", + "히어로", + "SMART", + "TREX", + "WON", + "KOACT", +) + + +def _pref_digit_suffixes() -> FrozenSet[str]: + """우선주 숫자 끝자리 — NON_STOCK_PREF_CODE_SUFFIXES (기본 5,7,9).""" + raw = get_env_from_db("NON_STOCK_PREF_CODE_SUFFIXES", "5,7,9") or "5,7,9" + return frozenset(s.strip().upper() for s in str(raw).split(",") if s.strip()) + + +def is_preferred_code(code: str) -> bool: + """종목코드만으로 우선주 여부 (name 없어도 판단).""" + c = (code or "").strip() + if len(c) < 1: + return False + last = c[-1].upper() + if last in _pref_digit_suffixes(): + return True + # K,L,M… 알파벳 끝 = 신형 우선주 코드 + if get_env_bool("NON_STOCK_EXCLUDE_ALPHA_SUFFIX", True) and last.isalpha(): + return True + return False + + +def is_etn_code(code: str) -> bool: + c = (code or "").strip() + return len(c) == 6 and c[0] == "7" + + +def _name_keywords() -> tuple: + """NON_STOCK_NAME_KEYWORDS — 콤마 구분. 비우면 기본 튜플.""" + raw = (get_env_from_db("NON_STOCK_NAME_KEYWORDS", "") or "").strip() + if not raw: + return _DEFAULT_NON_STOCK_NAME_KEYWORDS + parts = tuple(s.strip() for s in raw.split(",") if s.strip()) + return parts or _DEFAULT_NON_STOCK_NAME_KEYWORDS + + +def _name_hits_keyword(name: str) -> bool: + nm = name.upper() + for k in _name_keywords(): + if not k: + continue + if k.isascii(): + if k.upper() in nm: + return True + elif k in name: + return True + return False + + +def is_non_stock(name: str, code: str) -> bool: + """ETN/ETF/스팩/우선주 등 매수 금지 종목이면 True. + + name 이 비었거나 name==code 여도 코드 규칙으로 우선주·ETN 을 잡는다. + """ + code = (code or "").strip() + name = (name or "").strip() + + if not code: + return True + + if is_preferred_code(code): + return True + if is_etn_code(code): + return True + + if name: + if _name_hits_keyword(name): + return True + nm = name.upper() + if "스팩" in name or "SPAC" in nm: + return True + # 우선주 표기 (우 / 우B / 우C …) + if name.endswith("우") or "우B" in name or "우C" in name or name.endswith("(전환)"): + return True + + return False + + +def is_unmanageable_holding(name: str, code: str) -> bool: + """보유 동기화에서 빼서 봇이 매도하지 않을 종목 (시세 불가 ETF/ETN·스팩). + + 우선주는 시세·매도 가능 → False. (실수로 산 우선주는 매도로 정리) + """ + code = (code or "").strip() + name = (name or "").strip() + if is_etn_code(code): + return True + if not name or name == code: + return False + nm = name.upper() + for k in ("ETN", "ETF", "레버리지", "인버스", "KODEX", "TIGER", "KBSTAR"): + if k.isascii(): + if k in nm: + return True + elif k in name: + return True + if "스팩" in name or "SPAC" in nm: + return True + return False diff --git a/kis_trader/utils/stock_name.py b/kis_trader/utils/stock_name.py new file mode 100644 index 0000000..3a8cf6a --- /dev/null +++ b/kis_trader/utils/stock_name.py @@ -0,0 +1,97 @@ +""" +종목코드 → 표시용 종목명 (Mattermost·로그·DB 공통). + +키움 조건검색 실시간 응답에는 종목명이 없어 code=이름 임시값이 들어오는 경우가 많다. +우선순위: 유효 fallback → 메모리 캐시 → stock_meta → target_candidates_history → 잔고 prdt_name +""" +from __future__ import annotations + +import logging +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + +# 프로세스 내 재조회 방지 (매수체크 루프·체결 알림 공용) +_NAME_CACHE: Dict[str, str] = {} + + +def resolve_stock_display_name( + db, + code: str, + fallback: str = "", + *, + holdings_map: Optional[Dict[str, Dict]] = None, + cache_to_meta: bool = True, +) -> str: + """ + 종목코드에 대응하는 한글 종목명을 반환한다. + + Args: + db: TradeDB / TradeDBExt 인스턴스 + code: 6자리 종목코드 + fallback: 전략·후보에서 넘어온 이름 (코드와 같으면 무시) + holdings_map: {code: {name, qty, ...}} — OrderManager 잔고 캐시 등 + cache_to_meta: history/잔고에서 찾은 이름을 stock_meta 에 저장 + """ + code = str(code or "").strip() + if not code: + return str(fallback or "").strip() + + fb = str(fallback or "").strip() + if fb and fb != code: + _NAME_CACHE[code] = fb + return fb + + cached = _NAME_CACHE.get(code) + if cached and cached != code: + return cached + + # 1) stock_meta + try: + if hasattr(db, "get_stock_meta"): + meta = db.get_stock_meta(code) + if meta: + n = str(meta.get("name") or "").strip() + if n and n != code: + _NAME_CACHE[code] = n + return n + except Exception as exc: + logger.debug("get_stock_meta 실패(%s): %s", code, exc) + + # 2) target_candidates_history (조건검색 스냅샷 — 실매 후보 적재) + try: + conn = getattr(db, "conn", None) + if conn is not None: + row = conn.execute( + "SELECT name FROM target_candidates_history " + "WHERE code = %s AND name IS NOT NULL AND name != '' AND name != code " + "ORDER BY COALESCE(event_time, scan_time) DESC, id DESC LIMIT 1", + (code,), + ).fetchone() + if row: + n = str(row.get("name") or "").strip() + if n and n != code: + _NAME_CACHE[code] = n + if cache_to_meta and hasattr(db, "upsert_stock_meta"): + try: + db.upsert_stock_meta(code, name=n) + except Exception: + pass + return n + except Exception as exc: + logger.debug("target_candidates_history 이름 조회 실패(%s): %s", code, exc) + + # 3) 실계좌 잔고 (보유 종목 prdt_name) + if holdings_map: + h = holdings_map.get(code) or {} + n = str(h.get("name") or "").strip() + if n and n != code: + _NAME_CACHE[code] = n + if cache_to_meta and hasattr(db, "upsert_stock_meta"): + try: + db.upsert_stock_meta(code, name=n) + except Exception: + pass + return n + + return fb if fb else code diff --git a/kis_trader/utils/strategy_ids.py b/kis_trader/utils/strategy_ids.py index e1317b9..309d442 100644 --- a/kis_trader/utils/strategy_ids.py +++ b/kis_trader/utils/strategy_ids.py @@ -23,11 +23,10 @@ KIS_TRADER_STRATEGY_IDS: List[str] = [ # 사용자 지정 — 웹·계산에서 완전히 숨길 전략 (비활성·중복·저성능). # DB 에 기존 거래기록이 남아 있어도 실거래 분석·대시보드·보유탭·운영설정에서 표시·집계하지 않는다. -# SCALP : MOMENTUM 과 1분봉 슬롯 중복 (반등 vs 추세) → MOMENTUM 만 사용 # RANGE_BREAK : BREAKOUT 과 돌파 컨셉 중복 + 미검증 (파라서치 거래 0건) # DBBAND : 표본 부족·미성숙 (백테 2~4건) +# ※ SCALP 는 실거래 분석·대시보드에 노출 (스캘핑 봇 운영 중) HIDDEN_STRATEGY_IDS: List[str] = [ - "SCALP", "RANGE_BREAK", "DBBAND", ] diff --git a/kis_trader/utils/trade_time.py b/kis_trader/utils/trade_time.py new file mode 100644 index 0000000..1a1f300 --- /dev/null +++ b/kis_trader/utils/trade_time.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +""" +kis_trader/utils/trade_time.py — 실매·백테 시각 파싱 공통 +======================================================== +실매 buy_time: ``YYYY-MM-DD HH:MM:SS`` +백테 봉/틱키: ``YYYYMMDDHHMM`` / ``YYYYMMDDHHMMSS`` + +엔진별 ``_t2dt`` 가 ``[:12]``+``%Y%m%d%H%M`` 만 쓰면 실매 시간컷이 +``None`` 으로 무력화된다 → 전 전략이 이 함수를 쓰도록 통일. +""" +from __future__ import annotations + +from datetime import datetime + + +def parse_trade_datetime(t: str) -> datetime: + """실매·백테 시각 문자열 → datetime. 실패 시 ValueError.""" + s = str(t or "").strip() + if not s: + raise ValueError("empty time") + if "-" in s or "T" in s or ":" in s: + compact = ( + s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "") + ) + digits = "".join(c for c in compact if c.isdigit()) + else: + digits = "".join(c for c in s if c.isdigit()) + if len(digits) >= 14: + return datetime.strptime(digits[:14], "%Y%m%d%H%M%S") + if len(digits) >= 12: + return datetime.strptime(digits[:12], "%Y%m%d%H%M") + raise ValueError(f"bad time: {t!r}") diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py index f9ef738..3125ff8 100644 --- a/kis_trader/web/live_config_schema.py +++ b/kis_trader/web/live_config_schema.py @@ -97,20 +97,14 @@ def build_live_config_groups() -> List[GroupDef]: groups: List[GroupDef] = [ { "id": "daily_profit_global", - "title": "일일 익절 — 총합 (마스터)", + "title": "일일 익절 — 총합 (규칙·수동중단)", "hint": ( - "① 손익 감시 켜기 = 당일 실현손익을 목표와 비교 " - "(ON이면 목표 달성 시 신규매수 자동 차단) " - "② 신규매수 수동 중단 = 목표·손익과 무관하게 매수만 잠금 " - "(긴급 일시정지용, 기본 OFF). 매도·손절은 항상 유지" + "손익 감시 ON/OFF 는 전략별(*_DAILY_PROFIT_TARGET_ENABLED)만 사용. " + "여기 마스터는 총합 목표금액/%·수동 매수잠금·알림·트레일 규칙. " + "① 신규매수 수동 중단 = 목표·손익과 무관 매수만 잠금(긴급용, 기본 OFF). " + "매도·손절은 항상 유지" ), "fields": [ - _f( - "DAILY_PROFIT_TARGET_ENABLED", - "손익 감시 켜기 (일일익절)", - "bool", - hint="ON = 목표 달성 시 신규매수 자동 차단 · OFF = 목표와 무관하게 매수 계속", - ), _f("DAILY_PROFIT_TARGET_KRW", "목표 금액(원)", "int", default=0), _f("DAILY_PROFIT_TARGET_PCT", "목표 수익률(%)", "float", default=0, hint="퍼센트 그대로 (예: 2.5 = 2.5%) · 0=미사용"), @@ -152,34 +146,172 @@ def build_live_config_groups() -> List[GroupDef]: _f("DAILY_STOP_LOSS_PCT", "일일 손실 한도(분율, -0.05=-5%)", "float", default=0), _f("CONSECUTIVE_LOSS_LIMIT", "연속 손실 제한(회)", "int", default=0), _f("USE_RISK_CHECK", "리스크 체크 사용", "bool"), + _f("EXCLUDE_NON_STOCK", "비본주(우선주·ETF·ETN) 매수 제외", "bool", default=True, + hint="후보 name==code 여도 코드 끝 5/7/9·K/L… 로 우선주 차단"), + _f("NON_STOCK_PREF_CODE_SUFFIXES", "우선주 코드 끝자리(숫자)", "str", default="5,7,9", + hint="KRX 구형 우선주. 콤마 구분"), + _f("NON_STOCK_EXCLUDE_ALPHA_SUFFIX", "코드 끝 알파벳=우선주 제외", "bool", default=True, + hint="00088K·00781K 등"), ], }, { "id": "ws_trigger_global", "title": "TRIGGER 필터 — WS 공통", - "hint": "호가(0D)·프로그램(0w) · env_config(컬럼 없으면 env_config_ext)", + "hint": ( + "호가(0D)·프로그램(0w) · env_config(컬럼 없으면 env_config_ext) · " + "COLLECT=판정순간1장(filter_eval) / SAVE=후보~3초시계열(kiwoom_0d) — 둘은 다름" + ), "fields": [ - _f("KIWOOM_WS_ORDERBOOK_ENABLED", "호가 WS(0D)", "bool", default=True), - _f("KIWOOM_WS_PROGRAM_ENABLED", "프로그램 WS(0w)", "bool", default=True), - _f("ORDERBOOK_FILTER_ENABLED", "호가 필터(TRIGGER)", "bool", default=True), - _f("ORDERBOOK_MAX_SPREAD_PCT", "호가 최대 스프레드(%, 0.45=0.45%)", "float", default=0.45), - _f("PROGRAM_FILTER_ENABLED", "프로그램 필터(TRIGGER)", "bool", default=True), + _f("KIWOOM_WS_ORDERBOOK_ENABLED", "호가 WS(0D) 구독", "bool", default=True, + hint="키움 실시간 호가 수신(RAM) · 꺼면 필터·수집·시계열 모두 불가"), + _f("KIWOOM_WS_PROGRAM_ENABLED", "프로그램 WS(0w) 구독", "bool", default=True, + hint="키움 프로그램매매 수신(RAM)"), + _f( + "WS_TRIGGER_EVAL_SAVE_ENABLED", + "TRIGGER 판정 DB 저장(마스터)", + "bool", + default=True, + hint="OFF면 아래 COLLECT(판정 스냅) 전부 안 씀 · SAVE(시계열)와 무관", + ), + _f( + "WS_ORDERBOOK_COLLECT_ENABLED", + "호가 판정스냅(FILTER_EVAL) — 매수체크 순간 1장", + "bool", + default=True, + hint="≠ 연속시계열. TRIGGER 판정 때만 ws_orderbook source=filter_eval · 필터 OFF여도 저장 가능", + ), + _f( + "WS_ORDERBOOK_SAVE_ENABLED", + "호가 시계열(KIWOOM_0D) — 구독후보 ~3초 덤프", + "bool", + default=False, + hint="틱처럼 후보·보유만 연속 저장(source=kiwoom_0d) · 파람서치 호가축용 · 봇 재시작 필요 · 필터와 무관", + ), + _f( + "WS_TRIGGER_SNAPSHOT_DB_INTERVAL_SEC", + "호가/프로그램 시계열 간격(초)", + "float", + default=3.0, + hint="SAVE ON일 때만 · 종목당 최소 저장 간격 · 봇 재시작 필요", + ), + # 글로벌 ORDERBOOK_FILTER / MAX_SPREAD 폐기 → 아래「호가필터 — 전략별」 + _f( + "WS_PROGRAM_COLLECT_ENABLED", + "프로그램 판정스냅 — 매수체크 순간 1장", + "bool", + default=True, + hint="≠ 연속시계열. 판정 때만 ws_program · 필터 OFF여도 가능", + ), + _f( + "WS_PROGRAM_SAVE_ENABLED", + "프로그램 시계열 — 구독후보 ~3초 덤프", + "bool", + default=False, + hint="SAVE와 동일 Recorder · 봇 재시작 필요 · 필터와 무관", + ), + _f("PROGRAM_FILTER_ENABLED", "프로그램 필터(TRIGGER) — 실매 탈락", "bool", default=True), _f("WHIPSAW_FILTER_ENABLED", "휩쏘 필터(글로벌)", "bool", default=True), _f("WHIPSAW_DIP_PCT", "휩쏘 하락폭(분율, 0.003=0.3%)", "float", default=0.003), _f("WHIPSAW_RECOVERY_TOL_PCT", "휩쏘 회복 허용(분율, 0.001=0.1%)", "float", default=0.001), ], }, - { - "id": "strategy_switch", - "title": "전략 ON/OFF", - "hint": "config_{strategy} · 봇 재시작 없이 다음 루프 반영", - "fields": [ - _f(f"STRATEGY_{sid}_ENABLED", LIVE_STRATEGY_LABELS.get(sid, sid), "bool") - for sid in LIVE_STRATEGY_IDS - ], - }, ] + # 호가필터 — 전략별 (글로벌 ORDERBOOK_* 수치 폐기) + # SCALP 는 LIVE_STRATEGY_IDS 에서 숨김이어도 config_scalp 키는 노출 + from kis_trader.engine.orderbook_env import ( + OB_DEFAULT_ASK_WALL_MAX_QTY, + OB_DEFAULT_ENTRY_ASK_MAX_MULT, + OB_DEFAULT_ENTRY_BID_DEPTH_MULT, + OB_DEFAULT_ENTRY_BID_LEVELS, + OB_DEFAULT_FILTER_ENABLED, + OB_DEFAULT_MAX_SPREAD_PCT, + OB_DEFAULT_MIN_BID_ASK_RATIO, + OB_STRATEGY_PREFIXES, + ) + ob_fields: List[FieldDef] = [] + _ob_lbl = { + "SCALP": "스캘핑", + "TAIL": "꼬리잡기", + "MOMENTUM": "모멘텀", + "BREAKOUT": "돌파", + } + for pfx in OB_STRATEGY_PREFIXES: + lbl = _ob_lbl.get(pfx, pfx) + ob_fields.extend([ + _f( + f"{pfx}_ORDERBOOK_FILTER_ENABLED", + f"{lbl} 호가필터 ON", + "bool", + default=OB_DEFAULT_FILTER_ENABLED, + hint="ON=스프레드·잔량비·얇은호가·매도벽으로 매수 차단 · OFF=저장만", + ), + _f( + f"{pfx}_ORDERBOOK_MAX_SPREAD_PCT", + f"{lbl} 스프레드상한(%)", + "float", + default=OB_DEFAULT_MAX_SPREAD_PCT, + hint="0.45=0.45% · 그리드 max_spread_pct 앵커", + ), + _f( + f"{pfx}_ORDERBOOK_MIN_BID_ASK_RATIO", + f"{lbl} 매수/매도잔량비 하한", + "float", + default=OB_DEFAULT_MIN_BID_ASK_RATIO, + hint="그리드 min_bid_ask_ratio", + ), + _f( + f"{pfx}_ORDERBOOK_ENTRY_ASK_MAX_MULT", + f"{lbl} 매도벽 허용배수", + "float", + default=OB_DEFAULT_ENTRY_ASK_MAX_MULT, + hint="필요수량×N · 그리드 ask_max_mult", + ), + _f( + f"{pfx}_ORDERBOOK_ENTRY_BID_DEPTH_MULT", + f"{lbl} 매수호가깊이 배수", + "float", + default=OB_DEFAULT_ENTRY_BID_DEPTH_MULT, + hint="얇은호가 탈락 · 필요수량×N", + ), + _f( + f"{pfx}_ORDERBOOK_ENTRY_BID_LEVELS", + f"{lbl} 호가단계(N호가)", + "int", + default=OB_DEFAULT_ENTRY_BID_LEVELS, + ), + ]) + if pfx == "BREAKOUT": + ob_fields.append( + _f( + "BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY", + "돌파 매도벽 상한(주)", + "int", + default=OB_DEFAULT_ASK_WALL_MAX_QTY, + hint="그리드 ask_wall_max_qty", + ), + ) + groups.append({ + "id": "orderbook_strategy", + "title": "호가필터 — 전략별 (수치)", + "hint": ( + "글로벌 ORDERBOOK_MAX_SPREAD_PCT 등 폐기 · " + "파람서치 apply-best ↔ *_ORDERBOOK_* 정합" + ), + "fields": ob_fields, + }) + + groups.append({ + "id": "strategy_switch", + "title": "전략 ON/OFF", + "hint": "config_{strategy} · 봇 재시작 없이 다음 루프 반영", + "fields": [ + _f(f"STRATEGY_{sid}_ENABLED", LIVE_STRATEGY_LABELS.get(sid, sid), "bool") + for sid in LIVE_STRATEGY_IDS + ], + }) + + # 전략별 운용한도 budget_fields: List[FieldDef] = [] for sid in LIVE_STRATEGY_IDS: @@ -289,9 +421,51 @@ def build_live_config_groups() -> List[GroupDef]: hint="STRICT_FILL_VERIFY ON 일 때만 백테/파람서치에 적용 · 전 전략 공통 · 0=순수 백테 · 0.2=0.2%"), _f("BACKTEST_VOL_FILL_CAP_PCT", "백테 체결량 상한(진입봉 거래량×%)", "float", default=0.0, hint="STRICT_FILL_VERIFY ON 일 때만 · 전 전략 공통 · 0=무제한 · 소형주 IOC 미체결 근사"), + _f("TAIL_BACKTEST_TICK_FALLBACK_OHLC", "꼬리 OHLC 폴백", "bool", default=False, + hint="OFF=틱 없는 구간 체결 스킵(실매 정합) · ON=3분봉 OHLC 추측(유령거래)"), + _f("TAIL_BACKTEST_USE_TICK_EXIT", "꼬리 틱 청산", "bool", default=True, + hint="ON=ws_ticks 시간순 청산(실매 폴링 정합) · OFF=OHLC intrabar"), + _f("TAIL_BACKTEST_POLL_MS", "꼬리 틱청산 폴링(ms)", "int", default=100, + hint="실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 하한 50ms"), + _f("MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", "모멘텀 OHLC 폴백", "bool", default=False, + hint="OFF 권장 — 틱 없으면 청산/진입 스킵"), + _f("MOMENTUM_BACKTEST_POLL_MS", "모멘텀 틱청산 폴링(ms)", "int", default=100, + hint="실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 틱 사이 청산 검사 간격 · 하한 50ms"), + _f("BREAKOUT_BACKTEST_USE_TICK_EXIT", "돌파 틱 청산", "bool", default=True, + hint="ON=ws_ticks 시간순 청산 · OFF=OHLC intrabar"), + _f("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", "돌파 OHLC 폴백", "bool", default=False, + hint="OFF 권장 — B안 틱 없으면 high 폴백 안 함"), + _f("BREAKOUT_BACKTEST_POLL_MS", "돌파 틱청산 폴링(ms)", "int", default=100, + hint="실매 폴링 정합 · 하한 50ms"), + _f("SCALP_BACKTEST_USE_TICK_EXIT", "스캘핑 틱 청산", "bool", default=True, + hint="ON=ws_ticks 시간순 청산(실매 폴링 정합) · OFF=OHLC intrabar"), + _f("SCALP_BACKTEST_USE_TICK_ENTRY", "스캘핑 틱 진입", "bool", default=True, + hint="ON=예약 체결 시 해당 분 첫 틱가 · OFF=분봉 시가"), + _f("SCALP_BACKTEST_TICK_FALLBACK_OHLC", "스캘핑 OHLC 폴백", "bool", default=False, + hint="OFF 권장 — 틱 없으면 청산 스킵"), + _f("SCALP_BACKTEST_POLL_MS", "스캘핑 틱청산 폴링(ms)", "int", default=100, + hint="실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 하한 50ms"), + _f("RANGE_BREAK_BACKTEST_USE_TICK_EXIT", "박스돌파 틱 청산", "bool", default=True, + hint="ON=ws_ticks 시간순 청산 · OFF=OHLC intrabar"), + _f("RANGE_BREAK_BACKTEST_TICK_FALLBACK_OHLC", "박스돌파 OHLC 폴백", "bool", default=False, + hint="OFF 권장 — 틱 없으면 청산 스킵"), + _f("RANGE_BREAK_BACKTEST_POLL_MS", "박스돌파 틱청산 폴링(ms)", "int", default=100, + hint="실매 폴링 정합 · 하한 50ms"), + _f("DBBAND_BACKTEST_USE_TICK_EXIT", "더블BB 틱 청산", "bool", default=True, + hint="ON=ws_ticks 시간순 청산 · OFF=봉 OHLC 1회"), + _f("DBBAND_BACKTEST_TICK_FALLBACK_OHLC", "더블BB OHLC 폴백", "bool", default=False, + hint="OFF 권장 — 틱 없으면 청산 스킵"), + _f("DBBAND_BACKTEST_POLL_MS", "더블BB 틱청산 폴링(ms)", "int", default=100, + hint="실매 폴링 정합 · 하한 50ms"), + _f("TAIL_PARAM_SEARCH_MIN_START", "꼬리 파라서치 최소 시작일", "str", default="2026-07-07", + hint="조건식 변경일 이후만 탐색 (YYYY-MM-DD) · CLI --start 와 max 적용"), _f("USE_MARKET_IOC", "시장가 IOC", "bool", default=True, hint="실전 시장가 주문 방식 · 모의는 일반 시장가(01) 고정"), _f("ORDER_FILL_WAIT_SEC", "시장가 체결 대기(초)", "int", default=2), + _f("DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", "주문DB중복 체결복구", "bool", default=True, + hint="insert 실패(주문DB중복) 시 inquire-daily-ccld 1회 → active_trades 복구 · 실패 시에만 REST"), + _f("DUPLICATE_ORDER_RECOVERY_WAIT_SEC", "중복복구 체결대기(초)", "int", default=2, + hint="주문DB중복 복구 전용 · 미설정 시 ORDER_FILL_WAIT_SEC 사용"), _f("LIMIT_ORDER_FILL_WAIT_SEC", "지정가 체결 대기(초)", "int", default=1), _f("PENDING_FILL_POLL_INTERVAL_SEC", "미체결 재조회 주기(초)", "int", default=10), _f("PENDING_FILL_POLL_SEC", "재조회 1회 대기(초)", "int", default=1), @@ -333,7 +507,7 @@ def build_live_config_groups() -> List[GroupDef]: "hint": ( "REST 유량과 별개 — 전략 매수체크 루프의 sleep. 짧을수록 WS 실시간 신호를 빨리 잡지만 CPU↑. " "탈락 경로는 REST 0건(WS·DB·연산만)이라 줄여도 한투 유량 무관. 재시작 없이 반영. " - "비우면 전략별 기본값(loop 1~2.5초) 사용." + "비우면 전략별 기본값(loop 0.1초) 사용." ), "fields": [ _f("SCAN_REJECT_SLEEP_MIN", "탈락 후 sleep 최소(초)", "float", default=0.2, @@ -344,9 +518,9 @@ def build_live_config_groups() -> List[GroupDef]: _f("SCAN_BUY_OK_SLEEP_MAX", "매수성공 후 sleep 최대(초)", "float", default=2.0), _f("SCAN_BUY_FAIL_SLEEP_MIN", "주문실패 후 sleep 최소(초)", "float", default=0.3), _f("SCAN_BUY_FAIL_SLEEP_MAX", "주문실패 후 sleep 최대(초)", "float", default=0.8), - _f("STRATEGY_LOOP_SLEEP_MIN", "루프 끝 sleep 최소(초)", "float", default=1.0, - hint="비우면 전략별 기본값 · 한 바퀴 끝 대기"), - _f("STRATEGY_LOOP_SLEEP_MAX", "루프 끝 sleep 최대(초)", "float", default=2.0), + _f("STRATEGY_LOOP_SLEEP_MIN", "루프 끝 sleep 최소(초)", "float", default=0.1, + hint="한 바퀴 끝 대기 · WS 구독 캐시 폴링 · 0.1=실매 반응 빠르게"), + _f("STRATEGY_LOOP_SLEEP_MAX", "루프 끝 sleep 최대(초)", "float", default=0.1), ], }) @@ -445,8 +619,8 @@ def build_live_config_groups() -> List[GroupDef]: "fields": [ _f("MOMENTUM_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True, hint="ON=설정 시각 이후 보유 전량 장마감청산 · OFF=익절·손절·시간컷만"), - _f("MOMENTUM_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:25", - hint="이 시각 이후 보유 전량 청산 · 예: 15:25"), + _f("MOMENTUM_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:20", + hint="정규장 15:30 마감 기준 최소 10분 전 · 순차매도 여유"), ], }) @@ -457,14 +631,43 @@ def build_live_config_groups() -> List[GroupDef]: "fields": [ _f("TAIL_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True, hint="ON=설정 시각 이후 보유 전량 장마감 청산 · OFF=익절·손절·시간컷만"), - _f("TAIL_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:25", - hint="이 시각 이후 보유 전량 청산 · 예: 15:25"), + _f("TAIL_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:20", + hint="정규장 15:30 마감 기준 최소 10분 전 · 순차매도 여유"), + ], + }) + + groups.append({ + "id": "error_watch_mm", + "title": "오류감시 → Mattermost", + "hint": ( + "별도 프로세스 scripts/kis_error_watch_mm.py 가 journalctl -f 로 " + "kis_trader_main 을 감시 → Traceback/유닛다운 시 MM. 실매 봇과 분리." + ), + "fields": [ + _f("ERROR_WATCH_ENABLED", "오류감시 ON", "bool", default=True, + hint="감시 프로세스 시작 시 읽음 · OFF면 감시자 즉시 종료"), + _f("ERROR_WATCH_UNIT", "감시 systemd 유닛", "text", + default="kis_trader_main.service", + hint="journalctl -u 대상"), + _f("ERROR_WATCH_MM_CHANNEL", "알림 MM alias", "text", default="", + hint="비우면 KIS_SYSTEM_MM_CHANNEL(default=키스명령봇)"), + _f("ERROR_WATCH_COOLDOWN_SEC", "동일오류 쿨다운(초)", "int", default=180, + hint="같은 Traceback 지문 재알림 간격"), + _f("ERROR_WATCH_HEALTH_CHECK_SEC", "유닛생존 점검(초)", "int", default=60, + hint="systemctl is-active 주기"), + _f("ERROR_WATCH_TRACEBACK_EXTRA_LINES", "Traceback 추가줄", "int", default=12, + hint="Traceback 이후 스택 수집 줄 수"), + _f("ERROR_WATCH_STARTUP_NOTIFY", "감시자 기동 MM", "bool", default=True, + hint="감시 프로세스 시작 시 1회 알림"), + _f("ERROR_WATCH_MM_JITTER", "MM jitter", "bool", default=False, + hint="오류 알림은 즉시 권장(OFF)"), ], }) groups.append({ "id": "portfolio_hold", "title": "보유·매도 / 고아복구", + "hint": ( "보유·매도 탭 일괄매도·수동 보호·장마감 후 DB 동기화. " "MANUAL_HOLD_CODES 에 넣은 종목은 일괄매도·고아복구 모두 제외." @@ -476,6 +679,18 @@ def build_live_config_groups() -> List[GroupDef]: hint="미등록 봇고아 일괄 시장가매도 시 REST 429 방지 · 종목마다 sleep"), _f("ORPHAN_RECONCILE_ENABLED", "장마감 고아복구", "bool", default=True, hint="매일 15:36~16:00 1회 · 실계좌 잔고↔active_trades 대조 · orders BUY 있는 미기록분만 복구 · 수동·MANUAL_HOLD 제외"), + _f("ORPHAN_RECONCILE_PRE_EOD_ENABLED", "Pre-EOD 고아복구", "bool", default=True, + hint="활성 전략 중 가장 이른 EOD − N분 1회 · EOD 청산 전 active_trades 반영"), + _f("ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", "Pre-EOD 선행(분)", "int", default=7, + hint="예: BREAKOUT EOD 15:15 → 15:08 실행 · REST 잔고 1회/일"), + _f("GHOST_PURGE_ON_RECONCILE", "고아복구 시 유령잔고 삭제", "bool", default=True, + hint="ON=브로커 0주인데 active_trades 남은 종목 삭제(수동보호 제외) · Pre/Post EOD 동일 잔고조회에서 처리"), + _f("INTRADAY_HOLDINGS_DRIFT_ENABLED", "장중 qty 드리프트 감시", "bool", default=False, + hint="기본 OFF · ON=N분마다 잔고 REST · 알림/자동복구는 별도 키"), + _f("INTRADAY_HOLDINGS_DRIFT_INTERVAL_SEC", "드리프트 조회주기(초)", "int", default=300, + hint="INTRADAY_HOLDINGS_DRIFT_ENABLED=ON 일 때만"), + _f("INTRADAY_HOLDINGS_DRIFT_AUTO_RECOVER", "드리프트 자동복구", "bool", default=False, + hint="OFF=알림만 · ON=active_trades qty 보정(기본 OFF)"), ], }) diff --git a/kis_trader/ws/kis_ws.py b/kis_trader/ws/kis_ws.py index 8573a12..0fc01d5 100644 --- a/kis_trader/ws/kis_ws.py +++ b/kis_trader/ws/kis_ws.py @@ -83,7 +83,9 @@ class KISWebSocketPriceCache: """ # H0STCNT0 데이터 필드 인덱스 ('^' 구분) - # KIS H0STCNT0 국내주식 실시간체결 전문 순서 (0-based) + # KIS 공식 columns (open-trading-api ccnl_krx / MCP 확인): + # 10 ASKP1, 11 BIDP1, 12 CNTG_VOL(체결량), 13 ACML_VOL(누적거래량) + # 과거 IDX_VOLUME=11 은 BIDP1(매수호가≈가격)을 읽어 ws_ticks.volume 이 오염됨. IDX_CODE = 0 # MKSC_SHRN_ISCD: 유가증권 단축 종목코드 IDX_TIME = 1 # STCK_CNTG_HOUR: 체결 시간 IDX_PRICE = 2 # STCK_PRPR: 주식 현재가 (체결가) @@ -94,7 +96,11 @@ class KISWebSocketPriceCache: IDX_OPEN = 7 # STCK_OPRC: 주식 시가 (당일) IDX_HIGH = 8 # STCK_HGPR: 주식 고가 (당일) IDX_LOW = 9 # STCK_LWPR: 주식 저가 (당일) - IDX_VOLUME = 11 # ACML_VOL: 누적 거래량 (참고용) + IDX_ASKP1 = 10 # ASKP1: 매도호가1 + IDX_BIDP1 = 11 # BIDP1: 매수호가1 + IDX_CNTG_VOL = 12 # CNTG_VOL: 체결 거래량 (틱당, TickRecorder용) + IDX_ACML_VOL = 13 # ACML_VOL: 누적 거래량 (봉 델타용) + IDX_VOLUME = 12 # 하위호환 alias → CNTG_VOL # 재연결 정책 (KIS 2026-02-24 경고 준수) MAX_RECONNECT_ATTEMPTS = 10 # 총 재연결 최대 횟수 (STABLE_CONN_RESET_SEC 이상 안정 연결 후 끊기면 초기화) @@ -105,7 +111,7 @@ class KISWebSocketPriceCache: # 예: 5분 이상 정상 운영 후 네트워크 일시 장애 → '버스트 차단'이 아닌 '정상 재연결'로 간주. STABLE_CONN_RESET_SEC = 300.0 # 5분 - # Approval key 유효시간 (23시간, KIS REST 토큰과 별개) + # Approval key 유효시간 (23시간 캐시). KIS: access_token 갱신주기 6시간·유효 24시간. APPROVAL_KEY_CACHE_SEC = 82800 def __init__(self, app_key: str, app_secret: str, is_mock: bool = True): @@ -156,6 +162,8 @@ class KISWebSocketPriceCache: self._reconnect_times: list = [] # 최근 재연결 타임스탬프 목록 self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._last_connect_time: float = 0.0 # 마지막 연결 성공 시각 (안정 연결 판단용) + self._last_subscribe_error: str = "" # H0STCNT0 JSON rt_cd≠0 (즉시 끊김 원인 추적) + self._subscribe_resend_lock = threading.Lock() # ── CandleAggregator (스캘핑봇 연동 시 외부에서 주입) ───── # attach_candle_aggregator(agg) 로 연결, None이면 봉 집계 비활성 @@ -226,21 +234,15 @@ class KISWebSocketPriceCache: if self._running: return True - # ── [CRITICAL] 비정상 종료 후 재시작 대비: 이전 세션 강제 정리 ────────────── - # force_cleanup=True 시: - # 1. approval_key 새로 발급 (이전 세션 무효화) - # 2. _subscribed 세트 비우기 (메모리 정리) - # 3. _cache 비우기 (오래된 데이터 제거) - # KIS 서버는 연결이 끊기면 5~10 분 내 자동 구독 해제되므로, - # 새 approval_key 로 새 세션을 열면 이전 구독은 자동 소멸. + # ── [CRITICAL] 비정상 종료 후 재시작 대비: 구독·가격 캐시만 정리 ────────────── + # approval_key 는 .kis_approval_cache_*.json 파일 캐시 유지 (6h/24h KIS 정책). + # 국내·해외 WS 가 동일 키 공유 — 재시작마다 REST 재발급하면 invalid approval 유발. if force_cleanup: - logger.info("🧹 WebSocket 세션 초기화 (비정상 종료 대비)") - self._approval_key = None # 이전 approval_key 무효화 - self._approval_key_ts = 0.0 + logger.info("🧹 WebSocket 세션 초기화 (구독/가격 캐시 리셋, approval_key 파일 유지)") with self._sub_lock: - self._subscribed.clear() # 구독 목록 초기화 + self._subscribed.clear() with self._cache_lock: - self._cache.clear() # 캐시 초기화 + self._cache.clear() logger.info("✅ WebSocket 세션 초기화 완료 (구독/캐시 리셋)") # approval_key 발급 (연결 전 확인) @@ -394,35 +396,86 @@ class KISWebSocketPriceCache: # 내부 메서드 # ================================================================== - def _get_approval_key(self) -> Optional[str]: + def _approval_min_reissue_sec(self) -> float: + """KIS access_token/approval REST 재발급 최소 간격(초). 공식: 갱신주기 6시간.""" + return max(0.0, float(get_env_float("KIS_WS_APPROVAL_MIN_REISSUE_SEC", 21600.0))) + + def _approval_key_age_sec(self) -> float: + if not self._approval_key_ts: + return 999999.0 + return max(0.0, time.time() - self._approval_key_ts) + + def _invalidate_approval_key(self, reason: str = "") -> None: + """approval_key 캐시 클리어 — start(force_cleanup) 등 명시적 초기화에만 사용.""" + had = bool(self._approval_key) + self._approval_key = None + self._approval_key_ts = 0.0 + if had and reason: + logger.info("🔑 WebSocket approval_key 무효화 (%s)", reason) + + def _subscribe_gap_sec(self) -> float: + """종목별 H0STCNT0 구독 간격(초). 0에 가까우면 KIS 가 연결 직후 끊을 수 있음.""" + lo = float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.08)) + hi = float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MAX_SEC", 0.25)) + if hi < lo: + lo, hi = hi, lo + if hi <= 0: + return 0.0 + return random.uniform(lo, hi) + + def _reconnect_session_wait_sec(self) -> float: + """끊긴 직후 재접속 전 대기 — 서버 측 세션 해제 시간 확보.""" + return max(0.0, float(get_env_float("KIS_WS_RECONNECT_SESSION_WAIT_SEC", 2.0))) + + def _instant_drop_cooldown_sec(self) -> float: + """연속 즉시 끊김 후 재시도까지 대기(초). 장중·장외 분리.""" + if not self._is_market_hours(): + return self._seconds_until_market_open() + return max( + 30.0, + float(get_env_float("KIS_WS_INSTANT_DROP_COOLDOWN_SEC", 90.0)), + ) + + def _instant_drop_reason_text(self, streak: int, conn_sec: float) -> str: + """즉시 끊김 로그용 — 장외 오진 방지, 실제 의심 원인 명시.""" + parts: list[str] = [] + if not self._is_market_hours(): + parts.append("장외(WS 서비스 시간 외)") + else: + parts.append("장중") + parts.append(f"연속 {streak}회 {conn_sec:.1f}초 내 종료") + if self._last_subscribe_error: + parts.append(f"구독오류={self._last_subscribe_error}") + else: + parts.append( + "의심=①access_token 24h 만료·갱신(갱신주기 6h)과 겹침 " + "②재연결마다 approval_key REST 재요청 " + "③H0STCNT0 구독 연속 폭주(키움은 간격 있음)" + ) + return " | ".join(parts) + + def _get_approval_key(self, *, force_refresh: bool = False) -> Optional[str]: """ - WebSocket 전용 approval_key 발급. - REST 액세스 토큰과 완전히 별개 (endpoint: /oauth2/Approval). + WebSocket 전용 approval_key — kis_approval_manager 파일 캐시 (국내·해외 공유). + KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지, 재연결 시 동일 키 재사용. """ - now = time.time() - if self._approval_key and (now - self._approval_key_ts) < self.APPROVAL_KEY_CACHE_SEC: - return self._approval_key try: - url = f"{self._base_url}/oauth2/Approval" - body = { - "grant_type": "client_credentials", - "appkey": self.app_key, - "secretkey": self.app_secret, - } - r = requests.post(url, json=body, timeout=10) - data = r.json() - key = data.get("approval_key") - if key: - self._approval_key = key - self._approval_key_ts = now - logger.info( - "✅ WebSocket approval_key 발급 완료 (앞8자: %s…)", key[:8] - ) - return key - logger.error("❌ WebSocket approval_key 발급 실패: %s", data) - except Exception as e: - logger.error("❌ WebSocket approval_key 요청 예외: %s", e) - return None + from kis_approval_manager import KISApprovalManager + except ImportError as exc: + logger.error("kis_approval_manager import 실패: %s", exc) + return None + + mgr = KISApprovalManager.instance(self.is_mock) + key = mgr.get_approval_key( + self.app_key, + self.app_secret, + self._base_url, + force_refresh=force_refresh, + ) + if key: + self._approval_key = key + self._approval_key_ts = mgr.issued_ts or time.time() + return key def _build_sub_payload(self, code: str, subscribe: bool) -> str: """구독(tr_type=1) / 해제(tr_type=2) JSON 메시지 생성.""" @@ -475,16 +528,44 @@ class KISWebSocketPriceCache: pass return - # ── 구독 응답(JSON) 무시 ──────────────────────────────────── + # ── 구독 응답(JSON) ───────────────────────────────────────── if raw.startswith("{"): try: j = json.loads(raw) header = j.get("header", {}) if header.get("tr_id") == "H0STCNT0": body = j.get("body", {}) - rt = body.get("rt_cd", "") - msg = body.get("msg1", "") - logger.debug("H0STCNT0 구독 응답: rt_cd=%s msg=%s", rt, msg) + rt = str(body.get("rt_cd", "") or "").strip() + msg = str(body.get("msg1", "") or "").strip() + tr_key = str((body.get("output") or {}).get("tr_key") or "").strip() + if rt and rt != "0": + err_line = f"rt_cd={rt} msg={msg}" + (f" code={tr_key}" if tr_key else "") + self._last_subscribe_error = err_line + if rt == "1" and "ALREADY IN SUBSCRIBE" in msg.upper(): + logger.debug("H0STCNT0 구독 중복(무해): %s", err_line) + elif "INVALID APPROVAL" in msg.upper(): + logger.warning("⚠️ H0STCNT0 구독 거부: %s", err_line) + try: + from kis_approval_manager import KISApprovalManager + mgr = KISApprovalManager.instance(self.is_mock) + reloaded = mgr.reload_from_file() + if reloaded: + self._approval_key = reloaded + self._approval_key_ts = mgr.issued_ts or self._approval_key_ts + logger.info( + "🔑 invalid approval → 파일 캐시 동기화 (앞8자: %s…, " + "6h 미경과 REST 재발급 없음)", + reloaded[:8], + ) + except Exception: + pass + else: + logger.warning("⚠️ H0STCNT0 구독 거부: %s", err_line) + else: + logger.debug( + "H0STCNT0 구독 OK: %s", + tr_key or msg or "SUCCESS", + ) except Exception: pass return @@ -535,20 +616,36 @@ class KISWebSocketPriceCache: with self._cache_lock: self._cache[code] = {"data": data_compat, "ts": time.time()} - # ── CandleAggregator 연동: 틱 → 봉 집계 (스캘핑봇 전용, 연결 시 활성화) - if self._candle_agg is not None: - tick_time = fields[self.IDX_TIME].strip() if len(fields) > self.IDX_TIME else "" - vol_raw = fields[self.IDX_VOLUME].strip() if len(fields) > self.IDX_VOLUME else "0" + # 체결시간·체결량·누적량 (MCP: CNTG_VOL=12, ACML_VOL=13) + tick_time = fields[self.IDX_TIME].strip() if len(fields) > self.IDX_TIME else "" + cntg_vol = 0 + acml_vol = 0 + if len(fields) > self.IDX_CNTG_VOL: try: - tick_vol = int(vol_raw) + cntg_vol = int(str(fields[self.IDX_CNTG_VOL]).strip() or "0") except ValueError: - tick_vol = 0 - self._candle_agg.on_tick(code, price, tick_vol, tick_time) + cntg_vol = 0 + if len(fields) > self.IDX_ACML_VOL: + try: + acml_vol = int(str(fields[self.IDX_ACML_VOL]).strip() or "0") + except ValueError: + acml_vol = 0 + # ── CandleAggregator: 키움증분 코드는 CNTG, KIS 전용은 ACML 델타 + if self._candle_agg is not None: + use_inc = False + try: + use_inc = bool(self._candle_agg._volume_is_incremental(code)) + except Exception: + use_inc = False + agg_vol = cntg_vol if use_inc else acml_vol + self._candle_agg.on_tick(code, price, agg_vol, tick_time) + + # TickRecorder: 실매·백테 공통으로 틱당 체결량(CNTG) 저장 if self._tick_recorder is not None: try: self._tick_recorder.on_tick( - code, price, tick_vol, tick_time, source="kis", + code, price, cntg_vol, tick_time, source="kis", ) except Exception as ex: logger.debug("H0STCNT0→TickRecorder 실패 %s: %s", code, ex) @@ -585,10 +682,19 @@ class KISWebSocketPriceCache: import datetime as _dt now = _dt.datetime.now() open_h, open_m = (9, 0) if self.is_mock else (8, 25) + open_t = _dt.time(open_h, open_m) + close_t = _dt.time(16, 5) target = now.replace(hour=open_h, minute=open_m, second=0, microsecond=0) - if now.time() >= _dt.time(16, 5): - # 오늘 장 마감 → 내일 장 시작 + if now.weekday() >= 5: target += _dt.timedelta(days=1) + elif now.time() >= close_t: + target += _dt.timedelta(days=1) + elif now.time() >= open_t: + # 장중 — '다음 장 시작'이 아니라 호출부에서 cooldown 사용 + return max( + 30.0, + float(get_env_float("KIS_WS_INSTANT_DROP_COOLDOWN_SEC", 90.0)), + ) # 주말 건너뛰기 while target.weekday() >= 5: target += _dt.timedelta(days=1) @@ -601,10 +707,11 @@ class KISWebSocketPriceCache: KIS 정책: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 강제 대기. 장외 시간(16:05~08:25, 주말): 재연결 없이 다음 장까지 대기. """ - # 즉시 끊김(장외 서버 거부) 감지: INSTANT_DROP_SEC 이내 끊김이 N회 연속이면 장외 슬립 - INSTANT_DROP_SEC = 3.0 # 연결 후 이 초 이내에 끊기면 "즉시 종료"로 판정 - INSTANT_DROP_MAX = 3 # 연속 N회 즉시 종료 → 장외 슬립으로 전환 - _instant_drop_streak = 0 # 연속 즉시 종료 카운터 + # 즉시 끊김 감지 — env 로 조정 (기본: 3초 이내 × 3회) + INSTANT_DROP_SEC = float(get_env_float("KIS_WS_INSTANT_DROP_SEC", 3.0)) + INSTANT_DROP_MAX = int(get_env_int("KIS_WS_INSTANT_DROP_MAX", 3)) + _instant_drop_streak = 0 + _last_conn_duration = 0.0 while self._running: now = time.time() @@ -629,20 +736,30 @@ class KISWebSocketPriceCache: time.sleep(wait_sec % 60) continue - # ── 연속 즉시 종료 감지 → 장외 서버 거부로 간주 ───────────── - # 연결 직후 INSTANT_DROP_SEC 이내에 끊기는 패턴이 N회 반복되면 - # 서버가 장외이거나 앱키가 차단된 것으로 간주하고 긴 슬립 진입 + # ── 연속 즉시 종료 → 쿨다운 (장외/장중 메시지 분리) ──────── if _instant_drop_streak >= INSTANT_DROP_MAX: - wait_sec = self._seconds_until_market_open() - logger.warning( - "⚠️ WebSocket 연속 즉시 끊김 %d회 감지 (장외 서버 거부 추정) " - "→ %.0f분 대기 후 재시도 (KIS 차단 방지)", - _instant_drop_streak, wait_sec / 60, + wait_sec = self._instant_drop_cooldown_sec() + reason = self._instant_drop_reason_text( + _instant_drop_streak, _last_conn_duration, ) + if not self._is_market_hours(): + logger.warning( + "⚠️ KIS WebSocket 연속 즉시 끊김 %d회 — %s " + "→ 다음 장 시작까지 %.0f분 대기", + _instant_drop_streak, reason, wait_sec / 60, + ) + else: + logger.warning( + "⚠️ KIS WebSocket 연속 즉시 끊김 %d회 — %s " + "→ %.0f초 쿨다운 후 **기존 approval_key** 로 재접속 " + "(6h 이내 REST 재발급 금지, 구독 간격 적용)", + _instant_drop_streak, reason, wait_sec, + ) self._reconnect_count = 0 self._reconnect_times = [] self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC _instant_drop_streak = 0 + self._last_subscribe_error = "" for _ in range(int(wait_sec // 60)): if not self._running: return @@ -690,8 +807,18 @@ class KISWebSocketPriceCache: self._running = False break - # ── approval_key 갱신 ───────────────────────────────────── - approval_key = self._get_approval_key() + # ── approval_key — 6h 이내 REST 재발급 금지, 재연결은 캐시 키 재사용 ── + if self._reconnect_count > 0: + sess_wait = self._reconnect_session_wait_sec() + if sess_wait > 0: + logger.debug( + "⏳ WS 재접속 전 세션 대기 %.1fs (approval_key age=%.0f분)", + sess_wait, self._approval_key_age_sec() / 60, + ) + time.sleep(sess_wait) + approval_key = self._get_approval_key( + force_refresh=get_env_bool("KIS_WS_RECONNECT_REFRESH_KEY", False), + ) if not approval_key: logger.warning( "WebSocket approval_key 발급 불가 → %ds 대기 후 재시도", @@ -738,8 +865,9 @@ class KISWebSocketPriceCache: if not self._running: break - # ── 즉시 종료 여부 판정 (장외 서버 거부 감지) ──────────────── + # ── 즉시 종료 여부 판정 ─────────────────────────────────────── _conn_duration = time.time() - _conn_start + _last_conn_duration = _conn_duration if _conn_duration < INSTANT_DROP_SEC: _instant_drop_streak += 1 logger.debug( @@ -757,33 +885,45 @@ class KISWebSocketPriceCache: # ------------------------------------------------------------------ def _on_open(self, ws) -> None: - """연결 성공: 등록된 모든 종목 구독 요청 전송.""" + """연결 성공: 등록된 모든 종목 구독 (간격 두고 1회만 — 이중 전송 금지).""" self._connected = True - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC # 대기 시간 리셋 - self._last_connect_time = time.time() # 안정 연결 판단용 타임스탬프 기록 + self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC + self._last_connect_time = time.time() + self._last_subscribe_error = "" - logger.info("✅ KIS WebSocket 연결 성공 (H0STCNT0 | url=%s)", self._ws_url) + logger.info( + "✅ KIS WebSocket 연결 성공 (H0STCNT0 | url=%s | approval_age=%.0f분)", + self._ws_url, self._approval_key_age_sec() / 60, + ) - # 영구 구독 목록(홀딩 관심종목) 먼저 구독 등록 - for code in sorted(self._permanent_codes): - with self._sub_lock: - if code not in self._subscribed: - if len(self._subscribed) < self.MAX_SUBSCRIPTIONS: - self._subscribed.add(code) - else: - logger.warning("⚠️ 구독 한도로 영구구독 추가 불가: %s", code) - continue - self._send_sub_msg(code, subscribe=True) - if self._permanent_codes: - logger.info("📌 영구 구독 등록 완료: %d종목", len(self._permanent_codes)) - - # 재연결 시에도 구독 목록 재등록 with self._sub_lock: - codes = set(self._subscribed) - for code in sorted(codes): - self._send_sub_msg(code, subscribe=True) + for code in sorted(self._permanent_codes): + if code not in self._subscribed: + if len(self._subscribed) >= self.MAX_SUBSCRIPTIONS: + logger.warning("⚠️ 구독 한도로 영구구독 추가 불가: %s", code) + break + self._subscribed.add(code) + codes = sorted(self._subscribed) + + def _subscribe_all() -> None: + for i, code in enumerate(codes): + if i > 0: + gap = self._subscribe_gap_sec() + if gap > 0: + time.sleep(gap) + self._send_sub_msg(code, subscribe=True) + if codes: + logger.info( + "📡 WebSocket 구독 일괄 등록: %s (%d종목, gap=%.2f~%.2fs)", + ", ".join(codes), len(codes), + float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.08)), + float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MAX_SEC", 0.25)), + ) + if codes: - logger.info("📡 WebSocket 구독 일괄 등록: %s", ", ".join(sorted(codes))) + threading.Thread( + target=_subscribe_all, daemon=True, name="KIS-WS-Sub", + ).start() # ── 연결 성공 시 갭보정 콜백 (장 시간일 때만) ─────────────── # 봇 시작 후 처음 WS가 안정 연결되는 시점(9:00 이후)에 @@ -855,7 +995,7 @@ class CandleAggregator: - _db_writer : 독립 daemon 스레드 (봇 종료 시 자동 소멸) """ - MAX_CLOSE_BUFFER = 200 # RSI 계산용 close 보관 최대 개수 + MAX_CLOSE_BUFFER = 200 # RSI 계산용 close 보관 기본값 (WS_CANDLE_RAM_BUFFER 로 덮어씀) BATCH_SIZE = 50 # 이 개수 이상 쌓이면 즉시 배치 플러시 FLUSH_INTERVAL = 2.0 # 초 — BATCH_SIZE 미달이라도 이 주기로 플러시 @@ -867,6 +1007,11 @@ class CandleAggregator: """ self.db = db self.timeframes: list = timeframes if timeframes else [1, 3] + # MOMENTUM 전일시가(E) 등 — RAM에 최소 2영업일 1분봉 보관 (DB 병합 없이 키움 REST 갭보정) + self._ram_buffer_max = max( + self.MAX_CLOSE_BUFFER, + get_env_int("WS_CANDLE_RAM_BUFFER", 500), + ) self._lock = threading.Lock() # ── 트랙 1: RAM 버퍼 ───────────────────────────────────── @@ -935,9 +1080,15 @@ class CandleAggregator: 배치 기록 루프. - Queue에서 BATCH_SIZE개 모이면 즉시 배치 INSERT - BATCH_SIZE 미달이라도 FLUSH_INTERVAL초마다 플러시 + - 겸사겸사 봉주기 경과 후 미확정 상태로 방치된 저유동 종목 봉도 + 같은 주기로 강제확정 점검 (flush_stale_current_candles) """ batch: list = [] last_flush = time.time() + last_stale_check = 0.0 + stale_check_interval = float( + get_env_int("WS_CANDLE_STALE_CHECK_INTERVAL_SEC", 2) + ) while True: try: @@ -961,6 +1112,13 @@ class CandleAggregator: batch = [] last_flush = now + if now - last_stale_check >= stale_check_interval: + last_stale_check = now + try: + self.flush_stale_current_candles() + except Exception as e: + logger.debug("flush_stale_current_candles 실패(무시): %s", e) + # 루프 종료 시 남은 배치 처리 if batch: self._flush_batch(batch) @@ -1147,50 +1305,12 @@ class CandleAggregator: cur = self._current[key] if new_ctime != cur["candle_time"]: - # ── 봉 확정 ──────────────────────────────────────────── - closes = self._closes.setdefault(key, []) - closes.append(cur["close"]) - if len(closes) > self.MAX_CLOSE_BUFFER: - closes.pop(0) - - rsi2, rsi3, rsi5 = self._compute_rsi_set(closes) - - hp = self._holding_peak_for(code) - confirmed_candle = { - "code": code, - "tf": tf, - "candle_time": cur["candle_time"], - "open": cur["open"], - "high": cur["high"], - "low": cur["low"], - "close": cur["close"], - "volume": cur["volume"], - "rsi_2": rsi2, - "rsi_3": rsi3, - "rsi_5": rsi5, - "is_confirmed": 1, - "source": "ws", - } - if hp is not None: - confirmed_candle["holding_peak"] = hp - confirmed_candle["high"] = max(cur["high"], hp) - - # [트랙 1] 확정 봉을 RAM _confirmed 버퍼에 보관 (매수 루프 직접 참조용) - buf = self._confirmed.setdefault(key, []) - buf.append(confirmed_candle) - if len(buf) > self.MAX_CLOSE_BUFFER: - buf.pop(0) - - # [트랙 2] Queue에 던지고 즉시 반환 (논블로킹) → 기록원이 배치 저장 - try: - self._write_queue.put_nowait(confirmed_candle) - except queue.Full: - logger.warning("⚠️ CandleAggregator 쓰기 Queue 가득참 — 봉 1개 DROP (코드: %s)", code) - + # ── 봉 확정 (다음 틱 도착 → 정상 롤오버) ──────────────── + confirmed_candle = self._confirm_current_bucket(key, cur) logger.debug( "🕯 [봉확정] %s %dM %s C=%.0f RSI3=%s", - code, tf, cur["candle_time"], cur["close"], - f"{rsi3:.1f}" if rsi3 is not None else "N/A", + code, tf, confirmed_candle["candle_time"], confirmed_candle["close"], + f"{confirmed_candle['rsi_3']:.1f}" if confirmed_candle["rsi_3"] is not None else "N/A", ) # ── 새 봉 시작 (RAM만) ────────────────────────────────── @@ -1215,6 +1335,160 @@ class CandleAggregator: base = int(cur.get("_acml_base", 0) or 0) cur["volume"] = max(0, int(volume or 0) - base) + def _confirm_current_bucket(self, key: tuple, cur: Dict) -> Dict: + """ + 진행 중이던 ``_current[key]`` 봉을 확정봉으로 전환한다. + (``_process_tick``의 정상 롤오버 / ``flush_stale_current_candles``의 + 시간경과 강제확정 양쪽에서 공통으로 사용 — 락 보유 상태에서 호출) + + [트랙 1] RSI 계산 + ``_confirmed`` RAM 버퍼 적재 (매수 루프 즉시 참조용) + [트랙 2] DB 기록 Queue 적재 (논블로킹) + + 동일 ``candle_time`` 이 이미 있으면 **append 하지 않고 upsert**. + volume 은 더 큰 쪽을 유지 (갭보정 불완전봉 < WS 누적 < REST 완전봉). + """ + code, tf = key + ctime = str(cur.get("candle_time") or "")[:12] + buf = self._confirmed.setdefault(key, []) + closes = self._closes.setdefault(key, []) + + new_vol = int(cur.get("volume") or 0) + idx = next( + ( + i for i, c in enumerate(buf) + if str(c.get("candle_time") or "")[:12] == ctime + ), + -1, + ) + + hp = self._holding_peak_for(code) + if idx >= 0: + old = buf[idx] + old_vol = int(old.get("volume") or 0) + # 이미 더 완전한 volume 이 있으면(예: REST 완전봉) WS 부분봉으로 덮지 않음 + if new_vol < old_vol: + confirmed_candle = dict(old) + confirmed_candle["is_confirmed"] = 1 + if hp is not None: + confirmed_candle["holding_peak"] = max( + float(confirmed_candle.get("holding_peak") or 0), float(hp), + ) + confirmed_candle["high"] = max( + float(confirmed_candle.get("high") or 0), float(hp), + ) + return confirmed_candle + + low_cands = [ + x for x in (float(old.get("low") or 0), float(cur["low"])) if x > 0 + ] + confirmed_candle = { + "code": code, + "tf": tf, + "candle_time": ctime, + "open": float(old.get("open") or cur["open"]), + "high": max(float(old.get("high") or 0), float(cur["high"])), + "low": min(low_cands) if low_cands else float(cur["low"]), + "close": float(cur["close"]), + "volume": max(old_vol, new_vol), + "is_confirmed": 1, + "source": str(cur.get("source") or old.get("source") or "ws"), + } + if hp is not None: + confirmed_candle["holding_peak"] = hp + confirmed_candle["high"] = max( + float(confirmed_candle["high"]), float(hp), + ) + buf[idx] = confirmed_candle + closes[:] = [float(c.get("close") or 0) for c in buf] + rsi2, rsi3, rsi5 = self._compute_rsi_set(closes[: idx + 1]) + confirmed_candle["rsi_2"] = rsi2 + confirmed_candle["rsi_3"] = rsi3 + confirmed_candle["rsi_5"] = rsi5 + buf[idx] = confirmed_candle + else: + closes.append(float(cur["close"])) + if len(closes) > self._ram_buffer_max: + closes.pop(0) + rsi2, rsi3, rsi5 = self._compute_rsi_set(closes) + confirmed_candle = { + "code": code, + "tf": tf, + "candle_time": ctime, + "open": cur["open"], + "high": cur["high"], + "low": cur["low"], + "close": cur["close"], + "volume": new_vol, + "rsi_2": rsi2, + "rsi_3": rsi3, + "rsi_5": rsi5, + "is_confirmed": 1, + "source": "ws", + } + if hp is not None: + confirmed_candle["holding_peak"] = hp + confirmed_candle["high"] = max(cur["high"], hp) + buf.append(confirmed_candle) + if len(buf) > self._ram_buffer_max: + buf.pop(0) + closes[:] = [float(c.get("close") or 0) for c in buf] + + try: + self._write_queue.put_nowait(confirmed_candle) + except queue.Full: + logger.warning("⚠️ CandleAggregator 쓰기 Queue 가득참 — 봉 1개 DROP (코드: %s)", code) + + return confirmed_candle + + # ------------------------------------------------------------------ + # 저유동 종목 봉 강제확정: 다음 체결 틱이 안 와도 봉주기 경과 시 확정 + # ------------------------------------------------------------------ + def flush_stale_current_candles(self) -> int: + """ + ``_current``(진행 중 봉)가 자기 봉주기(tf분)를 이미 지났는데도 + 다음 체결 틱이 없어 확정되지 못한 채 머물러 있으면, 그 시점까지 + 쌓인 데이터로 강제 확정한다. + + 배경: 기존엔 "다음 틱이 와야 봉 확정"이라, 저유동 종목이 한동안 + 무거래면 이미 끝난 봉도 다음 체결 전까지 무한정 미확정 상태로 + 남아 매수 신호 인식이 그만큼 밀렸다(2026-07-08 원티드랩 14분 지연 + 사례 — 3분봉인데 다음 틱이 14분 뒤에야 와서 신호가 14분 늦게 잡힘). + 이 함수는 heartbeat 성격으로 주기 호출되어 그 지연을 봉주기(3분) + 이내로 되돌린다. 확정되는 값 자체는 실제 체결 데이터 그대로라 + 백테(ws_candles 그대로 재생)와 내용 차이는 없다 — "언제 인지하냐"만 + 앞당긴다. + + ``WS_CANDLE_FORCE_CONFIRM_ENABLED=false`` 로 즉시 롤백 가능. + """ + if not get_env_bool("WS_CANDLE_FORCE_CONFIRM_ENABLED", True): + return 0 + grace_sec = get_env_float("WS_CANDLE_FORCE_CONFIRM_GRACE_SEC", 0.0) + import datetime as _dt + + now = _dt.datetime.now() + confirmed_n = 0 + with self._lock: + for key in list(self._current.keys()): + cur = self._current.get(key) + if not cur: + continue + code, tf = key + try: + bucket_start = _dt.datetime.strptime(cur["candle_time"], "%Y%m%d%H%M") + except Exception: + continue + bucket_end = bucket_start + _dt.timedelta(minutes=tf) + if now < bucket_end + _dt.timedelta(seconds=grace_sec): + continue + confirmed_candle = self._confirm_current_bucket(key, cur) + self._current.pop(key, None) + confirmed_n += 1 + logger.info( + "⏱ [봉강제확정] %s %dM %s C=%.0f — 다음 체결 없음(봉주기 경과) → 즉시 확정", + code, tf, confirmed_candle["candle_time"], confirmed_candle["close"], + ) + return confirmed_n + # ------------------------------------------------------------------ # 재접속 갭 보정: REST get_minute_chart 로 빈 봉 채우기 # ------------------------------------------------------------------ @@ -1238,56 +1512,173 @@ class CandleAggregator: if rest_df is None or rest_df.empty: return 0 - with self._lock: - key = (code, tf) - closes = self._closes.setdefault(key, []) - conf_buf = self._confirmed.setdefault(key, []) - inserted = 0 + rows: list = [] + for _, row in rest_df.iterrows(): + ctime = str(row.get("time", ""))[:12] + if not ctime or len(ctime) < 12: + continue + close = float(row.get("close", 0) or 0) + if close <= 0: + continue + rows.append({ + "candle_time": ctime, + "open": float(row.get("open", close) or close), + "high": float(row.get("high", close) or close), + "low": float(row.get("low", close) or close), + "close": close, + "volume": int(float(row.get("volume", 0) or 0)), + "source": "rest", + }) + return self.merge_confirmed_bars(code, tf, rows, log_tag="REST") - for _, row in rest_df.iterrows(): - ctime = str(row.get("time", ""))[:12] # YYYYMMDDHHMM 12자리 + def merge_confirmed_bars( + self, + code: str, + tf: int, + bars: list, + *, + log_tag: str = "merge", + ) -> int: + """ + 확정봉 리스트를 RAM(+DB 큐)에 병합. + + - 신규 candle_time → insert + - 기존 candle_time → volume 이 더 클 때만 OHLCV upsert + (갭보정 불완전봉을 REST/완전 롤업이 덮어쓰도록) + """ + if not bars: + return 0 + with self._lock: + key = (code, tf) + closes = self._closes.setdefault(key, []) + conf_buf = self._confirmed.setdefault(key, []) + by_time = { + str(c.get("candle_time", ""))[:12]: i + for i, c in enumerate(conf_buf) + if str(c.get("candle_time", ""))[:12] + } + inserted = 0 + updated = 0 + for row in bars: + ctime = str(row.get("candle_time") or row.get("time") or "")[:12] if not ctime or len(ctime) < 12: continue - close = float(row.get("close", 0)) + close = float(row.get("close", 0) or 0) if close <= 0: continue + new_vol = int(float(row.get("volume", 0) or 0)) + src = str(row.get("source") or "rest") + idx = by_time.get(ctime) + + if idx is not None: + old = conf_buf[idx] + old_vol = int(old.get("volume") or 0) + if new_vol <= old_vol: + continue + low_cands = [ + x for x in ( + float(old.get("low") or 0), + float(row.get("low", close) or close), + ) if x > 0 + ] + candle = { + "code": code, + "tf": tf, + "candle_time": ctime, + "open": float(old.get("open") or row.get("open", close) or close), + "high": max( + float(old.get("high") or 0), + float(row.get("high", close) or close), + ), + "low": min(low_cands) if low_cands else float( + row.get("low", close) or close + ), + "close": close, + "volume": new_vol, + "is_confirmed": 1, + "source": src, + } + if old.get("holding_peak") is not None: + candle["holding_peak"] = old.get("holding_peak") + conf_buf[idx] = candle + closes[:] = [float(c.get("close") or 0) for c in conf_buf] + rsi2, rsi3, rsi5 = self._compute_rsi_set(closes[: idx + 1]) + candle["rsi_2"] = rsi2 + candle["rsi_3"] = rsi3 + candle["rsi_5"] = rsi5 + conf_buf[idx] = candle + try: + self._write_queue.put_nowait(candle) + except queue.Full: + pass + updated += 1 + continue - # [트랙 1] RAM 웜업 closes.append(close) - if len(closes) > self.MAX_CLOSE_BUFFER: + if len(closes) > self._ram_buffer_max: closes.pop(0) - rsi2, rsi3, rsi5 = self._compute_rsi_set(closes) - candle = { - "code": code, - "tf": tf, - "candle_time": ctime, - "open": float(row.get("open", close)), - "high": float(row.get("high", close)), - "low": float(row.get("low", close)), - "close": close, - "volume": int(row.get("volume", 0)), - "rsi_2": rsi2, - "rsi_3": rsi3, - "rsi_5": rsi5, + "code": code, + "tf": tf, + "candle_time": ctime, + "open": float(row.get("open", close) or close), + "high": float(row.get("high", close) or close), + "low": float(row.get("low", close) or close), + "close": close, + "volume": new_vol, + "rsi_2": rsi2, + "rsi_3": rsi3, + "rsi_5": rsi5, "is_confirmed": 1, - "source": "rest", + "source": src, } conf_buf.append(candle) - if len(conf_buf) > self.MAX_CLOSE_BUFFER: - conf_buf.pop(0) - - # [트랙 2] Queue에 넣어 기록원이 배치로 DB 저장 + by_time[ctime] = len(conf_buf) - 1 + if len(conf_buf) > self._ram_buffer_max: + conf_buf.sort(key=lambda x: str(x.get("candle_time", ""))) + while len(conf_buf) > self._ram_buffer_max: + conf_buf.pop(0) + closes[:] = [float(c["close"]) for c in conf_buf] + by_time = { + str(c.get("candle_time", ""))[:12]: i + for i, c in enumerate(conf_buf) + if str(c.get("candle_time", ""))[:12] + } try: self._write_queue.put_nowait(candle) except queue.Full: pass inserted += 1 + if inserted or updated: + conf_buf.sort(key=lambda x: str(x.get("candle_time", ""))) + closes[:] = [float(c["close"]) for c in conf_buf] - if inserted: - logger.info("🔧 [갭보정] %s %dM → REST %d봉 RAM 적재 + DB 큐 등록", code, tf, inserted) - return inserted + if inserted or updated: + logger.info( + "🔧 [갭보정] %s %dM → %s insert=%d update=%d RAM+DB큐", + code, tf, log_tag, inserted, updated, + ) + return inserted + updated + + def rollup_tf_from_1m(self, code: str, target_tf: int = 3) -> int: + """ + RAM 1분 확정봉 → target_tf 분봉 재합성 후 구멍만 보강. + 꼬리(3M) 트리거 웜업: 1M REST 1회만으로 3M 준비 (WS_GAP_ROLLUP_3M_FROM_1M). + """ + from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf + + tf = int(target_tf) + if tf <= 1: + return 0 + with self._lock: + bars_1m = list(self._confirmed.get((code, 1), [])) + if not bars_1m: + return 0 + rolled = rollup_1m_bars_to_tf(bars_1m, tf) + return self.merge_confirmed_bars( + code, tf, rolled, log_tag=f"rollup_1m→{tf}M", + ) # ------------------------------------------------------------------ # [트랙 1] RAM 버퍼 조회 — 매수/매도 루프에서 직접 호출 (DB 조회 없음) diff --git a/kis_trader/ws/kis_ws_overseas.py b/kis_trader/ws/kis_ws_overseas.py index 7430e4d..e1bbfb8 100644 --- a/kis_trader/ws/kis_ws_overseas.py +++ b/kis_trader/ws/kis_ws_overseas.py @@ -208,8 +208,6 @@ class KISOverseasWebSocket: if self._running: return True if force_cleanup: - self._approval_key = None - self._approval_key_ts = 0.0 with self._cache_lock: self._cache.clear() if not self._get_approval_key(): @@ -239,23 +237,24 @@ class KISOverseasWebSocket: # 내부: approval_key / 구독 메시지 / 파싱 # ================================================================== def _get_approval_key(self) -> Optional[str]: - now = time.time() - if self._approval_key and (now - self._approval_key_ts) < self.APPROVAL_KEY_CACHE_SEC: - return self._approval_key + """국내 WS 와 동일 approval_key 파일 캐시 공유 (6h/24h KIS 정책).""" try: - url = f"{self._base_url}/oauth2/Approval" - body = {"grant_type": "client_credentials", "appkey": self.app_key, "secretkey": self.app_secret} - r = requests.post(url, json=body, timeout=10) - key = r.json().get("approval_key") - if key: - self._approval_key = key - self._approval_key_ts = now - logger.info("✅ 해외 WS approval_key 발급 완료 (앞8자: %s…)", key[:8]) - return key - logger.error("❌ 해외 WS approval_key 발급 실패: %s", r.text[:200]) - except Exception as e: - logger.error("❌ 해외 WS approval_key 요청 예외: %s", e) - return None + from kis_approval_manager import KISApprovalManager + except ImportError as exc: + logger.error("kis_approval_manager import 실패: %s", exc) + return None + mgr = KISApprovalManager.instance(self.is_mock) + key = mgr.get_approval_key( + self.app_key, + self.app_secret, + self._base_url, + force_refresh=False, + ) + if key: + self._approval_key = key + self._approval_key_ts = mgr.issued_ts or time.time() + logger.debug("🔑 해외 WS approval_key 공유 (앞8자: %s…)", key[:8]) + return key def _build_sub_payload(self, tr_key: str, subscribe: bool) -> str: return json.dumps({ diff --git a/kis_trader/ws/trigger_eval_recorder.py b/kis_trader/ws/trigger_eval_recorder.py index 638e766..2b701c2 100644 --- a/kis_trader/ws/trigger_eval_recorder.py +++ b/kis_trader/ws/trigger_eval_recorder.py @@ -3,6 +3,9 @@ kis_trader/ws/trigger_eval_recorder.py — TRIGGER 필터 판정 시점 스냅 ============================================================================= 실매 ``orderbook_reject_for_entry`` / ``program_reject_for_entry`` 가 RAM 에서 본 스냅샷을 그대로 ws_orderbook / ws_program 에 저장 (source=filter_eval). + +수집(``WS_*_COLLECT_ENABLED``)과 필터(``*_FILTER_ENABLED``)는 분리 — +필터 OFF + 수집 ON 이면 판정 스냅만 쌓이고 주문은 막지 않음. 백테는 이 기록만 재생 → 3초 주기 WS 덤프보다 실매와 일치. """ from __future__ import annotations diff --git a/logs/market_open_sim_smoke.stdout b/logs/market_open_sim_smoke.stdout new file mode 100644 index 0000000..47f18ef --- /dev/null +++ b/logs/market_open_sim_smoke.stdout @@ -0,0 +1,71 @@ +[23:19:24] === market_open_sim_smoke START === +[23:19:24] log=/home/hoon/kis_bot/logs/market_open_sim_smoke.log +[23:19:24] 📌 target_candidates_history 테이블 확인/생성 +[23:19:24] 📌 ws_price_validation 테이블 확인/생성 +[23:19:24] 📌 stock_share_meta 테이블 확인/생성 +[23:19:24] 📊 DB 테이블 생성/확인 완료 +[23:19:24] ✅ TradeDB 초기화 완료: MariaDB 192.168.0.141:3306/kis_quant_db +[23:19:24] ✅ 달력: today=2026-07-12 next_open=2026-07-13 prev_td=2026-07-10 web_defaults=2026-07-03~2026-07-10 +[23:19:24] ✅ 전략 ON=['SCALP', 'SHORT', 'MOMENTUM', 'BREAKOUT'] OFF=['RANGE_BREAK', 'UPDOW', 'DBBAND'] +[23:19:24] ✅ TAIL_SKIP_HTS_SCAN_DUPES=false +[23:19:24] ✅ SHORT_SKIP_HTS_SCAN_DUPES=false +[23:19:24] ✅ MOMENTUM_SKIP_HTS_SCAN_DUPES=false +[23:19:24] ✅ BREAKOUT_SKIP_HTS_SCAN_DUPES=false +[23:19:24] ✅ SCALP_SKIP_HTS_SCAN_DUPES=false +[23:19:24] ✅ 장시간 판정 (fake 2026-07-13 09:05:00): market=True buy=True guard=True +[23:19:24] ✅ 엔진 DB 기본값 로드: tail_keys=82 mom=45 scalp=41 bo=44 +[23:19:24] ✅ active_trades HOLDING: BREAKOUT=1, MOMENTUM=10, SCALP=28 +[23:19:24] 🔑 모의 토큰 유효 (15.1h 남음) → 재사용 +[23:19:25] HTTP 500 on GET https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/trading/inquire-balance (1/5) → 백오프 후 재시도 +[23:19:26] ✅ KISClient 생성 ok mock=True acct=***0245 +[23:19:26] ✅ 잔고 조회 응답 type=dict +[23:19:26] ✅ 장시작 리포트 문자열 생성 OK (미전송) + +=== 모멘텀 (MOMENTUM) === + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + sl_pct(손절) 0.02 0.02 OK 비율 + tp_pct(익절) 0.03 0.03 OK 비율 + tp_max_pct(익절상한) 0.05 0.05 OK 비율 + shoulder_min_high 0.003 0.003 OK 비율 + shoulder_cut_pct 0.001 0.001 OK 비율 + mom_rsi_min 50 50 OK 그대로 + mom_rsi_max 80 80 OK 그대로 + mom_vol_mult 1.05 1.05 OK 그대로 + mom_vol_win 5 5 OK 정수 + max_daily 100 100 OK 정수 +[23:19:26] ✅ verify_momentum parity OK + +=== 돌파 (BREAKOUT) === + [A] 웹 vs param_search (동일 함수여야 100% 일치) + OK — 공통 42개 키 전부 일치 + [B] 웹 엔진비율 vs 실매 DB 비율 + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + BREAKOUT_STOP_LOSS_PCT 0.015 0.015 OK 비율 +[23:19:26] ✅ verify_breakout parity OK + +=== 꼬리 (TAIL/SHORT) === + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + sl_pct 0.03 0.03 OK 비율 + tp_pct 0.05 0.05 OK 비율 + shoulder_min_high 0.005 0.005 OK 비율 + shoulder_cut_pct 0.002 0.002 OK 비율 + rsi_threshold 85 85 OK 그대로 + rsi_period 14 14 OK 정수 + max_daily 20 20 OK 정수 + stop_atr_mult 2 2 OK 그대로 + target_atr_mult 1.5 1.5 OK 그대로 +[23:19:26] ✅ verify_tail parity OK +[23:19:26] ✅ 실매↔웹↔파람 변환 정합 스모크 통과 +[23:19:27] 📊 orders 테이블 확인/생성 완료 +[23:19:28] 🔧 [백테-합성] 1M→3M 구멍 보강 2312봉 (종목 264) +[23:19:36] ✅ 꼬리 백테 2026-07-10: trades=3 pnl=3077 +[23:19:45] 🔑 모의 토큰 유효 (15.0h 남음) → 재사용 +[23:19:45] 📂 [예수금캐시] kv_store 복원: 463,253,718원 (updated=2026-07-12 15:34:11) +[23:19:45] ⏰ 실전 토큰 만료/없음 → 갱신 시도 (남은시간: -4.7h) +[23:19:45] ✅ 실전 토큰 발급 완료 | 만료: 2026-07-13 23:19:45 | 앞12자: eyJ0eXAiOiJK… +[23:19:46] ✅ 돌파 백테 2026-07-10: trades=19 pnl=-57826 +[23:19:46] === SUMMARY ok=19 err=0 === +[23:19:46] RESULT: PASS diff --git a/logs/market_open_sim_smoke_run.out b/logs/market_open_sim_smoke_run.out new file mode 100644 index 0000000..669bcfe --- /dev/null +++ b/logs/market_open_sim_smoke_run.out @@ -0,0 +1,91 @@ +[03:16:41] === market_open_sim_smoke START === +[03:16:41] log=/home/hoon/kis_bot/logs/market_open_sim_smoke.log +[03:16:41] 📌 target_candidates_history 테이블 확인/생성 +[03:16:41] 📌 ws_price_validation 테이블 확인/생성 +[03:16:41] 📌 stock_share_meta 테이블 확인/생성 +[03:16:41] 📊 DB 테이블 생성/확인 완료 +[03:16:41] ✅ TradeDB 초기화 완료: MariaDB 192.168.0.141:3306/kis_quant_db +[03:16:41] ✅ 달력: today=2026-07-16 next_open=2026-07-17 prev_td=2026-07-16 web_defaults=2026-07-09~2026-07-16 +[03:16:41] ✅ 전략 ON=['SCALP', 'SHORT', 'MOMENTUM', 'BREAKOUT'] OFF=['RANGE_BREAK', 'UPDOW', 'DBBAND'] +[03:16:41] ✅ TAIL_SKIP_HTS_SCAN_DUPES=false +[03:16:41] ✅ SHORT_SKIP_HTS_SCAN_DUPES=false +[03:16:41] ✅ MOMENTUM_SKIP_HTS_SCAN_DUPES=false +[03:16:41] ✅ BREAKOUT_SKIP_HTS_SCAN_DUPES=false +[03:16:41] ✅ SCALP_SKIP_HTS_SCAN_DUPES=false +[03:16:41] ✅ 장시간 판정 (fake 2026-07-17 09:05:00): market=True buy=True guard=True +[03:16:41] ✅ 엔진 DB 기본값 로드: tail_keys=82 mom=49 scalp=41 bo=44 +[03:16:41] ✅ active_trades HOLDING: (없음) +[03:16:41] 🔑 모의 토큰 유효 (10.9h 남음) → 재사용 +[03:16:41] ✅ KISClient 생성 ok mock=True acct=***0245 +[03:16:41] ✅ 잔고 조회 응답 type=dict +[03:16:41] ✅ 장시작 리포트 문자열 생성 OK (미전송) + +=== 모멘텀 (MOMENTUM) === + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + sl_pct(손절) 0.05 0.05 OK 비율 + tp_pct(익절) 0.03 0.03 OK 비율 + tp_max_pct(익절상한) 0.05 0.05 OK 비율 + shoulder_min_high 0.03 0.03 OK 비율 + shoulder_cut_pct 0.004 0.004 OK 비율 + mom_rsi_min 52 52 OK 그대로 + mom_rsi_max 100 100 OK 그대로 + mom_vol_mult 3 3 OK 그대로 + mom_vol_win 5 5 OK 정수 + max_daily 30 30 OK 정수 +[03:16:41] ✅ verify_momentum parity OK + +=== 돌파 (BREAKOUT) === + [A] 웹 vs param_search (동일 함수여야 100% 일치) + OK — 공통 42개 키 전부 일치 + [B] 웹 엔진비율 vs 실매 DB 비율 + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + BREAKOUT_STOP_LOSS_PCT 0.05 0.05 OK 비율 +[03:16:41] ✅ verify_breakout parity OK + +=== 스캘핑 (SCALP) === + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + sl_pct(손절) 0.035 0.035 OK 비율 + tp_pct(익절) 0.03 0.03 OK 비율 + tp_max_pct(익절상한) 0.04 0.04 OK 비율 + drop_rate(낙폭) 0.01 0.01 OK 비율 + shoulder_min_high 0.03 0.03 OK 비율 + shoulder_cut_pct 0.001 0.001 OK 비율 + rsi_oversold 23 23 OK 그대로 + rsi_period 3 3 OK 정수 + max_daily 100 100 OK 정수 + use_macd_cross False False OK bool + skip_hts_scan_dupes False False OK bool + [B] param_search _ui_to_engine vs 실매 비율 + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + sl_pct 0.035 0.035 OK 비율 + tp_pct 0.03 0.03 OK 비율 + tp_max_pct 0.04 0.04 OK 비율 + drop_rate 0.01 0.01 OK 비율 + shoulder_min_high 0.03 0.03 OK 비율 + shoulder_cut_pct 0.001 0.001 OK 비율 +[03:16:41] ✅ verify_scalping parity OK + +=== 꼬리 (TAIL/SHORT) === + 필드 canonical(실매/파서치) web 왕복후 판정 + ------------------------------------------------------------------------------ + sl_pct 0.03 0.03 OK 비율 + tp_pct 0.05 0.05 OK 비율 + shoulder_min_high 0.007 0.007 OK 비율 + shoulder_cut_pct 0.0015 0.0015 OK 비율 + rsi_threshold 78 78 OK 그대로 + rsi_period 14 14 OK 정수 + max_daily 10 10 OK 정수 + stop_atr_mult 2 2 OK 그대로 + target_atr_mult 1.5 1.5 OK 그대로 +[03:16:41] ✅ verify_tail parity OK +[03:16:41] ✅ 실매↔웹↔파람 변환 정합 스모크 통과 +[03:16:41] 📊 orders 테이블 확인/생성 완료 +[03:16:42] ✅ 꼬리 백테 2026-07-16: trades=0 pnl=0 +[03:16:42] ⚠️ ws_ticks 데이터 없음 — B안 OHLC high 폴백 (틱 수집 후 재백테 권장) +[03:16:42] ✅ 돌파 백테 2026-07-16: trades=0 pnl=0 +[03:16:42] === SUMMARY ok=20 err=0 === +[03:16:42] RESULT: PASS diff --git a/logs/momentum_mode_combo_backfill_latest.logpath b/logs/momentum_mode_combo_backfill_latest.logpath new file mode 100644 index 0000000..ceb247f --- /dev/null +++ b/logs/momentum_mode_combo_backfill_latest.logpath @@ -0,0 +1 @@ +logs/momentum_mode_combo_backfill_20260716_024407.log diff --git a/logs/momentum_ratchet_ab_latest.logpath b/logs/momentum_ratchet_ab_latest.logpath new file mode 100644 index 0000000..669890d --- /dev/null +++ b/logs/momentum_ratchet_ab_latest.logpath @@ -0,0 +1 @@ +logs/momentum_ratchet_ab_20260716_021703.log diff --git a/logs/optuna_breakout_fine_latest.jsonpath b/logs/optuna_breakout_fine_latest.jsonpath new file mode 100644 index 0000000..a9f2d11 --- /dev/null +++ b/logs/optuna_breakout_fine_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260716_030605.json diff --git a/logs/optuna_breakout_latest.jsonpath b/logs/optuna_breakout_latest.jsonpath new file mode 100644 index 0000000..a9f2d11 --- /dev/null +++ b/logs/optuna_breakout_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260716_030605.json diff --git a/logs/optuna_breakout_wide_latest.jsonpath b/logs/optuna_breakout_wide_latest.jsonpath new file mode 100644 index 0000000..6924917 --- /dev/null +++ b/logs/optuna_breakout_wide_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_wide_20260716_025835.json diff --git a/logs/optuna_latest.jsonpath b/logs/optuna_latest.jsonpath new file mode 100644 index 0000000..a9f2d11 --- /dev/null +++ b/logs/optuna_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260716_030605.json diff --git a/logs/optuna_momentum_fine_latest.jsonpath b/logs/optuna_momentum_fine_latest.jsonpath new file mode 100644 index 0000000..dee958d --- /dev/null +++ b/logs/optuna_momentum_fine_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_fine_20260716_023221.json diff --git a/logs/optuna_momentum_latest.jsonpath b/logs/optuna_momentum_latest.jsonpath new file mode 100644 index 0000000..dee958d --- /dev/null +++ b/logs/optuna_momentum_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_fine_20260716_023221.json diff --git a/logs/optuna_momentum_ratchet_ab_latest.jsonpath b/logs/optuna_momentum_ratchet_ab_latest.jsonpath new file mode 100644 index 0000000..8972cfe --- /dev/null +++ b/logs/optuna_momentum_ratchet_ab_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/momentum_ratchet_ab_20260716_021733.json diff --git a/logs/param_search_optuna_breakout_fine_latest.logpath b/logs/param_search_optuna_breakout_fine_latest.logpath new file mode 100644 index 0000000..e81e902 --- /dev/null +++ b/logs/param_search_optuna_breakout_fine_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_breakout_fine_20260716_030435.log diff --git a/logs/param_search_optuna_breakout_fine_latest.study b/logs/param_search_optuna_breakout_fine_latest.study new file mode 100644 index 0000000..019ebd3 --- /dev/null +++ b/logs/param_search_optuna_breakout_fine_latest.study @@ -0,0 +1 @@ +breakout_fine_wideTune_20260715_20260716_030435 diff --git a/logs/param_search_optuna_breakout_wide_latest.logpath b/logs/param_search_optuna_breakout_wide_latest.logpath new file mode 100644 index 0000000..aa2f6cc --- /dev/null +++ b/logs/param_search_optuna_breakout_wide_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_breakout_wide_20260716_025746.log diff --git a/logs/param_search_optuna_breakout_wide_latest.study b/logs/param_search_optuna_breakout_wide_latest.study new file mode 100644 index 0000000..77de37d --- /dev/null +++ b/logs/param_search_optuna_breakout_wide_latest.study @@ -0,0 +1 @@ +breakout_wide_20260715_20260716_025746 diff --git a/logs/param_search_optuna_momentum_fine_latest.logpath b/logs/param_search_optuna_momentum_fine_latest.logpath new file mode 100644 index 0000000..ea0211f --- /dev/null +++ b/logs/param_search_optuna_momentum_fine_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_momentum_fine_20260716_022448.log diff --git a/logs/param_search_optuna_momentum_fine_latest.study b/logs/param_search_optuna_momentum_fine_latest.study new file mode 100644 index 0000000..79d3ad7 --- /dev/null +++ b/logs/param_search_optuna_momentum_fine_latest.study @@ -0,0 +1 @@ +momentum_fine_lateRatchet_20260715_20260716_022448 diff --git a/logs/param_search_optuna_momentum_wide_latest.logpath b/logs/param_search_optuna_momentum_wide_latest.logpath new file mode 100644 index 0000000..6b24181 --- /dev/null +++ b/logs/param_search_optuna_momentum_wide_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_momentum_wide_20260716_013324.log diff --git a/logs/param_search_optuna_momentum_wide_latest.study b/logs/param_search_optuna_momentum_wide_latest.study new file mode 100644 index 0000000..4a621ce --- /dev/null +++ b/logs/param_search_optuna_momentum_wide_latest.study @@ -0,0 +1 @@ +momentum_wide_20260715_20260716_013324 diff --git a/logs/param_search_optuna_scalp_fine_latest.logpath b/logs/param_search_optuna_scalp_fine_latest.logpath new file mode 100644 index 0000000..d7e6c55 --- /dev/null +++ b/logs/param_search_optuna_scalp_fine_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_scalp_fine_20260716_005745.log diff --git a/logs/param_search_optuna_scalp_fine_latest.study b/logs/param_search_optuna_scalp_fine_latest.study new file mode 100644 index 0000000..f6a918b --- /dev/null +++ b/logs/param_search_optuna_scalp_fine_latest.study @@ -0,0 +1 @@ +scalp_fine_wideTune_20260715_20260716_005745 diff --git a/logs/param_search_optuna_scalp_wide_latest.logpath b/logs/param_search_optuna_scalp_wide_latest.logpath new file mode 100644 index 0000000..c51ba5f --- /dev/null +++ b/logs/param_search_optuna_scalp_wide_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_scalp_wide_20260716_004618.log diff --git a/logs/param_search_optuna_scalp_wide_latest.study b/logs/param_search_optuna_scalp_wide_latest.study new file mode 100644 index 0000000..4c73456 --- /dev/null +++ b/logs/param_search_optuna_scalp_wide_latest.study @@ -0,0 +1 @@ +scalp_wide_20260715_20260716_004618 diff --git a/logs/param_search_optuna_tail_coarse_latest.logpath b/logs/param_search_optuna_tail_coarse_latest.logpath new file mode 100644 index 0000000..e5a2c43 --- /dev/null +++ b/logs/param_search_optuna_tail_coarse_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_tail_coarse_20260711_025821.log diff --git a/logs/param_search_optuna_tail_fine_latest.logpath b/logs/param_search_optuna_tail_fine_latest.logpath new file mode 100644 index 0000000..5ffa50e --- /dev/null +++ b/logs/param_search_optuna_tail_fine_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_tail_fine_20260716_011800.log diff --git a/logs/param_search_optuna_tail_fine_latest.study b/logs/param_search_optuna_tail_fine_latest.study new file mode 100644 index 0000000..2a12633 --- /dev/null +++ b/logs/param_search_optuna_tail_fine_latest.study @@ -0,0 +1 @@ +tail_fine_wide1Tune_20260715_20260716_011800 diff --git a/logs/param_search_optuna_tail_full_latest.logpath b/logs/param_search_optuna_tail_full_latest.logpath new file mode 100644 index 0000000..664c25d --- /dev/null +++ b/logs/param_search_optuna_tail_full_latest.logpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/logs/param_search_optuna_tail_full_20260711_105425.log diff --git a/logs/param_search_optuna_tail_wide2_latest.logpath b/logs/param_search_optuna_tail_wide2_latest.logpath new file mode 100644 index 0000000..2ae4cad --- /dev/null +++ b/logs/param_search_optuna_tail_wide2_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_tail_wide2_20260716_011239.log diff --git a/logs/param_search_optuna_tail_wide2_latest.study b/logs/param_search_optuna_tail_wide2_latest.study new file mode 100644 index 0000000..2c1741c --- /dev/null +++ b/logs/param_search_optuna_tail_wide2_latest.study @@ -0,0 +1 @@ +tail_wide2_20260715_20260716_011239 diff --git a/logs/param_search_optuna_tail_wide_latest.logpath b/logs/param_search_optuna_tail_wide_latest.logpath new file mode 100644 index 0000000..cfe210a --- /dev/null +++ b/logs/param_search_optuna_tail_wide_latest.logpath @@ -0,0 +1 @@ +logs/param_search_optuna_tail_wide_20260716_010944.log diff --git a/logs/param_search_optuna_tail_wide_latest.study b/logs/param_search_optuna_tail_wide_latest.study new file mode 100644 index 0000000..8acec84 --- /dev/null +++ b/logs/param_search_optuna_tail_wide_latest.study @@ -0,0 +1 @@ +tail_wide_20260715_20260716_010944 diff --git a/logs/tail_live_review_20260709.txt b/logs/tail_live_review_20260709.txt new file mode 100644 index 0000000..646ee98 --- /dev/null +++ b/logs/tail_live_review_20260709.txt @@ -0,0 +1,43 @@ +=== 7/9 SHORT 실매 검토 (2026-07-09 12:27 KST) === + +[env] id=625, TAIL_REENTRY_MIN_EDGE_KRW=2000, skip_hts=false + +[체결] buy/sell on 2026-07-09: 3건, 당일 실현합 -7,053원 + 376980 buy=07-08 11:47 sell=07-09 08:30 pnl=-9,355 손절 (전일 포지션) + 114840 buy=10:21 sell=10:27 pnl=+1,024 래칫컷 + 424870 buy=12:09 sell=12:24 pnl=+1,278 래칫컷 + +[종목별 당일 실현] + 376980: 1회, -9,355원 + 114840: 1회, +1,024원 + 424870: 1회, +1,278원 + +[journalctl 탈락 TOP — 09:00~] + 탈락-봉등락: ~24,550 (1차 병목, skip_hts=false) + 탈락-데이터: ~13,108 + 탈락-종목일일손익: 11,036 (전부 114840 재진입 엣지 미달) + 탈락-패턴: 438 + +[게이트 상세] + 114840: +1,024원 < 2,000원 (재진입 최소엣지 미달) × 11,036 + 손실한도 차단: 0건 + +[7/8 대비] + 7/8: 2~3건, -7,665원 | 봉등락·유니버스 + 7/9: 3건(신규2), -7,053원 | 봉등락 >> 게이트(엣지) + + +[백테스트 7/9 — skip_hts=false (DB)] + Case A edge=2000: trades=1 pnl=+327 + Case B edge=0: trades=1 pnl=+327 + → 당일 재진입 시나리오 없어 A/B 동일. 실매 3건 vs 백테 1건 갭은 봉등락·유니버스·전일포지션(376980) 차이. + +[배포] + env 스냅샷 id=626 (12:29) + 봇 재시작 PID=192324 (12:29:17) + engine reentry_min_edge_krw=0 확인 + +[모니터링 — 7/10] + journalctl -u kis_trader_main.service -f | grep -E "종목일일손익|114840|424870" + tail -f logs/tail_symbol_gate_verify_20260709.log # 백테 결과 + 재진입 최소엣지 미달 로그 0건 기대 (엣지=0) diff --git a/scripts/_diag_mom_2min_gap.py b/scripts/_diag_mom_2min_gap.py new file mode 100644 index 0000000..4d4e994 --- /dev/null +++ b/scripts/_diag_mom_2min_gap.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""SK 10:36 BT vs 10:38 live — DB 타임라인 + 틱/봉 진단.""" +from __future__ import annotations + +import subprocess +from database import TradeDB + +CODE = "475150" +DAY = "20260713" +DAY_DASH = "2026-07-13" + + +def main() -> None: + db = TradeDB() + try: + cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM trade_history").fetchall()] + print("trade_history cols:", cols) + + rows = db.conn.execute( + "SELECT code, name, strategy, buy_price, sell_price, qty, realized_pnl, " + "buy_date, sell_date, sell_reason, hold_minutes " + "FROM trade_history WHERE code=%s AND buy_date LIKE %s ORDER BY buy_date", + (CODE, DAY_DASH + "%"), + ).fetchall() + print(f"\ntrade_history SK today n={len(rows)}") + for r in rows: + d = dict(r) + print( + f" buy={d.get('buy_date')} sell={d.get('sell_date')} " + f"bp={d.get('buy_price')} sp={d.get('sell_price')} qty={d.get('qty')} " + f"pnl={d.get('realized_pnl')} reason={d.get('sell_reason')} " + f"strat={d.get('strategy')}" + ) + + print("\n1m candles 10:34-10:40:") + cans = db.conn.execute( + "SELECT candle_time, open, high, low, close, volume FROM ws_candles " + "WHERE code=%s AND timeframe=%s AND candle_time BETWEEN %s AND %s " + "ORDER BY candle_time", + (CODE, 1, DAY + "1034", DAY + "1040"), + ).fetchall() + for c in cans: + print( + f" {c['candle_time']} O={c['open']} H={c['high']} " + f"L={c['low']} C={c['close']} V={c['volume']}" + ) + + print("\nfirst/last tick per minute 10:35-10:39:") + for m in ("1035", "1036", "1037", "1038", "1039"): + tt0, tt1 = DAY + m + "00", DAY + m + "59" + r = db.conn.execute( + "SELECT COUNT(*) n, MIN(tick_time) mn, MAX(tick_time) mx, " + "MIN(price) lo, MAX(price) hi FROM ws_ticks " + "WHERE code=%s AND tick_time BETWEEN %s AND %s", + (CODE, tt0, tt1), + ).fetchone() + first = db.conn.execute( + "SELECT tick_time, price FROM ws_ticks " + "WHERE code=%s AND tick_time BETWEEN %s AND %s " + "ORDER BY tick_time ASC LIMIT 1", + (CODE, tt0, tt1), + ).fetchone() + print( + f" {m}: n={r['n']} {r['mn']}~{r['mx']} " + f"first={dict(first) if first else None} range={r['lo']}~{r['hi']}" + ) + + # nearest tick to live buy 10:38:06 at 51600 + print("\nticks near live buy 51600 @10:38:") + near = db.conn.execute( + "SELECT tick_time, price, volume FROM ws_ticks " + "WHERE code=%s AND tick_time BETWEEN %s AND %s AND price BETWEEN %s AND %s " + "ORDER BY tick_time LIMIT 20", + (CODE, DAY + "103700", DAY + "103900", 51500, 51700), + ).fetchall() + for t in near: + print(f" {dict(t)}") + + # ticks near BT buy 50800 @10:36 + print("\nticks near BT buy 50800 @10:36:") + near2 = db.conn.execute( + "SELECT tick_time, price, volume FROM ws_ticks " + "WHERE code=%s AND tick_time BETWEEN %s AND %s AND price BETWEEN %s AND %s " + "ORDER BY tick_time LIMIT 20", + (CODE, DAY + "103600", DAY + "103700", 50700, 50900), + ).fetchall() + for t in near2: + print(f" {dict(t)}") + + hcols = [r["Field"] for r in db.conn.execute( + "SHOW COLUMNS FROM target_candidates_history" + ).fetchall()] + print("\nhistory cols:", hcols) + if "slot_key" in hcols: + hs2 = db.conn.execute( + "SELECT slot_key, COUNT(*) n FROM target_candidates_history " + "WHERE code=%s AND slot_key LIKE %s GROUP BY slot_key ORDER BY slot_key", + (CODE, DAY + "103%"), + ).fetchall() + print("SK slots 103x:") + for h in hs2: + print(f" {h['slot_key']} n={h['n']}") + + finally: + db.close() + + print("\n=== journal 10:34-10:41 ===") + p = subprocess.run( + [ + "journalctl", "-u", "kis_trader_main.service", + "--since", "2026-07-13 10:34:00", + "--until", "2026-07-13 10:41:00", + "--no-pager", + ], + capture_output=True, text=True, + ) + keys = ("475150", "이터닉스", "MOMENTUM") + for line in p.stdout.splitlines(): + if any(k in line for k in keys): + print(line[:240]) + + +if __name__ == "__main__": + main() diff --git a/scripts/_diag_mom_tick_today.py b/scripts/_diag_mom_tick_today.py new file mode 100644 index 0000000..58ab653 --- /dev/null +++ b/scripts/_diag_mom_tick_today.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""오늘 모멘텀 거래종목 vs 유니버스 틱 커버 진단 (adhoc).""" +from __future__ import annotations + +from database import TradeDB + +DAY = "20260713" +CODES = ["475150", "039340", "241710"] +TRADE_MINS = [ + "202607130930", + "202607130931", + "202607130943", + "202607130944", + "202607130952", + "202607130953", + "202607130959", + "202607131014", + "202607131019", + "202607131020", + "202607131026", + "202607131027", + "202607131032", + "202607131033", + "202607131036", + "202607131038", + "202607131101", + "202607131102", + "202607131116", + "202607131118", +] + + +def main() -> None: + db = TradeDB() + try: + cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_ticks").fetchall()] + print("ws_ticks sample cols:", cols[:12]) + + for code in CODES: + rows = db.conn.execute( + "SELECT COUNT(*) n, MIN(tick_time) mn, MAX(tick_time) mx " + "FROM ws_ticks WHERE code=%s AND tick_time LIKE %s", + (code, DAY + "%"), + ).fetchone() + print(f"ticks {code}: n={rows['n']} range={rows['mn']}~{rows['mx']}") + + sk = db.conn.execute( + "SELECT LEFT(tick_time,12) m, COUNT(*) n FROM ws_ticks " + "WHERE code=%s AND tick_time >= %s AND tick_time <= %s " + "GROUP BY LEFT(tick_time,12) ORDER BY m", + ("475150", DAY + "090000", DAY + "113059"), + ).fetchall() + have = {r["m"]: int(r["n"]) for r in sk} + print(f"SK minutes with ticks 09:00-11:30: {len(have)}") + print("trade-related minutes tick count:") + for m in TRADE_MINS: + print(f" {m[8:]} n={have.get(m, 0)}") + + nc = db.conn.execute( + "SELECT COUNT(DISTINCT code) c FROM ws_ticks WHERE tick_time LIKE %s", + (DAY + "%",), + ).fetchone() + print("distinct codes with ticks today:", nc["c"]) + + try: + cc = db.conn.execute( + "SELECT COUNT(DISTINCT code) c FROM ws_candles " + "WHERE candle_time LIKE %s AND timeframe=%s", + (DAY + "%", 1), + ).fetchone() + print("distinct codes with 1m candles today:", cc["c"]) + except Exception as e: + print("candles query skip:", e) + + # 30% coverage 의미: 유니버스 전종목 전분봉 중 틱 있는 분 비율 + # 거래 3종만 보면? + for code in CODES: + bars = db.conn.execute( + "SELECT COUNT(DISTINCT LEFT(candle_time,12)) n FROM ws_candles " + "WHERE code=%s AND candle_time LIKE %s AND timeframe=%s " + "AND LEFT(candle_time,12) BETWEEN %s AND %s", + (code, DAY + "%", 1, DAY + "0900", DAY + "1530"), + ).fetchone() + tmin = db.conn.execute( + "SELECT COUNT(DISTINCT LEFT(tick_time,12)) n FROM ws_ticks " + "WHERE code=%s AND tick_time LIKE %s " + "AND LEFT(tick_time,12) BETWEEN %s AND %s", + (code, DAY + "%", DAY + "0900", DAY + "1530"), + ).fetchone() + bn = int(bars["n"] or 0) + tn = int(tmin["n"] or 0) + pct = (100.0 * tn / bn) if bn else 0.0 + print(f"cover {code}: tick_mins={tn} candle_mins={bn} pct={pct:.1f}%") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/_run_breakout_optuna_715.sh b/scripts/_run_breakout_optuna_715.sh new file mode 100755 index 0000000..0074baf --- /dev/null +++ b/scripts/_run_breakout_optuna_715.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="breakout_fine_20260715_${TS}" +LOG="logs/param_search_optuna_breakout_fine_${TS}.log" +echo "$LOG" > logs/param_search_optuna_breakout_fine_latest.logpath +echo "$STUDY" > logs/param_search_optuna_breakout_fine_latest.study +# 120만 한도 정합 (구 study는 env_config 단독→600만 버그). 새 study-name 필수. +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy breakout \ + --mode fine \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 200 \ + --min_trades 1 \ + --orderbook-filter off \ + --no-progress \ + --apply-best \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_breakout_optuna_fine_wideTune_715.sh b/scripts/_run_breakout_optuna_fine_wideTune_715.sh new file mode 100755 index 0000000..a2a6ce3 --- /dev/null +++ b/scripts/_run_breakout_optuna_fine_wideTune_715.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="breakout_fine_wideTune_20260715_${TS}" +LOG="logs/param_search_optuna_breakout_fine_${TS}.log" +echo "$LOG" > logs/param_search_optuna_breakout_fine_latest.logpath +echo "$STUDY" > logs/param_search_optuna_breakout_fine_latest.study +# wide(7/15) Top 분지 fine — apply 없음. 확인 후 최빈/1위 적용. +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy breakout \ + --mode fine \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 200 \ + --min_trades 1 \ + --min_win_rate 0 \ + --min_pf 0 \ + --sort-by pnl \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=/home/hoon/kis_bot/$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_breakout_optuna_wide_715.sh b/scripts/_run_breakout_optuna_wide_715.sh new file mode 100755 index 0000000..8150265 --- /dev/null +++ b/scripts/_run_breakout_optuna_wide_715.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="breakout_wide_20260715_${TS}" +LOG="logs/param_search_optuna_breakout_wide_${TS}.log" +echo "$LOG" > logs/param_search_optuna_breakout_wide_latest.logpath +echo "$STUDY" > logs/param_search_optuna_breakout_wide_latest.study +# wide 축 스크리닝 — apply 없음. 아침 fine/최빈은 wide Top 확인 후. +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy breakout \ + --mode wide \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 100 \ + --min_trades 1 \ + --min_win_rate 0 \ + --min_pf 0 \ + --sort-by pnl \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=/home/hoon/kis_bot/$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_error_watch_mm.sh b/scripts/_run_error_watch_mm.sh new file mode 100755 index 0000000..5503cdf --- /dev/null +++ b/scripts/_run_error_watch_mm.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# 오류감시 기동 (systemd 없이 nohup). sudo 있으면 deploy 유닛 사용 권장. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p logs +pkill -f 'scripts/kis_error_watch_mm.py' 2>/dev/null || true +sleep 1 +nohup "$ROOT/.venv/bin/python" -u "$ROOT/scripts/kis_error_watch_mm.py" \ + >> "$ROOT/logs/kis_error_watch_mm.log" 2>&1 & +echo "PID=$!" +echo "LOG=$ROOT/logs/kis_error_watch_mm.log" +echo "tail -f $ROOT/logs/kis_error_watch_mm.log" diff --git a/scripts/_run_momentum_optuna_715.sh b/scripts/_run_momentum_optuna_715.sh new file mode 100755 index 0000000..4ed4326 --- /dev/null +++ b/scripts/_run_momentum_optuna_715.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="momentum_fine_20260715_${TS}" +LOG="logs/param_search_optuna_momentum_fine_${TS}.log" +echo "$LOG" > logs/param_search_optuna_momentum_fine_latest.logpath +echo "$STUDY" > logs/param_search_optuna_momentum_fine_latest.study + +# E(전일시가) ON 유지. DB 전일봉 없으면 prepare 시 키움 REST 1회/종목 → 메모리만. +# 이전 E-OFF 탐색은 폐기 — 새 study-name. +unset MOMENTUM_TRIGGER_E_CONFIRM || true +export MOMENTUM_BACKTEST_REST_WARMUP=1 +export MOMENTUM_BACKTEST_REST_WARMUP_BARS="${MOMENTUM_BACKTEST_REST_WARMUP_BARS:-700}" +export MOMENTUM_BACKTEST_REST_SLEEP_SEC="${MOMENTUM_BACKTEST_REST_SLEEP_SEC:-0.25}" + +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy momentum \ + --mode fine \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 200 \ + --min_trades 1 \ + --min_win_rate 0 \ + --min_pf 0 \ + --sort-by pnl \ + --orderbook-filter off \ + --no-progress \ + --apply-best \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_momentum_optuna_fine_wideTune_715.sh b/scripts/_run_momentum_optuna_fine_wideTune_715.sh new file mode 100644 index 0000000..3949f9c --- /dev/null +++ b/scripts/_run_momentum_optuna_fine_wideTune_715.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="momentum_fine_lateRatchet_20260715_${TS}" +LOG="logs/param_search_optuna_momentum_fine_${TS}.log" +echo "$LOG" > logs/param_search_optuna_momentum_fine_latest.logpath +echo "$STUDY" > logs/param_search_optuna_momentum_fine_latest.study +unset MOMENTUM_TRIGGER_E_CONFIRM || true +export MOMENTUM_BACKTEST_REST_WARMUP=1 +export MOMENTUM_BACKTEST_REST_WARMUP_BARS="${MOMENTUM_BACKTEST_REST_WARMUP_BARS:-700}" +export MOMENTUM_BACKTEST_REST_SLEEP_SEC="${MOMENTUM_BACKTEST_REST_SLEEP_SEC:-0.25}" +# fine + 늦게잠금 래칫 격자 + JSON 경로 고지 테스트 — apply 없음 +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy momentum \ + --mode fine \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 200 \ + --min_trades 1 \ + --min_win_rate 0 \ + --min_pf 0 \ + --sort-by pnl \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=/home/hoon/kis_bot/$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_momentum_optuna_wide_715.sh b/scripts/_run_momentum_optuna_wide_715.sh new file mode 100755 index 0000000..d6e1944 --- /dev/null +++ b/scripts/_run_momentum_optuna_wide_715.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="momentum_wide_20260715_${TS}" +LOG="logs/param_search_optuna_momentum_wide_${TS}.log" +echo "$LOG" > logs/param_search_optuna_momentum_wide_latest.logpath +echo "$STUDY" > logs/param_search_optuna_momentum_wide_latest.study + +unset MOMENTUM_TRIGGER_E_CONFIRM || true +export MOMENTUM_BACKTEST_REST_WARMUP=1 +export MOMENTUM_BACKTEST_REST_WARMUP_BARS="${MOMENTUM_BACKTEST_REST_WARMUP_BARS:-700}" +export MOMENTUM_BACKTEST_REST_SLEEP_SEC="${MOMENTUM_BACKTEST_REST_SLEEP_SEC:-0.25}" + +# wide 축 스크리닝 — apply 없음. fine 재설계·적용은 wide Top 확인 후. +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy momentum \ + --mode wide \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 100 \ + --min_trades 1 \ + --min_win_rate 0 \ + --min_pf 0 \ + --sort-by pnl \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_scalp_optuna_715.sh b/scripts/_run_scalp_optuna_715.sh new file mode 100755 index 0000000..87f2dab --- /dev/null +++ b/scripts/_run_scalp_optuna_715.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="scalp_fine_20260715_${TS}" +LOG="logs/param_search_optuna_scalp_fine_${TS}.log" +echo "$LOG" > logs/param_search_optuna_scalp_fine_latest.logpath +echo "$STUDY" > logs/param_search_optuna_scalp_fine_latest.study + +# 모멘텀과 동일: 거래일 2026-07-15 · fine 200 · 호가 OFF · apply-best +# 포트 한도 = config_scalp 병합 (SCALP_TOTAL_BUDGET_KRW=120만) — env_config 단독 금지(600만 버그) +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy scalp \ + --mode fine \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 200 \ + --min_trades 1 \ + --min_win_rate 0 \ + --min_pf 0 \ + --sort-by score \ + --orderbook-filter off \ + --no-progress \ + --apply-best \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_tail_optuna_fine_715.sh b/scripts/_run_tail_optuna_fine_715.sh new file mode 100755 index 0000000..1673299 --- /dev/null +++ b/scripts/_run_tail_optuna_fine_715.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="tail_fine_wide1Tune_20260715_${TS}" +LOG="logs/param_search_optuna_tail_fine_${TS}.log" +echo "$LOG" > logs/param_search_optuna_tail_fine_latest.logpath +echo "$STUDY" > logs/param_search_optuna_tail_fine_latest.study +# wide1 분지 fine — apply 없음. 격자 변경 → 새 study-name. +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy tail \ + --mode fine \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 200 \ + --min_trades 1 \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_tail_optuna_wide2_715.sh b/scripts/_run_tail_optuna_wide2_715.sh new file mode 100755 index 0000000..6080e4f --- /dev/null +++ b/scripts/_run_tail_optuna_wide2_715.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +# wide 격자 v2(확장) — categorical 변경이라 구 study 재사용 금지 +STUDY="tail_wide2_20260715_${TS}" +LOG="logs/param_search_optuna_tail_wide2_${TS}.log" +echo "$LOG" > logs/param_search_optuna_tail_wide2_latest.logpath +echo "$STUDY" > logs/param_search_optuna_tail_wide2_latest.study +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy tail \ + --mode wide \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 100 \ + --min_trades 1 \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/_run_tail_optuna_wide_715.sh b/scripts/_run_tail_optuna_wide_715.sh new file mode 100755 index 0000000..9b15e12 --- /dev/null +++ b/scripts/_run_tail_optuna_wide_715.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs +TS=$(date +%Y%m%d_%H%M%S) +STUDY="tail_wide_20260715_${TS}" +LOG="logs/param_search_optuna_tail_wide_${TS}.log" +echo "$LOG" > logs/param_search_optuna_tail_wide_latest.logpath +echo "$STUDY" > logs/param_search_optuna_tail_wide_latest.study +# 7/15 축 스크리닝 — apply 없음. 새 study-name 필수(wide 그리드 신규). +nohup python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy tail \ + --mode wide \ + --start 2026-07-15 \ + --end 2026-07-15 \ + --trials 100 \ + --min_trades 1 \ + --orderbook-filter off \ + --no-progress \ + --study-name "$STUDY" \ + > "$LOG" 2>&1 & +echo "PID=$!" +echo "STUDY=$STUDY" +echo "LOG=$LOG" +echo "tail -f /home/hoon/kis_bot/$LOG" diff --git a/scripts/append_tail_optuna_compare.py b/scripts/append_tail_optuna_compare.py new file mode 100644 index 0000000..b63ba0b --- /dev/null +++ b/scripts/append_tail_optuna_compare.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Optuna 결과 JSON vs 현재 DB — 백테 비교표를 로그 파일 끝에 append (DB 미저장).""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from database import TradeDB +from kis_trader.backtest import tail_backtest_common as tbc +from kis_trader.engine import tail_engine as te + + +COMPARE_KEYS = [ + "entry_mode", + "min_drop_rate", + "min_recovery_ratio", + "tail_ratio_min", + "tail_pct_min", + "max_rec_3m", + "shoulder_min_high", + "shoulder_cut_pct", + "stop_atr_mult", + "target_atr_mult", + "atr_sl_min_pct", + "atr_sl_max_pct", + "atr_tp_min_pct", + "atr_tp_max_pct", + "max_daily_change", + "max_loss_krw", + "limit_atr_mult", + "tail_vol_mult", + "tail_vol_win", + "ratchet_tiers", + "rsi_threshold", + "cooldown_min", + "bar_chg_min_pct", + "bar_chg_max_pct", + "symbol_daily_loss_limit_krw", + "symbol_daily_loss_limit_pct", + "reentry_min_edge_krw", + "reentry_require_nonneg", + "trail_tiers", + "trail_drop_pct", + "trail_arm_krw", + "pattern_pin", + "pattern_engulfing", + "pattern_piercing", + "max_daily", + "max_spread_pct", + "min_bid_ask_ratio", +] + + +def _same(a, b) -> bool: + if a == b: + return True + try: + return abs(float(a) - float(b)) < 1e-9 + except Exception: + return str(a) == str(b) + + +def _run(candles_by_code, universe, base, port, fee, tax, slot, budget, meta, overrides): + params = dict(base) + tbc.merge_tail_portfolio_into_params(params, port) + params.update(overrides or {}) + trades = tbc.run_tail_backtest_web_aligned( + candles_by_code, + params, + universe, + slot_money=slot, + fee_rate=fee, + sell_tax=tax, + total_budget_krw=budget, + meta_out=dict(meta), + ) + wins = [t for t in trades if float(t.get("pnl") or 0) > 0] + pnl = sum(float(t.get("pnl") or 0) for t in trades) + wr = (len(wins) / len(trades) * 100.0) if trades else 0.0 + return { + "trades": len(trades), + "wins": len(wins), + "wr": wr, + "pnl": pnl, + "params": {k: params.get(k) for k in COMPARE_KEYS}, + } + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--json", required=True, help="optuna_tail_*.json 경로") + ap.add_argument("--log", required=True, help="append 대상 로그 경로") + ap.add_argument("--date", default="2026-07-10", help="백테 일자 YYYY-MM-DD") + args = ap.parse_args() + + json_path = Path(args.json) + log_path = Path(args.log) + data = json.loads(json_path.read_text(encoding="utf-8")) + best = (data.get("results") or [None])[0] + if not best: + msg = "❌ Optuna results 비어 있음 — 비교 스킵\n" + with log_path.open("a", encoding="utf-8") as f: + f.write(msg) + print(msg, end="") + return 1 + + best_params = best.get("params") or {} + day = args.date + start_key = day.replace("-", "") + "0000" + end_key = day.replace("-", "") + "2359" + start_ymd, end_ymd = start_key[:8], end_key[:8] + + db = TradeDB() + base = te.get_tail_defaults_from_db(db) + universe, src, n_slots, _ = tbc.resolve_tail_universe( + start_ymd, end_ymd, use_saved_history=True, strategy_id="SHORT", + ) + tf = int(base.get("timeframe") or 3) + rsi = int(base.get("rsi_period") or 14) + candles_by_code, _, _ = tbc.load_tail_candles_by_code( + db, start_key, end_key, tf, rsi_period=rsi, + ) + row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() + r = dict(row) if row else {} + fee, tax, _ = tbc.fee_and_slot_from_env_row(r) + port = tbc.resolve_tail_portfolio_params(r, base) + slot = float(port["slot_money"]) + budget = float(port["total_budget_krw"]) + meta = {"db": db, "start_key": start_key, "end_key": end_key} + + cur = _run(candles_by_code, universe, base, port, fee, tax, slot, budget, meta, {}) + bst = _run( + candles_by_code, universe, base, port, fee, tax, slot, budget, meta, best_params, + ) + delta = bst["pnl"] - cur["pnl"] + trial_no = best.get("optuna_trial_number") or data.get("optuna_best_trial_number") + mode = data.get("mode") or "?" + elapsed = data.get("elapsed_sec") + + lines = [] + lines.append("") + lines.append("=" * 72) + lines.append(f"[COMPARE] CURRENT_DB vs OPTUNA_{mode.upper()}_BEST | {day} | DB미저장") + lines.append("=" * 72) + lines.append(f"json={json_path}") + lines.append(f"universe={src} slots={n_slots} | trial=#{trial_no} elapsed={elapsed}s") + lines.append("") + lines.append("| 구분 | 거래 | 승 | 승률 | 손익 |") + lines.append("|------|------|----|------|------|") + lines.append( + f"| CURRENT_DB | {cur['trades']} | {cur['wins']} | {cur['wr']:.1f}% | {cur['pnl']:+,.0f} |" + ) + lines.append( + f"| OPTUNA_BEST | {bst['trades']} | {bst['wins']} | {bst['wr']:.1f}% | {bst['pnl']:+,.0f} |" + ) + lines.append(f"| Δ(best-current) | | | | {delta:+,.0f} |") + lines.append("") + lines.append("| 파라미터 | CURRENT_DB | OPTUNA_BEST | diff |") + lines.append("|----------|------------|--------------|------|") + for k in COMPARE_KEYS: + a = cur["params"].get(k) + b = bst["params"].get(k) + mark = "" if _same(a, b) else "<<" + lines.append(f"| {k} | {a} | {b} | {mark} |") + lines.append("") + lines.append("DB 저장 없음 (--apply-best 미사용)") + lines.append("=" * 72) + lines.append("") + text = "\n".join(lines) + with log_path.open("a", encoding="utf-8") as f: + f.write(text) + print(text, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backfill_trade_candles.py b/scripts/backfill_trade_candles.py new file mode 100644 index 0000000..f36cdf1 --- /dev/null +++ b/scripts/backfill_trade_candles.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +청산·보유 종목의 매수~매도(또는 ~now) 구간 1분봉을 키움 REST로 즉시 백필. + +예: + nohup python3 -u scripts/backfill_trade_candles.py \\ + --like '2026-07-16%' > /tmp/backfill_trade_candles_0716.log 2>&1 & + tail -f /tmp/backfill_trade_candles_0716.log +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from database import TradeDB +from kis_trader.engine.post_sell_candle_backfill import backfill_trades_from_db + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--like", default="2026-07-16%", help="buy_date LIKE (%% 바인딩)") + ap.add_argument( + "--strategies", + default="", + help="콤마 구분 strategy (비우면 전체)", + ) + ap.add_argument("--no-active", action="store_true", help="active_trades 제외") + ap.add_argument("--active-days", type=int, default=5) + args = ap.parse_args() + + strategies = [s.strip() for s in str(args.strategies).split(",") if s.strip()] or None + db = TradeDB() + try: + results = backfill_trades_from_db( + db, + buy_date_like=str(args.like), + strategies=strategies, + include_active=not args.no_active, + active_max_age_days=int(args.active_days), + ) + finally: + try: + db.close() + except Exception: + pass + + ok = sum(1 for r in results if r.get("ok")) + improved = sum(1 for r in results if int(r.get("after") or 0) > int(r.get("before") or 0)) + print( + f"DONE jobs={len(results)} ok={ok} improved={improved} " + f"upsert_sum={sum(int(r.get('upserted') or 0) for r in results)}" + ) + for r in results: + if not r.get("ok") or int(r.get("after") or 0) > int(r.get("before") or 0): + print( + f" {r.get('strategy')} {r.get('code')} " + f"{r.get('start')}~{r.get('end')} " + f"{r.get('before')}→{r.get('after')} " + f"upsert={r.get('upserted')} err={r.get('error')!r}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/kis_error_watch_mm.py b/scripts/kis_error_watch_mm.py new file mode 100755 index 0000000..4431684 --- /dev/null +++ b/scripts/kis_error_watch_mm.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +kis_error_watch_mm.py — kis_trader_main journalctl 실시간(tail -f) 감시 → Mattermost + +실매 봇과 분리된 프로세스. journald 만 보고 오류 시 MM 알림. + - Traceback / FATAL / dead=[...] / 유닛 다운 등 + - 동일·유사 알림은 쿨다운으로 스팸 방지 + - 상태 JSON 즉시 저장(재시작 후에도 쿨다운 유지) + +실행: + nohup .venv/bin/python -u scripts/kis_error_watch_mm.py \\ + >> logs/kis_error_watch_mm.log 2>&1 & + tail -f logs/kis_error_watch_mm.log + +테스트: + .venv/bin/python scripts/kis_error_watch_mm.py --test-mm + +systemd (선택): + sudo cp deploy/kis_error_watch_mm.service /etc/systemd/system/ + sudo systemctl daemon-reload && sudo systemctl enable --now kis_error_watch_mm +""" +from __future__ import annotations + +import argparse +import hashlib +import logging +import os +import re +import signal +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Pattern, Tuple + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from kis_trader.utils.env import ( # noqa: E402 + get_env_bool, + get_env_float, + get_env_from_db, + get_env_int, +) +from kis_trader.utils.logger import atomic_load_json, atomic_save_json, msg_mm # noqa: E402 + +LOG_PATH = ROOT / "logs" / "kis_error_watch_mm.log" +STATE_PATH = ROOT / "logs" / "kis_error_watch_mm_state.json" +LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(message)s", + datefmt="%H:%M:%S", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger("error_watch") + +_STOP = False + + +def _on_signal(signum, _frame) -> None: + global _STOP + _STOP = True + log.info("⏹ signal=%s → 종료 예약", signum) + + +def _cfg() -> dict: + """DB/env 설정 — 하드코딩 수치 금지, get_env_* 만.""" + # Traceback·FATAL·비어있지 않은 dead=·유닛 크래시 시그니처 + default_match = ( + r"(?i)(" + r"Traceback \(most recent call last\)|" + r"\bCRITICAL\b|\bFATAL\b|MemoryError|SIGBUS|Segmentation fault|" + r"dead=\[[^\]]|" # dead=[] 제외, dead=['Strat-... 매칭 + r"Main process exited|Failed with result|" + r"can't open file|" + r"강제\s*종료|Out of memory" + r")" + ) + default_ignore = ( + r"(?i)(" + r"numexpr\.utils|" + r"\[MM 스킵\]|" + r"MM 발송 실패|" + r"heartbeat ws=" + r")" + ) + return { + "enabled": get_env_bool("ERROR_WATCH_ENABLED", True), + "unit": str( + get_env_from_db("ERROR_WATCH_UNIT", "kis_trader_main.service") + or "kis_trader_main.service" + ).strip(), + "channel": str( + get_env_from_db("ERROR_WATCH_MM_CHANNEL", "") + or get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default") + or "default" + ).strip() + or "default", + "cooldown_sec": max(30, get_env_int("ERROR_WATCH_COOLDOWN_SEC", 180)), + "context_lines": max(1, min(20, get_env_int("ERROR_WATCH_CONTEXT_LINES", 5))), + "traceback_extra": max(0, min(40, get_env_int("ERROR_WATCH_TRACEBACK_EXTRA_LINES", 12))), + "health_sec": max(15, get_env_int("ERROR_WATCH_HEALTH_CHECK_SEC", 60)), + "match_re": str( + get_env_from_db("ERROR_WATCH_MATCH_REGEX", default_match) or default_match + ), + "ignore_re": str( + get_env_from_db("ERROR_WATCH_IGNORE_REGEX", default_ignore) or default_ignore + ), + "jitter": get_env_bool("ERROR_WATCH_MM_JITTER", False), + } + + +def _compile_re(pat: str, name: str) -> Optional[Pattern[str]]: + try: + return re.compile(pat) + except re.error as e: + log.error("❌ regex 컴파일 실패 (%s): %s", name, e) + return None + + +def _load_state() -> dict: + st = atomic_load_json(STATE_PATH, default={}) + if not isinstance(st, dict): + return {} + return st + + +def _save_state(st: dict) -> None: + atomic_save_json(STATE_PATH, st) + + +def _fp(text: str) -> str: + # 시각·PID 제거 후 지문 → 같은 오류 반복 쿨다운 + norm = re.sub(r"\d{2}:\d{2}:\d{2}", "", text) + norm = re.sub(r"python\[\d+\]", "python[PID]", norm) + norm = re.sub(r"\s+", " ", norm).strip()[:800] + return hashlib.sha1(norm.encode("utf-8", errors="ignore")).hexdigest()[:16] + + +def _can_alert(st: dict, fingerprint: str, cooldown_sec: int) -> bool: + now = time.time() + last_ts = float(st.get("last_alert_ts") or 0) + last_fp = str(st.get("last_fingerprint") or "") + if fingerprint == last_fp and (now - last_ts) < cooldown_sec: + return False + if (now - last_ts) < float(get_env_float("ERROR_WATCH_GLOBAL_MIN_GAP_SEC", 20.0)): + # 서로 다른 오류라도 최소 간격 + if fingerprint != last_fp and (now - last_ts) < cooldown_sec * 0.15: + return False + return True + + +def _send_alert(title: str, lines: List[str], channel: str, jitter: bool, st: dict, fingerprint: str) -> bool: + body_lines = [ + f"🚨 **[오류감시] {title}**", + f"- 시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f"- 유닛: `{get_env_from_db('ERROR_WATCH_UNIT', 'kis_trader_main.service')}`", + "```", + ] + clipped = "\n".join(lines)[:3500] + body_lines.append(clipped) + body_lines.append("```") + body = "\n".join(body_lines) + ok = msg_mm(body, channel_alias=channel, jitter=jitter) + st["last_alert_ts"] = time.time() + st["last_fingerprint"] = fingerprint + st["last_title"] = title + st["alert_count"] = int(st.get("alert_count") or 0) + 1 + _save_state(st) + log.info("📤 MM %s title=%s fp=%s", "OK" if ok else "FAIL", title, fingerprint) + return ok + + +def _unit_active(unit: str) -> Tuple[bool, str]: + try: + r = subprocess.run( + ["systemctl", "is-active", unit], + capture_output=True, + text=True, + timeout=5, + ) + state = (r.stdout or "").strip() or (r.stderr or "").strip() or "unknown" + return state == "active", state + except Exception as e: + return False, f"check_error:{e}" + + +def _follow_journal(unit: str) -> subprocess.Popen: + # -n 0: 과거 덤프 없이 follow만 (기동 직후 과거 Traceback 폭주 방지) + cmd = [ + "journalctl", + "-u", unit, + "-f", + "-n", "0", + "--output=short-iso", + "--no-pager", + ] + log.info("📡 follow: %s", " ".join(cmd)) + return subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + errors="replace", + ) + + +def run_watch() -> int: + cfg = _cfg() + if not cfg["enabled"]: + log.warning("ERROR_WATCH_ENABLED=false → 종료") + return 0 + + match_re = _compile_re(cfg["match_re"], "MATCH") + ignore_re = _compile_re(cfg["ignore_re"], "IGNORE") + if match_re is None: + return 2 + + unit = cfg["unit"] + channel = cfg["channel"] + st = _load_state() + log.info( + "✅ 감시 시작 unit=%s ch=%s cooldown=%ss health=%ss", + unit, channel, cfg["cooldown_sec"], cfg["health_sec"], + ) + + # 기동 알림 (감시자 살아있음 확인) + if get_env_bool("ERROR_WATCH_STARTUP_NOTIFY", True): + active, state = _unit_active(unit) + msg_mm( + f"👁️ **[오류감시 기동]** `{unit}` → `{state}`" + f"{' ✅' if active else ' ⚠️ 비활성'}", + channel_alias=channel, + jitter=False, + ) + + proc = _follow_journal(unit) + buf: List[str] = [] + collecting_tb = False + tb_left = 0 + last_health = time.time() + was_active = True + + assert proc.stdout is not None + + while not _STOP: + # health poll + now = time.time() + if now - last_health >= cfg["health_sec"]: + last_health = now + active, state = _unit_active(unit) + if not active: + fp = _fp(f"unit_down:{unit}:{state}") + if _can_alert(st, fp, cfg["cooldown_sec"]): + _send_alert( + f"유닛 비활성 ({state})", + [f"systemctl is-active {unit} → {state}"], + channel, + cfg["jitter"], + st, + fp, + ) + was_active = False + elif not was_active: + # 복구 알림 + fp = _fp(f"unit_up:{unit}") + if _can_alert(st, fp, max(30, cfg["cooldown_sec"] // 3)): + _send_alert( + "유닛 복구 (active)", + [f"systemctl is-active {unit} → active"], + channel, + cfg["jitter"], + st, + fp, + ) + was_active = True + + # journalctl 죽었으면 재기동 + if proc.poll() is not None: + log.warning("⚠️ journalctl 종료 code=%s → 재기동", proc.returncode) + proc = _follow_journal(unit) + assert proc.stdout is not None + + # non-blocking-ish read with timeout via select + import select + + ready, _, _ = select.select([proc.stdout], [], [], 1.0) + if not ready: + continue + line = proc.stdout.readline() + if line == "": + # EOF — 재기동 + time.sleep(1.0) + if proc.poll() is not None: + proc = _follow_journal(unit) + assert proc.stdout is not None + continue + + line = line.rstrip("\n") + if not line: + continue + + # ignore + if ignore_re is not None and ignore_re.search(line): + continue + + # Traceback 블록 수집 + if "Traceback (most recent call last)" in line: + collecting_tb = True + tb_left = cfg["traceback_extra"] + buf = [line] + continue + + if collecting_tb: + buf.append(line) + tb_left -= 1 + # 들여쓴 프레임이 끝나고 일반 로그가 오면 종료 + if tb_left <= 0 or ( + len(buf) > 2 + and not line.startswith(" ") + and not line.startswith("\t") + and "File \"" not in line + and not line.lstrip().startswith("File ") + and "Error" not in line + and "Exception" not in line + ): + collecting_tb = False + block = buf[:] + buf = [] + fp = _fp("\n".join(block)) + if _can_alert(st, fp, cfg["cooldown_sec"]): + _send_alert("Traceback", block, channel, cfg["jitter"], st, fp) + continue + + if match_re.search(line): + # 직전 컨텍스트는 journal에 없으므로 히트 라인 + 이후 N줄은 어려움 → 히트만 + ctx = [line] + fp = _fp(line) + if _can_alert(st, fp, cfg["cooldown_sec"]): + title = "로그 오류 매칭" + if "dead=[" in line: + title = "전략 dead 감지" + elif "exited" in line.lower() or "Failed with result" in line: + title = "프로세스 종료" + _send_alert(title, ctx, channel, cfg["jitter"], st, fp) + + try: + proc.terminate() + except Exception: + pass + log.info("👋 오류감시 종료") + return 0 + + +def run_test_mm() -> int: + ch = str( + get_env_from_db("ERROR_WATCH_MM_CHANNEL", "") + or get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default") + or "default" + ).strip() or "default" + ok = msg_mm( + "🧪 **[오류감시 테스트]** kis_error_watch_mm.py --test-mm OK", + channel_alias=ch, + jitter=False, + ) + print(f"test_mm channel={ch} ok={ok}") + return 0 if ok else 1 + + +def main() -> int: + signal.signal(signal.SIGINT, _on_signal) + signal.signal(signal.SIGTERM, _on_signal) + ap = argparse.ArgumentParser(description="kis_trader journal 오류 → Mattermost") + ap.add_argument("--test-mm", action="` `", help="테스트 메시지 1회 발송 후 종료") + args = ap.parse_args() + if args.test_mm: + return run_test_mm() + return run_watch() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/market_open_sim_smoke.py b/scripts/market_open_sim_smoke.py new file mode 100644 index 0000000..c1a320e --- /dev/null +++ b/scripts/market_open_sim_smoke.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +내일(다음 거래일) 장 시작(09:05)을 가정한 격리 스모크. +- 실매매 서비스(WS/주문)는 건드리지 않음 +- FORCE_MARKET_OPEN 을 DB/실매에 쓰지 않음 (프로세스 내 datetime 패치만) +- 계좌 조회(REST) + 엔진/DB 기본값 + 장시작 리포트 문자열 + 직전 거래일 백테 스모크 + +로그: logs/market_open_sim_smoke.log +""" +from __future__ import annotations + +import logging +import os +import sys +import traceback +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, List, Tuple +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +LOG_PATH = ROOT / "logs" / "market_open_sim_smoke.log" +LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(message)s", + datefmt="%H:%M:%S", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler(LOG_PATH, encoding="utf-8"), + ], +) +log = logging.getLogger("market_open_sim") + +errors: List[str] = [] +oks: List[str] = [] + + +def _ok(msg: str) -> None: + oks.append(msg) + log.info("✅ %s", msg) + + +def _err(msg: str) -> None: + errors.append(msg) + log.error("❌ %s", msg) + + +def _next_trading_day(from_d: date) -> date: + from kis_trader.utils.kr_trading_day import is_kr_trading_day + + d = from_d + timedelta(days=1) + for _ in range(21): + if is_kr_trading_day(d): + return d + d += timedelta(days=1) + raise RuntimeError("next trading day not found") + + +def step_calendar() -> datetime: + from kis_trader.utils.kr_trading_day import ( + clamp_to_prev_kr_trading_day, + is_kr_trading_day, + trading_dates_payload, + ) + + today = date.today() + tom = _next_trading_day(today) + assert is_kr_trading_day(tom), tom + prev = clamp_to_prev_kr_trading_day(today) + payload = trading_dates_payload(7) + _ok( + f"달력: today={today} next_open={tom} prev_td={prev} " + f"web_defaults={payload.get('start')}~{payload.get('end')}" + ) + # 장시작 09:05 가정 + return datetime(tom.year, tom.month, tom.day, 9, 5, 0) + + +def step_strategy_flags() -> None: + from kis_trader.utils.env import get_env_bool + + flags = { + "SCALP": get_env_bool("STRATEGY_SCALP_ENABLED", True), + "SHORT": get_env_bool("STRATEGY_SHORT_ENABLED", True), + "MOMENTUM": get_env_bool("STRATEGY_MOMENTUM_ENABLED", False), + "BREAKOUT": get_env_bool("STRATEGY_BREAKOUT_ENABLED", False), + "RANGE_BREAK": get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False), + "UPDOW": get_env_bool("STRATEGY_UPDOW_ENABLED", False), + "DBBAND": get_env_bool("STRATEGY_DBBAND_ENABLED", False), + } + on = [k for k, v in flags.items() if v] + off = [k for k, v in flags.items() if not v] + _ok(f"전략 ON={on} OFF={off}") + # HTS 스킵은 false 유지 규칙 + for key in ( + "TAIL_SKIP_HTS_SCAN_DUPES", + "SHORT_SKIP_HTS_SCAN_DUPES", + "MOMENTUM_SKIP_HTS_SCAN_DUPES", + "BREAKOUT_SKIP_HTS_SCAN_DUPES", + "SCALP_SKIP_HTS_SCAN_DUPES", + ): + if get_env_bool(key, False): + _err(f"{key}=true (기본 false 유지 규칙 위반)") + else: + _ok(f"{key}=false") + + +def step_market_hours(fake_now: datetime) -> None: + from kis_trader.strategies.base import BaseStrategy + from kis_trader.network.market_guard import MarketGuard + + class _Dummy(BaseStrategy): + strategy_id = "SHORT" + + def __init__(self): + # Thread/풀 초기화 우회: 최소 속성만 + self.strategy_id = "SHORT" + + def check_buy(self, *a, **k): # pragma: no cover + return False + + def check_sell_signals(self, *a, **k): # pragma: no cover + return None + + def run(self): # pragma: no cover + return None + + with patch("kis_trader.strategies.base.dt") as mock_dt, patch( + "kis_trader.network.market_guard.dt" + ) as mock_dt2: + mock_dt.now.return_value = fake_now + mock_dt2.now.return_value = fake_now + d = _Dummy() + # BaseStrategy.check_market_status 는 self 만 필요 + open_ok = BaseStrategy.check_market_status(d) + buy_ok = BaseStrategy.check_buy_allowed(d) + mg_ok = MarketGuard._is_market_hours() + if open_ok and buy_ok and mg_ok: + _ok(f"장시간 판정 (fake {fake_now}): market=True buy=True guard=True") + else: + _err( + f"장시간 판정 실패: market={open_ok} buy={buy_ok} guard={mg_ok} " + f"fake={fake_now}" + ) + + +def step_engine_defaults() -> None: + try: + from kis_trader.engine import momentum_engine as me + from kis_trader.engine import scalping_engine as se + from kis_trader.engine import tail_engine as te + from kis_trader.strategies import breakout as bo + + te_d = te.get_tail_defaults_from_db() if hasattr(te, "get_tail_defaults_from_db") else None + me_d = me.get_momentum_defaults_from_db() + # scalping / breakout + if hasattr(se, "get_scalping_defaults_from_db"): + se_d = se.get_scalping_defaults_from_db() + else: + se_d = {"ok": True} + from kis_trader.backtest import breakout_backtest_common as bbc + from kis_trader.utils.env import get_merged_env_dict + + env_row = get_merged_env_dict() or {} + if hasattr(bbc, "get_breakout_defaults_from_env_row"): + bo_d = bbc.get_breakout_defaults_from_env_row(env_row) + else: + bo_d = {} + _ok( + f"엔진 DB 기본값 로드: tail_keys={len(te_d or {})} " + f"mom={len(me_d or {})} scalp={len(se_d or {})} bo={len(bo_d or {})}" + ) + # 손절 키 존재 스모크 + for name, d in (("mom", me_d),): + if d and "sl_pct" in d and float(d["sl_pct"]) <= 0: + _err(f"{name} sl_pct 비정상: {d.get('sl_pct')}") + except Exception as e: + _err(f"엔진 기본값 로드 실패: {e}") + log.error(traceback.format_exc()) + + +def step_account_and_open_report() -> None: + """REST 잔고만 — WS/주문 없음. 장시작 리포트 문자열 생성.""" + try: + from kis_trader.execution.kis_client import KISClient + + client = KISClient() + # TradingBot._fetch_asset_snapshot 과 유사하게 잔고 조회 + bal = None + for meth in ("get_balance", "inquire_balance", "account_balance"): + fn = getattr(client, meth, None) + if callable(fn): + try: + bal = fn() + break + except Exception: + continue + if bal is None: + # inquire-balance 계열 탐색 + for name in dir(client): + if "balance" in name.lower() and callable(getattr(client, name)): + try: + bal = getattr(client, name)() + if bal: + break + except Exception: + continue + mock = getattr(client, "mock", None) + acct = getattr(client, "account_no", "") or "" + _ok(f"KISClient 생성 ok mock={mock} acct=***{str(acct)[-4:]}") + if bal is not None: + _ok(f"잔고 조회 응답 type={type(bal).__name__}") + else: + # 주말이면 모의/실전 REST 가 비정상일 수 있음 — 치명으로 안 봄 + log.warning("⚠️ 잔고 조회 메서드 미확인/실패 (장외 REST 가능) — 계속") + + # 장시작 리포트 포맷만 검증 (MM 미전송) + lines = [ + "🌅 **[장 시작 알림 - 09:00]** (SIM)", + f"- 🤖 활성 전략: (smoke)", + f"- 계좌: {'모의' if mock else '실전'}", + "📈 오늘도 안전 매매! 손절 라인 준수.", + ] + body = "\n".join(lines) + assert "장 시작" in body + _ok("장시작 리포트 문자열 생성 OK (미전송)") + except Exception as e: + _err(f"계좌/장시작 리포트 실패: {e}") + log.error(traceback.format_exc()) + + +def step_verify_three_paths() -> None: + try: + from kis_trader.scripts import verify_three_paths as v3 + + bad = 0 + for fn_name in ("verify_momentum", "verify_breakout", "verify_scalping", "verify_tail"): + fn = getattr(v3, fn_name, None) + if not callable(fn): + continue + n = int(fn() or 0) + bad += n + if n: + _err(f"{fn_name} mismatch={n}") + else: + _ok(f"{fn_name} parity OK") + if bad == 0: + _ok("실매↔웹↔파람 변환 정합 스모크 통과") + except Exception as e: + _err(f"verify_three_paths 실패: {e}") + log.error(traceback.format_exc()) + + +def step_prev_day_backtest_smoke() -> None: + """직전 거래일 1일 · 꼬리+돌파 웹 API 경로 (Flask test_client, 서버 불필요).""" + from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day + + day = clamp_to_prev_kr_trading_day(date.today()) + try: + from backtest_web import app + + with app.test_client() as c: + for label, path in ( + ("꼬리", "/api/backtest/tail"), + ("돌파", "/api/backtest/breakout"), + ): + r = c.get( + path, + query_string={ + "start": day, + "end": day, + "universe": "history", + }, + ) + if r.status_code != 200: + _err(f"{label} 백테 HTTP {r.status_code}") + continue + d = r.get_json(silent=True) or {} + if d.get("error"): + _err(f"{label} 백테 error: {d.get('error')}") + continue + s = d.get("summary") or {} + _ok( + f"{label} 백테 {day}: trades={s.get('total_trades', '?')} " + f"pnl={s.get('total_pnl', '?')}" + ) + except Exception as e: + _err(f"웹 백테 스모크 실패: {e}") + log.error(traceback.format_exc()) + + +def step_holdings_db() -> None: + try: + from kis_trader.utils.env import _get_db + + db = _get_db() + if not db: + _err("TradeDB 연결 실패") + return + cols = db.conn.execute("SHOW COLUMNS FROM active_trades").fetchall() + col_names = [ + (c["Field"] if isinstance(c, dict) else c[0]) for c in (cols or []) + ] + if "status" in col_names: + rows = db.conn.execute( + "SELECT strategy, COUNT(*) AS n FROM active_trades " + "WHERE status=%s GROUP BY strategy", + ("HOLDING",), + ).fetchall() + else: + rows = db.conn.execute( + "SELECT strategy, COUNT(*) AS n FROM active_trades GROUP BY strategy" + ).fetchall() + summary = [] + for r in rows or []: + if isinstance(r, dict): + summary.append(f"{r.get('strategy')}={r.get('n')}") + else: + summary.append(f"{r[0]}={r[1]}") + _ok(f"active_trades HOLDING: {', '.join(summary) or '(없음)'}") + except Exception as e: + _err(f"active_trades 조회 실패: {e}") + log.error(traceback.format_exc()) + + +def main() -> int: + log.info("=== market_open_sim_smoke START ===") + log.info("log=%s", LOG_PATH) + # 실매 FORCE 오염 방지 + os.environ.pop("FORCE_MARKET_OPEN", None) + os.environ.pop("FORCE_BUY_TEST", None) + + try: + fake_now = step_calendar() + step_strategy_flags() + step_market_hours(fake_now) + step_engine_defaults() + step_holdings_db() + step_account_and_open_report() + step_verify_three_paths() + step_prev_day_backtest_smoke() + except Exception as e: + _err(f"치명: {e}") + log.error(traceback.format_exc()) + + log.info("=== SUMMARY ok=%d err=%d ===", len(oks), len(errors)) + for e in errors: + log.info("ERR: %s", e) + if errors: + log.info("RESULT: FAIL") + return 1 + log.info("RESULT: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/momentum_ratchet_ab_715.py b/scripts/momentum_ratchet_ab_715.py new file mode 100644 index 0000000..53eb143 --- /dev/null +++ b/scripts/momentum_ratchet_ab_715.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +이전 모멘텀 fine #1 파라미터 고정 × 래칫만 A/B (apply 없음). + +기준 JSON: optuna_momentum_fine_20260716_014654.json (best ~+83k, 래칫 축 없음) +기간: 2026-07-15 / 포트 120만 / orderbook off +""" +from __future__ import annotations + +import json +import os +import sys +import time +from datetime import datetime + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("MOMENTUM_BACKTEST_REST_WARMUP", "1") +os.environ.setdefault("MOMENTUM_BACKTEST_REST_WARMUP_BARS", "700") +os.environ.setdefault("MOMENTUM_BACKTEST_REST_SLEEP_SEC", "0.25") + +from kis_trader.backtest.optuna_common import announce_optuna_json_path +from kis_trader.backtest.optuna_momentum import prepare_momentum_search_context +from kis_trader.backtest.param_search_momentum import evaluate_momentum_param_combo +from kis_trader.utils.env import get_env_from_db + +# 래칫 후보: OFF + 실매 + fine 격자 대표 + 최근 Optuna 선호 +RATCHET_CASES = [ + ("OFF", ""), + ("LIVE", "5:2,10:1.5"), + ("OPTUNA_TOP", "2:1.5,5:1"), + ("MID", "2:1,5:0.8,8:0.6"), +] + +BASE_JSON = os.path.join( + ROOT, + "kis_trader/backtest/results/optuna_momentum_fine_20260716_014654.json", +) + + +def main() -> int: + with open(BASE_JSON, encoding="utf-8") as f: + src = json.load(f) + base_combo = dict(src["results"][0]["params"]) + start = str(src.get("start") or "2026-07-15") + end = str(src.get("end") or start) + + print("=" * 72, flush=True) + print("모멘텀 래칫 A/B | 이전 fine#1 고정 | apply 없음", flush=True) + print(f"기준 JSON: {BASE_JSON}", flush=True) + print(f"기간: {start} ~ {end}", flush=True) + print(f"고정 params: {json.dumps(base_combo, ensure_ascii=False)}", flush=True) + print( + f"DB MOMENTUM_RATCHET_TIERS(참고): {get_env_from_db('MOMENTUM_RATCHET_TIERS', '')!r}", + flush=True, + ) + print("=" * 72, flush=True) + + t0 = time.time() + ctx = prepare_momentum_search_context( + start, + end, + "fine", + orderbook_filter="off", + ) + if ctx is None: + print("❌ context 준비 실패", flush=True) + return 1 + print( + f"✅ context OK | {time.time() - t0:.1f}s | " + f"slot={ctx.slot_money:,.0f} max={ctx.max_stocks} budget={ctx.total_budget_krw:,.0f}", + flush=True, + ) + + rows = [] + for label, ratchet in RATCHET_CASES: + combo = dict(base_combo) + combo["ratchet_tiers"] = ratchet + print("-" * 72, flush=True) + print(f"▶ 케이스 {label} | ratchet_tiers={ratchet!r}", flush=True) + t1 = time.time() + result = evaluate_momentum_param_combo( + combo, + base_fixed=ctx.base_fixed, + grid_keys=list(ctx.grid_keys) + ["ratchet_tiers"], + codes_candles=ctx.codes_candles, + min_trades=1, + min_win_rate=0.0, + min_pf=0.0, + universe_by_slot=ctx.universe_by_slot, + slot_money=ctx.slot_money, + max_stocks=ctx.max_stocks, + total_budget_krw=ctx.total_budget_krw, + fee_rate=ctx.fee_rate, + sell_tax=ctx.sell_tax, + period_days=ctx.period_days, + cache_holder=ctx.cache_holder, + ticks_by_code=ctx.ticks_by_code, + orderbook_by_code=ctx.orderbook_by_code, + program_by_code=ctx.program_by_code, + log_verdict_by_code=ctx.log_verdict_by_code, + start_key=ctx.start_key, + end_key=ctx.end_key, + ) + elapsed = time.time() - t1 + if result is None: + row = { + "label": label, + "ratchet_tiers": ratchet, + "ok": False, + "elapsed_sec": round(elapsed, 2), + "note": "evaluate None", + } + print(f" ❌ None ({elapsed:.1f}s)", flush=True) + else: + row = { + "label": label, + "ratchet_tiers": ratchet, + "ok": True, + "elapsed_sec": round(elapsed, 2), + "total_pnl": float(result.get("total_pnl") or 0), + "total_trades": int(result.get("total_trades") or 0), + "win_rate": float(result.get("win_rate") or 0), + "pf": float(result.get("pf") or 0) if result.get("pf") is not None else None, + } + print( + f" ✅ pnl={row['total_pnl']:,.0f} | trades={row['total_trades']} | " + f"wr={row['win_rate']:.1f}% | pf={row['pf']} | {elapsed:.1f}s", + flush=True, + ) + rows.append(row) + + ok_rows = [r for r in rows if r.get("ok")] + ok_rows.sort(key=lambda r: (-float(r["total_pnl"]), -int(r["total_trades"]))) + + print("=" * 72, flush=True) + print("📊 A/B 결과 (PnL 내림차순)", flush=True) + for i, r in enumerate(ok_rows, 1): + print( + f" {i}. [{r['label']}] ratchet={r['ratchet_tiers']!r} | " + f"pnl={r['total_pnl']:,.0f} | trades={r['total_trades']} | wr={r['win_rate']:.1f}%", + flush=True, + ) + if len(ok_rows) >= 2: + best, worst = ok_rows[0], ok_rows[-1] + print( + f"Δ best−worst = {best['total_pnl'] - worst['total_pnl']:+,.0f}원 " + f"({best['label']} vs {worst['label']})", + flush=True, + ) + off = next((r for r in ok_rows if r["label"] == "OFF"), None) + live = next((r for r in ok_rows if r["label"] == "LIVE"), None) + if off and live: + print( + f"Δ OFF−LIVE = {off['total_pnl'] - live['total_pnl']:+,.0f}원 " + f"(OFF {off['total_pnl']:,.0f} / LIVE {live['total_pnl']:,.0f})", + flush=True, + ) + print("=" * 72, flush=True) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir = os.path.join(ROOT, "kis_trader/backtest/results") + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"momentum_ratchet_ab_{ts}.json") + out = { + "kind": "momentum_ratchet_ab", + "apply": False, + "base_json": BASE_JSON, + "base_pnl_reported": src["results"][0].get("total_pnl"), + "base_params": base_combo, + "start": start, + "end": end, + "slot_money": int(ctx.slot_money), + "max_stocks": int(ctx.max_stocks), + "total_budget_krw": int(ctx.total_budget_krw), + "cases": rows, + "ranked": ok_rows, + "elapsed_sec": round(time.time() - t0, 1), + } + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="momentum", mode="ratchet_ab", note="래칫 A/B 최종 JSON", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_candle_upsert_rollup.py b/scripts/smoke_candle_upsert_rollup.py new file mode 100644 index 0000000..5ae98a1 --- /dev/null +++ b/scripts/smoke_candle_upsert_rollup.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +스모크: 1M→N분 완전버킷 롤업 + confirm/merge volume upsert. + +근본원인(2026-07-16 샘표): 불완전 롤업 삽입 + 동일 candle_time append 중복 +→ RAM prior volume 왜곡 → 실매 vol 통과 / 백테 탈락. + +실행: + python3 -u scripts/smoke_candle_upsert_rollup.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from kis_trader.engine.candle_rollup import floor_candle_time_to_tf, rollup_1m_bars_to_tf +from kis_trader.ws.kis_ws import CandleAggregator + + +def bar(ct, o, h, l, c, v, src="ws"): + return { + "candle_time": ct, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + "source": src, + } + + +def main() -> None: + assert floor_candle_time_to_tf("202607160912", 3) == "202607160912" + assert floor_candle_time_to_tf("202607160913", 3) == "202607160912" + assert floor_candle_time_to_tf("202607160914", 3) == "202607160912" + + partial = [ + bar("202607160912", 100, 101, 99, 100, 100), + bar("202607160913", 100, 102, 99, 101, 200), + ] + assert rollup_1m_bars_to_tf(partial, 3) == [] + + full = partial + [bar("202607160914", 101, 110, 100, 105, 4226)] + rolled = rollup_1m_bars_to_tf(full, 3) + assert len(rolled) == 1 + assert rolled[0]["candle_time"] == "202607160912" + assert rolled[0]["volume"] == 100 + 200 + 4226 + + more = full + [ + bar("202607160915", 105, 106, 104, 105, 50), + bar("202607160916", 105, 107, 104, 106, 60), + ] + assert len(rollup_1m_bars_to_tf(more, 3)) == 1 + + agg = CandleAggregator(db=None, timeframes=[1, 3]) + code = "007540" + assert agg.merge_confirmed_bars( + code, 3, + [bar("202607160912", 43000, 44000, 42000, 43500, 515, "rollup_1m")], + log_tag="smoke_partial", + ) == 1 + assert agg.merge_confirmed_bars( + code, 3, + [bar("202607160912", 43000, 44500, 42000, 43800, 4526, "rest")], + log_tag="smoke_full", + ) == 1 + buf = agg._confirmed[(code, 3)] + assert len(buf) == 1 and buf[0]["volume"] == 4526 + assert agg.merge_confirmed_bars( + code, 3, + [bar("202607160912", 43000, 44000, 42000, 43700, 100, "ws")], + log_tag="smoke_small", + ) == 0 + assert buf[0]["volume"] == 4526 + + agg2 = CandleAggregator(db=None, timeframes=[3]) + key = (code, 3) + agg2.merge_confirmed_bars( + code, 3, + [bar("202607160912", 43000, 44000, 42000, 43500, 515, "rollup_1m")], + ) + with agg2._lock: + confirmed = agg2._confirm_current_bucket(key, { + "candle_time": "202607160912", + "open": 43000, + "high": 44200, + "low": 42000, + "close": 43600, + "volume": 800, + "source": "ws", + }) + assert len(agg2._confirmed[key]) == 1 + assert confirmed["volume"] == 800 + with agg2._lock: + agg2._confirm_current_bucket(key, { + "candle_time": "202607160912", + "open": 43000, + "high": 44100, + "low": 42000, + "close": 43400, + "volume": 100, + "source": "ws", + }) + assert agg2._confirmed[key][0]["volume"] == 800 + + print("SMOKE_OK candle_upsert_rollup") + + +if __name__ == "__main__": + main() diff --git a/scripts/tail_live_bt_forensics.py b/scripts/tail_live_bt_forensics.py new file mode 100644 index 0000000..6174f4f --- /dev/null +++ b/scripts/tail_live_bt_forensics.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +꼬리 실매 vs 백테 건별 forensics (C). + +실매 trade_history(SHORT) 각 건에 대해: + 유니버스 IN/OUT · 재편입 · 당일봉 entry_i · 웜업 후 align 신호 · 백테 체결 여부 + +사용: + python3 -u scripts/tail_live_bt_forensics.py --date 2026-07-16 + nohup python3 -u scripts/tail_live_bt_forensics.py --date 2026-07-16 \ + > logs/tail_live_bt_forensics_20260716.log 2>&1 & +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--date", default="2026-07-16", help="YYYY-MM-DD") + args = ap.parse_args() + day = args.date.replace("-", "") + day_dash = f"{day[:4]}-{day[4:6]}-{day[6:8]}" + + from database import TradeDB + from kis_trader.engine import tail_engine as te + from kis_trader.engine.tail_engine import ( + _eval_live_align_lookback, + _last_closed_bar_index, + _universe_enter_minutes, + ) + from kis_trader.backtest import tail_backtest_common as tbc + from kis_trader.backtest.universe_timeline import build_universe_timeline + + db = TradeDB() + live = db.conn.execute( + "SELECT code, name, buy_date, buy_price, sell_date, realized_pnl " + "FROM trade_history WHERE strategy=%s AND buy_date LIKE %s " + "ORDER BY buy_date", + ("SHORT", f"{day_dash}%"), + ).fetchall() + print(f"=== 꼬리 forensics {day_dash} live={len(live)} ===") + + base = te.get_tail_defaults_from_db(db) + universe, src, n_slots, _ = tbc.resolve_tail_universe( + day, day, use_saved_history=True, strategy_id="SHORT", + ) + tl = build_universe_timeline( + strategy_id="SHORT", start_ymd=day, end_ymd=day, + debounce_sec=0, strict=False, + ) + start_key, end_key = day + "0000", day + "2359" + candles_by_code, _, _ = tbc.load_tail_candles_by_code( + db, start_key, end_key, int(base.get("timeframe") or 3), + rsi_period=int(base.get("rsi_period") or 14), + ) + # REST 웜업 (유니버스 교집합) + tbc.inject_tail_rest_warmup_memory( + candles_by_code, start_key, + timeframe=int(base.get("timeframe") or 3), + universe_by_slot=universe, + ) + + port = tbc.resolve_tail_portfolio_params( + dict(db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() or {}), + base, + ) + row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() + fee, tax, _ = tbc.fee_and_slot_from_env_row(dict(row) if row else None) + params = dict(base) + tbc.merge_tail_portfolio_into_params(params, port) + meta = {"db": db, "start_key": start_key, "end_key": end_key} + bt_trades = tbc.run_tail_backtest_web_aligned( + candles_by_code, params, universe, + slot_money=float(port["slot_money"]), + fee_rate=fee, sell_tax=tax, + total_budget_krw=float(port["total_budget_krw"]), + meta_out=meta, + ) + bt_by_code = {} + for t in bt_trades: + bt_by_code.setdefault(str(t.get("code")), []).append(t) + + enter_mins = _universe_enter_minutes(universe, tl, None) + print(f"universe src={src} slots={n_slots} enter_minutes={len(enter_mins)}") + print(f"warmup bars target={tbc.tail_backtest_candle_warmup_bars()} " + f"rest={meta.get('skip_stats', {}).get('rest_warmup')}") + print(f"BT trades={len(bt_trades)} pnl={sum(int(t.get('pnl') or 0) for t in bt_trades)}") + print() + + for r in live: + code = str(r["code"]) + buy_ts = str(r["buy_date"]) + buy_hm = buy_ts[11:16].replace(":", "") + t12 = day + buy_hm + name = r.get("name") or code + print(f"── {code} {name} live {buy_ts} @{int(r['buy_price'])} pnl={r['realized_pnl']}") + + # transitions that day + prev = False + trans = [] + for et_row in db.conn.execute( + """SELECT event_time, MAX(code=%s) has_me + FROM target_candidates_history + WHERE strategy_id=%s AND event_time LIKE %s + GROUP BY event_time ORDER BY event_time""", + (code, "SHORT", f"{day_dash}%"), + ).fetchall(): + has = bool(et_row["has_me"]) + if has != prev: + trans.append((str(et_row["event_time"]), "IN" if has else "OUT")) + prev = has + print(f" transitions: {trans[:8]}{'...' if len(trans) > 8 else ''}") + + in_at_buy = False + if tl is not None: + codes = tl.codes_at(t12 + "00") or [] + in_at_buy = code in codes + print(f" universe@buy {t12}: {'IN' if in_at_buy else 'OUT'}") + + bars = candles_by_code.get(code) or [] + n_prev = sum(1 for c in bars if str(c.get("candle_time") or "")[:8] < day) + ei = _last_closed_bar_index(bars, t12, int(base.get("timeframe") or 3)) + print(f" candles n={len(bars)} prev_day={n_prev} entry_i@buy={ei}", + f"bar={bars[ei]['candle_time'] if ei >= 0 else None}") + + st = {"daily_cnt": 0, "last_exit_dt": None, "daily_pnl_krw": 0.0} + if ei >= 19: + rej, msg, sig = _eval_live_align_lookback( + bars, ei, params, st, + lookback=max(1, int(params.get("live_signal_lookback_bars") or 1)), + ) + print(f" align: reject={rej} msg={(msg or '')[:70]} " + f"sig={bool(sig)} px={sig.get('entry_price') if sig else None}") + else: + print(f" align: SKIP entry_i={ei} < 19 (웜업 부족)") + + hits = bt_by_code.get(code) or [] + if hits: + for h in hits: + print(f" BT hit: {h.get('entry_time')} @{h.get('entry')} " + f"→ {h.get('exit_time')} pnl={h.get('pnl')}") + else: + print(" BT hit: NONE") + print() + + db.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tail_symbol_gate_verify_20260709.py b/scripts/tail_symbol_gate_verify_20260709.py new file mode 100644 index 0000000..ec52d20 --- /dev/null +++ b/scripts/tail_symbol_gate_verify_20260709.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""7/9 SHORT 종목일일손익게이트 백테 검증 — Case A(edge=2000) vs B(edge=0).""" +from __future__ import annotations + +import sys +import traceback +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from database import TradeDB +from kis_trader.engine import tail_engine as te +from kis_trader.backtest import tail_backtest_common as tbc +from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay + + +def _summarize(trades, label: str) -> dict: + total = len(trades) + wins = [t for t in trades if float(t.get("pnl") or 0) > 0] + pnl = sum(float(t.get("pnl") or 0) for t in trades) + by_code: dict = defaultdict(list) + for t in trades: + by_code[t.get("code")].append(t) + multi = sum(1 for v in by_code.values() if len(v) > 1) + wr = (len(wins) / total * 100) if total else 0.0 + print( + f" trades={total} wins={len(wins)} WR={wr:.1f}% " + f"pnl={pnl:+,.0f} multi_codes={multi}", + flush=True, + ) + for code, ts in sorted(by_code.items()): + cum = 0.0 + parts = [] + for t in ts: + cum += float(t.get("pnl") or 0) + hm = str(t.get("entry_time") or t.get("candle_time") or "")[8:12] + parts.append( + f"{hm}:{t.get('pnl', 0):+.0f}({t.get('exit_reason', '')})→cum{cum:+.0f}" + ) + if len(ts) > 1: + print(f" [{code}] " + " | ".join(parts), flush=True) + return {"label": label, "trades": total, "pnl": pnl, "multi": multi} + + +def main() -> int: + print("=== 7/9 SHORT 게이트 백테 검증 (skip_hts=DB) ===", flush=True) + db = TradeDB() + start, end = "2026-07-09", "2026-07-09" + start_key = start.replace("-", "") + "0000" + end_key = end.replace("-", "") + "2359" + start_ymd, end_ymd = start_key[:8], end_key[:8] + + try: + base = te.get_tail_defaults_from_db(db) + skip_hts = bool(base.get("skip_hts_scan_dupes")) + print(f"skip_hts_scan_dupes(DB)={skip_hts}", flush=True) + + universe, src, n_slots, _ = tbc.resolve_tail_universe( + start_ymd, end_ymd, use_saved_history=True, strategy_id="SHORT", + ) + tf = int(base.get("timeframe") or 3) + rsi = int(base.get("rsi_period") or 14) + candles_by_code, _, _ = tbc.load_tail_candles_by_code( + db, start_key, end_key, tf, rsi_period=rsi, + ) + codes = len(candles_by_code) + bars = sum(len(v) for v in candles_by_code.values()) + slot = float(base.get("slot_money") or 300000) + budget = float(base.get("total_budget_krw") or slot * int(base.get("max_stocks") or 4)) + print( + f"[로드] universe={src} slots={n_slots} codes={codes} bars={bars} " + f"slot={slot} budget={budget}", + flush=True, + ) + + fee = float(base.get("fee_rate") or 0.00015) + tax = float(base.get("sell_tax") or 0.0023) + meta = {"db": db, "start_key": start_key, "end_key": end_key} + use_tick = tail_backtest_wants_tick_replay(base) + print(f"[로드] tick_replay={use_tick}", flush=True) + + cases = [ + ("A GATE_ON edge=2000", 30000.0, 1.5, 2000.0), + ("B GATE_ON edge=0", 30000.0, 1.5, 0.0), + ] + results = [] + for label, krw, pct, edge in cases: + params = dict(base) + params["symbol_daily_loss_limit_krw"] = krw + params["symbol_daily_loss_limit_pct"] = pct + params["reentry_min_edge_krw"] = edge + params["skip_hts_scan_dupes"] = skip_hts + print( + f"\n--- {label}: krw={krw} pct={pct} edge={edge} ---", + flush=True, + ) + trades = tbc.run_tail_backtest_web_aligned( + candles_by_code, + params, + universe, + slot_money=slot, + fee_rate=fee, + sell_tax=tax, + total_budget_krw=budget, + meta_out=meta, + ) + results.append(_summarize(trades, label)) + + print("\n======== SUMMARY ========", flush=True) + for r in results: + print(f" {r['label']}: trades={r['trades']} pnl={r['pnl']:+,.0f} multi={r['multi']}", flush=True) + print("\n[LIVE 7/9] trades=3(신규2) pnl=-7,053 (376980 전일포지션 포함)", flush=True) + print("✅ VERIFY DONE", flush=True) + return 0 + except Exception: + traceback.print_exc() + return 1 + finally: + db.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/css/backtest.css b/static/css/backtest.css index b3721c3..a1cba56 100644 --- a/static/css/backtest.css +++ b/static/css/backtest.css @@ -89,13 +89,91 @@ .text-pnl-neg { color: var(--red) !important; } /* 파라미터 설명 툴팁 */ .param-help { font-size: 10px; color: var(--muted); margin-top: 2px; } - /* 운영 설정 탭 */ + /* 운영 설정 탭 — 최대 2열, 위에서 아래로 읽기 */ .lc-field-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 20px; + max-width: 920px; } - .lc-field-label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 2px; } - .lc-field-hint { font-size: 10px; color: var(--muted); margin-top: 2px; } + @media (max-width: 700px) { + .lc-field-grid { grid-template-columns: 1fr; } + } + .lc-field { + min-width: 0; + } + .lc-field input.form-control { + max-width: 100%; + } + .lc-field-label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 2px; line-height: 1.35; } + .lc-field-hint { font-size: 10px; color: var(--muted); margin-top: 2px; line-height: 1.35; } .lc-field-tbl { font-size: 10px; color: #58a6ff; margin-left: 4px; } .lc-field-key { font-size: 10px; color: #8b949e; margin-left: 4px; font-weight: normal; } + /* 키/테이블 태그: 평소엔 숨기고 호버 시만 — 라벨·입력에 시선 집중 */ + .lc-field-key, + .lc-field-tbl { + opacity: 0.35; + transition: opacity 0.12s ease; + } + .lc-field:hover .lc-field-key, + .lc-field:hover .lc-field-tbl, + .lc-field:focus-within .lc-field-key, + .lc-field:focus-within .lc-field-tbl { + opacity: 1; + } + + /* 카테고리 안 전략·주제 구역 */ + .lc-strat-stack { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 960px; + } + .lc-strat-block { + border: 1px solid var(--border); + border-left-width: 4px; + border-radius: 8px; + padding: 10px 12px 12px; + background: rgba(22, 27, 34, 0.55); + } + .lc-strat-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 8px; + padding-bottom: 5px; + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 650; + letter-spacing: 0.02em; + color: var(--text); + } + .lc-strat-head .lc-strat-sid { + font-size: 11px; + font-weight: 500; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .lc-topic-head { + font-size: 12px; + font-weight: 600; + color: var(--muted); + margin: 4px 0 8px; + padding-left: 2px; + } + .lc-strat-common { border-left-color: #6e7681; } + .lc-strat-short { border-left-color: #39d2c0; } + .lc-strat-momentum { border-left-color: #d29922; } + .lc-strat-breakout { border-left-color: #3fb950; } + .lc-strat-updow { border-left-color: #58a6ff; } + .lc-strat-scalp { border-left-color: #8b949e; } + .lc-strat-range_break { border-left-color: #79c0ff; } + .lc-strat-dbband { border-left-color: #f0883e; } + .lc-strat-other { border-left-color: #bc8cff; } + .lc-strat-topic { border-left-color: #6e7681; } + .lc-strat-short .lc-strat-head { color: #39d2c0; } + .lc-strat-momentum .lc-strat-head { color: #d29922; } + .lc-strat-breakout .lc-strat-head { color: #3fb950; } + .lc-strat-updow .lc-strat-head { color: #58a6ff; } + .lc-strat-scalp .lc-strat-head { color: #c9d1d9; } + .lc-strat-common .lc-strat-head { color: #8b949e; } diff --git a/static/js/backtest.js b/static/js/backtest.js index 6da513e..5496ee0 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -2,6 +2,8 @@ // 유틸 // ──────────────────────────────────────────── const $ = id => document.getElementById(id); +/** 서버/로컬 휴장일 YYYY-MM-DD 집합 — dashInitDate 등 조기 호출보다 먼저 선언(TDZ 방지) */ +let _krHolidays = new Set(); const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR'); /** 원화 가격·평가금 — 소수점 없이 정수 (KIS 주식 호가 단위) */ const fmtWon = n => { @@ -19,6 +21,27 @@ const fmtPct = n => { const v = Number(n); return (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; }; +/** 틱 분봉커버 문구 — 구독구간(주) + 전체(참고). REST 웜업 봉 때문에 전체가 낮아 보임. */ +function fmtTickCoverageLabel(tickMeta) { + const m = tickMeta || {}; + const sub = m.tick_bar_coverage_pct_subscribed != null + ? m.tick_bar_coverage_pct_subscribed + : m.tick_bar_coverage_pct; + const all = m.tick_bar_coverage_pct_all; + const friend = m.tick_bar_coverage_pct_friend; + const codes = (m.tick_codes_with_data != null && m.tick_codes_total != null) + ? `${m.tick_codes_with_data}/${m.tick_codes_total}종` + : ''; + let s = `구독구간 ${sub != null ? sub : '—'}%`; + if (m.tick_bar_coverage_pct_traded != null) { + const tn = m.tick_codes_traded != null ? `${m.tick_codes_traded}종` : ''; + s += ` · 거래종목 ${m.tick_bar_coverage_pct_traded}%` + (tn ? `(${tn})` : ''); + } + if (all != null) s += ` · 전체 ${all}%`; + if (friend != null && friend !== all) s += ` · 틱종목 ${friend}%`; + if (codes) s += ` (${codes})`; + return s; +} /** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (API가 최신순이어도 프론트에서 재정렬) */ function tradeExitSortKey(t) { const keys = ['exit_time', 'sell_time', 'sell_date', 'buy_time', 'entry_time', 'buy_date']; @@ -52,7 +75,8 @@ function fmtTradeTime(raw) { + d.slice(8, 10) + ':' + d.slice(10, 12) + ':' + d.slice(12, 14); } if (d.length >= 12) { - return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' ' + d.slice(8, 10) + ':' + d.slice(10, 12); + return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' ' + + d.slice(8, 10) + ':' + d.slice(10, 12) + ':00'; } if (d.length >= 8) return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8); return s; @@ -105,8 +129,11 @@ function normalizeVirtualTrade(t, meta) { function sortVirtualTradeRows(rows, mode) { const list = [...rows]; const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey)); + const cmpBuy = (a, b) => String(a.buyKey).localeCompare(String(b.buyKey)); switch (mode) { case 'exit_asc': return list.sort(cmpExit); + case 'buy_desc': return list.sort((a, b) => cmpBuy(b, a)); + case 'buy_asc': return list.sort(cmpBuy); case 'pnl_desc': return list.sort((a, b) => b.pnl - a.pnl); case 'pnl_asc': return list.sort((a, b) => a.pnl - b.pnl); case 'rate_desc': return list.sort((a, b) => b.rateNum - a.rateNum); @@ -116,15 +143,55 @@ function sortVirtualTradeRows(rows, mode) { } } +/** 실매·백테 거래내역 공통 컨텍스트: 한도 · 장중 누적 최고 · 최종 누적 */ +function fillTradePnLContext(elOrId, opts) { + opts = opts || {}; + const el = typeof elOrId === 'string' ? $(elOrId) : elOrId; + if (!el) return; + const tb = Number(opts.totalBudget || 0); + const peak = Number(opts.peakCum || 0); + const peakAt = opts.peakAt ? fmtTradeTime(opts.peakAt) : ''; + const label = opts.label || '거래내역'; + let line = `📋 ${label} · 한도 ${tb > 0 ? fmtWon(tb) : '—'}원`; + if (peak > 0 && peakAt) { + line += ` | 장중 누적 최고 +${fmtWon(peak)}원 (${peakAt})`; + } + if (opts.totalPnl != null && Number.isFinite(Number(opts.totalPnl))) { + line += ` | 최종 누적 ${fmtKrw(opts.totalPnl)}`; + } + el.innerHTML = line; + el.style.display = 'block'; +} + function _syncTradeSortButtons(tbodyId, mode) { document.querySelectorAll(`.trade-sort-bar[data-tbody="${tbodyId}"] .trade-sort-btn`).forEach(btn => { btn.classList.toggle('active', btn.dataset.sort === mode); }); } +function attachCumPnlClient(trades, totalBudget) { + const out = (trades || []).map(t => Object.assign({}, t)); + if (!out.length) return out; + if (out.every(t => Number.isFinite(Number(t.cum_pnl)))) return out; + const ordered = [...out].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); + let cum = 0; + const tb = Number(totalBudget || 0); + for (const t of ordered) { + if (t.is_open) continue; + cum += Number(t.pnl ?? t.realized_pnl ?? 0); + t.cum_pnl = Math.round(cum); + t.cum_return_pct = tb > 0 ? Math.round((cum / tb) * 10000) / 100 : 0; + } + return out; +} + function renderVirtualTrades(tbodyId, trades, opts) { opts = opts || {}; - _virtualTradesCache[tbodyId] = trades || []; + let src = trades || []; + if (opts.showCumulative) { + src = attachCumPnlClient(src, opts.totalBudget); + } + _virtualTradesCache[tbodyId] = src; _virtualTradesOpts[tbodyId] = opts; if (!_virtualTradesSort[tbodyId]) _virtualTradesSort[tbodyId] = 'exit_desc'; const mode = _virtualTradesSort[tbodyId]; @@ -439,11 +506,9 @@ function dashInitDate() { const el = $('dash_date'); if (!el) return; if (!el.value) { - const t = new Date(); - const y = t.getFullYear(); - const m = String(t.getMonth() + 1).padStart(2, '0'); - const d = String(t.getDate()).padStart(2, '0'); - el.value = y + '-' + m + '-' + d; + el.value = kstTradingDayIso(_krHolidays); + } else { + el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays); } } async function loadDashboard() { @@ -877,45 +942,107 @@ function kstYesterdayIso() { function kstDaysAgoIso(n) { return fmtKstDate(new Date(Date.now() - n * 24 * 3600 * 1000)); } -/** 실거래 조회 기본 거래일 — 장 시작(09:00 KST) 전이면 전날, 그 외엔 당일 */ -function kstTradingDayIso() { - return kstHourNow() < 9 ? kstYesterdayIso() : kstTodayParts().iso; +function setKrHolidays(list) { + _krHolidays = new Set((list || []).map(String).filter(Boolean)); +} +/** iso 하루 전 (캘린더) */ +function kstAddDaysIso(iso, deltaDays) { + const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10)); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + deltaDays); + const yy = dt.getUTCFullYear(); + const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(dt.getUTCDate()).padStart(2, '0'); + return `${yy}-${mm}-${dd}`; +} +function kstWeekdayMon0(iso) { + const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10)); + // UTC 정오로 파싱해 요일 드리프트 방지 + return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)).getUTCDay(); // 0=일 … 6=토 +} +function kstIsTradingDayIso(iso, holidays) { + if (!iso) return false; + const wd = kstWeekdayMon0(iso); + if (wd === 0 || wd === 6) return false; + const hol = holidays || _krHolidays; + return !(hol && hol.has(iso)); +} +/** 주말·휴장이면 이전 장운영일(YYYY-MM-DD) */ +function kstClampToPrevTradingDayIso(iso, holidays, maxBack) { + let cur = String(iso || '').slice(0, 10); + if (!cur) return cur; + const lim = Math.max(1, maxBack || 14); + for (let i = 0; i <= lim; i++) { + if (kstIsTradingDayIso(cur, holidays)) return cur; + cur = kstAddDaysIso(cur, -1); + } + return cur; +} +/** 실거래 조회 기준일 — 장 시작 전이면 전날, 이후 주말/휴장 보정 */ +function kstTradingDayIso(holidays) { + const base = kstHourNow() < 9 ? kstYesterdayIso() : kstTodayParts().iso; + return kstClampToPrevTradingDayIso(base, holidays); } function setDateVal(id, val) { const el = $(id); if (el && val) el.value = val; } -function initDefaultDates() { - const todayKst = kstTodayParts().iso; - // 실거래 분석: 시작·종료 모두 당일(장 시작 전이면 전날) — 단일 거래일 기본 조회. - // 과거를 보려면 사용자가 시작일만 앞으로 당기면 됨(달 변경 불필요). - const tradingDayKst = kstTradingDayIso(); +/** 날짜 인풋: 주말/휴장 선택 시 이전 장운영일로 스냅 */ +function bindKrTradingDayDateInputs() { + document.querySelectorAll('input[type="date"]').forEach(el => { + if (el.dataset.krTradingBound === '1') return; + el.dataset.krTradingBound = '1'; + el.addEventListener('change', function() { + if (!this.value) return; + const c = kstClampToPrevTradingDayIso(this.value, _krHolidays); + if (c && c !== this.value) this.value = c; + }); + }); +} +function initDefaultDates(datesPayload) { + const hol = (datesPayload && datesPayload.holidays) ? datesPayload.holidays : [..._krHolidays]; + setKrHolidays(hol); + const endKst = (datesPayload && datesPayload.end) + ? datesPayload.end + : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); + const startKst = (datesPayload && datesPayload.start) + ? datesPayload.start + : kstClampToPrevTradingDayIso(kstAddDaysIso(endKst, -7), _krHolidays); + // 실거래 분석: 단일 거래일 기본 조회 (장전·주말·휴장 → 이전 장운영일) + const tradingDayKst = (datesPayload && datesPayload.trading_day) + ? datesPayload.trading_day + : kstTradingDayIso(_krHolidays); setDateVal('act_start', tradingDayKst); setDateVal('act_end', tradingDayKst); - // 백테 탭: 시작일 일주일 전 ~ 오늘 (KST) - const weekAgoKst = kstDaysAgoIso(7); - setDateVal('bt_start', weekAgoKst); - setDateVal('bt_end', todayKst); - setDateVal('tl_start', weekAgoKst); - setDateVal('tl_end', todayKst); - setDateVal('db_start', weekAgoKst); - setDateVal('db_end', todayKst); - setDateVal('db_fetch_start', weekAgoKst); - setDateVal('bo_start', weekAgoKst); - setDateVal('bo_end', todayKst); - setDateVal('rb_start', weekAgoKst); - setDateVal('rb_end', todayKst); - setDateVal('mom_start', weekAgoKst); - setDateVal('mom_end', todayKst); - setDateVal('hd_start', todayKst); - setDateVal('hd_end', todayKst); - setDateVal('hd_fetch_start', todayKst); + setDateVal('dash_date', tradingDayKst); + setDateVal('lc_date', tradingDayKst); + // 백테 탭: 시작 ~ 종료 (둘 다 거래일) + setDateVal('bt_start', startKst); + setDateVal('bt_end', endKst); + setDateVal('tl_start', startKst); + setDateVal('tl_end', endKst); + setDateVal('db_start', startKst); + setDateVal('db_end', endKst); + setDateVal('db_fetch_start', startKst); + setDateVal('bo_start', startKst); + setDateVal('bo_end', endKst); + setDateVal('rb_start', startKst); + setDateVal('rb_end', endKst); + setDateVal('mom_start', startKst); + setDateVal('mom_end', endKst); + setDateVal('hd_start', tradingDayKst); + setDateVal('hd_end', tradingDayKst); + setDateVal('hd_fetch_start', tradingDayKst); + setDateVal('ubx_start', startKst); + setDateVal('ubx_end', endKst); const lr = $('lastRefresh'); if (lr) lr.textContent = '업데이트: ' + new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); + bindKrTradingDayDateInputs(); } (function() { - initDefaultDates(); + // 주말만이라도 즉시 보정 → /api/env/params 의 dates 로 휴장일 재보정 + initDefaultDates(null); const hdFetch = $('hd_fetch_start'); if (hdFetch) { hdFetch.addEventListener('change', function() { @@ -928,6 +1055,7 @@ function initDefaultDates() { fetch('/api/env/params') .then(r => r.json()) .then(d => { + if (d && d.dates) initDefaultDates(d.dates); const set = (id, val) => { if (val !== null && val !== undefined) $(id).value = val; }; const setM = set; // 돌파 탭 필드 (setM 미정의 시 ReferenceError → catch로 HTML 기본값 21 고정됨) // 스캘핑 탭 @@ -955,6 +1083,18 @@ function initDefaultDates() { const v = String(s.use_macd_cross).trim().toLowerCase(); $('bt_use_macd').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on'); } + set('bt_rsi_period', s.rsi_period); + set('bt_vol_mult', s.vol_mult); + set('bt_time_start', s.time_start_hm); + set('bt_time_end', s.time_end_hm); + set('bt_max_daily', s.max_daily); + if (s.max_stocks != null) set('bt_max_stocks', s.max_stocks); + if (s.total_budget_krw != null) set('bt_total_budget', s.total_budget_krw); + if ($('bt_skip_hts_dupes')) $('bt_skip_hts_dupes').checked = s.skip_hts_scan_dupes !== false; + if ($('bt_require_reversal')) { + const rv = s.require_reversal_candle; + $('bt_require_reversal').checked = rv !== false && rv !== 0 && rv !== '0' && rv !== 'false'; + } // 꼬리잡기 탭 — config_short + env_config (실매·파라서치와 동일) fillTailFormFromApi(d.tail || {}); @@ -1004,12 +1144,14 @@ function initDefaultDates() { setM('bo_min_price', b.min_price); if (b.confirm_margin_pct != null) setM('bo_confirm_margin', b.confirm_margin_pct); if (b.body_min_pct != null) setM('bo_body_min', b.body_min_pct); + if ($('bo_ratchet')) $('bo_ratchet').value = (b.ratchet_tiers != null ? String(b.ratchet_tiers) : ''); setM('bo_max_loss', b.max_loss_krw); if (b.use_ema_filter !== undefined && $('bo_use_ema_f')) { $('bo_use_ema_f').checked = !!b.use_ema_filter; } if (b.ema_fast_period != null) setM('bo_ema_fast', b.ema_fast_period); if (b.ema_slow_period != null) setM('bo_ema_slow', b.ema_slow_period); + if ($('bo_skip_hts_dupes')) $('bo_skip_hts_dupes').checked = !!b.skip_hts_scan_dupes; if (b.ob_filter_enabled !== undefined && $('bo_ob_filter')) { $('bo_ob_filter').checked = !!b.ob_filter_enabled; } @@ -1023,6 +1165,9 @@ function initDefaultDates() { if (b.max_stocks != null) setM('bo_max_stocks', b.max_stocks); if (b.total_budget_krw != null) setM('bo_total_budget', b.total_budget_krw); // 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원 + if (b.daily_profit_enabled !== undefined && $('bo_daily_profit_enabled')) { + $('bo_daily_profit_enabled').checked = !!b.daily_profit_enabled; + } if ($('bo_daily_trail_tiers')) { $('bo_daily_trail_tiers').value = (b.daily_trail_tiers != null ? String(b.daily_trail_tiers) : ''); } @@ -1105,6 +1250,37 @@ function renderActual(d) { $('a_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('a_hold').textContent = s.avg_hold_min + '분'; + + // 운용한도 대비 수익률 — API summary에 없으면 params.total_budget_krw 로 계산 + const aSign = (v) => (v > 0 ? '+' : ''); + let botPct = s.bot_pct; + let dailyAvg = s.daily_avg_pct; + const tb = Number(p.total_budget_krw || 0); + if ((botPct == null || botPct === '') && tb > 0) { + botPct = Math.round((Number(s.total_pnl || 0) / tb) * 10000) / 100; + } + if ((dailyAvg == null || dailyAvg === '') && botPct != null) { + const startEl = $('act_start'); + const endEl = $('act_end'); + let days = 1; + try { + if (startEl && endEl && startEl.value && endEl.value) { + const t0 = new Date(startEl.value + 'T00:00:00'); + const t1 = new Date(endEl.value + 'T00:00:00'); + days = Math.max(1, Math.round((t1 - t0) / 86400000) + 1); + } + } catch (e) { days = 1; } + dailyAvg = Math.round((Number(botPct) / days) * 1000) / 1000; + } + if ($('a_bot_pct')) { + $('a_bot_pct').textContent = (botPct == null) ? '-' : (aSign(botPct) + botPct + '%'); + if (botPct != null) colorPnl($('a_bot_pct'), botPct); + } + if ($('a_daily_avg_pct')) { + $('a_daily_avg_pct').textContent = (dailyAvg == null) ? '-' : (aSign(dailyAvg) + dailyAvg + '%'); + if (dailyAvg != null) colorPnl($('a_daily_avg_pct'), dailyAvg); + } + const openCnt = Number((d.meta || {}).open_count || 0); const closedCnt = Number((d.meta || {}).closed_count || 0); const hint = $('act_table_hint'); @@ -1113,16 +1289,13 @@ function renderActual(d) { } const actCtx = $('act_trade_context'); if (actCtx) { - const tb = Number(p.total_budget_krw || 0); - const peak = Number(s.peak_cum_pnl || 0); - const peakAt = s.peak_cum_at || ''; - let line = `📋 실매 trade_history · 한도 ${tb > 0 ? fmtWon(tb) : '—'}원`; - if (peak > 0 && peakAt) { - line += ` | 장중 누적 최고 +${fmtWon(peak)}원 (${peakAt})`; - } - line += ` | 최종 누적 ${fmtKrw(s.total_pnl)}`; - actCtx.innerHTML = line; - actCtx.style.display = 'block'; + fillTradePnLContext(actCtx, { + label: '실매 trade_history', + totalBudget: Number(p.total_budget_krw || 0), + peakCum: Number(s.peak_cum_pnl || 0), + peakAt: s.peak_cum_at || '', + totalPnl: s.total_pnl, + }); } const note = $('act_filter_note'); if (note) { @@ -1139,6 +1312,14 @@ function renderActual(d) { + `누적손익 열은 매도 완료 순 합계입니다.`, ); } + const tickLive = d.tick_live || {}; + if (tickLive.tick_bar_coverage_pct != null || tickLive.tick_bar_coverage_pct_traded != null) { + const rows = tickLive.ws_tick_rows_loaded != null + ? Number(tickLive.ws_tick_rows_loaded).toLocaleString() : '—'; + parts.push( + `📊 실매 틱DB | 틱 ${rows}건 · 분봉커버 ${fmtTickCoverageLabel(tickLive)}`, + ); + } if (parts.length) { note.style.display = ''; note.innerHTML = parts.join('
'); @@ -1234,6 +1415,7 @@ function runBreakoutBacktest() { min_price: $('bo_min_price').value, confirm_margin_pct: $('bo_confirm_margin')?.value || '0', body_min_pct: $('bo_body_min')?.value || '0', + ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '', use_ema_filter: $('bo_use_ema_f')?.checked ? 1 : 0, ema_fast_period: $('bo_ema_fast')?.value || '9', ema_slow_period: $('bo_ema_slow')?.value || '21', @@ -1249,8 +1431,11 @@ function runBreakoutBacktest() { daily_trail_drop_pct: $('bo_daily_trail_drop')?.value || '0', daily_trail_arm_krw: $('bo_daily_trail_arm')?.value || '0', daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing', + daily_profit_enabled: $('bo_daily_profit_enabled')?.checked ? 1 : 0, eod_enabled: $('bo_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', + skip_hts_scan_dupes: $('bo_skip_hts_dupes')?.checked ? 1 : 0, + env_timeline: envTimelineParam('bo_env_timeline'), }; const qs = new URLSearchParams(params).toString(); showSpinner(true); @@ -1296,6 +1481,7 @@ function saveBreakoutConfig() { min_price: parseFloat($('bo_min_price').value), confirm_margin_pct: parseFloat($('bo_confirm_margin')?.value || '0'), body_min_pct: parseFloat($('bo_body_min')?.value || '0'), + ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '', use_ema_filter: !!($('bo_use_ema_f')?.checked), ema_fast_period: parseInt($('bo_ema_fast')?.value || '9', 10), ema_slow_period: parseInt($('bo_ema_slow')?.value || '21', 10), @@ -1307,8 +1493,10 @@ function saveBreakoutConfig() { pg_filter: !!($('bo_pg_filter')?.checked), daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing', + daily_profit_enabled: !!($('bo_daily_profit_enabled')?.checked), + skip_hts_scan_dupes: !!($('bo_skip_hts_dupes')?.checked), }; - if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.')) return; + if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF'))) return; fetch('/api/backtest/breakout/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), @@ -1358,11 +1546,11 @@ function renderBreakoutBacktest(d) { const tickMeta = s.tick_backtest || {}; const buySrc = s.backtest_buy_source || ''; if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) { - const cov = tickMeta.tick_bar_coverage_pct != null ? tickMeta.tick_bar_coverage_pct : '—'; + const covLabel = fmtTickCoverageLabel(tickMeta); const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—'; const srcLabel = buySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (buySrc === 'ohlc_fallback' ? 'OHLC high 폴백' : buySrc); const tickColor = buySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; - barHtml += `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${cov}%
`; + barHtml += `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning @@ -1373,8 +1561,13 @@ function renderBreakoutBacktest(d) { if (boPb) boPb.innerHTML = barHtml; const boCtx = $('bo_trade_context'); if (boCtx) { - boCtx.innerHTML = '📋 ' + summaryLine; - boCtx.style.display = 'block'; + fillTradePnLContext(boCtx, { + label: '백테 BREAKOUT', + totalBudget: Number(p.total_budget_krw || 0), + peakCum: Number(s.peak_cum_pnl || 0), + peakAt: s.peak_cum_at || '', + totalPnl: s.total_pnl, + }); } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; @@ -1437,7 +1630,11 @@ function renderBreakoutBacktest(d) { console.error('bo chart render', chartErr); } - renderVirtualTrades('bo_tbody', d.trades || []); + renderVirtualTrades('bo_tbody', d.trades || [], { + showDebug: true, + showCumulative: true, + totalBudget: Number(p.total_budget_krw || 0), + }); if (boRa) boRa.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -1482,6 +1679,7 @@ function runRangeBreakBacktest() { total_budget_krw: $('rb_total_budget')?.value || '0', use_high_chase_filter: $('rb_use_high_chase')?.checked ? 1 : 0, universe: $('rb_use_univ_history')?.checked ? 'history' : 'all', + env_timeline: envTimelineParam('rb_env_timeline'), }; showSpinner(true); fetch('/api/backtest/range_break?' + new URLSearchParams(params).toString()) @@ -1554,8 +1752,22 @@ function renderRangeBreakBacktest(d) { setTxt('rb_pnl', fmtKrw(s.total_pnl)); colorPnl($('rb_pnl'), s.total_pnl); setTxt('rb_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); + colorPnl($('rb_pf'), (s.profit_factor || 0) - 1); setTxt('rb_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('rb_hold', (s.avg_hold_min || 0) + '분'); + const rbSign = (v) => (v > 0 ? '+' : ''); + setTxt('rb_bot_pct', rbSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); + colorPnl($('rb_bot_pct'), s.bot_pct || 0); + setTxt('rb_daily_avg_pct', rbSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); + colorPnl($('rb_daily_avg_pct'), s.daily_avg_pct || 0); + if ((s.total_trades || 0) > 0 && $('rb_winbar_card')) { + $('rb_winbar_card').style.display = ''; + const wr = s.win_rate || 0; + setTxt('rb_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); + setTxt('rb_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); + if ($('rb_ratio_g')) $('rb_ratio_g').style.width = wr + '%'; + if ($('rb_ratio_r')) $('rb_ratio_r').style.width = (100 - wr) + '%'; + } try { if (d.equity && d.equity.length) { lineChart('rb_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익', '#58a6ff'); @@ -1567,7 +1779,30 @@ function renderRangeBreakBacktest(d) { barChart('rb_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else destroyChart('rb_daily_chart'); } catch (e) { console.error('rb chart', e); } - renderVirtualTrades('rb_tbody', d.trades || []); + renderVirtualTrades('rb_tbody', d.trades || [], { + showCumulative: true, + totalBudget: Number(p.total_budget_krw || s.total_budget_krw || 0), + }); + let rbCtx = $('rb_trade_context'); + if (!rbCtx) { + const bar = document.querySelector('.trade-sort-bar[data-tbody="rb_tbody"]'); + if (bar) { + rbCtx = document.createElement('div'); + rbCtx.id = 'rb_trade_context'; + rbCtx.className = 'mb-2 p-2 rounded'; + rbCtx.style.cssText = 'font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none'; + bar.parentNode.insertBefore(rbCtx, bar); + } + } + if (rbCtx) { + fillTradePnLContext(rbCtx, { + label: '백테 RANGE_BREAK', + totalBudget: Number(p.total_budget_krw || s.total_budget_krw || 0), + peakCum: Number(s.peak_cum_pnl || 0), + peakAt: s.peak_cum_at || '', + totalPnl: s.total_pnl, + }); + } if (ra) ra.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -1658,8 +1893,11 @@ function runMomentumBacktest() { daily_trail_drop_pct: v('mom_daily_trail_drop') || '0', daily_trail_arm_krw: v('mom_daily_trail_arm') || '0', daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing', + daily_profit_enabled: $('mom_daily_profit_enabled')?.checked ? 1 : 0, eod_enabled: $('mom_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', + backtest_skip_pre_subscribe: $('mom_skip_pre_sub')?.checked ? 1 : 0, + env_timeline: envTimelineParam('mom_env_timeline'), }; const qs = new URLSearchParams(params).toString(); fetch('/api/backtest/momentum?' + qs) @@ -1727,10 +1965,12 @@ function saveMomentumConfig() { pg_filter: !!($('mom_pg_filter')?.checked), daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing', + daily_profit_enabled: !!($('mom_daily_profit_enabled')?.checked), eod_enabled: !!($('mom_eod_enabled')?.checked), eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', + backtest_skip_pre_subscribe: !!($('mom_skip_pre_sub')?.checked), }; - if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.')) return; + if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF'))) return; fetch('/api/backtest/momentum/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), @@ -1785,8 +2025,13 @@ function renderMomentumBacktest(d) { if (momPb) momPb.innerHTML = momSummaryLine; const momCtx = $('mom_trade_context'); if (momCtx) { - momCtx.innerHTML = '📋 ' + momSummaryLine; - momCtx.style.display = 'block'; + fillTradePnLContext(momCtx, { + label: '백테 MOMENTUM', + totalBudget: Number(p.total_budget_krw || 0), + peakCum: Number(s.peak_cum_pnl || 0), + peakAt: s.peak_cum_at || '', + totalPnl: s.total_pnl, + }); } setTxt('mom_total', (s.total_trades || 0) + '건'); @@ -1827,14 +2072,14 @@ function renderMomentumBacktest(d) { const tickMeta = s.tick_backtest || {}; const skipStats = s.skip_stats || {}; if (tickMeta.tick_bar_coverage_pct != null || skipStats.tick_exit_count != null) { - const cov = tickMeta.tick_bar_coverage_pct != null ? tickMeta.tick_bar_coverage_pct : '—'; + const covLabel = fmtTickCoverageLabel(tickMeta); const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—'; const te = skipStats.tick_entry_count != null ? skipStats.tick_entry_count : '—'; const tx = skipStats.tick_exit_count != null ? skipStats.tick_exit_count : '—'; const oe = skipStats.ohlc_entry_count != null ? skipStats.ohlc_entry_count : '—'; const ox = skipStats.ohlc_exit_count != null ? skipStats.ohlc_exit_count : '—'; const tickLine = - `📊 ws_ticks ${rows}건 · 분봉커버 ${cov}% | ` + + `📊 ws_ticks ${rows}건 · 분봉커버 ${covLabel} | ` + `진입 틱${te}/시가${oe} · 청산 틱${tx}/OHLC${ox}`; if (momPb) momPb.innerHTML += `
${tickLine}
`; if (momCtx) momCtx.innerHTML += `
${tickLine}
`; @@ -1857,7 +2102,6 @@ function renderMomentumBacktest(d) { `💡 장중 누적 최고 +${peakCum.toLocaleString()}원 (${peakAt})` + ` · 최종 ${fmtKrw(s.total_pnl)}`; if (momPb) momPb.innerHTML += `
${peakLine}
`; - if (momCtx) momCtx.innerHTML += `
${peakLine}
`; } const winbar = $('mom_winbar_card'); @@ -1911,12 +2155,14 @@ function renderMomentumBacktest(d) { function saveScalpConfig() { const body = { save_kind: 'reversal', + rsi_period: parseInt($('bt_rsi_period')?.value || '3', 10), rsi_oversold: parseFloat($('bt_rsi_oversold').value), rsi_overbought: parseFloat($('bt_rsi_overbought')?.value || 75), sl_pct: parseFloat($('bt_sl').value), tp_pct: parseFloat($('bt_tp').value), tp_max_pct: parseFloat($('bt_tp_max').value), drop_rate: parseFloat($('bt_drop').value), + vol_mult: parseFloat($('bt_vol_mult')?.value || '0'), shoulder_min_high: parseFloat($('bt_smin').value), shoulder_cut_pct: parseFloat($('bt_scut').value), cooldown_min: parseFloat($('bt_cooldown').value), @@ -1927,9 +2173,18 @@ function saveScalpConfig() { min_margin: parseFloat($('bt_min_margin').value), use_defense_filters: !!($('bt_use_defense')?.checked), use_macd_cross: !!($('bt_use_macd')?.checked), + time_start_hm: parseInt($('bt_time_start')?.value || '830', 10), + time_end_hm: parseInt($('bt_time_end')?.value || '1530', 10), + max_daily: parseInt($('bt_max_daily')?.value || '3', 10), + skip_hts_scan_dupes: !!($('bt_skip_hts_dupes')?.checked), + require_reversal_candle: !!($('bt_require_reversal')?.checked), + slot_money: parseFloat($('bt_slot')?.value || '0'), + max_stocks: parseInt($('bt_max_stocks')?.value || '3', 10), + total_budget_krw: parseFloat($('bt_total_budget')?.value || '0'), }; if (!confirm(`💾 스캘핑 봇에 아래 파라미터를 저장합니까?\n\n` + - `RSI과매도: ${body.rsi_oversold} / RSI과열: ${body.rsi_overbought}\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n낙폭필터: ${body.drop_rate}%\n` + + `RSI기간: ${body.rsi_period} / 과매도: ${body.rsi_oversold} / 과열: ${body.rsi_overbought}\n` + + `손절: ${body.sl_pct}% / 익절: ${body.tp_pct}% / 낙폭: ${body.drop_rate}% / 거래량×: ${body.vol_mult}\n` + `\n⚠️ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return; fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -2001,6 +2256,9 @@ function fillTailFormFromApi(t) { } if (t.eod_hm != null) set('tl_eod_hm', t.eod_hm); set('tl_maxd', t.max_daily); + if (t.symbol_daily_loss_limit_krw != null) set('tl_symbol_loss_krw', t.symbol_daily_loss_limit_krw); + if (t.symbol_daily_loss_limit_pct != null) set('tl_symbol_loss_pct', t.symbol_daily_loss_limit_pct); + if (t.reentry_min_edge_krw != null) set('tl_reentry_min_edge', t.reentry_min_edge_krw); set('tl_rsi_period', t.rsi_period); set('tl_tail_pct', t.tail_pct_min); set('tl_max_rec_3m', t.max_rec_3m); @@ -2034,8 +2292,8 @@ function fillTailFormFromApi(t) { if (t.bar_chg_max_pct != null) set('tl_bar_chg_max', t.bar_chg_max_pct); 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 === true; - if ($('tl_tick_fallback_ohlc')) $('tl_tick_fallback_ohlc').checked = t.backtest_tick_fallback_ohlc !== false; + if ($('tl_use_tick_db')) $('tl_use_tick_db').checked = t.backtest_use_tick_db !== 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; if ($('tl_pat_engulfing')) $('tl_pat_engulfing').checked = !!t.pattern_engulfing; @@ -2053,6 +2311,9 @@ function fillTailFormFromApi(t) { $('tl_pg_filter').checked = !!t.pg_filter_enabled; } // 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값·방식 복원 + if (t.daily_profit_enabled !== undefined && $('tl_daily_profit_enabled')) { + $('tl_daily_profit_enabled').checked = !!t.daily_profit_enabled; + } if ($('tl_daily_trail_tiers')) { $('tl_daily_trail_tiers').value = (t.daily_trail_tiers != null ? String(t.daily_trail_tiers) : ''); } @@ -2172,6 +2433,9 @@ function fillMomentumFormFromApi(m) { if (m.eod_enabled !== undefined && $('mom_eod_enabled')) { $('mom_eod_enabled').checked = !!m.eod_enabled; } + if (m.backtest_skip_pre_subscribe !== undefined && $('mom_skip_pre_sub')) { + $('mom_skip_pre_sub').checked = !!m.backtest_skip_pre_subscribe; + } if (m.eod_hm != null) set('mom_eod_hm', m.eod_hm); set('mom_sl', m.sl_pct); set('mom_tp', m.tp_pct); @@ -2234,6 +2498,9 @@ function fillMomentumFormFromApi(m) { if (m.ema_fast_period != null) set('mom_ema_fast', m.ema_fast_period); if (m.ema_slow_period != null) set('mom_ema_slow', m.ema_slow_period); // 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원 + if (m.daily_profit_enabled !== undefined && $('mom_daily_profit_enabled')) { + $('mom_daily_profit_enabled').checked = !!m.daily_profit_enabled; + } if ($('mom_daily_trail_tiers')) { $('mom_daily_trail_tiers').value = (m.daily_trail_tiers != null ? String(m.daily_trail_tiers) : ''); } @@ -2499,6 +2766,9 @@ function saveTailConfig() { time_start: parseInt($('tl_ts').value, 10), time_end: parseInt($('tl_te').value, 10), max_daily: parseInt($('tl_maxd').value, 10), + symbol_daily_loss_limit_krw: parseFloat($('tl_symbol_loss_krw')?.value || '30000'), + symbol_daily_loss_limit_pct: parseFloat($('tl_symbol_loss_pct')?.value || '1.5'), + reentry_min_edge_krw: parseFloat($('tl_reentry_min_edge')?.value || '0'), min_price: parseFloat($('tl_min_price').value), max_daily_change: parseFloat($('tl_max_daily_change').value), ma20_max_above: parseFloat($('tl_ma20_above').value), @@ -2531,11 +2801,12 @@ function saveTailConfig() { ratchet_tiers: ($('tl_ratchet') && $('tl_ratchet').value.trim()) || '', daily_trail_tiers: ($('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing', + daily_profit_enabled: !!($('tl_daily_profit_enabled')?.checked), max_hold_bars: 0, 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_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.checked !== false, + backtest_tick_fallback_ohlc: !!$('tl_tick_fallback_ohlc')?.checked, pattern_hammer: $('tl_pat_hammer')?.checked !== false, pattern_pin: !!$('tl_pat_pin')?.checked, pattern_engulfing: !!$('tl_pat_engulfing')?.checked, @@ -2556,7 +2827,7 @@ function saveTailConfig() { `ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}배 | ` + `캡 SL ${body.atr_sl_min_pct}~${body.atr_sl_max_pct}% TP ${body.atr_tp_min_pct}~${body.atr_tp_max_pct}%\n` + `래칫: ${body.ratchet_tiers || '(OFF·단일어깨)'}\n` + - `당일 다단트레일: ${body.daily_trail_tiers || '(미설정)'}\n\n` + + `다단트레일: ${body.daily_profit_enabled ? 'ON' : 'OFF'} | 다단규칙: ${body.daily_trail_tiers || '(미설정)'}\n\n` + `저장: config_short (SHORT·tail_engine·tail_param_search 와 동일 키)`)) return; fetch('/api/backtest/tail/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -2584,6 +2855,11 @@ function formatUniverseLabel(p) { return '유니버스: 시뮬레이션(폴백)'; } +/** env 타임라인 쿼리값 — 체크박스 없으면 0(OFF 기본). */ +function envTimelineParam(checkboxId) { + return $(checkboxId)?.checked ? 1 : 0; +} + function runBacktest() { const params = { start: $('bt_start').value, @@ -2610,7 +2886,12 @@ function runBacktest() { min_margin: $('bt_min_margin').value, use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0, use_macd_cross: $('bt_use_macd')?.checked ? 1 : 0, + skip_hts_scan_dupes: $('bt_skip_hts_dupes')?.checked ? 1 : 0, + require_reversal_candle: $('bt_require_reversal')?.checked ? 1 : 0, + max_stocks: $('bt_max_stocks')?.value, + total_budget_krw: $('bt_total_budget')?.value, force_eod_exit: 0, // 실매 정렬 기본: 마지막봉 강제청산 OFF + env_timeline: envTimelineParam('bt_env_timeline'), }; params.universe = $('bt_use_univ_history')?.checked ? 'history' : 'sim'; const qs = new URLSearchParams(params).toString(); @@ -2640,10 +2921,27 @@ function renderBacktest(d) { `${p.time_window} | 일${p.max_daily}회 | ` + `종목수 ${p.codes_analyzed}개`; $('bt_params_bar').innerHTML = btSummaryLine; + const btTick = (d.tick_backtest || d.meta && d.meta.tick_backtest) || {}; + const btBuySrc = d.backtest_buy_source || (d.meta && d.meta.backtest_buy_source) || ''; + if (btTick.tick_bar_coverage_pct != null || btBuySrc) { + const covLabel = fmtTickCoverageLabel(btTick); + const rows = btTick.ws_tick_rows_loaded != null + ? Number(btTick.ws_tick_rows_loaded).toLocaleString() : '—'; + const srcLabel = btBuySrc === 'ws_ticks' ? '틱DB(ws_ticks)' + : (btBuySrc === 'ohlc_fallback' ? 'OHLC 폴백' : (btBuySrc || '—')); + const tickColor = btBuySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; + $('bt_params_bar').innerHTML += + `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; + } const ctx = $('bt_trade_context'); if (ctx) { - ctx.innerHTML = '📋 ' + btSummaryLine; - ctx.style.display = ''; + fillTradePnLContext(ctx, { + label: '백테 SCALP', + totalBudget: Number(p.total_budget_krw || 0), + peakCum: Number(s.peak_cum_pnl || 0), + peakAt: s.peak_cum_at || '', + totalPnl: s.total_pnl, + }); } $('b_total').textContent = fmt(s.total_trades) + '건'; @@ -2659,6 +2957,16 @@ function renderBacktest(d) { $('b_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('b_hold').textContent = s.avg_hold_min + '분'; + const btSign = (v) => (v > 0 ? '+' : ''); + if ($('b_bot_pct')) { + $('b_bot_pct').textContent = btSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; + colorPnl($('b_bot_pct'), s.bot_pct || 0); + } + if ($('b_daily_avg_pct')) { + $('b_daily_avg_pct').textContent = btSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'; + colorPnl($('b_daily_avg_pct'), s.daily_avg_pct || 0); + } + if (s.total_trades > 0) { $('bt_winbar_card').style.display = ''; const wr = s.win_rate; @@ -2679,7 +2987,11 @@ function renderBacktest(d) { d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); - renderVirtualTrades('bt_tbody', d.trades || [], { showRsi: true }); + renderVirtualTrades('bt_tbody', d.trades || [], { + showRsi: true, + showCumulative: true, + totalBudget: Number((d.params && d.params.total_budget_krw) || 0), + }); } // ──────────────────────────────────────────── @@ -2944,6 +3256,7 @@ function runDbBandBacktest() { slot_money: $('db_slot').value, use_trend_filter: $('db_use_trend').checked ? '1' : '0', exit_mode: ($('db_exit_mode') && $('db_exit_mode').value) || 'classic', + env_timeline: envTimelineParam('db_env_timeline'), }); showSpinner(true); fetch('/api/dbband/backtest?' + qs.toString()) @@ -3244,6 +3557,9 @@ function runTailBacktest() { time_start: $('tl_ts').value, time_end: $('tl_te').value, max_daily: $('tl_maxd').value, + symbol_daily_loss_limit_krw: $('tl_symbol_loss_krw')?.value || '30000', + symbol_daily_loss_limit_pct: $('tl_symbol_loss_pct')?.value || '1.5', + reentry_min_edge_krw: $('tl_reentry_min_edge')?.value || '0', slot_money: $('tl_slot').value, max_stocks: $('tl_max_stocks').value, total_budget_krw: $('tl_total_budget').value, @@ -3279,7 +3595,7 @@ function runTailBacktest() { 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_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.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', pattern_engulfing: $('tl_pat_engulfing')?.checked ? '1' : '0', @@ -3294,8 +3610,10 @@ function runTailBacktest() { daily_trail_drop_pct: $('tl_daily_trail_drop')?.value || '0', daily_trail_arm_krw: $('tl_daily_trail_arm')?.value || '0', daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing', + daily_profit_enabled: $('tl_daily_profit_enabled')?.checked ? 1 : 0, eod_enabled: $('tl_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', + env_timeline: envTimelineParam('tl_env_timeline'), }; const qs = new URLSearchParams(params).toString(); showSpinner(true); @@ -3324,8 +3642,13 @@ function renderTailBacktest(d) { $('tl_params_bar').innerHTML = tlSummaryLine; const tlCtx = $('tl_trade_context'); if (tlCtx) { - tlCtx.innerHTML = '📋 ' + tlSummaryLine; - tlCtx.style.display = ''; + fillTradePnLContext(tlCtx, { + label: '백테 SHORT(꼬리)', + totalBudget: Number(p.total_budget_krw || 0), + peakCum: Number(s.peak_cum_pnl || 0), + peakAt: s.peak_cum_at || '', + totalPnl: s.total_pnl, + }); } $('tl_total').textContent = (s.total_trades||0) + '건'; @@ -3358,11 +3681,11 @@ function renderTailBacktest(d) { const tickMeta = s.tick_backtest || {}; const buySrc = s.backtest_buy_source || ''; if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) { - const cov = tickMeta.tick_bar_coverage_pct != null ? tickMeta.tick_bar_coverage_pct : '—'; + const covLabel = fmtTickCoverageLabel(tickMeta); const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—'; const srcLabel = buySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (buySrc === 'ohlc_fallback' ? 'OHLC 폴백' : buySrc); const tickColor = buySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; - const tickLine = `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${cov}%
`; + const tickLine = `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; $('tl_params_bar').innerHTML += tickLine; if (tlCtx) tlCtx.innerHTML += tickLine; const srcMap = s.tick_entry_sources || {}; @@ -3388,7 +3711,7 @@ function renderTailBacktest(d) { $('tl_params_bar').innerHTML += `
${line}
`; if (tlCtx) tlCtx.innerHTML += `
${line}
`; } - // 장중 누적손익 최고점(고점 도달 시각) → 트레일 익절 튜닝 참고 + // 장중 누적손익 최고점 — 파라미터 바에 참고 표기 (거래내역 컨텍스트와 동일 수치) const tlPeakCum = Number(s.peak_cum_pnl || 0); const tlPeakAt = fmtTradeTime(s.peak_cum_at || ''); if (tlPeakCum > 0 && tlPeakAt) { @@ -3396,7 +3719,6 @@ function renderTailBacktest(d) { `💡 장중 누적 최고 +${tlPeakCum.toLocaleString()}원 (${tlPeakAt})` + ` · 최종 ${fmtKrw(s.total_pnl)}`; $('tl_params_bar').innerHTML += `
${peakLine}
`; - if (tlCtx) tlCtx.innerHTML += `
${peakLine}
`; } if ((s.total_trades||0) > 0) { @@ -3892,6 +4214,23 @@ function hdRenderResult(d, name) { $('hd_hold').textContent = (s.avg_hold_days||0) + '일'; const sign = v => (v > 0 ? '+' : ''); + if ($('hd_bot_pct_sum')) { + $('hd_bot_pct_sum').textContent = sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; + colorPnl($('hd_bot_pct_sum'), s.bot_pct || 0); + } + if ($('hd_daily_avg_pct')) { + let dAvg = s.daily_avg_pct; + if (dAvg == null && s.bot_pct != null) { + // 홀딩은 일봉 — 보유일 평균이 있으면 그걸로, 없으면 bot_pct 그대로 표시용 + const holdD = Number(s.avg_hold_days || 0); + dAvg = holdD > 0 + ? Math.round((Number(s.bot_pct) / Math.max(1, holdD)) * 1000) / 1000 + : s.bot_pct; + } + $('hd_daily_avg_pct').textContent = (dAvg == null) ? '-' : (sign(dAvg) + dAvg + '%'); + if (dAvg != null) colorPnl($('hd_daily_avg_pct'), dAvg); + } + // Buy & Hold 비교 (전체 구간) if (s.bnh_pct !== undefined && s.bnh_pct !== null) { $('hd_bnh_row').style.display = ''; @@ -3957,6 +4296,29 @@ function hdRenderResult(d, name) { renderVirtualTrades('hd_trade_tbody', d.trades || [], { meta: { code: hdCurrentCode || '', name: hdCurrentName || '' }, + showCumulative: true, + totalBudget: Number((d.params && (d.params.total_budget_krw || d.params.slot_money)) || 0) || 1000000, + }); + const hdBudget = Number((d.params && (d.params.total_budget_krw || d.params.slot_money)) || 0) || 1000000; + let hdPeak = Number(s.peak_cum_pnl || 0); + let hdPeakAt = s.peak_cum_at || ''; + if (!(hdPeak > 0)) { + let cum = 0; + const ordered = [...(d.trades || [])].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); + for (const t of ordered) { + cum += Number(t.pnl ?? t.realized_pnl ?? 0); + if (cum > hdPeak) { + hdPeak = cum; + hdPeakAt = t.sell_time || t.exit_time || t.sell_date || ''; + } + } + } + fillTradePnLContext('hd_trade_context', { + label: '백테 HOLDING', + totalBudget: hdBudget, + peakCum: hdPeak, + peakAt: hdPeakAt, + totalPnl: s.total_pnl, }); $('hd_result_area').scrollIntoView({behavior:'smooth'}); } @@ -4051,10 +4413,9 @@ function lcInitDate() { const el = $('lc_date'); if (!el) return; if (!el.value) { - const t = new Date(); - el.value = t.getFullYear() + '-' - + String(t.getMonth() + 1).padStart(2, '0') + '-' - + String(t.getDate()).padStart(2, '0'); + el.value = kstTradingDayIso(_krHolidays); + } else { + el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays); } } @@ -4117,18 +4478,20 @@ function lcRenderStatus(status) { function lcFieldInput(f) { const id = 'lc_' + f.key; const hint = f.hint ? `
${lcEsc(f.hint)}
` : ''; - const tbl = f.table ? `${lcEsc(f.table)}` : ''; - const keyTag = f.key ? `${lcEsc(f.key)}` : ''; + const tbl = f.table ? `${lcEsc(f.table)}` : ''; + const keyTag = f.key + ? `${lcEsc(f.key)}` + : ''; if (f.type === 'bool') { const chk = f.value ? 'checked' : ''; - return `
+ return `
${tbl}${hint}
`; } if (f.type === 'text') { const val = f.value != null ? f.value : ''; - return `
+ return `
${tbl} @@ -4137,7 +4500,7 @@ function lcFieldInput(f) { } const step = f.type === 'int' ? '1' : '0.01'; const val = f.value != null ? f.value : ''; - return `
+ return `
${tbl} @@ -4145,11 +4508,157 @@ function lcFieldInput(f) {
`; } +/** env 키 → 전략 구역 ID (카테고리 안 시각 구분용) */ +function lcInferStrategy(key) { + const k = String(key || '').toUpperCase(); + const m = k.match(/^STRATEGY_([A-Z0-9_]+)_ENABLED$/); + if (m) return m[1]; + // 긴 prefix 우선 + const pairs = [ + ['RANGE_BREAK_', 'RANGE_BREAK'], + ['UPDOWN_', 'UPDOW'], + ['UPDOW_', 'UPDOW'], + ['BREAKOUT_', 'BREAKOUT'], + ['MOMENTUM_', 'MOMENTUM'], + ['SCALP_', 'SCALP'], + ['DBBAND_', 'DBBAND'], + ['TAIL_', 'SHORT'], + ['SHORT_', 'SHORT'], + ]; + for (let i = 0; i < pairs.length; i++) { + if (k.startsWith(pairs[i][0])) return pairs[i][1]; + } + return 'COMMON'; +} + +/** 공통 섹션용 — 주제(호가/프로그램/휩쏘…)로 한 번 더 묶기 */ +function lcInferTopic(key) { + const k = String(key || '').toUpperCase(); + if (k.includes('ORDERBOOK') || k.includes('SPREAD')) return 'orderbook'; + if (k.includes('PROGRAM')) return 'program'; + if (k.includes('WHIPSAW')) return 'whipsaw'; + if (k.startsWith('WS_TRIGGER') || k.includes('TRIGGER_EVAL')) return 'trigger_master'; + if (k.includes('PENDING') || k.includes('FILL') || k.includes('ORDER_') || k.includes('IOC') + || k.includes('DEDUP') || k.includes('SLIP') || k.includes('STRICT_FILL') + || k.includes('CANCEL_PARTIAL') || k.includes('DUPLICATE_ORDER')) return 'fill'; + if (k.includes('BACKTEST') || k.includes('PARAM_SEARCH') || k.includes('POLL_MS') + || k.includes('TICK_')) return 'backtest'; + if (k.startsWith('KIS_') || k.includes('BALANCE_MAX') || k.includes('INTERVAL_SEC')) return 'rest'; + if (k.startsWith('SCAN_') || k.startsWith('STRATEGY_LOOP')) return 'scan'; + if (k.includes('ORPHAN') || k.includes('GHOST') || k.includes('MANUAL_HOLD') + || k.includes('BULK_SELL') || k.includes('DRIFT')) return 'portfolio'; + if (k.includes('DAILY_PROFIT') || k.includes('DAILY_STOP') || k.includes('CONSECUTIVE_LOSS') + || k.includes('USE_RISK')) return 'risk'; + return 'misc'; +} + +const LC_TOPIC_ORDER = [ + 'trigger_master', 'orderbook', 'program', 'whipsaw', + 'fill', 'backtest', 'rest', 'scan', 'risk', 'portfolio', 'misc', +]; +const LC_TOPIC_LABELS = { + trigger_master: 'TRIGGER 마스터', + orderbook: '호가', + program: '프로그램', + whipsaw: '휩쏘', + fill: '체결·주문', + backtest: '백테·틱청산', + rest: 'REST 유량', + scan: '스캔 루프', + risk: '리스크', + portfolio: '보유·고아', + misc: '기타', +}; + +const LC_STRAT_ORDER = [ + 'COMMON', 'SHORT', 'MOMENTUM', 'BREAKOUT', 'UPDOW', + 'SCALP', 'RANGE_BREAK', 'DBBAND', 'OTHER', +]; +const LC_STRAT_LABELS = { + COMMON: '공통', + SHORT: '꼬리잡기', + MOMENTUM: '모멘텀', + BREAKOUT: '돌파', + UPDOW: 'UPDOWN 박스', + SCALP: '스캘핑', + RANGE_BREAK: '박스권돌파', + DBBAND: '더블BB', + OTHER: '기타', +}; + +/** 한 카테고리 fields → 전략별 구역 배열 */ +function lcPartitionFieldsByStrategy(fields) { + const buckets = {}; + (fields || []).forEach(f => { + let sid = lcInferStrategy(f.key); + if (LC_STRAT_ORDER.indexOf(sid) < 0) sid = 'OTHER'; + if (!buckets[sid]) buckets[sid] = []; + buckets[sid].push(f); + }); + return LC_STRAT_ORDER + .filter(sid => buckets[sid] && buckets[sid].length) + .map(sid => ({ + sid, + label: LC_STRAT_LABELS[sid] || sid, + fields: buckets[sid], + })); +} + +/** 공통 필드만 있을 때 주제별로 재분할 (2구역 이상이면 사용) */ +function lcPartitionFieldsByTopic(fields) { + const buckets = {}; + (fields || []).forEach(f => { + const tid = lcInferTopic(f.key); + if (!buckets[tid]) buckets[tid] = []; + buckets[tid].push(f); + }); + return LC_TOPIC_ORDER + .filter(tid => buckets[tid] && buckets[tid].length) + .map(tid => ({ + sid: 'topic', + topicId: tid, + label: LC_TOPIC_LABELS[tid] || tid, + fields: buckets[tid], + })); +} + +function lcRenderFieldGrid(fields) { + let html = '
'; + (fields || []).forEach(f => { html += lcFieldInput(f); }); + html += '
'; + return html; +} + +function lcRenderBlock(p) { + const cls = p.sid === 'topic' + ? 'lc-strat-block lc-strat-topic' + : ('lc-strat-block lc-strat-' + String(p.sid).toLowerCase()); + const sidTag = (p.sid === 'COMMON' || p.sid === 'topic') + ? '' + : `${lcEsc(p.sid)}`; + return `
+
${lcEsc(p.label)}${sidTag}
+ ${lcRenderFieldGrid(p.fields)} +
`; +} + function lcRenderGroups(groups) { const root = $('lc_groups'); if (!root) return; let html = ''; (groups || []).forEach(g => { + const parts = lcPartitionFieldsByStrategy(g.fields || []); + const splitStrat = parts.length > 1; + // 전략이 하나뿐(공통만)이면 주제로 한 번 더 나눔 + let renderParts = parts; + let useBlocks = splitStrat; + if (!splitStrat && parts.length === 1) { + const topics = lcPartitionFieldsByTopic(parts[0].fields); + if (topics.length > 1) { + renderParts = topics; + useBlocks = true; + } + } html += `
@@ -4157,10 +4666,15 @@ function lcRenderGroups(groups) { ${g.hint ? `
${lcEsc(g.hint)}
` : ''}
-
-
`; - (g.fields || []).forEach(f => { html += lcFieldInput(f); }); - html += '
'; +
`; + if (useBlocks) { + html += '
'; + renderParts.forEach(p => { html += lcRenderBlock(p); }); + html += '
'; + } else { + html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []); + } + html += '
'; }); root.innerHTML = html; } @@ -4260,12 +4774,15 @@ const UBX_PARAM_FIELDS = [ ]; function ubxOnTabShow() { - // 날짜 기본값 (최근 6개월) + // 날짜 기본값 — 주말/휴장이면 이전 장운영일 const end = $('ubx_end'), start = $('ubx_start'); - if (end && !end.value) end.value = new Date().toISOString().slice(0, 10); + if (end && !end.value) end.value = kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); + else if (end && end.value) end.value = kstClampToPrevTradingDayIso(end.value, _krHolidays); if (start && !start.value) { - const d = new Date(); d.setMonth(d.getMonth() - 6); - start.value = d.toISOString().slice(0, 10); + const endIso = (end && end.value) ? end.value : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); + start.value = kstClampToPrevTradingDayIso(kstAddDaysIso(endIso, -180), _krHolidays); + } else if (start && start.value) { + start.value = kstClampToPrevTradingDayIso(start.value, _krHolidays); } ubxLoadCfg(); ubxLoadWatch(); @@ -4567,7 +5084,25 @@ function ubxRenderTrades(trades, sub, meta) { hold_min: t.bars_held, }; }); - renderVirtualTrades('ubx_trades_tbody', mapped, { meta }); + // peak / 최종 누적 (매도순) + let peak = 0, peakAt = '', cum = 0; + const ordered = [...mapped].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); + for (const t of ordered) { + cum += Number(t.pnl || 0); + if (cum > peak) { peak = cum; peakAt = t.exit_time || ''; } + } + renderVirtualTrades('ubx_trades_tbody', mapped, { + meta, + showCumulative: true, + totalBudget: 1000000, + }); + fillTradePnLContext('ubx_trade_context', { + label: '백테 UPDOWN_BOX', + totalBudget: 1000000, + peakCum: peak, + peakAt, + totalPnl: cum, + }); const subEl = $('ubx_trades_sub'); if (subEl) subEl.textContent = sub || ''; card.style.display = ''; diff --git a/templates/backtest.html b/templates/backtest.html index 2746ee0..3d740f6 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -28,8 +28,7 @@ - - + @@ -142,6 +141,9 @@

+ @@ -173,7 +175,7 @@
- +
총 거래
-
@@ -182,7 +184,7 @@
승률
-
-
순손익
-
+
순손익(수수료·세금 포함)
-
Profit Factor
-
@@ -193,6 +195,12 @@
평균 보유(분)
-
+
+
총 수익률(운용한도 대비)
-
+
+
+
일평균 수익률
-
+
@@ -241,6 +249,8 @@ 정렬 + + @@ -396,28 +406,49 @@
- - - - - - - - -
RSI 기간RSI 계산 캔들 수 → 라이브 봇 엔진 기본 3 (scalping_engine ``rsi_period`` 기본 3, 백테 입력으로 그리드 가능)
RSI 과매도이 값 이하일 때 매수 신호 → 봇 DB: SCALP_RSI_OVERSOLD (기본 25)
RSI 과열이 값 초과면 진입 금지 (고점 추격 방지) → 봇 DB: SCALP_RSI_OVERBOUGHT (기본 75)
손절(%)매수가 대비 하락 시 손절 → 봇 DB: SCALP_STOP_LOSS_PCT (기본 1.5%)
익절(%)매수가 대비 상승 시 익절(2순위) → 봇 DB: SCALP_TAKE_PROFIT_PCT
익절상한(%)목표가 천장 — min(익절, 상한) → DB: SCALP_TP_MAX_PCT (기본 2%, 0=OFF)
낙폭필터(%)당일 시가→저가 낙폭이 이 값 이상이어야 진입 → 봇 DB: SCALP_MIN_DROP_RATE (기본 1.5%)
투자금(원)1회 최대 투자금 → 봇: SCALP_MAX_LOSS_PER_TRADE_KRW ÷ SCALP_STOP_LOSS_PCT 공식 적용 (없으면 MAX_LOSS_PER_TRADE_KRW), 상한은 SCALP_MAX_BUY_AMOUNT(없으면 MAX_BUY_AMOUNT_PER_STOCK)
예) 200,000÷0.015 = 13,333,333원
- -
- - - - - - - -
쿨다운(분)청산 후 같은 종목 재진입 금지(분) → 봇 DB: REENTRY_COOLDOWN_SEC(초) ÷60 자동 변환 (scalping_engine.py)
거래량배수신호봉 거래량 ≥ 20봉 평균 × N배. 0=비활성 → 봇 DB: VOL_MULTIPLIER (라이브·백테 동일)
어깨발동%진입가 대비 이 % 이상 수익 후 max_price 추적 시작 → DB: SCALP_SHOULDER_MIN_HIGH_PCT (없으면 SHOULDER_MIN_HIGH_PCT)
어깨폭%고점 대비 이 % 하락 시 1순위 어깨컷 → DB: SCALP_SHOULDER_CUT_PCT (꼬리잡기와 동일 로직)
매수시작/종료매수 가능 시간대 (HHMM) → 봇 DB: SCALP_TIME_START / SCALP_TIME_END (없으면 글로벌 TIME_START/TIME_END)
일일최대매수종목당 하루 최대 거래횟수 → 봇 DB: SCALP_MAX_DAILY (없으면 엔진 기본 3)
-
- -
⚡ 진입가 = 신호봉 다음 봉 시가 (실제 주문과 동일하게 1봉 지연) | 수수료 0.015%×2 + 거래세 0.18% 자동 차감
-
라이브-백테스트 100% 동일 엔진: 라이브 ScalpingStrategy.check_buy / check_sell_signals 가 모두 ``scalping_engine.check_buy_signal_live`` / ``check_sell_signal_live`` 를 직접 호출합니다.
+ 📖 SCAN vs TRIGGER vs 청산
+ SCAN: 키움 scalp_re · TRIGGER: RSI V자 + (skip OFF 시) 낙폭·거래량·반전캔들 · 청산: 어깨→익절→손절→금액손실→EOD (tp_max는 익절 상한) · 진입=신호봉 다음봉 시가 · 탐색: param_search_scalping.py - +
총 거래
-
@@ -588,6 +598,12 @@
평균 보유(분)
-
+
+
총 수익률(운용한도 대비)
-
+
+
+
일평균 수익률
-
+
@@ -629,6 +645,8 @@ 정렬 + + @@ -640,7 +658,7 @@ 종목매수시각매도시각 매수가매도가수량 - 손익(원)수익률%보유(분)매도사유진입RSI + 손익(원)수익률%누적손익누적%보유(분)매도사유진입RSI @@ -719,17 +737,23 @@
- +
- + +
+
+ +
@@ -797,16 +821,22 @@ - +
-
📅 당일 누적손익 트레일 익절 — 그날 실현손익 고점 대비 되돌리면 신규매수 중단 (실매 daily_profit_halt 동일). 다단 입력 시 단일 되돌림은 무시
+
📅 다단트레일 ON이면 당일 실현손익 고점 대비 되돌릴 때 신규매수만 중단. DB=SHORT_DAILY_PROFIT_TARGET_ENABLED. 이번만 끄려면 체크 해제 후 실행(봇저장 X면 DB 유지).
+
+
+ + +
+
- +
- +
@@ -899,8 +929,20 @@
- - + + +
+
+ + +
+
+ + +
+
+ +
@@ -1060,7 +1102,9 @@ 총 운용한도동시 보유 매입금 합 ≤ SHORT_TOTAL_BUDGET_KRW (0=동시보유×1회투자금) 동시보유SHORT_MAX_STOCKS / MAX_STOCKS — 시각순 포트폴리오, 1봉 1매수 봇저장insert_env_snapshotconfig_short + env_config 자동 분리 - RSI과열기준RSI_OVERHEAT_THRESHOLD · 쿨다운 REENTRY_COOLDOWN_SEC + 일일최대매수TAIL_MAX_DAILY — 무한루프 안전판(기본 20). 실질 제어는 아래 손실한도·재진입최소. + 종목손실한도TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW/PCT(양수) — 종목 당일 실현손익이 −한도 이하면 그 종목만 재진입 금지. KRW·% 중 먼저 닿는 쪽. + 재진입최소(원)TAIL_REENTRY_MIN_EDGE_KRW — 2회째 진입부터 당일 누적순익이 이 값 미만이면 차단(회전매매 방지).
@@ -1151,6 +1195,8 @@ 정렬 + + @@ -1242,6 +1288,10 @@
+
+ + +
분봉수집 @@ -1384,6 +1434,16 @@
+ +
+
TRIGGER 필터 — HTS breakout SCAN 통과 후 엔진 검사
+
+
+ + +
+
+
@@ -1457,7 +1517,7 @@
- +
@@ -1479,6 +1539,10 @@
+
+ + +
@@ -1514,16 +1578,22 @@
- +
-
📅 당일 누적손익 트레일 익절 — 그날 실현손익 고점 대비 되돌리면 신규매수 중단 (실매 daily_profit_halt 동일). 다단 입력 시 단일 되돌림은 무시
+
📅 다단트레일 ON이면 당일 실현손익 고점 대비 되돌릴 때 신규매수만 중단. DB=BREAKOUT_DAILY_PROFIT_TARGET_ENABLED. 이번만 끄려면 체크 해제 후 실행(봇저장 X면 DB 유지).
+
+
+ + +
+
- +
- +
@@ -1554,6 +1624,12 @@ 저장 후보 이력 (target_candidates_history) — 체크: DB 슬롯·후보 / 해제: ws_candles 전 종목
+
+ + +
@@ -1658,6 +1734,8 @@ 정렬 + + @@ -1669,7 +1747,8 @@ 종목매수시각매도시각 매수가매도가수량 - 손익(원)수익률%보유(분)매도사유 + 손익(원)수익률%누적손익누적% + 보유(분)체결디버그매도사유 @@ -1795,6 +1874,12 @@ 저장 후보 이력 (target_candidates_history, strategy_id=RANGE_BREAK)
+
+ + +
@@ -1809,26 +1894,67 @@
+
+
+ + +
+
@@ -2072,7 +2204,7 @@
- +
@@ -2121,15 +2253,21 @@
- -
📅 당일 누적손익 트레일 익절 — 그날 실현손익 고점 대비 되돌리면 신규매수 중단 (실매 daily_profit_halt 동일). 다단 입력 시 단일 되돌림은 무시
+ +
📅 다단트레일 ON이면 당일 실현손익 고점 대비 되돌릴 때 신규매수만 중단. DB=MOMENTUM_DAILY_PROFIT_TARGET_ENABLED. 이번만 끄려면 체크 해제 후 실행(봇저장 X면 DB 유지).
+
+
+ + +
+
- +
- +
@@ -2151,6 +2289,20 @@
+
+
+ + +
+
+ + +
+
@@ -2242,6 +2394,8 @@ 정렬 + + @@ -2317,7 +2471,7 @@
승률
-
-
순손익
-
+
순손익(수수료·세금 포함)
-
Profit Factor
-
@@ -2328,6 +2482,12 @@
평균 보유(일)
-
+
+
총 수익률(운용한도 대비)
-
+
+
+
일평균 수익률
-
+
가상 거래 내역 (최근 200건)
+
정렬 + + @@ -2409,7 +2572,7 @@ - +
종목매수일매도일매수가매도가수량손익(원)수익률%보유(일)매도사유수량손익(원)수익률%누적손익누적%보유(일)매도사유
@@ -2636,10 +2799,13 @@