diff --git a/.cursor/rules/backtest-web-restart.mdc b/.cursor/rules/backtest-web-restart.mdc index 949393b..ff0dae2 100644 --- a/.cursor/rules/backtest-web-restart.mdc +++ b/.cursor/rules/backtest-web-restart.mdc @@ -12,6 +12,17 @@ alwaysApply: true `curl 200` / `systemctl active` 만으로는 검증 완료가 **아님**. 콘솔 `ReferenceError`·버튼 미동작은 브라우저 클릭 없이는 놓친다. +## 0) 🚨 브라우저 URL — 절대 고정 (로컬호스트 금지) + +| 용도 | URL | +|------|-----| +| **브라우저 검증 (필수)** | **`http://192.168.0.149:5050/`** | +| curl/헬스체크 (서버 로컬만) | `http://127.0.0.1:5050/` | + +- Cursor 브라우저·에이전트는 **`127.0.0.1` / `localhost` 로 열지 말 것** → `chrome-error` / 접속 실패. +- **항상** `http://192.168.0.149:5050/` 로 navigate·스냅샷·클릭. +- 모델이 바뀌어도 이 주소만 쓴다. “로컬이면 127” 추측 **금지**. + ## 1) 재시작 ```bash @@ -23,10 +34,11 @@ 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` +- curl 은 서버 쪽 헬스용. **브라우저 검증 URL 과 혼동하지 말 것.** ## 2) 브라우저 검증 (수정 범위만큼) -브라우저 도구로 `http://127.0.0.1:5050/` (또는 LAN `http://192.168.0.149:5050/`) 을 연다. +브라우저 도구로 **`http://192.168.0.149:5050/`** 만 연다. (`127.0.0.1` 금지) - 강력 새로고침에 해당하는 방식으로 최신 JS/HTML 로드 - **수정한 탭·버튼·날짜 인풋·정렬·거래내역**을 실제로 클릭/입력 @@ -36,7 +48,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/ 최소 산출물(보고에 포함): - 재시작 결과 (`active` + HTTP 코드) -- 연 URL + 누른 탭/버튼 +- 연 URL (**반드시** `http://192.168.0.149:5050/…`) + 누른 탭/버튼 - 콘솔 오류 유무 (없으면 “콘솔 오류 없음”) ## 언제 diff --git a/.cursorrules b/.cursorrules index 6673b5c..8b4610b 100644 --- a/.cursorrules +++ b/.cursorrules @@ -55,7 +55,8 @@ - `--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` 없음을 확인한 뒤에야 완료 보고. + - **그걸로 끝내지 말 것.** 브라우저는 **`http://192.168.0.149:5050/` 만** 연다 (`127.0.0.1`/`localhost` 금지 — Cursor 브라우저에서 실패함). + - 수정한 탭·버튼을 눌러보고, 콘솔 `Uncaught`/`ReferenceError` 없음을 확인한 뒤에야 완료 보고. - `curl 200`만 = 검증 미완료. 실매 봇은 웹과 무관하면 재시작하지 말 것. - 15. **실매↔웹백테↔파람 정합 — 파람은 Optuna 기본** (상세: `.cursor/rules/live-backtest-optuna-parity.mdc`) - 전략/파라미터 수정 시 실매 엔진·DB → 웹백테 인풋/API → Optuna 그리드(실매값 포함)를 **한 세트로** 맞춘다. @@ -81,8 +82,17 @@ - 20. 수정 사항이 실매에 영향이 가는지 백테 파라미터에만만 영향이 가는지 명확히 분류 후 보고하고 수정한다. - 21. 백테 웹페이지 탭 ui 수정시 다른 전략 탭도 모두 수정되어야 한다. - 가상거래내역, 실거래내역 ui 는 모두 동일해야한다. +- 22. **봉 정합 다음 할 일**은 `docs/정합성.md` §8 (깨끗한 장일 검증 → 웹백테 1회 → §5 체크). 새 ±1 보정·freeze OFF로 “해결”하지 말 것. # [CRITICAL SYSTEM DIRECTIVES: 절대 엄수 사항 - 위반 시 작동 중지] +## 0. 🚨 봉 정합·진입 정렬 — 절대 금지 (위반 시 작업 중지) +- **신호 = T−1 확정봉, 진입 = T (시가/첫 틱).** `live_backtest_align=True` 잠금. + 유니버스·신호·진입을 **±1분(또는 ±1봉) 보정·오프셋·슬롯 해킹**으로 맞추는 코드 **절대 금지**. + 실매↔백테가 어긋나면 **봉이 확정 후 커지는지(freeze)** 부터 보고, ±1 땜빵으로 때우지 마라. (`docs/정합성.md`) +- **`WS_CANDLE_FREEZE_ON_CONFIRM` 끄기(false) 절대 금지.** 사용자 **명시 승인** 없이 DB/기본값을 false·레거시 덮어쓰기로 되돌리지 마라. +- **`*_SKIP_HTS_SCAN_DUPES` 를 true로 바꾸기 절대 금지.** (TAIL/MOMENTUM/BREAKOUT/SCALP 포함) + 사용자가 명시하기 전까지 **false / 0 유지**. “HTS와 맞추려고” true로 바꾸지 마라. (상세: 항목 16 · `hts-condition-grids.mdc`) + ## 1. 🚨 하드코딩 절대 금지 (NO HARDCODING) - 어떠한 경우에도 코드 내부에 임계값, 비율, 점수, 시간 등의 수치를 직접 하드코딩하지 마라. - 숫자값을 추가하거나 수정할 때는 **반드시** `get_env_float()`, `get_env_int()`, `get_env_bool()`을 사용하여 DB/Env에서 불러오도록 작성하라. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 25c6769..457a03c 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -6,4 +6,7 @@ 효율적 아키텍처: 모든 API 요청은 SafeRequest를 상속받아 429 에러 재시도 로직을 포함하며, 무거운 연산은 스캔(Scan) 단계가 아닌 매수 타점(Trigger) 단계에서 실행한다. -검증 후 출력: 코드 출력 전 1)손절 로직 유무 2)API 호출 제한 준수 3)기존 로직과의 기능 동일성을 스스로 검토한 후 최종 결과를 한국어로 출력한다. \ No newline at end of file +검증 후 출력: 코드 출력 전 1)손절 로직 유무 2)API 호출 제한 준수 3)기존 로직과의 기능 동일성을 스스로 검토한 후 최종 결과를 한국어로 출력한다. + +한국어로만 답해. +.aider.conf.yml 내용은 수정하지 마. diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index 627eca6..c9c5f07 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/backtest_web.py b/backtest_web.py index 77822ce..a9d3b3e 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -497,6 +497,13 @@ def _resolve_backtest_universe( if use_saved_history and strategy_id: try: from kis_trader.database.db_manager import get_db as _get_ext_db + from kis_trader.backtest.universe_timeline import ( + universe_exit_debounce_sec_for_strategy, + ) + + # EXIT 디바운스 = 전략 공통 (CONDITION_EXIT_GRACE / 전략별 오버라이드) + # SCALP만 특수처리하면 웹↔Optuna 유니버스 두께가 어긋남 (7/20 스캘핑 −1만 사례) + debounce_sec = universe_exit_debounce_sec_for_strategy(strategy_id) strict = False strict_lag = 1 @@ -504,13 +511,9 @@ def _resolve_backtest_universe( from kis_trader.backtest.momentum_backtest_common import ( momentum_backtest_universe_strict_enabled, momentum_backtest_universe_strict_lag_min, - momentum_universe_exit_debounce_sec, ) strict = momentum_backtest_universe_strict_enabled() strict_lag = momentum_backtest_universe_strict_lag_min() - debounce_sec = momentum_universe_exit_debounce_sec() - else: - debounce_sec = 0 history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, @@ -1753,6 +1756,17 @@ def _scalp_ui_defaults_from_db() -> Dict[str, Any]: "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)), + "eod_enabled": ( + str(env.get("SCALP_EOD_ENABLED") or "1").strip().lower() + in ("1", "true", "t", "y", "yes", "on") + if env.get("SCALP_EOD_ENABLED") not in (None, "", "None") + else bool(_d.get("eod_enabled", True)) + ), + "eod_hm": ( + str(env.get("SCALP_EOD_HM") or "15:25").strip() + if env.get("SCALP_EOD_HM") not in (None, "", "None") + else str(_d.get("eod_hm") or "15:25") + ), } @@ -2335,18 +2349,17 @@ def api_backtest_scalping(): use_macd_cross = bool(_def.get("use_macd_cross", False)) else: use_macd_cross = str(_use_macd).strip().lower() in ("1", "true", "y", "yes", "on") - _force_eod_raw = request.args.get("force_eod_exit") - if _force_eod_raw in (None, ""): - _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: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:20" - ).strip() or "15:20", - } + # EOD — 실매 SCALP_EOD_* (기본 15:25). force_eod_exit 는 eod_enabled 미지정 시에만 레거시 + _eod_src = _mom_def if _mom_def is not None else _def + eod_patch = _eod_params_from_request( + request, _eod_src, default_enabled=True, default_hm="15:25", + ) + if request.args.get("eod_enabled") in (None, ""): + _legacy_force_eod = request.args.get("force_eod_exit") + if _legacy_force_eod not in (None, ""): + eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in ( + "1", "true", "y", "yes", "on", + ) # ── 모드 분기: reversal vs momentum (모멘텀 탭은 /api/backtest/momentum) ── # 실매매 봇이 ``check_buy_signal_momentum_live`` 를 사용 중이면 백테스트도 @@ -3876,6 +3889,14 @@ def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: if "require_reversal_candle" in body: patch["SCALP_REQUIRE_REVERSAL_CANDLE"] = _env_bool_10(body.get("require_reversal_candle")) + if "eod_enabled" in body: + patch["SCALP_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) + x = gv("eod_hm") + if x is not None: + eod_s = str(x).strip() + if eod_s: + patch["SCALP_EOD_HM"] = eod_s + return patch @@ -6371,6 +6392,143 @@ def api_permanent_subs_delete(): db.close() + db.close() + + +# ───────────────────────────────────────────────────────────────────────────── +# DART 수주 공시 SCAN / 워치 / 백테 +# ───────────────────────────────────────────────────────────────────────────── + +@app.route("/api/dart/disclosures", methods=["GET"]) +def api_dart_disclosures(): + """최근 공시 목록 (웹 탭 실시간 폴링).""" + limit = int(request.args.get("limit", 50) or 50) + limit = max(1, min(200, limit)) + db = _db() + try: + try: + from kis_trader.scan.dart_order_tracker import ensure_dart_disclosure_columns + ensure_dart_disclosure_columns(db) + except Exception: + pass + rows = db.conn.execute( + """ + SELECT rcept_no, corp_code, corp_name, stock_code, report_nm, + rcept_dt, url, first_seen_at, mm_sent, + filter_ok, sales_pct, filter_reason + FROM dart_disclosures + ORDER BY first_seen_at DESC + LIMIT %s + """, + (limit,), + ).fetchall() + watch = [] + try: + from kis_trader.scan.dart_watchlist import list_active_watch + watch = list_active_watch(db) + except Exception: + pass + snap = db.get_merged_env_snapshot() or {} + return jsonify({ + "ok": True, + "rows": [dict(r) for r in rows], + "watch": watch, + "flags": { + "scan": str(snap.get("DART_SCAN_ENABLED") or snap.get("DART_ENABLED") or "true").lower() + in ("1", "true", "yes", "on"), + "subscribe": str(snap.get("DART_SUBSCRIBE_ENABLED") or "false").lower() + in ("1", "true", "yes", "on"), + "trade": str(snap.get("DART_TRADE_ENABLED") or "false").lower() + in ("1", "true", "yes", "on"), + "strategy": str(snap.get("STRATEGY_DART_ENABLED") or "false").lower() + in ("1", "true", "yes", "on"), + "watch_max": int(float(snap.get("DART_WATCH_MAX") or 15)), + "watch_ttl_hours": int(float(snap.get("DART_WATCH_TTL_HOURS") or 24)), + "quality_filter": str(snap.get("DART_QUALITY_FILTER_ENABLED") or "true").lower() + in ("1", "true", "yes", "on"), + "min_sales_pct": float(snap.get("DART_MIN_SALES_PCT") or 5.0), + "require_theme": str(snap.get("DART_REQUIRE_THEME") or "true").lower() + in ("1", "true", "yes", "on"), + }, + }) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + finally: + db.close() + + +@app.route("/api/dart/config", methods=["POST"]) +def api_dart_config(): + """SCAN/구독/매매 스위치·워치 한도 저장 → config_dart.""" + body = request.get_json(force=True, silent=True) or {} + patch = {} + for k in ( + "DART_SCAN_ENABLED", "DART_SUBSCRIBE_ENABLED", "DART_TRADE_ENABLED", + "STRATEGY_DART_ENABLED", "DART_WATCH_MAX", "DART_WATCH_TTL_HOURS", + "DART_POLL_SEC", "DART_LOOKBACK_DAYS", + "DART_QUALITY_FILTER_ENABLED", "DART_MIN_SALES_PCT", "DART_REQUIRE_THEME", + ): + if k in body: + patch[k] = str(body[k]).strip() + if not patch: + return jsonify({"ok": False, "error": "no keys"}), 400 + db = _db() + try: + db.insert_env_snapshot(patch) + return jsonify({"ok": True, "saved": list(patch.keys())}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + finally: + db.close() + + +@app.route("/api/backtest/dart", methods=["GET"]) +def api_backtest_dart(): + from kis_trader.backtest.dart_backtest_common import run_dart_backtest_web_aligned + from kis_trader.engine import dart_engine as de + start = (request.args.get("start") or "").strip() + end = (request.args.get("end") or "").strip() + if not start or not end: + return jsonify({"ok": False, "error": "start/end 필수"}), 400 + db = _db() + try: + env_row = db.get_merged_env_snapshot() or {} + params = de.get_dart_defaults_from_db(env_row=env_row) + # 쿼리 오버라이드 + for qk, pk in ( + ("rsi_oversold", "rsi_oversold"), + ("rsi_reclaim", "rsi_reclaim"), + ("sl_pct", "sl_pct"), + ("tp_pct", "tp_pct"), + ("vol_mult", "vol_mult"), + ("event_window_bars", "event_window_bars"), + ("slot_money", "slot_money"), + ): + if request.args.get(qk) not in (None, ""): + params[pk] = float(request.args.get(qk)) if qk != "event_window_bars" else int(float(request.args.get(qk))) + result = run_dart_backtest_web_aligned( + start=start, end=end, params=params, env_row=env_row, + ) + return jsonify(result) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + finally: + db.close() + + +@app.route("/api/backtest/dart/defaults", methods=["GET"]) +def api_backtest_dart_defaults(): + from kis_trader.engine import dart_engine as de + db = _db() + try: + env_row = db.get_merged_env_snapshot() or {} + return jsonify({"ok": True, "params": de.get_dart_defaults_from_db(env_row=env_row)}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + finally: + db.close() + + # ───────────────────────────────────────────────────────────────────────────── # 웹 파라미터 탐색 (SCALP / MOMENTUM / BREAKOUT / SHORT) # ───────────────────────────────────────────────────────────────────────────── diff --git a/config_schema.py b/config_schema.py index 8c00a31..f5cc067 100644 --- a/config_schema.py +++ b/config_schema.py @@ -20,6 +20,7 @@ CONFIG_TABLE_NAMES: Tuple[str, ...] = ( "config_range_break", "config_updow", "config_dbband", + "config_dart", ) STRATEGY_ID_TO_TABLE: Dict[str, str] = { @@ -30,6 +31,7 @@ STRATEGY_ID_TO_TABLE: Dict[str, str] = { "RANGE_BREAK": "config_range_break", "UPDOW": "config_updow", "DBBAND": "config_dbband", + "DART": "config_dart", } # 전략 ON/OFF · MM alias — 접두어 규칙 예외 없이 전략 테이블에만 저장 @@ -41,6 +43,7 @@ _EXPLICIT_KEY_TABLE: Dict[str, str] = { "STRATEGY_RANGE_BREAK_ENABLED": "config_range_break", "STRATEGY_UPDOW_ENABLED": "config_updow", "STRATEGY_DBBAND_ENABLED": "config_dbband", + "STRATEGY_DART_ENABLED": "config_dart", "KIS_SCALP_MM_CHANNEL": "config_scalp", "KIS_SHORT_MM_CHANNEL": "config_short", "KIS_MOMENTUM_MM_CHANNEL": "config_momentum", @@ -48,6 +51,7 @@ _EXPLICIT_KEY_TABLE: Dict[str, str] = { "KIS_RANGE_BREAK_MM_CHANNEL": "config_range_break", "KIS_UPDOW_MM_CHANNEL": "config_updow", "KIS_DBBAND_MM_CHANNEL": "config_dbband", + "KIS_DART_MM_CHANNEL": "config_dart", } # 꼬리 오케스트레이터(SHORT strategy_id) — TAIL_* 와 분리 @@ -88,6 +92,11 @@ def classify_config_key(key: str) -> str: return "config_updow" if k.startswith("DBBAND_"): return "config_dbband" + # DART_API_KEY 만 글로벌 시크릿(env_config), 나머지 DART_* → config_dart + if k == "DART_API_KEY": + return "env_config" + if k.startswith("DART_"): + return "config_dart" if k.startswith("BREAKOUT_"): return "config_breakout" if k.startswith("RANGE_BREAK_"): diff --git a/database.py b/database.py index 8c854b5..27bbc0b 100644 --- a/database.py +++ b/database.py @@ -305,6 +305,42 @@ ENV_CONFIG_KEYS = ( "MM_DUAL_CHANNEL_ENABLED", # OpenRouter API 연동용 키 "OPENROUTER_API_KEY", "OPENROUTER_MODEL_ID", + # Open DART — API키만 env_config, 나머지 config_dart (classify) + "DART_API_KEY", + "STRATEGY_DART_ENABLED", "KIS_DART_MM_CHANNEL", + # SCAN 감시 + "DART_SCAN_ENABLED", # 폴링·DB·MM (구 DART_ENABLED) + "DART_ENABLED", # 하위호환 alias → SCAN + "DART_POLL_SEC", "DART_LOOKBACK_DAYS", "DART_MM_CHANNEL", + "DART_SAVE_CANDIDATES", "DART_REPORT_NAME_KEYWORDS", + "DART_SKIP_CORRECTION", "DART_LISTED_ONLY", "DART_LIST_MAX_PAGES", + # SCAN 품질필터 — 건설/재개발 제외 · 매출대비% · 수출·테마 + "DART_QUALITY_FILTER_ENABLED", + "DART_EXCLUDE_CORP_KEYWORDS", + "DART_EXCLUDE_CONTENT_KEYWORDS", + "DART_MIN_SALES_PCT", + "DART_SKIP_IF_SALES_PCT_UNKNOWN", + "DART_THEME_KEYWORDS", + "DART_REQUIRE_THEME", + "DART_FETCH_DOCUMENT", + # 임시 구독(영구구독 아님) + "DART_SUBSCRIBE_ENABLED", # 기본 false — 워치리스트 적재 + "DART_WATCH_TTL_HOURS", # 워치 유지 시간. 기본 24 + "DART_WATCH_MAX", # 동시 워치 상한. 기본 15 + # 실매 TRIGGER (기본 OFF) + "DART_TRADE_ENABLED", + "DART_SLOT_MONEY", "DART_MAX_STOCKS", "DART_TOTAL_BUDGET_KRW", + "DART_MAX_BUY_AMOUNT", "DART_MIN_INVEST_RATIO_OF_SLOT", + "DART_CAND_LIMIT", + "DART_TIME_START", "DART_TIME_END", + "DART_RSI_PERIOD", "DART_RSI_OVERSOLD", "DART_RSI_RECLAIM", + "DART_STOP_LOSS_PCT", "DART_TAKE_PROFIT_PCT", + "DART_TRAIL_PCT", "DART_TRAIL_ARM_PCT", + "DART_MAX_HOLD_BARS", "DART_EVENT_WINDOW_BARS", + "DART_MIN_PRICE", "DART_VOL_MULT", "DART_VOL_WINDOW", + "DART_MAX_LOSS_PER_TRADE_KRW", + "DART_FORCE_EOD_EXIT", + "DART_BACKTEST_USE_TICK_EXIT", "DART_BACKTEST_TICK_FALLBACK_OHLC", # 봇별 Mattermost 채널 — 전략별 config_* (KIS_LONG 은 홀딩봇 전용 → env_config) "KIS_LONG_MM_CHANNEL", # 롱 위시리스트/뉴스 리포트 전용 키 @@ -432,6 +468,7 @@ ENV_CONFIG_KEYS = ( "MOMENTUM_BACKTEST_USE_TICK_EXIT", "MOMENTUM_BACKTEST_USE_TICK_ENTRY", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", + "MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE", "BREAKOUT_BACKTEST_USE_TICK_DB", "BREAKOUT_BACKTEST_USE_TICK_EXIT", "BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", @@ -449,6 +486,8 @@ ENV_CONFIG_KEYS = ( "SCALP_BACKTEST_TICK_FALLBACK_OHLC", "SCALP_BACKTEST_POLL_MS", "SCALP_BACKTEST_SELL_SLIP_PCT", + "SCALP_BACKTEST_CANDLE_WARMUP_BARS", + "SCALP_UNIVERSE_EXIT_DEBOUNCE_SEC", "RANGE_BREAK_BACKTEST_USE_TICK_EXIT", "RANGE_BREAK_BACKTEST_TICK_FALLBACK_OHLC", "RANGE_BREAK_BACKTEST_POLL_MS", @@ -466,6 +505,7 @@ ENV_CONFIG_KEYS = ( "MOMENTUM_BACKTEST_SCAN_SEC", "MOMENTUM_BACKTEST_UNIVERSE_SCAN_AT", "MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", + "MOMENTUM_PREV_DAY_OPEN_HM_MAX", "MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", @@ -657,6 +697,7 @@ ENV_CONFIG_KEYS = ( "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", # MOMENTUM E조건(전일시가) — 실매매 최소 1분봉 수·갭보정 REST 조회량 (약 2영업일) "MOMENTUM_LIVE_MIN_CANDLES", "MOMENTUM_GAP_FILL_LIMIT", + "MOMENTUM_PREV_DAY_OPEN_HM_MAX", "BREAKOUT_LIVE_BACKTEST_ALIGN", "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", "RANGE_BREAK_LIVE_BACKTEST_ALIGN", "RANGE_BREAK_LIVE_SIGNAL_LOOKBACK_BARS", "BREAKOUT_ENTRY_MODE", @@ -715,6 +756,13 @@ ENV_CONFIG_KEYS = ( "WS_GAP_FILL_CODE_SLEEP_MIN_SEC", "WS_GAP_FILL_CODE_SLEEP_MAX_SEC", "WS_GAP_FILL_PHASE_PAUSE_SEC", "WS_GAP_FILL_WORKERS", + # 갭보정 REST가 진행 중(미완성) 당일 분봉을 confirmed 에 넣지 않음 (장초 직전봉% 왜곡 방지) + # 2026-07-16 이노테나/한울: 미완성 09:00 이 confirmed → prevChg≈몸통% 로 실매만 통과 + "WS_GAP_FILL_SKIP_INCOMPLETE_BUCKET", + # 확정봉(is_confirmed=1) OHLCV 동결 — 갭보정/백필이 volume 등을 키워 실매↔백테 정합 깨는 것 방지 + # docs/정합성.md · 기본 true · false 로 즉시 롤백 + "WS_CANDLE_FREEZE_ON_CONFIRM", + "WS_CANDLE_FREEZE_DB_LOOKUP_CHUNK", # 1M REST → RAM 3M 롤업 (꼬리 웜업·3M REST 생략) "WS_GAP_ROLLUP_3M_FROM_1M", "WS_GAP_FILL_CANDIDATE_MODE", @@ -779,6 +827,8 @@ ENV_CONFIG_KEYS = ( "BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", "MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", "TAIL_EOD_ENABLED", "TAIL_EOD_HM", + # 스캘핑 EOD — 실매 기존 15:25 장마감청산과 동일 (strategy_eod SCALP) + "SCALP_EOD_ENABLED", "SCALP_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", @@ -1113,6 +1163,7 @@ 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_DART_KEYS = _CONFIG_SPLIT["config_dart"] CONFIG_TABLE_KEYS: Dict[str, Tuple[str, ...]] = { "env_config": ENV_GLOBAL_KEYS, "config_scalp": CONFIG_SCALP_KEYS, @@ -1122,6 +1173,7 @@ CONFIG_TABLE_KEYS: Dict[str, Tuple[str, ...]] = { "config_range_break": CONFIG_RANGE_BREAK_KEYS, "config_updow": CONFIG_UPDOW_KEYS, "config_dbband": CONFIG_DBBAND_KEYS, + "config_dart": CONFIG_DART_KEYS, } @@ -1465,6 +1517,37 @@ class TradeDB: ) CHARACTER SET utf8mb4 """) + # 13. DART 수주/단일판매·공급계약 공시 중복방지 + 이력 + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_disclosures ( + rcept_no VARCHAR(32) NOT NULL PRIMARY KEY, + corp_code VARCHAR(16) NOT NULL DEFAULT '', + corp_name VARCHAR(100) NOT NULL DEFAULT '', + stock_code VARCHAR(20) NOT NULL DEFAULT '', + report_nm VARCHAR(255) NOT NULL DEFAULT '', + rcept_dt VARCHAR(8) NOT NULL DEFAULT '', + url VARCHAR(255) NOT NULL DEFAULT '', + first_seen_at VARCHAR(30) NOT NULL, + mm_sent TINYINT NOT NULL DEFAULT 0, + KEY idx_dart_seen (first_seen_at), + KEY idx_dart_stock (stock_code) + ) CHARACTER SET utf8mb4 + """) + + # 14. DART 임시 워치리스트 (영구구독 아님 — TTL) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_watchlist ( + stock_code VARCHAR(20) NOT NULL PRIMARY KEY, + corp_name VARCHAR(100) NOT NULL DEFAULT '', + rcept_no VARCHAR(32) NOT NULL DEFAULT '', + report_nm VARCHAR(255) NOT NULL DEFAULT '', + added_at VARCHAR(30) NOT NULL, + expires_at VARCHAR(30) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + KEY idx_dart_watch_exp (expires_at) + ) CHARACTER SET utf8mb4 + """) + self._migrate_add_columns() self._migrate_env_config_to_columns() logger.info("📊 DB 테이블 생성/확인 완료") diff --git a/docs/BREAKOUT_LIVE_BT_QA.md b/docs/BREAKOUT_LIVE_BT_QA.md index 49f59cb..1def4fb 100644 --- a/docs/BREAKOUT_LIVE_BT_QA.md +++ b/docs/BREAKOUT_LIVE_BT_QA.md @@ -21,7 +21,7 @@ ### 동작 -- 공통 `resolve_universe_exit_debounce_sec()` → 기본값 = `CONDITION_EXIT_GRACE_SEC`(운영 **120**) +- 공통 `resolve_universe_exit_debounce_sec()` → 기본값 = `CONDITION_EXIT_GRACE_SEC`(운영 **60**, 기존 120→2026-07-17 축소) - 돌파/꼬리/모멘텀 타임라인 `debounce_sec`에 연결 - 전략별 오버라이드: `BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC` / `TAIL_*` / `MOMENTUM_*` (있으면 우선, `0`=OFF) @@ -34,7 +34,7 @@ | 09:11 | 2 | - history는 **effective 유니버스 저장** 설계 → 09:07에 1종이면 그 tick의 live effective도 1종으로 보는 게 맞음 (저장이 grace를 빼먹은 버그로 단정하지 않음). 새벽 스냅샷 후 장초 첫 변동·콜드스타트 가능성이 큼. -- 디바운스 ON 시: 09:07 스냅샷에서도 overnight 종목을 **120초** 유지 → 09:11(약 4분 후)에는 신규 스냅샷 기준으로 정리. 실매 EXIT grace와 같은 전환 규칙. +- 디바운스 ON 시: 09:07 스냅샷에서도 overnight 종목을 **60초** 유지 → 이후 신규 스냅샷 기준으로 정리. 실매 EXIT grace와 같은 전환 규칙. ### 영향 범위 @@ -104,7 +104,7 @@ | 순위 | 항목 | 상태 | |------|------|------| -| 다음 | **2번 검증** — 장초 이노테나·한울: 실매 RAM/틱 vs DB 확정봉 `직전봉약세` 가설 검증 (후보, 미확정) | **다음 순서** | +| 다음 | **2번 검증** — 장초 이노테나·한울 `직전봉약세` | **근본원인 확정 + 갭보정 진행분 제외 적용** (봇 재시작 시 반영) | | 후보 | 1번 A — 마지막 봉 경과 시 포트폴리오 루프에서 **즉시** 슬롯 해제 (전 전략 공통) | 대기 — 저유동은 C로도 분 생성 불가 시 필요 | | 진행 | **1번 C — 판 뒤 1회 REST 백필** | **적용** (아래) | | — | 3번 슬롯 경쟁 | 패스 | @@ -118,5 +118,60 @@ - 로그: `/tmp/backfill_trade_candles_0716c.log` (및 선행 0716b) - 한계: 거래 자체가 없는 분(저유동)은 REST에도 없음 → **C만으로는 완전 메꿈 불가**, A 보완 여지 -- 웹 돌파 **2026-07-16** 재백테 ↔ 실매 비교는 C 반영 후 / 2번 검증과 병행 +- 웹 돌파 **2026-07-16** 재백테 ↔ 실매 비교는 C 반영 후 / 장초 직전봉 수정과 병행 - 실매 봇: post-sell 훅 반영하려면 **재시작 필요** (백필 CLI는 봇 없이 완료 가능) + +--- + +## 장초 직전봉 (이노테나·한울) — 2번 검증 결과 (2026-07-17) + +### 현상 + +| | 이노테나 `333050` | 한울 `320000` | +|--|--|--| +| 실매 매수 | 09:01:09 @5350 | 09:01:42 @11590 | +| 실매 로그 `prevChg` | **1.34%** | **0.61%** | +| BT (DB+웜업) | `탈락-직전봉약세` **0.19%** | `탈락-직전봉약세` **-2.37%** | +| `PREV_CHG_MIN` | 0.3 (실매=BT 동일) | 동일 | + +### 숫자 재현 (스펙: 전전봉 종가 → 직전봉 종가 %) + +- 이노테나 09:00: O=5220 C=5290 / 전일 15:30 C=5280 + - **전일대비 C2C** `(5290-5280)/5280` = **0.19%** ← BT 탈락 + - **당일 몸통** `(5290-5220)/5220` = **1.34%** ← 실매 `prevChg`와 **일치** +- 한울: 전일 15:58 C=11810 → 09:00 C=11530 → C2C **-2.37%**; 몸통 ≈ **0.70%** ≈ 실매 0.61%(RAM OHLC 미세차) + +### 실매 journal 타임라인 (결정적) + +1. `09:00:25` 333050 갭보정 REST 빈응답 → `09:00:34` **500봉 RAM 적재** (진행 중 **당일 09:00** 포함) +2. `09:00:34~09:01:01` 내내 `탈락-직전봉약세 prev=-1.14%` + - `(5220-5280)/5280 = -1.14%` → 갭보정이 넣은 **미완성 09:00(종가≈시가 5220)** 이 확정봉으로 잡힌 상태 +3. `09:01:01` `봉강제확정` 09:00 C=5290 +4. `09:01:02~` `탈락-저항미돌파` (직전봉 게이트 **통과**) → `09:01:06` `BREAKOUT-B ... prevChg=1.34%` 매수 + +한울도 동일 패턴: 장중 `prev=-2.96%`(갭/전일) → 09:00 확정 후 저항 대기 → `prevChg=0.61%`로 매수. + +### 근본원인 + +**갭보정(REST ka10080)이 장중에 “아직 진행 중인 당일 1분봉”을 `is_confirmed`로 RAM에 넣음.** + +- 실매 `check_buy` = `get_candles`(확정) + `get_current_candle`(진행) → **같은 09:00이 확정·진행 양쪽에** 있거나, 확정 체인이 **전일 종가 대신 시가(불완전봉 종가)** 를 `prev_prev`로 물음. +- 그 결과 직전봉 필터가 문서/코드 스펙(전전봉→직전봉 **종가대비**)이 아니라 **당일 첫 봉 몸통%** 에 가깝게 통과. +- BT는 DB 확정봉(+웜업 전일 종가)만 쓰므로 **0.19%/-2.37%로 올바르게 거름** → 장초 실매만 사는 괴리. + +코드 위치: `kis_ws.merge_confirmed_bars` / `fill_gap_from_rest` (현재 분 제외 없음) + `breakout.check_buy` (`virtual = confirmed + forming`). + +### 수정 방향 (승인 후 — 아직 미적용) + +1. **갭보정 시 `candle_time >= 현재 진행 분` 봉은 confirmed merge 제외** (또는 `_current`만 갱신). 실매·BT 모두 전일 종가 C2C로 통일. +2. (선택) 매수 성공 로그에 `confirmed[-2/-1] candle_time/close` 남겨 재발 추적. +3. 스펙을 “직전봉 몸통%”로 바꾸려면 실매·웹·Optuna를 **한 세트**로 바꿔야 함 — 비권장(HTS/주석과 불일치). + +**권장: 1번(진행분 REST 제외).** 핵심 매매 임계값 변경이 아니라 인프라 정합. + +### 수정 적용 (2026-07-17) + +- env: `WS_GAP_FILL_SKIP_INCOMPLETE_BUCKET` (기본 **true**, DB 시드) +- `fill_gap_from_rest` / `merge_confirmed_bars`: 진행 중 버킷(`>=` 현재 봉시작) **insert 금지 + RAM purge** +- 스모크: `scripts/smoke_candle_upsert_rollup.py` (진행분 skip/purge) +- **실매 반영: `kis_trader_main` 재시작 필요** (미재시작 시 기존 프로세스 구코드) diff --git a/docs/계정.md b/docs/계정.md new file mode 100644 index 0000000..6d63dbb --- /dev/null +++ b/docs/계정.md @@ -0,0 +1,182 @@ +# 계정 · 슬롯 · 스케일업 + +> **대화 정리 (2026-07-19)** — 시드/복리 가정, 슬롯 한도, 전략별 자본 격리, **계정 2개 운용** 시 슬리피지·용도 분리. +> 세금·수수료·슬리피지는 표에 미반영(순수익 가정이면 Gross는 더 높게 잡아야 함). + +관련: [정합성.md](./정합성.md) + +--- + +## 0. 한 줄 결론 + +| 주제 | 결론 | +|------|------| +| 같은 로직·같은 종목·계정 2개 동시 매수 | 시장엔 **슬롯×2 한 방**과 같음 → 중소형주 **슬리피지·자가당착** | +| 슬롯 올리기 (1천~20만 원대) | **30만 → 60만은 먼지**, 안전 상한 대략 **슬롯당 ~300만** | +| 시드 더 키울 때 | 계정 복제보다 **20만~100만 원대 전용 전략**으로 유동성 확보 | +| 비율 슬롯 | **전략 ON 개수로 N빵/뻥튀기 금지**. 슬롯% + **전략별 격벽(Cap)** | +| 복리 표 | `총수익 = P × ((1+r)^n − 1)`, 두 시드 차이 = `(P2−P1) × ((1+r)^n − 1)` | + +--- + +## 1. 계정 두개 — 용도 (왜 나누나) + +대화에서 말한 **봇/계정 2대** 목적 정리. “같은 전략을 두 배로 긁기”가 목적이면 **비추천**. + +| 구분 | 용도 예시 | 비고 | +|------|-----------|------| +| **A 계정 (운영)** | 실매 메인. 4전략, 슬롯 Hard-Cap 준수 | IP·앱키·WS 구독(41) 한도 본진 | +| **B 계정 (고액·테스트·분리)** | 파라미터 실험 / 대형주 전용 / 고액 시드 격리 | **가능하면 VM·IP·앱키 분리** (같은 집 동시 → 차단·레이트리밋 위험) | +| 하지 말 것 | 동일 타점·동일 종목에 A·B가 거의 동시 시장가 | 호가 = **합산 체결**. 후행 계정이 1~2틱 불리 | + +**인프라 메모** + +- WS 구독·REST 초당한도·429는 **계정 수만큼** 부담이 늘 수 있음 (공유 IP면 더 위험). +- HTS 접속 OK ≠ OpenAPI 건강. 모의/실전 도메인·토큰 분리 유지. +- 스케일업 정석: **자본 통합 + 슬롯 Cap** 또는 **유동성 다른 유니버스(대형주) 전략 추가**. + +--- + +## 2. 복리 계산식 (임의 원금 대입용) + +거래일 `n` (주식 1년 ≈ **250**), 일 수익률 `r`. + +``` +잔고 = P × (1+r)^n +총수익금 = P × ((1+r)^n − 1) +시드 차이 = (P2 − P1) × ((1+r)^n − 1) +``` + +### 2.1 참고 승수 (매일 동일 r 가정) + +| 일 수익률 r | 20거래일 (월) | 250거래일 (년) | 대략 연 누적 수익률 | +|-------------|---------------|----------------|---------------------| +| 1.0% (0.01) | ×1.220 → **+22.0%** | ×12.03 → **+~1103%** | 대회급·비현실 고정 | +| 0.2% (0.002) | — | ×1.647 → **+~64.7%** | 보수 목표 가정 | +| 0.4% (0.004) | — | ×2.714 → **+~171.4%** | 전략 Gross 가정이면 순은 더 낮음 | + +**단리 vs 복리:** 단리면 0.2%→50%, 0.4%→100%(정확히 2배). 복리면 **0.4%가 0.2%의 2배가 아니라 ~171%**로 벌어짐. +**고정 슬롯 금액(예: 항상 100만)** = 실질 **단리**. 표의 복리 숫자는 **잔고 비율로 슬롯이 커질 때**만 해당. + +--- + +## 3. 시드별 1년 표 (참고) + +### 3.1 일 0.2% · 250일 + +| 시작 원금 | 1년 뒤 잔고 | 누적 총수익 | +|-----------|-------------|-------------| +| 100만 | ~164.7만 | ~64.7만 | +| 200만 | ~329.4만 | ~129.4만 | +| 500만 | ~823.4만 | ~323.4만 | +| 1,000만 | ~1,646.8만 | ~646.8만 | + +### 3.2 일 0.4% · 250일 (슬롯 100만 가정 시 “풀가동 복리” 예시) + +| 운용 기준 | 시작 | 1년 뒤 | 누적 수익 | +|-----------|------|--------|-----------| +| 슬롯 1개 | 100만 | ~271.4만 | ~171.4만 | +| 전략 1 · 동시 5슬롯 | 500만 | ~1,356.9만 | ~856.9만 | +| 4전략 · 총 20슬롯 | 2,000만 | ~5,427.5만 | ~3,427.5만 | + +> **주의:** “전략당 0.4%” ≠ 계좌 일 0.4% 자동. 동시 슬롯·현금비중·손절·수수료에 따라 계좌 일 수익률은 훨씬 작아질 수 있음. +> 월 20%·일 1% 고정은 **소액·무제약 대회 상위** 스케일 이야기지, 기관·버핏 체급 비교용이 아님. + +--- + +## 4. 슬롯 · 주가대 · 시장 충격 + +운영 유니버스: **주가 1,000원 ~ 200,000원**, 현재 슬롯 **~30만**(60만도 스텔스). + +| 주가대 | 슬롯 60만 체감 | 슬리피지 없이 대략 상한(정상 유동성) | +|--------|----------------|--------------------------------------| +| 1천~5천 | 120~600주 | **~300만~500만** | +| 1만~5만 | 소수 십주 | **~500만~1,000만** | +| 5만~20만 | 소수 주 | 더 넉넉 (단, **틱% 왜곡** 주의) | + +**실무 합의:** 이 유니버스에서 **슬롯 Hard-Cap ≈ 300만**. 그 이상은 1호가 소진·부분체결 위험. + +### 4.1 20만 ~ 100만 원대 (대형·대장 전용으로 추가할 때) + +| 주가대 | 틱 | 대략 슬롯 상한(참고) | +|--------|----|----------------------| +| 20만~50만 미만 | 500원 | **~3,000만~5,000만** | +| 50만~100만 미만 | 1,000원 | **~5,000만~1억** | + +함정: 유동성은 크지만 **0.4% 파동 빈도↓**, 틱당 %가 커서 손절/익절 틱 수가 달라짐 → **중소형 파라미터 복붙 금지**, 타임프레임·돌파 가중치 튜닝 필요. + +--- + +## 5. 전략당 한도 (후보 20개 ≠ 20슬롯 매수) + +후보 실시간 20개여도 **동시 보유 상한**으로 자본을 잡음. + +| 동시 보유 | 슬롯 300만 기준 전략당 한도 | 4전략 총시드(참고) | +|-----------|------------------------------|---------------------| +| 3 | 900만 | 3,600만 | +| **5 (권장)** | **1,500만** | **6,000만** | +| 10 | 3,000만 | 1.2억 | +| 20 무제한 | 6,000만 | 2.4억 — 현금잠김·429·동조 손절 위험 | + +동시 타점 다수 → **상위 N개만 큐 정렬 후 매수**(점수·수급·호가잔량 등). 전량 매수 금지. + +--- + +## 6. 비율 슬롯 · 전략별 격리 (질문 답) + +### 6.1 “켜진 전략 수에 따라 슬롯이 4배/2배?” + +**아니요.** ON 전략이 1개라고 남은 3전략 몫을 몰아주면 **집중 리스크** (한 번 손절이 계좌에 증폭). +시장이 조용해서 3전략이 쉬는 날 = 오히려 보수적으로 현금 유지하는 쪽이 맞음. + +### 6.2 추천 구조 (이중 잠금) + +| 단계 | 무엇 | 예시 (총자본 2,000만) | +|------|------|------------------------| +| 1 | **1슬롯** = `min(총잔고 × 고정%, Cap)` | 5% → 100만, Cap 300만 | +| 2 | **전략별 Cap** | 총자본의 25% → 전략당 최대 500만(≈5슬롯) | +| 3 | (선택) **계좌 최소 현금** | 예: 20% 락 | + +→ “이 전략 더 쓰고 저 전략 덜 쓰기” = **시장이 아니라 전략 Cap 안에서만** 허용. +→ 유연한 오버플로우(유휴 자본 일부 대출)는 **나중에**. 당장은 **독립 비율 + Hard-Cap + 전략 격벽**이면 충분. + +### 6.3 의사코드 + +```text +slot = min(int(total_balance * SLOT_PCT), SLOT_MAX_CAP) # 예: 5%, 300만 +if strategy_invested + slot > total_balance * STRATEGY_CAP_PCT: # 예: 25% + reject buy +else: + allow buy +``` + +--- + +## 7. 계정 2개 × 슬롯 300만 = 600만? + +| 질문 | 답 | +|------|----| +| 시차 있으면 괜찮은가? | **짧은 시차면 더 나쁨** — 선주문 소진 → 후주문이 2호가 | +| 언제 괜찮은가? | **종목·시간·전략이 겹치지 않을 때**, 또는 호가가 수억인 대형주 | +| 대안 | 한 계정 Cap 유지 / **20만+ 전용 전략**으로 용량 확장 | + +IOC/FOK·부분체결 시 잔량 취소 여부는 실매 주문 모듈에 명시해 둘 것. + +--- + +## 8. 체크리스트 (스케일업 전) + +- [ ] 슬롯 `min(잔고×%, Cap)` — Cap은 유니버스별(중소형 ~300만 / 대형 별도) +- [ ] 전략별 투자금 Cap (N빵 금지) +- [ ] 동시 보유 상한 ≤ 후보 수 +- [ ] 계정 2대면 **용도 분리** 문서화 (운영 vs 테스트/대형) — 동일 타점 동시 금지 +- [ ] VM·앱키·IP 분리 여부 / 429·WS 41 한도 점검 +- [ ] “일 0.4%”가 **슬롯 Gross**인지 **계좌 순**인지 구분해서 기록 + +--- + +## 9. 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-07-19 | 대화(시드·0.2/0.4% 복리·슬롯300·전략격벽·계정2·대형주) → 본 MD 최초 정리 | diff --git a/docs/정합성.md b/docs/정합성.md new file mode 100644 index 0000000..ae4cffd --- /dev/null +++ b/docs/정합성.md @@ -0,0 +1,176 @@ +# 정합성 + +> **최종안 (2026-07-17)** — 실매 · 웹백테 · Optuna가 **같은 분봉(OHLCV) 진실**을 쓰게 하는 규칙. +> 신호/진입 바 오프셋(±1) 땜빵은 **금지**. 봉이 확정 후에도 커지는 것이 근본 원인이다. + +관련: [CANDLE_FLOW.md](./CANDLE_FLOW.md) · [SCALP_BACKTEST_VS_LIVE.md](./SCALP_BACKTEST_VS_LIVE.md) · [BACKTEST_ALIGNMENT_FINAL.md](./BACKTEST_ALIGNMENT_FINAL.md) + +--- + +## 0. 한 줄 원칙 + +``` +실매가 확정한 1분봉(그 순간의 OHLCV) = DB에 동결 = 웹백테/Optuna가 읽는 봉 +``` + +- **틱** = 체결가(진입/청산 슬리피지) +- **분봉** = 매수 여부(RSI·거래량배수·평균 등) +- **EOD** = 포지션 장마감 청산(≈15:25). **봉 동결이 아님.** + +### 0.1 누가 무엇을 읽나 (오해 방지) + +| 곳 | 읽는 것 | 아님 | +|----|---------|------| +| **실매(장중)** | WS로 막 확정된 봉 → RAM + DB에 **첫 INSERT** | — | +| **웹백테 · 파람/Optuna** | DB **`ws_candles`(+`ws_ticks`)** 과거 재생 | Optuna가 **실시간 WS에 붙지 않음** | +| **아침 기동** | 같은 DB 로드 + 없는 분만 INSERT | “새벽 INSERT만 보는” 전용 분기 **없음** | + +대화에서 쓰인 “실매가 확정한 **실시간** 분봉” = **장중 확정 순간의 숫자**를 뜻함. +→ 파람을 실시간 피드로 바꾸라는 뜻이 **아님**. +→ 고칠 코드 = **존재 시 UPDATE 금지**뿐. 파람/아침용 봉 소스를 둘로 나누지 않음. + +--- + +## 1. 잠긴 매매 규칙 (변경 금지) + +| 항목 | 규칙 | +|------|------| +| 신호 봉 | **T−1 확정봉** (`live_backtest_align=True`) | +| 진입 봉 | **T 확정봉** | +| 유니버스 | 초 단위 `event_time` — 분 슬롯 ±1 해킹 금지 | +| HTS | SCAN 후보 참고. `*_SKIP_HTS_SCAN_DUPES` 기본 **false** 유지 | +| 차트봉 | 매매·백테·Optuna 경로에 사용하지 않음 (`ws_candles` + `ws_ticks`) | + +--- + +## 2. 근본 원인 (측정, 2026-07-16) + +| 현상 | 수치/관찰 | 의미 | +|------|-----------|------| +| 장중 확정 후 재기록 | 봉 마감 +5분 이후 `updated_at` ≈ **76%** | 갭보정 REST가 기존 봉 **UPDATE**(큰 volume 우선) | +| 새벽/매도 후 백필 | 다음날 `updated_at` ≈ **3.8%** (00:48대), 매도 종목과 100% 겹침 | `POST_SELL_CANDLE_BACKFILL`이 hold 구간 UPSERT | +| 갭 구멍 INSERT | ENTER 시 결측분 흔함 | 웜업용 — **INSERT는 유지**, 덮어쓰기만 문제 | +| 샘표류(007540) | 실매 vol_avg≈1328 vs DB≈634 | 같은 공식, **lookback 시리즈가 다름** | + +**정합을 깨는 본체 = 확정 후 UPDATE.** INSERT(구멍 메우기)가 아니다. + +--- + +## 3. 최종 설계: Freeze-on-confirm + +### 3.1 규칙 + +1. **행이 이미 있으면 OHLCV·RSI 덮어쓰기 금지** (RAM `merge_confirmed_bars` + DB upsert + 매도/보유 백필 동일). +2. **없을 때만 INSERT** — 갭 구멍·웜업 유지. +3. 진행 중(미확정) 버킷은 기존처럼 confirmed에 넣지 않음 (`WS_GAP_FILL_SKIP_INCOMPLETE_BUCKET`). +4. (옵션, 후속) 거래량을 틱 합으로 통일 — 1번 안정화 후. + +### 3.2 수정 대상 (구현 시) + +| 경로 | 현재 | 목표 | +|------|------|------| +| `kis_ws.merge_confirmed_bars` | 기존 봉 + `new_vol > old_vol` → upsert | **존재 시 skip** | +| `kis_ws` DB batch / `_flush_batch` | `ON DUPLICATE KEY UPDATE` volume 등 덮음 | **존재 시 no-op** (또는 INSERT IGNORE / 조건부) | +| `post_sell_candle_backfill` `_INSERT_SQL` | `volume=IF(VALUES>volume…)` | **존재 시 skip**, 없는 분만 INSERT | +| 기타 REST/키움 갭보정 → merge | 위 merge 경유 | merge 규칙만 바꿔도 대부분 흡수 | + +### 3.3 하지 않을 것 + +- 09:05 시작·새벽 수집 전면 중지만으로 “해결” 선언 (보조책은 가능, 본체 아님) +- 신호/유니버스 ±1분 보정 +- “EOD 했으니 당일 봉만 보면 된다” (EOD≠봉동결; lookback·전일시가 필요) +- 매수 직전 REST 재조회로 “안정화 체크”를 본치료로 쓰기 (느림·429·나중에 또 덮이면 무의미) + +### 3.4 운영 보조 (선택, 본치료 아님) + +| 아이디어 | 기대 | 한계 | +|----------|------|------| +| 09:05 기동 (조건식·봉 정리 후) | 장초 혼선 ↓ | 장중 UPDATE·새벽 백필 미해결 | +| 새벽/장전 **덮어쓰기** 금지 | freeze와 동일 방향 | 수집 자체 금지가 아니라 INSERT only | +| 실시간 “봉 안정” 폴링 | 체감용 | freeze 없으면 DB는 결국 갈라짐 | + +--- + +## 4. 구현 순서 (승인 후) + +1. **존재 시 덮어쓰기 금지** + `merge_confirmed_bars` / DB upsert / 백필 — 공통 규칙. +2. **스모크** + 확정 직후 volume vs 1시간 뒤 · 다음날 — **동일**해야 함. +3. **7/16 샘표류 대조** + vol 평균·PASS/FAIL이 실매 저널과 같은지 (웹백테 동일 파라미터). + +코드 변경 전: 핵심 매매 로직이 아닌 **저장/병합 계층** 패치. +시그널 T−1/T·청산식은 이 작업에서 건드리지 않음. + +--- + +## 5. 검증 체크리스트 + +- [ ] 확정 봉 DB row: 이후 REST/백필이 volume을 키우지 않음 +- [ ] 결측 분은 여전히 INSERT로 채워짐 (웜업 0건 폭주 없음) +- [ ] 재시작 후 RAM 재로드 → 확정분과 DB 일치 +- [ ] 갭보정 로그: `update=` 가 0에 수렴(또는 skip 카운트), `insert=` 만 정상 +- [ ] 전략 2개 이상 경로 스모크 (예: SCALP + TAIL) — 공유 `ws_candles` 부작용 없음 +- [ ] 웹백테 / Optuna: 동일 `ws_candles` → 실매 저널과 PASS·지표 근접 +- [ ] `POST_SELL_CANDLE_BACKFILL` 켠 상태에서도 hold 구간 **구멍만** 채움 + +--- + +## 6. 영향 범위 분류 + +| 구분 | 영향 | +|------|------| +| **실매** | RAM/DB에 남는 확정봉이 “첫 확정값”으로 고정 → 이후 매수 판정 입력 안정 | +| **웹백테·Optuna** | 같은 DB를 읽으므로 실매와 입력 정렬 (엔진식 변경 없음) | +| **과거 DB** | 이미 덮여 커진 봉은 자동 복구 안 됨. 필요 시 해당일 재수집·재백테는 별도 | + +--- + +## 7. 승인 상태 + +| 항목 | 상태 | +|------|------| +| 방향: freeze-on-confirm / INSERT only | **사용자 승인·구현 진행 (2026-07-17)** | +| env | `WS_CANDLE_FREEZE_ON_CONFIRM` 기본 **true** (false=레거시 덮어쓰기) | +| 구현 | `kis_ws` merge/confirm/DB flush · DB시드 · `post_sell_candle_backfill` · `fill_kiwoom_candles` | +| 재시작 | `db_seed=N` 로그 = DB 확정봉을 RAM에 시드 (REST로 안 덮음) | + +첫 패치: **존재 시 UPDATE 금지** (확정행 OHLCV 동결). 미확정→확정 갱신은 허용. +재시작 시 RAM이 비면 REST가 “첫 삽입”처럼 보이므로, **DB에 이미 있으면 DB값으로만 RAM 시드**. + +--- + +## 8. 다음에 할 일 (쉬운 말 · 우선순위) + +> 코드(freeze)는 이미 켜져 있다. 아래는 **새 기능이 아니라 “잘 됐는지 확인”** 순서다. + +### 1순위 — 깨끗한 장일 하루만 검증 + +**말:** 봉이 끝난 직후 적힌 거래량(volume)이, 한 시간 뒤·다음날에도 **그대로**여야 한다. +중간에 REST·갭보정·매도백필이 숫자를 키우면 실패. + +**보는 법:** +- 확정 직후 volume vs 1시간 뒤 · 다음날 → **같아야 함** +- 갭보정 로그: `freeze_skip` 있고, `update≈0`, **`insert`만** 정상 (구멍 메우기) + +### 2순위 — 웹백테 1회 (같은 날 · 같은 파라미터) + +**말:** 그날 실매가 본 봉으로, 웹 백테도 같은 시험을 한 번 돌려 본다. +PASS/지표가 실매 저널과 **크게 안 벌어지면** OK. + +**주의:** 이미 덮여 커진 날(예: **7/15–16**)은 참고만. **정합 합격 기준으로 쓰지 말 것.** + +### 3순위 — 이 문서 §5 체크리스트 닫기 + +위 1·2가 통과하면 §5 미체크 칸을 체크한다. +그때 “정합 검증 완료”라고 말해도 된다. + +### 선택(후속) — 틱 합으로 volume 통일 + +freeze가 안정된 **뒤에만**. 지금은 필수 아님. + +### 안 하는 것 (여기선 안내만 — 강제 금지는 `.cursorrules`) + +- 신호/진입을 ±1분으로 땜빵하지 않는다 +- freeze를 끄거나 `*_SKIP_HTS_SCAN_DUPES`를 true로 바꾸지 않는다 diff --git a/docs/조건식.md b/docs/조건식.md new file mode 100644 index 0000000..fbc7ba8 --- /dev/null +++ b/docs/조건식.md @@ -0,0 +1,52 @@ +# HTS 조건식 (SCAN 참고) + +> HTS 조건검색 = **후보 유니버스(SCAN)** 전용. +> TRIGGER/Optuna 그리드에 숫자를 맞추라고 강제하지 않음. (`hts-condition-grids.mdc`) +> `*_SKIP_HTS_SCAN_DUPES` 기본 **false** 유지 (사용자 명시 전까지). + +키움 조건식 이름 ↔ 봇: `CONDITION_SHORT_NAME=tail` → `strategy_id=SHORT` + +--- + +## 꼬리 (tail / SHORT) + +**논리:** 아래 4항 **AND** (HTS에서 동시에 만족) + +| # | 조건 (HTS 원문) | +|---|-----------------| +| A | 주가등락률 : **[일] 0봉전(중) 시가대비 0봉전 종가등락률** **-10% 이상 ~ -0.5% 이하** | +| G | 주가등락률 : **[1분] 1봉전(중) 종가대비 0봉전 종가등락률** **-10% 이상 ~ -0.5% 이하** | +| B | **체결강도** **85% 이상 ~ 400% 이하** | +| D | 거래량비율 : **[일] 3봉 전 거래량대비 동일주기** **150% 이상 ~ 2000% 이하** | + +### 한 줄 요약 + +``` +[일] 시가→종가 -10% ~ -0.5% +∧ [1분] 직전봉종가→현재봉종가 -10% ~ -0.5% +∧ 체결강도 85% ~ 400% +∧ [일] 3봉전 대비 거래량 150% ~ 2000% +``` + +### 코드 TRIGGER와의 관계 (참고) + +| 축 | HTS (SCAN) | 코드 (`TAIL_SKIP_HTS_SCAN_DUPES=false` 시) | +|----|------------|---------------------------------------------| +| 등락 A/G | **일봉** 시가대비 + **1분** 직전대비 | **별도 시험**: `TAIL_BAR_CHG_*` = **3분** 직전대비 (이름에 HTS A 붙이지 말 것) | +| 체결강도 B | SCAN | TRIGGER에서 동일 재검사 안 함 | +| 거래량 D | **일봉** 3봉전 대비 | `TAIL_VOL_MULT` → **신호봉(3분)** 거래량 배수 | + +- `skip=false` + `BAR_CHG_MAX=-1.0` = 의도된 이중 필터(3분 최소 하락). 장에 안 맞으면 0건 가능 (2026-07-20). +- 그리드에 `-0.5` 있음 → 파람이 `-1.0`을 고른 것뿐. 시스템 고장 아님. + +### 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-07-20 | 사용자 HTS 원문 반영 (일 -10~-0.5 / 1분 -10~-0.5 / 강도 85~400 / 일거래량 150~2000) | + +--- + +## 돌파 / 모멘텀 / 스캘핑 + +(미기재 — 사용자 제공 시 추가) diff --git a/kis_trader/backtest/backtest_portfolio_common.py b/kis_trader/backtest/backtest_portfolio_common.py index 9cc4461..a30ca06 100644 --- a/kis_trader/backtest/backtest_portfolio_common.py +++ b/kis_trader/backtest/backtest_portfolio_common.py @@ -21,18 +21,27 @@ def flatten_remaining_portfolio_trades( """루프 종료 후 미청산 포지션을 마지막 확정봉 종가로 장부에 남긴다. 분봉이 중간에 끊겨 EOD/max_hold 판정이 안 돈 종목(실매는 벽시계로 청산) 정합용. - ``eod_enabled`` 이면 sell_reason=``eod``, 아니면 ``bt_flatten``. + 마지막 봉 시각이 EOD 시각 **이후**일 때만 ``장마감청산``/``eod`` — + 그 전이면 ``bt_flatten`` (14:57 봉을 장마감으로 위장하지 않음). """ - reason = str(default_reason or "bt_flatten") + reason_default = str(default_reason or "bt_flatten") + eod_on = False + eod_hm = "15:20" + eod_reason = "eod" + _is_eod_bar = None if params is not None and strategy: try: - from kis_trader.engine.strategy_eod import resolve_strategy_eod_params + from kis_trader.engine.strategy_eod import ( + is_backtest_eod_bar, + resolve_strategy_eod_params, + ) - eod_on, _eod_hm = resolve_strategy_eod_params(params, strategy) - if eod_on: - reason = "eod" + eod_on, eod_hm = resolve_strategy_eod_params(params, strategy) + sid = str(strategy or "").strip().upper() + eod_reason = "장마감청산" if sid == "SCALP" else "eod" + _is_eod_bar = is_backtest_eod_bar except Exception: - pass + eod_on = False n = 0 for code in list(portfolio.keys()): @@ -61,6 +70,13 @@ def flatten_remaining_portfolio_trades( if exit_price <= 0 or not sell_time: del portfolio[code] continue + reason = reason_default + if eod_on and _is_eod_bar is not None: + try: + if _is_eod_bar(sell_time, True, eod_hm, default_hm=eod_hm): + reason = eod_reason + except Exception: + pass trade: Dict[str, Any] = { "code": code, "buy_time": pos["entry_time"], @@ -180,6 +196,13 @@ STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = { "per_stock_cap": ("DBBAND_MAX_BUY_AMOUNT",), "min_invest_env": ("DBBAND_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"), }, + "DART": { + "max_stocks": ("DART_MAX_STOCKS", "MAX_STOCKS"), + "total_budget": ("DART_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"), + "slot": ("DART_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), + "per_stock_cap": ("DART_MAX_BUY_AMOUNT",), + "min_invest_env": ("DART_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"), + }, } diff --git a/kis_trader/backtest/breakout_backtest_common.py b/kis_trader/backtest/breakout_backtest_common.py index 39a6af2..50559a0 100644 --- a/kis_trader/backtest/breakout_backtest_common.py +++ b/kis_trader/backtest/breakout_backtest_common.py @@ -232,12 +232,9 @@ def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] def breakout_universe_exit_debounce_sec() -> int: """실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지.""" - from kis_trader.backtest.universe_timeline import resolve_universe_exit_debounce_sec + from kis_trader.backtest.universe_timeline import universe_exit_debounce_sec_for_strategy - return resolve_universe_exit_debounce_sec( - strategy_env_key="BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC", - default_when_no_grace=0, - ) + return universe_exit_debounce_sec_for_strategy("BREAKOUT") def breakout_backtest_candle_warmup_bars() -> int: @@ -322,6 +319,7 @@ def resolve_breakout_universe( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, + exit_debounce_sec=breakout_universe_exit_debounce_sec(), ) if history: return history, "history", len(history), 1 diff --git a/kis_trader/backtest/dart_backtest_common.py b/kis_trader/backtest/dart_backtest_common.py new file mode 100644 index 0000000..17b87ba --- /dev/null +++ b/kis_trader/backtest/dart_backtest_common.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +kis_trader/backtest/dart_backtest_common.py — DART 웹백테 정렬 +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from database import TradeDB +from kis_trader.backtest import scalping_backtest_common as sbc +from kis_trader.engine import dart_engine as de + + +def load_dart_events(db: TradeDB, start: str, end: str) -> List[Dict[str, Any]]: + try: + from kis_trader.scan.dart_order_tracker import ensure_dart_disclosure_columns + ensure_dart_disclosure_columns(db) + except Exception: + pass + rows = db.conn.execute( + """ + SELECT rcept_no, stock_code, corp_name, report_nm, rcept_dt, first_seen_at + FROM dart_disclosures + WHERE stock_code <> '' + AND (filter_ok IS NULL OR filter_ok = 1) + AND ( + (first_seen_at >= %s AND first_seen_at <= %s) + OR (rcept_dt >= %s AND rcept_dt <= %s) + ) + ORDER BY first_seen_at ASC + """, + ( + start + " 00:00:00", + end + " 23:59:59", + start.replace("-", ""), + end.replace("-", ""), + ), + ).fetchall() + return [dict(r) for r in rows] + + +def event_to_candle_time(ev: Dict[str, Any]) -> str: + fs = str(ev.get("first_seen_at") or "") + if fs: + s = fs.replace("-", "").replace(":", "").replace(" ", "") + if len(s) >= 12: + return s[:12] + rd = str(ev.get("rcept_dt") or "") + if len(rd) == 8: + return rd + "0900" + return "" + + +def _load_code_candles(db: TradeDB, code: str, start: str, end: str) -> List[Dict]: + sk = start.replace("-", "") + "0900" + ek = end.replace("-", "") + "1530" + rows = db.conn.execute( + """ + SELECT candle_time, open, high, low, close, volume + FROM ws_candles + WHERE timeframe=1 AND code=%s + AND candle_time >= %s AND candle_time <= %s + ORDER BY candle_time ASC + """, + (code, sk, ek), + ).fetchall() + out = [] + for r in rows: + out.append({ + "candle_time": str(r["candle_time"]), + "open": float(r["open"] or 0), + "high": float(r["high"] or 0), + "low": float(r["low"] or 0), + "close": float(r["close"] or 0), + "volume": float(r["volume"] or 0), + "is_confirmed": 1, + }) + return out + + +def run_dart_backtest_web_aligned( + *, + start: str, + end: str, + params: Optional[Dict[str, Any]] = None, + env_row: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + p = dict(params or de.get_dart_defaults_from_db(env_row=env_row)) + db = TradeDB() + events: List[Dict[str, Any]] = [] + all_trades: List[Dict[str, Any]] = [] + try: + fee_rate, sell_tax, slot = sbc.fee_and_slot_from_env(env_row, strategy="DART") + if params and params.get("slot_money"): + slot = float(params["slot_money"]) + events = load_dart_events(db, start, end) + codes_done = set() + for ev in events: + code = str(ev.get("stock_code") or "").strip() + if not code: + continue + ect = event_to_candle_time(ev) + if not ect: + continue + key = (code, ect[:8]) + if key in codes_done: + continue + codes_done.add(key) + candles = _load_code_candles(db, code, start, end) + if not candles: + continue + trades = de.run_dart_backtest_code( + candles, + p, + event_candle_time=ect, + slot_money=slot, + fee_rate=fee_rate, + sell_tax=sell_tax, + ) + for t in trades: + t["code"] = code + t["name"] = ev.get("corp_name") or code + t["rcept_no"] = ev.get("rcept_no") + all_trades.append(t) + finally: + db.close() + + wins = sum(1 for t in all_trades if float(t.get("pnl") or 0) > 0) + n = len(all_trades) + pnl = sum(float(t.get("pnl") or 0) for t in all_trades) + return { + "ok": True, + "strategy": "DART", + "start": start, + "end": end, + "trades": all_trades, + "trade_count": n, + "win_rate": (wins / n * 100.0) if n else 0.0, + "total_pnl": pnl, + "events": len(events), + "params": p, + } diff --git a/kis_trader/backtest/momentum_backtest_common.py b/kis_trader/backtest/momentum_backtest_common.py index 3ee6d74..266e0f8 100644 --- a/kis_trader/backtest/momentum_backtest_common.py +++ b/kis_trader/backtest/momentum_backtest_common.py @@ -54,18 +54,15 @@ def momentum_universe_exit_debounce_sec() -> int: (overnight→장초 wipe / 단발 EXIT 노이즈 정합). ``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 가 있으면 그 값 우선(0=OFF). """ - from kis_trader.backtest.universe_timeline import resolve_universe_exit_debounce_sec + from kis_trader.backtest.universe_timeline import universe_exit_debounce_sec_for_strategy - return resolve_universe_exit_debounce_sec( - strategy_env_key="MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", - default_when_no_grace=30, - ) + return universe_exit_debounce_sec_for_strategy("MOMENTUM") def momentum_backtest_candle_warmup_bars() -> int: - """백테 지표 warm-up — 실매 ``get_candles(50)`` 과 동일하게 전일·당일 장전 봉 선행.""" + """백테 지표·E(전일시가) warm-up — 실매 갭보정(~500)과 맞춰 전일 장시작까지 덮음.""" from kis_trader.utils.env import get_env_int - return max(0, int(get_env_int("MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", 50))) + return max(0, int(get_env_int("MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", 400))) def prepend_momentum_candle_warmup( @@ -126,15 +123,13 @@ _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 + """기간 시작일 기준 직전 거래일 **장시작 시가** 봉이 있으면 HTS E 해석 가능. + + 전일 오후 봉만 있는 경우(웜업 50 등)는 False → REST 웜업으로 보강. + """ + from kis_trader.engine.momentum_hts_logic import candles_have_prev_session_open + + return candles_have_prev_session_open(rows or [], str(period_day or "")[:8]) def _kiwoom_gap_credentials() -> Tuple[str, str, bool]: diff --git a/kis_trader/backtest/momentum_portfolio_backtest.py b/kis_trader/backtest/momentum_portfolio_backtest.py index 521a047..b005c6e 100644 --- a/kis_trader/backtest/momentum_portfolio_backtest.py +++ b/kis_trader/backtest/momentum_portfolio_backtest.py @@ -40,9 +40,11 @@ from kis_trader.engine.momentum_tick_replay import ( momentum_backtest_scan_sec, momentum_backtest_skip_pre_subscribe, momentum_backtest_use_tick_exit, + momentum_backtest_wallclock_last_price, momentum_live_align_enabled, resolve_momentum_sell_for_bar, try_momentum_sell_on_ticks, + update_momentum_bt_last_px, ) from kis_trader.backtest.momentum_tick_loader import entry_before_first_tick from kis_trader.backtest.momentum_universe_timeline import ( @@ -149,6 +151,8 @@ def _try_open_momentum_position( "target": pe["target"], "max_price": entry_price, "rsi": pe.get("rsi"), + "_bt_last_px": entry_price, + "_bt_last_px_t": str(entry_time or "")[:12], } src = str(pe.get("entry_source") or "ohlc_open") if src == "ws_ticks": @@ -215,7 +219,8 @@ def _record_momentum_sell( all_trades: List[Dict], tick_exit_count: int, ohlc_exit_count: int, -) -> Tuple[int, int]: + wallclock_exit_count: int = 0, +) -> Tuple[int, int, int]: trade: Dict[str, Any] = { "code": code, "buy_time": pos["entry_time"], @@ -237,9 +242,11 @@ def _record_momentum_sell( del portfolio[code] if exit_source == "ws_ticks": tick_exit_count += 1 + elif exit_source == "wallclock_last": + wallclock_exit_count += 1 else: ohlc_exit_count += 1 - return tick_exit_count, ohlc_exit_count + return tick_exit_count, ohlc_exit_count, wallclock_exit_count def _process_sells_for_scan( @@ -252,29 +259,68 @@ def _process_sells_for_scan( all_trades: List[Dict], tick_exit_count: int, ohlc_exit_count: int, + wallclock_exit_count: int, scan_sec: int, -) -> Tuple[int, int]: - """스캔 시각까지 틱·OHLC 청산 (실매 루프: 매도 먼저).""" +) -> Tuple[int, int, int]: + """스캔 시각까지 틱·벽시계 last 청산 (실매 루프: 매도 먼저).""" bar_t = scan_key[:12] is_eod = is_strategy_eod_bar(bar_t, params, "MOMENTUM") + use_wall = momentum_backtest_wallclock_last_price(params) for code in list(portfolio.keys()): ctx = ctx_by_code.get(code) if ctx is None: continue idx = ctx["time_index"].get(bar_t) - if idx is None: - continue candles = ctx["candles"] - c = candles[idx] + c = candles[idx] if idx is not None else None pos = portfolio[code] if str(pos.get("entry_time") or "")[:12] == bar_t: continue entry_time = str(pos.get("entry_time") or "") - sold = False + minute_ticks = [] if momentum_backtest_use_tick_exit(params) and ticks_by_code: minute_ticks = collect_minute_ticks(ticks_by_code, code, bar_t) - # 공유메모리 컬럼 뷰면 dict 재구성 없이 뷰 캡핑(동일 문자열 비교). 아니면 기존 리스트 캡핑. + + if minute_ticks: + try: + from kis_trader.backtest.shared_ticks import TickColumnView + _is_view = isinstance(minute_ticks, TickColumnView) + except Exception: + _is_view = False + if _is_view: + capped = minute_ticks.cap_by_tick_time_le(scan_key[:14]) + else: + capped = [ + tk for tk in minute_ticks + if str(tk.get("tick_time") or "")[:14] <= scan_key[:14] + ] + if capped: + if _is_view: + last_i = None + for i in capped.iter_idx(): + last_i = i + if last_i is not None: + update_momentum_bt_last_px( + pos, float(capped.owner._price[last_i]), bar_t, + ) + else: + for tk in reversed(capped): + try: + px = float(tk.get("price") or 0) + except (TypeError, ValueError): + continue + if px > 0: + update_momentum_bt_last_px(pos, px, bar_t) + break + elif c is not None: + try: + update_momentum_bt_last_px(pos, float(c["close"]), bar_t) + except (TypeError, ValueError, KeyError): + pass + + sold = False + if minute_ticks: try: from kis_trader.backtest.shared_ticks import TickColumnView _is_view = isinstance(minute_ticks, TickColumnView) @@ -293,12 +339,13 @@ def _process_sells_for_scan( ) if tick_res: reason, fill_px, sell_time, hold_min = tick_res - tick_exit_count, ohlc_exit_count = _record_momentum_sell( + tick_exit_count, ohlc_exit_count, wallclock_exit_count = _record_momentum_sell( portfolio=portfolio, code=code, ctx=ctx, pos=pos, reason=reason, exit_price=fill_px, sell_time_key=sell_time, hold_min=hold_min, exit_source="ws_ticks", all_trades=all_trades, tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count, + wallclock_exit_count=wallclock_exit_count, ) sold = True @@ -308,13 +355,28 @@ def _process_sells_for_scan( if not _is_minute_tail_scan(scan_key, scan_sec): continue - cur_c_info = { - "open": float(c["open"]), - "high": float(c["high"]), - "low": float(c["low"]), - "close": float(c["close"]), - "candle_time": bar_t, - } + if c is None and not (use_wall and pos.get("_bt_last_px")): + continue + + if c is not None: + cur_c_info = { + "open": float(c["open"]), + "high": float(c["high"]), + "low": float(c["low"]), + "close": float(c["close"]), + "candle_time": bar_t, + } + else: + last_px = float(pos.get("_bt_last_px") or 0) + if last_px <= 0: + continue + cur_c_info = { + "open": last_px, + "high": last_px, + "low": last_px, + "close": last_px, + "candle_time": bar_t, + } sell_res = resolve_momentum_sell_for_bar( pos, cur_c_info, params, is_eod=is_eod, @@ -324,14 +386,15 @@ def _process_sells_for_scan( if not sell_res: continue reason, exit_price, sell_time_key, hold_min, exit_source = sell_res - tick_exit_count, ohlc_exit_count = _record_momentum_sell( + tick_exit_count, ohlc_exit_count, wallclock_exit_count = _record_momentum_sell( portfolio=portfolio, code=code, ctx=ctx, pos=pos, reason=reason, exit_price=exit_price, sell_time_key=sell_time_key, hold_min=hold_min, exit_source=exit_source, all_trades=all_trades, tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count, + wallclock_exit_count=wallclock_exit_count, ) - return tick_exit_count, ohlc_exit_count + return tick_exit_count, ohlc_exit_count, wallclock_exit_count def _universe_codes_for_scan( @@ -522,6 +585,7 @@ def run_momentum_backtest_portfolio( skipped_micro_buys = 0 tick_exit_count = 0 ohlc_exit_count = 0 + wallclock_exit_count = 0 entry_stats: Dict[str, int] = {} live_align = momentum_live_align_enabled(params) live_scan_queue = momentum_backtest_live_scan_queue_enabled(params) @@ -589,13 +653,14 @@ def run_momentum_backtest_portfolio( 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( + tick_exit_count, ohlc_exit_count, wallclock_exit_count = _process_sells_for_scan( portfolio, ctx_by_code, scan_key, params=params, ticks_by_code=ticks_by_code, all_trades=all_trades, tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count, + wallclock_exit_count=wallclock_exit_count, scan_sec=scan_sec, ) @@ -700,22 +765,41 @@ def run_momentum_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] - if str(portfolio[code]["entry_time"])[:12] == str(t)[:12]: + c = candles[idx] if idx is not None else None + pos = portfolio[code] + if str(pos.get("entry_time") or "")[:12] == str(t)[:12]: + continue + if c is not None: + try: + update_momentum_bt_last_px(pos, float(c["close"]), t) + except (TypeError, ValueError, KeyError): + pass + elif not ( + momentum_backtest_wallclock_last_price(params) + and pos.get("_bt_last_px") + ): continue is_eod = is_strategy_eod_bar(t, params, "MOMENTUM") - cur_c_info = { - "open": float(c["open"]), - "high": float(c["high"]), - "low": float(c["low"]), - "close": float(c["close"]), - "candle_time": t, - } - pos = portfolio[code] + if c is not None: + cur_c_info = { + "open": float(c["open"]), + "high": float(c["high"]), + "low": float(c["low"]), + "close": float(c["close"]), + "candle_time": t, + } + else: + last_px = float(pos.get("_bt_last_px") or 0) + if last_px <= 0: + continue + cur_c_info = { + "open": last_px, + "high": last_px, + "low": last_px, + "close": last_px, + "candle_time": t, + } sell_res = resolve_momentum_sell_for_bar( pos, cur_c_info, params, is_eod=is_eod, @@ -725,12 +809,13 @@ def run_momentum_backtest_portfolio( if not sell_res: continue reason, exit_price, sell_time_key, hold_min, exit_source = sell_res - tick_exit_count, ohlc_exit_count = _record_momentum_sell( + tick_exit_count, ohlc_exit_count, wallclock_exit_count = _record_momentum_sell( portfolio=portfolio, code=code, ctx=ctx, pos=pos, reason=reason, exit_price=exit_price, sell_time_key=sell_time_key, hold_min=hold_min, exit_source=exit_source, all_trades=all_trades, tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count, + wallclock_exit_count=wallclock_exit_count, ) if len(portfolio) >= max_stocks: @@ -776,9 +861,10 @@ def run_momentum_backtest_portfolio( skip_stats: Dict[str, Any] = {} if skipped_micro_buys: skip_stats["skipped_micro_buys"] = skipped_micro_buys - if tick_exit_count or ohlc_exit_count: + if tick_exit_count or ohlc_exit_count or wallclock_exit_count: skip_stats["tick_exit_count"] = tick_exit_count skip_stats["ohlc_exit_count"] = ohlc_exit_count + skip_stats["wallclock_exit_count"] = wallclock_exit_count if entry_stats: skip_stats.update(entry_stats) if live_scan_queue and live_align: diff --git a/kis_trader/backtest/optuna_breakout.py b/kis_trader/backtest/optuna_breakout.py index 8518012..11aa1e2 100644 --- a/kis_trader/backtest/optuna_breakout.py +++ b/kis_trader/backtest/optuna_breakout.py @@ -207,14 +207,12 @@ def prepare_breakout_search_context( fallback_sim_interval = 5 if not use_fallback_universe and start_ymd and end_ymd: try: - from kis_trader.database.db_manager import get_db as _get_ext_db - _ext = _get_ext_db() - history = _ext.get_universe_by_candle_time( - strategy_id="BREAKOUT", start_ymd=start_ymd, end_ymd=end_ymd, + from kis_trader.backtest.breakout_backtest_common import resolve_breakout_universe + history, src, n_bins, _scan_iv = resolve_breakout_universe( + start_ymd, end_ymd, use_saved_history=True, ) if history: universe_by_slot = history - n_bins = len(history) avg = sum(len(v) for v in history.values()) / max(1, n_bins) logger.info("✅ 유니버스: BREAKOUT 이력 | %s분봉 · 평균 %.1f종목", n_bins, avg) except Exception as exc: diff --git a/kis_trader/backtest/optuna_common.py b/kis_trader/backtest/optuna_common.py index 0c250ad..f0512e8 100644 --- a/kis_trader/backtest/optuna_common.py +++ b/kis_trader/backtest/optuna_common.py @@ -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", "scalp") +OPTUNA_STRATEGIES = ("tail", "momentum", "breakout", "scalp", "dart") def mariadb_creds() -> dict: diff --git a/kis_trader/backtest/optuna_dart.py b/kis_trader/backtest/optuna_dart.py new file mode 100644 index 0000000..55c6bf3 --- /dev/null +++ b/kis_trader/backtest/optuna_dart.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""kis_trader/backtest/optuna_dart.py — DART Optuna TPE.""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +import optuna +from optuna.samplers import TPESampler + +from database import TradeDB +from kis_trader.backtest.optuna_common import ( + announce_optuna_json_path, + resolve_optuna_storage_url, +) +from kis_trader.backtest.param_search_dart import ( + _dart_grids, + apply_params_to_db, + evaluate_dart_param_combo, +) +from kis_trader.engine import dart_engine as de + +logger = logging.getLogger("param_search_optuna") +_FAIL = -1e18 + + +def prepare_dart_search_context(start: str, end: str, mode: str) -> Optional[Dict[str, Any]]: + grids = _dart_grids() + if mode not in grids: + logger.error("DART mode: %s (fast/coarse/fine)", mode) + return None + db = TradeDB() + try: + env_row = db.get_merged_env_snapshot() or {} + finally: + db.close() + return { + "start": start, + "end": end, + "mode": mode, + "grid": grids[mode], + "env_row": env_row, + "base": de.get_dart_defaults_from_db(env_row=env_row), + } + + +def run_dart_optuna( + ctx: Dict[str, Any], + *, + n_trials: int, + storage_url: Optional[str] = None, + study_name: Optional[str] = None, + min_trades: int = 1, + sampler_name: str = "tpe", + seed: int = 42, + show_progress: bool = False, + **_kwargs: Any, +) -> optuna.Study: + grid: Dict[str, Any] = ctx["grid"] + storage = storage_url or resolve_optuna_storage_url() + name = study_name or "dart_%s_%s" % ( + ctx["mode"], datetime.now().strftime("%Y%m%d_%H%M%S"), + ) + sampler = TPESampler(seed=seed) if sampler_name != "random" else optuna.samplers.RandomSampler(seed=seed) + study = optuna.create_study( + study_name=name, + storage=storage, + direction="maximize", + sampler=sampler, + load_if_exists=True, + ) + + def objective(trial: optuna.Trial) -> float: + params = dict(ctx["base"]) + for axis, values in grid.items(): + params[axis] = trial.suggest_categorical(axis, list(values)) + res = evaluate_dart_param_combo( + params, start=ctx["start"], end=ctx["end"], env_row=ctx["env_row"], + ) + n = int(res.get("trade_count") or 0) + pnl = float(res.get("total_pnl") or 0) + trial.set_user_attr("trade_count", n) + trial.set_user_attr("total_pnl", pnl) + trial.set_user_attr("win_rate", float(res.get("win_rate") or 0)) + if n < min_trades: + return _FAIL + return pnl + + study.optimize(objective, n_trials=n_trials, show_progress_bar=show_progress) + announce_optuna_json_path("dart", study) + return study + + +def apply_best_dart_trial(study: optuna.Study) -> bool: + try: + best = study.best_trial + except Exception: + return False + if float(best.value or _FAIL) <= 0: + logger.warning("DART best PnL<=0 — DB 미적용") + return False + apply_params_to_db(dict(best.params)) + return True diff --git a/kis_trader/backtest/optuna_scalping.py b/kis_trader/backtest/optuna_scalping.py index 8e4f6f4..cd07f3a 100644 --- a/kis_trader/backtest/optuna_scalping.py +++ b/kis_trader/backtest/optuna_scalping.py @@ -63,6 +63,8 @@ class ScalpSearchContext: period_days: int portfolio: Dict[str, Any] grid_keys: List[str] + start_key: str = "" + end_key: str = "" ticks_by_code: Any = None tick_rows: int = 0 tick_backtest_meta: Dict[str, Any] = field(default_factory=dict) @@ -279,6 +281,8 @@ def prepare_scalp_search_context( period_days=period_days, portfolio=portfolio, grid_keys=scalp_grid_axis_keys(mode), + start_key=start_key, + end_key=end_key, ticks_by_code=ticks_by_code, tick_rows=int(tick_rows), tick_backtest_meta=tick_backtest_meta, @@ -352,6 +356,8 @@ def run_scalp_optuna( ticks_by_code=ctx.ticks_by_code, orderbook_by_code=ctx.orderbook_by_code, program_by_code=ctx.program_by_code, + start_key=ctx.start_key, + end_key=ctx.end_key, ) if result is None: trial.set_user_attr("gates_ok", False) @@ -478,6 +484,8 @@ def run_scalp_optuna( ticks_by_code=ctx.ticks_by_code, orderbook_by_code=ctx.orderbook_by_code, program_by_code=ctx.program_by_code, + start_key=ctx.start_key, + end_key=ctx.end_key, ) def _save_partial(_data: Dict[str, Any]) -> None: diff --git a/kis_trader/backtest/param_search_breakout.py b/kis_trader/backtest/param_search_breakout.py index cadab28..d12f6d7 100644 --- a/kis_trader/backtest/param_search_breakout.py +++ b/kis_trader/backtest/param_search_breakout.py @@ -1276,15 +1276,12 @@ def run_search( end_ymd = end.replace("-", "") if end else "" if not use_fallback_universe and start_ymd and end_ymd: try: - from kis_trader.database.db_manager import get_db as _get_ext_db # type: ignore - _ext = _get_ext_db() - history = _ext.get_universe_by_candle_time( - strategy_id="BREAKOUT", - start_ymd=start_ymd, end_ymd=end_ymd, + from kis_trader.backtest.breakout_backtest_common import resolve_breakout_universe + history, _src, n_bins, _scan_iv = resolve_breakout_universe( + start_ymd, end_ymd, use_saved_history=True, ) if history: universe_by_slot = history - n_bins = len(history) avg = sum(len(v) for v in history.values()) / max(1, n_bins) print(f"✅ 유니버스: 신봇 BREAKOUT 이력 사용 | {n_bins:,}분봉 · 평균 {avg:.1f}종목") else: diff --git a/kis_trader/backtest/param_search_dart.py b/kis_trader/backtest/param_search_dart.py new file mode 100644 index 0000000..f9c0a61 --- /dev/null +++ b/kis_trader/backtest/param_search_dart.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +kis_trader/backtest/param_search_dart.py — DART Optuna/Grid 축 +""" +from __future__ import annotations + +from typing import Any, Dict, List + +from database import TradeDB +from kis_trader.backtest.dart_backtest_common import run_dart_backtest_web_aligned +from kis_trader.engine import dart_engine as de +from kis_trader.utils.env import get_env_bool + + +def _dart_grids() -> Dict[str, Dict[str, List[Any]]]: + """mode → 축. 실매 기본값이 각 축에 포함.""" + d = de.get_dart_defaults_from_db() + rsi_os = float(d.get("rsi_oversold", 30)) + rsi_rc = float(d.get("rsi_reclaim", 35)) + sl = float(d.get("sl_pct", 0.02)) + tp = float(d.get("tp_pct", 0.04)) + vol = float(d.get("vol_mult", 1.5)) + win = int(d.get("event_window_bars", 120)) + return { + "fast": { + "rsi_oversold": sorted({rsi_os, 25.0, 30.0, 35.0}), + "rsi_reclaim": sorted({rsi_rc, 32.0, 35.0, 40.0}), + "sl_pct": sorted({sl, 0.015, 0.02, 0.025}), + "tp_pct": sorted({tp, 0.03, 0.04, 0.05}), + "vol_mult": sorted({vol, 1.2, 1.5, 2.0}), + "event_window_bars": sorted({win, 60, 120, 180}), + }, + "coarse": { + "rsi_oversold": [20.0, 25.0, 30.0, 35.0], + "rsi_reclaim": [30.0, 35.0, 40.0, 45.0], + "sl_pct": [0.015, 0.02, 0.03], + "tp_pct": [0.03, 0.04, 0.06], + "vol_mult": [1.0, 1.5, 2.0, 2.5], + "event_window_bars": [60, 90, 120, 180], + "trail_pct": [0.01, 0.015, 0.02], + "trail_arm_pct": [0.015, 0.02, 0.03], + }, + "fine": { + "rsi_oversold": sorted({rsi_os, rsi_os - 2, rsi_os + 2, 28.0, 30.0}), + "rsi_reclaim": sorted({rsi_rc, rsi_rc - 2, rsi_rc + 2, 35.0, 38.0}), + "sl_pct": sorted({sl, 0.018, 0.02, 0.022}), + "tp_pct": sorted({tp, 0.035, 0.04, 0.045}), + "vol_mult": sorted({vol, 1.3, 1.5, 1.8}), + "event_window_bars": sorted({win, 90, 120, 150}), + }, + } + + +def apply_params_to_db(params: Dict[str, Any]) -> None: + """Optuna --apply-best 전용. 포트폴리오 키 제외.""" + if not get_env_bool("DART_TRADE_ENABLED", False): + # 적용은 허용하되 매매 스위치는 사용자 것 유지 + pass + mapping = { + "rsi_oversold": "DART_RSI_OVERSOLD", + "rsi_reclaim": "DART_RSI_RECLAIM", + "sl_pct": "DART_STOP_LOSS_PCT", + "tp_pct": "DART_TAKE_PROFIT_PCT", + "vol_mult": "DART_VOL_MULT", + "event_window_bars": "DART_EVENT_WINDOW_BARS", + "trail_pct": "DART_TRAIL_PCT", + "trail_arm_pct": "DART_TRAIL_ARM_PCT", + "rsi_period": "DART_RSI_PERIOD", + "max_hold_bars": "DART_MAX_HOLD_BARS", + } + patch = {} + for k, env_k in mapping.items(): + if k in params: + patch[env_k] = str(params[k]) + if not patch: + return + db = TradeDB() + try: + db.insert_env_snapshot(patch) + finally: + db.close() + + +def evaluate_dart_param_combo( + params: Dict[str, Any], + *, + start: str, + end: str, + env_row: Dict[str, Any], +) -> Dict[str, Any]: + base = de.get_dart_defaults_from_db(env_row=env_row) + base.update(params) + return run_dart_backtest_web_aligned( + start=start, end=end, params=base, env_row=env_row, + ) diff --git a/kis_trader/backtest/param_search_optuna.py b/kis_trader/backtest/param_search_optuna.py index d25135d..f6d413b 100644 --- a/kis_trader/backtest/param_search_optuna.py +++ b/kis_trader/backtest/param_search_optuna.py @@ -75,6 +75,11 @@ from kis_trader.backtest.optuna_scalping import ( prepare_scalp_search_context, run_scalp_optuna, ) +from kis_trader.backtest.optuna_dart import ( + apply_best_dart_trial, + prepare_dart_search_context, + run_dart_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, @@ -106,6 +111,7 @@ STRATEGY_MODES: Dict[str, List[str]] = { "momentum": ["fast", "exit", "rr", "coarse", "fine", "wide", "full"], "breakout": ["fast", "coarse", "fine", "wide", "full"], "scalp": ["fast", "trigger", "exit", "coarse", "fine", "full", "wide"], + "dart": ["fast", "coarse", "fine"], } @@ -864,6 +870,23 @@ def main() -> None: if args.apply_best: apply_best_scalp_trial(study) + elif strategy == "dart": + ctx_d = prepare_dart_search_context(args.start, args.end, mode) + if ctx_d is None: + sys.exit(1) + study = run_dart_optuna( + ctx_d, + n_trials=n_trials, + storage_url=storage_url, + study_name=study_name, + min_trades=args.min_trades, + sampler_name=sampler_name, + seed=args.seed, + show_progress=not args.no_progress, + ) + if args.apply_best: + apply_best_dart_trial(study) + else: ctx_b = prepare_breakout_search_context( args.start, args.end, mode, diff --git a/kis_trader/backtest/param_search_scalping.py b/kis_trader/backtest/param_search_scalping.py index 63812cd..f01c3b5 100644 --- a/kis_trader/backtest/param_search_scalping.py +++ b/kis_trader/backtest/param_search_scalping.py @@ -737,6 +737,8 @@ def evaluate_scalp_param_combo( ticks_by_code: Any = None, orderbook_by_code: Any = None, program_by_code: Any = None, + start_key: str = "", + end_key: str = "", ) -> Optional[Dict[str, Any]]: """단일 스캘핑(reversal) 조합 백테 — Grid 워커·Optuna objective 공통. @@ -757,6 +759,19 @@ def evaluate_scalp_param_combo( engine_params["_bt_program_by_code"] = program_by_code meta: Dict[str, Any] = {} + sk = str(start_key or "").strip() + ek = str(end_key or "").strip() + if len(sk) >= 12: + meta["start_key"] = sk[:12] + engine_params["_backtest_period_start_key"] = sk[:12] + elif len(sk) >= 8: + meta["start_key"] = sk[:8] + "0000" + engine_params["_backtest_period_start_key"] = meta["start_key"] + if len(ek) >= 12: + meta["end_key"] = ek[:12] + elif len(ek) >= 8: + meta["end_key"] = ek[:8] + "2359" + 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, @@ -920,12 +935,14 @@ def _evaluate_scalp_chunk( """워커: 청크 내 조합 평가 — 시각순 포트폴리오·총한도 (scalping_backtest_common).""" shared = worker_shared_get() ticks_preloaded = None + period_start_key = "" 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") + period_start_key = str(shared.get("start_key") or "")[:12] # ws_ticks 공유메모리(opt-in): descriptor 로 read-only attach (워커당 1회 재사용). if not ticks_preloaded: _desc = shared.get("ticks_shared_descriptor") @@ -948,8 +965,12 @@ def _evaluate_scalp_chunk( engine_params["max_stocks"] = int(max_stocks) engine_params["total_budget_krw"] = float(total_budget_krw) engine_params["portfolio_mode"] = True + if period_start_key: + engine_params["_backtest_period_start_key"] = period_start_key meta: Dict[str, Any] = {} + if period_start_key: + meta["start_key"] = period_start_key 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, @@ -1043,6 +1064,11 @@ def _load_candles_for_search(start: str, end: str, rsi_period: int) -> dict: if len(rows) < rsi_period + 5: continue codes_candles[code] = [dict(r) for r in rows] + if codes_candles: + from kis_trader.backtest.scalping_backtest_common import ( + prepend_scalp_candle_warmup, + ) + prepend_scalp_candle_warmup(db, codes_candles, start_key[:12]) finally: db.close() return codes_candles @@ -1186,17 +1212,13 @@ def run_search(start: str, end: str, mode: str, top_n: int, print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → 시뮬레이션 유니버스 (조합별 거래 수 확대)") elif start_ymd and end_ymd: try: - # 신봇 이력 (초단위 event_time) → 1분 캔들 시각으로 리샘플링 - from kis_trader.database.db_manager import get_db as _get_ext_db # type: ignore - _ext = _get_ext_db() - history = _ext.get_universe_by_candle_time( - strategy_id="SCALP", - start_ymd=start_ymd, - end_ymd=end_ymd, + # Optuna/웹과 동일 — resolve_scalp_universe (EXIT debounce 포함) + from kis_trader.backtest.scalping_backtest_common import resolve_scalp_universe + history, _src, n_bins, _scan_iv = resolve_scalp_universe( + start_ymd, end_ymd, use_saved_history=True, strategy_id="SCALP", ) if history: universe_by_slot = history - n_bins = len(history) avg = sum(len(v) for v in history.values()) / max(1, n_bins) print( f"✅ 유니버스: 신봇 이력 사용 (event_time → 1분 캔들 리샘플링) | " @@ -1271,6 +1293,8 @@ def run_search(start: str, end: str, mode: str, top_n: int, "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, + "start_key": start_key[:12] if start_key else "", + "end_key": end_key[:12] if end_key else "", }) payload_bytes = shared.estimate_bytes() n_cpu = os.cpu_count() or 4 diff --git a/kis_trader/backtest/range_break_backtest_common.py b/kis_trader/backtest/range_break_backtest_common.py index c91490a..36594a9 100644 --- a/kis_trader/backtest/range_break_backtest_common.py +++ b/kis_trader/backtest/range_break_backtest_common.py @@ -46,11 +46,15 @@ def resolve_range_break_universe( if use_saved_history and strategy_id: try: from kis_trader.database.db_manager import get_db as _get_ext_db + from kis_trader.backtest.universe_timeline import ( + universe_exit_debounce_sec_for_strategy, + ) history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, + exit_debounce_sec=universe_exit_debounce_sec_for_strategy(strategy_id), ) if history: return history, "history", len(history), 1 diff --git a/kis_trader/backtest/scalping_backtest_common.py b/kis_trader/backtest/scalping_backtest_common.py index 2b02646..9d4bfc8 100644 --- a/kis_trader/backtest/scalping_backtest_common.py +++ b/kis_trader/backtest/scalping_backtest_common.py @@ -54,10 +54,12 @@ def resolve_scalp_universe( try: from kis_trader.database.db_manager import get_db as _get_ext_db + debounce_sec = scalp_universe_exit_debounce_sec() history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, + exit_debounce_sec=debounce_sec, ) if history: return history, "history", len(history), 1 @@ -66,13 +68,79 @@ def resolve_scalp_universe( return None, "all", 0, 1 +def scalp_universe_exit_debounce_sec() -> int: + """실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지.""" + from kis_trader.backtest.universe_timeline import universe_exit_debounce_sec_for_strategy + + return universe_exit_debounce_sec_for_strategy("SCALP") + + +def scalp_backtest_candle_warmup_bars() -> int: + """백테 RSI 등 warm-up — 실매 봉 버퍼와 같이 기간 시작 전 N봉 prepend.""" + from kis_trader.utils.env import get_env_int + + return max(0, int(get_env_int("SCALP_BACKTEST_CANDLE_WARMUP_BARS", 50))) + + +def prepend_scalp_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봉을 종목별로 prepend. + RSI 판별용 — 포트폴리오 all_times 는 ``_backtest_period_start_key`` 로 기간만 사용. + """ + wb = ( + scalp_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] + 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 + # 이미 기간 전 봉이 있으면 skip (idempotent) + 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( + "SELECT candle_time, open, high, low, close, volume " + "FROM ws_candles WHERE timeframe=1 AND code=%s " + "AND candle_time < %s AND is_confirmed=1 " + "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) + return total_prepended + + def load_scalp_candles_by_code( db, start_key: str, end_key: str, rsi_period: int = 3, ) -> Tuple[Dict[str, List[Dict]], int]: - """ws_candles 1분봉 전 종목 로드.""" + """ws_candles 1분봉 전 종목 로드 (+ 기간 전 웜업 prepend).""" codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", @@ -97,6 +165,7 @@ def load_scalp_candles_by_code( candles_by_code[code] = [dict(r) for r in rows] total_candles += len(rows) + prepend_scalp_candle_warmup(db, candles_by_code, str(start_key)[:12]) return candles_by_code, total_candles @@ -137,6 +206,14 @@ def run_scalping_backtest_web_aligned( engine_params.setdefault("scan_interval_min", 1) engine_params.setdefault("portfolio_mode", True) + # 기간 시작 키 — 웜업 봉이 all_times 에 섞이지 않도록 (돌파·모멘텀과 동일) + _sk_w = str((meta_out or {}).get("start_key") or "")[:12] + if _sk_w: + engine_params["_backtest_period_start_key"] = _sk_w + _db_w = (meta_out or {}).get("db") + if _db_w is not None and str(mode).strip().lower() != "momentum": + prepend_scalp_candle_warmup(_db_w, candles_by_code, _sk_w) + 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") @@ -194,6 +271,9 @@ def run_scalping_backtest_web_aligned( meta_out["backtest_buy_source"] = "ohlc_fallback" else: meta_out["backtest_buy_source"] = "align" + if meta_out is not None: + meta_out["universe_exit_debounce_sec"] = scalp_universe_exit_debounce_sec() + meta_out["candle_warmup_bars"] = scalp_backtest_candle_warmup_bars() 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 6682eb7..8809b7f 100644 --- a/kis_trader/backtest/scalping_portfolio_backtest.py +++ b/kis_trader/backtest/scalping_portfolio_backtest.py @@ -10,6 +10,7 @@ from datetime import datetime 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, @@ -26,6 +27,11 @@ from kis_trader.engine.scalping_engine import ( compute_rsi_series, effective_tp_pct_from_params, ) +from kis_trader.engine.strategy_eod import ( + eod_bar_time_key, + is_strategy_eod_bar, + resolve_strategy_eod_params, +) from kis_trader.engine.tick_exit_common import ( backtest_sell_slip_pct, backtest_tick_poll_ms, @@ -125,7 +131,6 @@ def run_scalping_backtest_portfolio( rsi_period = int(params.get("rsi_period", 3)) 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) max_stocks = _max_stocks_from_params(params) @@ -153,6 +158,7 @@ def run_scalping_backtest_portfolio( 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 @@ -170,7 +176,10 @@ def run_scalping_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]] = {} @@ -248,27 +257,75 @@ def run_scalping_backtest_portfolio( break # 1시각 1매수 # ── Phase 1: 보유 종목 청산 ── + # 실매는 벽시계 EOD(15:25) — 해당 분봉이 없는 종목도 직전가로 장마감청산 + is_eod_t = is_strategy_eod_bar(t, params, "SCALP") for code in list(portfolio.keys()): ctx = ctx_by_code.get(code) if ctx is None: continue - idx = ctx["time_index"].get(t) - if idx is None: + pos = portfolio[code] + entry_t = str(pos.get("entry_time") or "") + entry_key = entry_t[:12] if entry_t else "" + t_key = str(t)[:12] + if entry_key and t_key <= entry_key: continue + + idx = ctx["time_index"].get(t) candles = ctx["candles"] + day = t_key[:8] if len(t_key) >= 8 else str(t)[:8] + + if idx is None: + if not is_eod_t: + continue + # 벽시계 EOD: 이 시각 봉 없음 → 진입 이후 마지막 확정봉 종가 + last = None + for c in reversed(candles): + ct = str(c.get("candle_time") or "") + if not ct: + continue + if entry_key and ct[:12] < entry_key: + continue + if ct[:12] > t_key: + continue + last = c + break + if last is None: + continue + exit_price = float(last.get("close") or 0) + if exit_price <= 0: + continue + _eod_on, eod_hm = resolve_strategy_eod_params(params, "SCALP") + sell_time = eod_bar_time_key(day, eod_hm, default_hm="15:25") or t_key + trade = { + "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": "장마감청산", + "hold_min": 0, + "exit_source": "wallclock_eod", + } + if pos.get("rsi") is not None: + try: + trade["rsi_entry"] = round(float(pos["rsi"]), 1) + except (TypeError, ValueError): + pass + all_trades.append(trade) + ctx["last_exit_dt"][day] = _t2dt(sell_time) + ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 + del portfolio[code] + continue + c = candles[idx] - day = t[:8] hi = float(c["high"]) lo = float(c["low"]) cl = float(c["close"]) op = float(c["open"]) - is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit - - pos = portfolio[code] - if t == pos["entry_time"]: - continue + is_eod = is_eod_t cur_c_info = { "open": op, @@ -308,6 +365,12 @@ def run_scalping_backtest_portfolio( reason, exit_price = res sell_time = t ohlc_exit_count += 1 + # EOD 봉이 15:30만 있어도 사유·시각은 실매 EOD(15:25)에 맞춤 + if reason == "장마감청산": + eod_on, eod_hm = resolve_strategy_eod_params(params, "SCALP") + eod_key = eod_bar_time_key(day, eod_hm, default_hm="15:25") + if eod_key: + sell_time = eod_key trade: Dict[str, Any] = { "code": code, "buy_time": pos["entry_time"], @@ -419,6 +482,12 @@ def run_scalping_backtest_portfolio( skip_stats["ohlc_exit_count"] = ohlc_exit_count if tick_entry_count: skip_stats["tick_entry_count"] = tick_entry_count + flat_n = flatten_remaining_portfolio_trades( + portfolio, ctx_by_code, all_trades, + params=params, strategy=strategy, + ) + 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"]) diff --git a/kis_trader/backtest/tail_backtest_common.py b/kis_trader/backtest/tail_backtest_common.py index 671c064..e74f2bd 100644 --- a/kis_trader/backtest/tail_backtest_common.py +++ b/kis_trader/backtest/tail_backtest_common.py @@ -294,11 +294,15 @@ def resolve_tail_universe( if use_saved_history and strategy_id: try: from kis_trader.database.db_manager import get_db as _get_ext_db + from kis_trader.backtest.universe_timeline import ( + universe_exit_debounce_sec_for_strategy, + ) history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, + exit_debounce_sec=universe_exit_debounce_sec_for_strategy(strategy_id), ) if history: return history, "history", len(history), 1 @@ -698,12 +702,9 @@ def run_tail_backtest_web_aligned( if len(_sk) >= 8 and len(_ek) >= 8: 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, + universe_exit_debounce_sec_for_strategy, ) + _deb = universe_exit_debounce_sec_for_strategy(TAIL_STRATEGY_ID) _tl = build_universe_timeline( strategy_id=TAIL_STRATEGY_ID, start_ymd=_sk[:8], end_ymd=_ek[:8], diff --git a/kis_trader/backtest/tail_param_search.py b/kis_trader/backtest/tail_param_search.py index ec5174e..5cc4acb 100644 --- a/kis_trader/backtest/tail_param_search.py +++ b/kis_trader/backtest/tail_param_search.py @@ -1038,8 +1038,8 @@ TAIL_GRID_AXIS_HINTS_KO: Dict[str, str] = { "reentry_min_edge_krw": "재진입 최소 당일 누적순익(원) — 2회째부터, 수수료 감안 엣지", "reentry_require_nonneg": "재진입 시 당일 누적순익≥0 필수 — OFF=엣지(원)만 검사", "cooldown_min": "청산 후 동일종목 재진입 콜다운(분) — 연타 방지", - "bar_chg_min_pct": "진입 신호봉 등락 하한(%) — 과대 급락 제외", - "bar_chg_max_pct": "진입 신호봉 등락 상한(%) — 최소 하락폭 요구", + "bar_chg_min_pct": "3분 신호봉 직전대비 등락 하한(%) — 과대 급락 제외", + "bar_chg_max_pct": "3분 신호봉 직전대비 등락 상한(%) — 이보다 덜 빠지면 탈락(예:-0.5=최소0.5%하락)", "rsi_threshold": "RSI 과열 진입 거부 기준(%) — 낮을수록 매수 적음", "pattern_pin": "핀바(긴 아래꼬리 단일봉) 진입 패턴 ON/OFF", "pattern_engulfing": "장악형(2봉 반전) 진입 패턴 ON/OFF", @@ -1047,7 +1047,7 @@ 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와 정합 검증용", + "skip_hts_scan_dupes": "TRIGGER 3분 직전대비 등락(BAR_CHG) 재검사 생략 — HTS 일봉A/1분G와 축 다름", "cand_limit": "실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 매수체크 (0=무제한)", "max_daily_change": "급등주 필터(당일 등락률 상한, %)", "trail_pct": "보조 트레일 폭(고점 대비, 0=OFF). 래칫 ON이면 1단계보다 arm이 커야 효과", diff --git a/kis_trader/backtest/universe_timeline.py b/kis_trader/backtest/universe_timeline.py index 20bc69a..5e62188 100644 --- a/kis_trader/backtest/universe_timeline.py +++ b/kis_trader/backtest/universe_timeline.py @@ -41,6 +41,32 @@ def resolve_universe_exit_debounce_sec( return max(0, int(default_when_no_grace)) +# strategy_id → (전략별 오버라이드 env 키, CONDITION_EXIT_GRACE=0 일 때 폴백) +# 웹·Optuna·resolve_*_universe 가 이 맵만 쓰면 debounce=0 특수케이스가 사라진다. +_STRATEGY_UNIVERSE_EXIT_DEBOUNCE: Dict[str, Tuple[Optional[str], int]] = { + "SCALP": ("SCALP_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), + "MOMENTUM": ("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", 30), # 모멘텀 기존 폴백 30 유지 + "BREAKOUT": ("BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), + "SHORT": ("TAIL_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), + "TAIL": ("TAIL_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), + "RANGE_BREAK": ("RANGE_BREAK_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), +} + + +def universe_exit_debounce_sec_for_strategy(strategy_id: str) -> int: + """전략 공통 — history 슬롯·타임라인 EXIT 디바운스 초. + + 우선순위: ``{STRATEGY}_UNIVERSE_EXIT_DEBOUNCE_SEC`` → ``CONDITION_EXIT_GRACE_SEC`` + → 전략별 폴백(모멘텀만 30, 나머지 0). + """ + sid = str(strategy_id or "").strip().upper() + key, dflt = _STRATEGY_UNIVERSE_EXIT_DEBOUNCE.get(sid, (None, 0)) + return resolve_universe_exit_debounce_sec( + strategy_env_key=key, + default_when_no_grace=dflt, + ) + + def _event_time_to_key(event_time: str) -> str: et = str(event_time or "") if len(et) < 19: diff --git a/kis_trader/engine/dart_engine.py b/kis_trader/engine/dart_engine.py new file mode 100644 index 0000000..c7f9237 --- /dev/null +++ b/kis_trader/engine/dart_engine.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +""" +kis_trader/engine/dart_engine.py — DART 수주 공시 후 RSI 반등 TRIGGER +==================================================================== +[SCAN] Open DART 단일판매·공급계약 → dart_disclosures / dart_watchlist +[TRIGGER] 공시 이후 이벤트 창 안에서 1분봉 RSI 과매도 후 재돌파 시 진입 +[EXIT] 손절·익절·트레일·보유봉·EOD + +실매·웹백테·Optuna 동일 판정 함수 사용. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from kis_trader.engine.scalping_engine import compute_rsi_series +from kis_trader.utils.env import get_env_bool + +DART_STRATEGY_ID = "DART" + + +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 dart_scan_enabled() -> bool: + """SCAN 폴링 ON — DART_SCAN_ENABLED 우선, 없으면 DART_ENABLED.""" + if get_env_from_db_raw("DART_SCAN_ENABLED") not in (None, ""): + return get_env_bool("DART_SCAN_ENABLED", True) + return get_env_bool("DART_ENABLED", True) + + +def get_env_from_db_raw(key: str) -> Any: + try: + from kis_trader.utils.env import get_env_from_db + return get_env_from_db(key, "") + except Exception: + return "" + + +def get_dart_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """DB/env → 엔진 params (웹·실매·Optuna 공통).""" + r: Dict[str, Any] = {} + if env_row: + r = dict(env_row) + else: + try: + from database import TradeDB + db = TradeDB() + try: + r = dict(db.get_merged_env_snapshot() or {}) + finally: + db.close() + except Exception: + r = {} + + def _f(key: str, default: float) -> float: + try: + v = r.get(key) + if v in (None, "", "None"): + return float(default) + return float(v) + except Exception: + return float(default) + + def _i(key: str, default: int) -> int: + try: + v = r.get(key) + if v in (None, "", "None"): + return int(default) + return int(float(v)) + except Exception: + return int(default) + + def _b(key: str, default: bool) -> bool: + v = r.get(key) + if v in (None, "", "None"): + return default + return _to_bool(v, default) + + return { + "slot_money": _i("DART_SLOT_MONEY", _i("SLOT_MONEY_DEFAULT", 300_000)), + "max_stocks": _i("DART_MAX_STOCKS", 5), + "total_budget_krw": _i("DART_TOTAL_BUDGET_KRW", 1_500_000), + "short_max_buy_amount": _i("DART_MAX_BUY_AMOUNT", 0), + "time_start_hm": _i("DART_TIME_START", 930), + "time_end_hm": _i("DART_TIME_END", 1520), + "rsi_period": _i("DART_RSI_PERIOD", 5), + "rsi_oversold": _f("DART_RSI_OVERSOLD", 30.0), + "rsi_reclaim": _f("DART_RSI_RECLAIM", 35.0), + "sl_pct": abs(_f("DART_STOP_LOSS_PCT", 0.02)), + "tp_pct": abs(_f("DART_TAKE_PROFIT_PCT", 0.04)), + "trail_pct": abs(_f("DART_TRAIL_PCT", 0.015)), + "trail_arm_pct": abs(_f("DART_TRAIL_ARM_PCT", 0.02)), + "max_hold_bars": _i("DART_MAX_HOLD_BARS", 60), + "event_window_bars": _i("DART_EVENT_WINDOW_BARS", 120), + "min_price": _f("DART_MIN_PRICE", 1000.0), + "vol_mult": _f("DART_VOL_MULT", 1.5), + "vol_window": _i("DART_VOL_WINDOW", 10), + "max_loss_krw": _i("DART_MAX_LOSS_PER_TRADE_KRW", 150_000), + "force_eod_exit": _b("DART_FORCE_EOD_EXIT", True), + "trade_enabled": _b("DART_TRADE_ENABLED", False), + "subscribe_enabled": _b("DART_SUBSCRIBE_ENABLED", False), + "watch_ttl_hours": _i("DART_WATCH_TTL_HOURS", 24), + "watch_max": _i("DART_WATCH_MAX", 15), + "scan_enabled": _b("DART_SCAN_ENABLED", _b("DART_ENABLED", True)), + } + + +def dart_min_bars_required(params: Optional[Dict[str, Any]] = None) -> int: + p = params or {} + return max( + int(p.get("rsi_period", 5)) + 5, + int(p.get("vol_window", 10)) + 3, + 20, + ) + + +def _candle_hm(ct: str) -> Optional[int]: + s = str(ct or "") + if len(s) < 12: + return None + try: + return int(s[8:12]) + except Exception: + return None + + +def _bars_since_event(candles: List[Dict], i: int, event_ct: str) -> Optional[int]: + """신호봉 i 기준으로 event_ct(YYYYMMDDHHMM…) 이후 경과 봉 수.""" + ev = str(event_ct or "")[:12] + if len(ev) < 12: + return None + n = 0 + for j in range(i + 1): + ct = str(candles[j].get("candle_time") or "")[:12] + if ct >= ev: + n += 1 + return n + + +def eval_dart_buy_at_index( + candles: List[Dict], + i: int, + params: Dict[str, Any], + *, + event_candle_time: str, +) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: + """ + DART TRIGGER — 공시 시각 이후 event_window_bars 안에서 + RSI 과매도 후 rsi_reclaim 상향 돌파 + (선택) 거래량 배수. + """ + rsi_period = int(params.get("rsi_period", 5)) + rsi_os = float(params.get("rsi_oversold", 30.0)) + rsi_rc = float(params.get("rsi_reclaim", 35.0)) + vol_mult = float(params.get("vol_mult", 1.5)) + vol_win = int(params.get("vol_window", 10)) + time_start = int(params.get("time_start_hm", 930)) + time_end = int(params.get("time_end_hm", 1520)) + min_price = float(params.get("min_price", 1000.0)) + event_win = int(params.get("event_window_bars", 120)) + + need = dart_min_bars_required(params) + if i < need or i >= len(candles): + return ("탈락-봉부족", "need=%d i=%d" % (need, i), None) + + c = candles[i] + ct = str(c.get("candle_time") or "") + hm = _candle_hm(ct) + if hm is None: + return ("탈락-시간없음", ct, None) + if hm < time_start or hm >= time_end: + return (None, None, None) + + since = _bars_since_event(candles, i, event_candle_time) + if since is None or since <= 0: + return ("탈락-공시전이벤트", str(event_candle_time)[:12], None) + if since > event_win: + return ("탈락-이벤트창초과", "since=%d win=%d" % (since, event_win), None) + + try: + cl = float(c["close"]) + vol = float(c.get("volume", 0) or 0) + except Exception as e: + return ("탈락-캔들파싱", str(e), None) + if cl < min_price: + return ("탈락-최소가격", "%.0f" % cl, None) + + closes = [float(x["close"]) for x in candles[: i + 1]] + rsis = compute_rsi_series(closes, rsi_period) + rsi = rsis[i] + prev = rsis[i - 1] if i >= 1 else None + if rsi is None or prev is None: + return ("탈락-RSI없음", None, None) + + # 직전 과매도 구간을 찍고, 현재봉에서 reclaim 상향 + if not (prev <= rsi_os and rsi >= rsi_rc and rsi > prev): + return (None, None, None) + + if vol_win > 0 and vol_mult > 0: + vs = [ + float(candles[k].get("volume", 0) or 0) + for k in range(max(0, i - vol_win), i) + ] + avg = sum(vs) / len(vs) if vs else 0.0 + if avg > 0 and vol < avg * vol_mult: + return ( + "탈락-거래량부족", + "%.0f < %.0f×%.2f" % (vol, avg, vol_mult), + None, + ) + + entry = float(c.get("open") or cl) # align: 신호=T-1 확정 후 진입은 호출측 + return ( + None, + None, + { + "entry_price": entry, + "rsi": round(float(rsi), 2), + "signal_time": ct[:12], + "event_bars": since, + }, + ) + + +def check_buy_signal_dart_live( + candles: List[Dict], + params: Dict[str, Any], + *, + event_candle_time: str, +) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: + """ + 실매: 마지막 확정봉(T-1)에서 신호, 진입가는 호출측이 T 시가/틱 정렬. + live_backtest_align — ±1 보정 금지. + """ + if not candles: + return ("탈락-봉없음", None, None) + # 확정봉만 + conf = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] + if len(conf) < 2: + conf = list(candles) + # 신호 = 직전 확정봉 (마지막이 진행중일 수 있음) + sig_i = len(conf) - 2 if len(conf) >= 2 else len(conf) - 1 + if sig_i < 0: + return ("탈락-봉부족", None, None) + reject, msg, sig = eval_dart_buy_at_index( + conf, sig_i, params, event_candle_time=event_candle_time, + ) + if reject or not sig: + return (reject, msg, None) + # 진입 참고가: 다음 봉 시가(있으면) else 신호봉 종가 + if sig_i + 1 < len(conf): + nxt = conf[sig_i + 1] + try: + sig["entry_price"] = float(nxt.get("open") or sig["entry_price"]) + sig["entry_time"] = str(nxt.get("candle_time") or "")[:12] + except Exception: + pass + else: + sig["entry_time"] = sig.get("signal_time") + return (None, None, sig) + + +def check_sell_signal_dart_live( + *, + buy_price: float, + highest: float, + last_price: float, + bars_held: int, + params: Dict[str, Any], + now_hm: Optional[int] = None, +) -> Tuple[bool, str]: + """손절·익절·트레일·보유한도·EOD.""" + if buy_price <= 0 or last_price <= 0: + return False, "" + sl = abs(float(params.get("sl_pct", 0.02))) + tp = abs(float(params.get("tp_pct", 0.04))) + trail = abs(float(params.get("trail_pct", 0.015))) + arm = abs(float(params.get("trail_arm_pct", 0.02))) + max_hold = int(params.get("max_hold_bars", 60)) + pnl = (last_price - buy_price) / buy_price + if pnl <= -sl: + return True, "손절" + if pnl >= tp: + return True, "익절" + hi = max(float(highest or buy_price), last_price) + peak = (hi - buy_price) / buy_price + if peak >= arm and hi > 0: + dd = (hi - last_price) / hi + if dd >= trail: + return True, "트레일" + if max_hold > 0 and bars_held >= max_hold: + return True, "보유한도" + if _to_bool(params.get("force_eod_exit"), True) and now_hm is not None: + if now_hm >= 1520: + return True, "EOD" + return False, "" + + +def run_dart_backtest_code( + candles: List[Dict], + params: Dict[str, Any], + *, + event_candle_time: str, + slot_money: float, + fee_rate: float = 0.00015, + sell_tax: float = 0.0018, +) -> List[Dict[str, Any]]: + """단일 종목·단일 공시 이벤트 백테 (간단 포트 외 호출용).""" + trades: List[Dict[str, Any]] = [] + if not candles: + return trades + conf = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] + if len(conf) < dart_min_bars_required(params): + return trades + + position = None + for i in range(1, len(conf)): + # 신호 = i-1, 진입 = i (align) + reject, _msg, sig = eval_dart_buy_at_index( + conf, i - 1, params, event_candle_time=event_candle_time, + ) + if position is None and sig and not reject: + entry = float(conf[i].get("open") or conf[i].get("close") or 0) + if entry <= 0: + continue + qty = max(1, int(slot_money / entry)) + position = { + "entry_price": entry, + "entry_time": str(conf[i].get("candle_time") or "")[:12], + "qty": qty, + "highest": entry, + "bars": 0, + "rsi": sig.get("rsi"), + } + continue + if position is None: + continue + position["bars"] += 1 + cl = float(conf[i].get("close") or 0) + hi = float(conf[i].get("high") or cl) + position["highest"] = max(position["highest"], hi) + hm = _candle_hm(str(conf[i].get("candle_time") or "")) + sell, reason = check_sell_signal_dart_live( + buy_price=position["entry_price"], + highest=position["highest"], + last_price=cl, + bars_held=position["bars"], + params=params, + now_hm=hm, + ) + if not sell: + continue + buy_p = position["entry_price"] + sell_p = cl + qty = position["qty"] + gross = (sell_p - buy_p) * qty + fee = (buy_p + sell_p) * qty * fee_rate + tax = sell_p * qty * sell_tax + pnl = gross - fee - tax + trades.append({ + "entry_time": position["entry_time"], + "exit_time": str(conf[i].get("candle_time") or "")[:12], + "buy_price": buy_p, + "sell_price": sell_p, + "qty": qty, + "pnl": pnl, + "reason": reason, + "rsi_entry": position.get("rsi"), + }) + position = None + return trades diff --git a/kis_trader/engine/momentum_env_keys.py b/kis_trader/engine/momentum_env_keys.py index 1b17e9b..325068a 100644 --- a/kis_trader/engine/momentum_env_keys.py +++ b/kis_trader/engine/momentum_env_keys.py @@ -68,6 +68,7 @@ MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_BACKTEST_USE_TICK_EXIT", "MOMENTUM_BACKTEST_USE_TICK_ENTRY", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", + "MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE", "MOMENTUM_BACKTEST_POLL_MS", "MOMENTUM_BACKTEST_SELL_SLIP_PCT", "MOMENTUM_BACKTEST_BUY_SLIP_PCT", @@ -77,6 +78,7 @@ MOMENTUM_CONFIG_KEYS = frozenset({ "MOMENTUM_BACKTEST_SCAN_SEC", "MOMENTUM_BACKTEST_UNIVERSE_SCAN_AT", "MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", + "MOMENTUM_PREV_DAY_OPEN_HM_MAX", "MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", "MOMENTUM_LIVE_MIN_CANDLES", diff --git a/kis_trader/engine/momentum_hts_logic.py b/kis_trader/engine/momentum_hts_logic.py index 93a629b..ee94e00 100644 --- a/kis_trader/engine/momentum_hts_logic.py +++ b/kis_trader/engine/momentum_hts_logic.py @@ -47,12 +47,20 @@ def resolve_prev_trading_day_open( """ 전일(직전 거래일) 시가 — HTS momentum E 조건 ``close > prev_open`` 확인용. - 1분봉에서 전일 첫 봉 open = 일봉 시가. + 1분봉에서 전일 **장 시작 구간** 첫 봉 open = 일봉 시가. + 전일 오후 봉만 있으면(웜업 부족) 오후 open을 시가로 오인하므로 None 반환. """ + from kis_trader.utils.env import get_env_int + + # 전일 시가로 인정할 최대 HHMM (기본 09:10 — 그 이후만 있으면 장시작 시가 미확정) + open_hm_max = max(900, int(get_env_int("MOMENTUM_PREV_DAY_OPEN_HM_MAX", 910))) + prev_day: Optional[str] = None prev_open: Optional[float] = None + earliest_hm: Optional[int] = None for j in range(i - 1, -1, -1): - d = str(candles[j].get("candle_time", ""))[:8] + ct = str(candles[j].get("candle_time", "")) + d = ct[:8] if not d or d >= day: continue if prev_day is None: @@ -62,9 +70,36 @@ def resolve_prev_trading_day_open( op = float(candles[j].get("open", 0) or 0) if op > 0: prev_open = op + hm = None + if len(ct) >= 12: + try: + hm = int(ct[8:12]) + except (TypeError, ValueError): + hm = None + if hm is not None: + if earliest_hm is None or hm < earliest_hm: + earliest_hm = hm + if prev_open is None or prev_open <= 0: + return None + # 전일 시가 = 정규장 시작 근처 봉이 시리즈에 있어야 함 (오후만 있으면 미확정) + if earliest_hm is None or earliest_hm > open_hm_max: + return None return prev_open +def candles_have_prev_session_open( + candles: List[Dict], + day: str, +) -> bool: + """기간일 ``day`` 기준 직전 거래일 **장시작 시가** 봉이 있는지 (E조건 해석 가능).""" + if not candles: + return False + d = str(day or "")[:8] + if len(d) < 8: + return True + return resolve_prev_trading_day_open(candles, len(candles) - 1, d) is not None + + def _volume_pulse_ok( candles: List[Dict], i: int, diff --git a/kis_trader/engine/momentum_tick_replay.py b/kis_trader/engine/momentum_tick_replay.py index 36cfc65..ddcef81 100644 --- a/kis_trader/engine/momentum_tick_replay.py +++ b/kis_trader/engine/momentum_tick_replay.py @@ -4,6 +4,8 @@ - 진입: live_align 신호봉(T-1) → 진입봉(T) 첫 틱/시가 - 청산: 1~2초 폴링 근사로 ``check_sell_signal_momentum_live`` → **틱 체결가** +- 틱 공백: OHLC intrabar 폴백(기본 OFF) 대신 **last price + 벽시계** + (``MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE``, 기본 ON) """ from __future__ import annotations @@ -51,6 +53,145 @@ def momentum_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None ) +def momentum_backtest_wallclock_last_price(params: Optional[Dict[str, Any]] = None) -> bool: + """틱 공백 시 실매처럼 last price + 벽시계로 청산 검사 (기본 ON). + + OHLC intrabar 폴백과 다름 — 고가·저가 경로를 만들지 않고 + 직전 틱가(없으면 분봉 종가 1개)만 사용. 파람서치가 OHLC 가짜경로에 + 맞추는 것을 피하면서 시간컷/EOD·현재가 청산을 실매에 맞춘다. + 끄려면 ``MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE=0``. + """ + return _param_bool( + params, + "backtest_wallclock_last_price", + "MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE", + True, + ) + + +def update_momentum_bt_last_px( + position: Dict[str, Any], + px: float, + t_key: str = "", +) -> None: + """백테 보유 중 last price 캐시 (틱·봉 종가 갱신).""" + try: + v = float(px) + except (TypeError, ValueError): + return + if v <= 0: + return + position["_bt_last_px"] = v + tk = str(t_key or "").strip() + if tk: + position["_bt_last_px_t"] = tk[:12] + + +def resolve_momentum_wallclock_last_px( + position: Dict[str, Any], + bar: Optional[Dict[str, Any]], + *, + ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None, + code: str = "", + minute_key: str = "", +) -> Optional[float]: + """last price: 해당 분 마지막 틱 → 분봉 종가 → 캐시 → 진입가.""" + mk = str(minute_key or (bar or {}).get("candle_time") or "")[:12] + if ticks_by_code and code and mk: + minute_ticks = collect_minute_ticks(ticks_by_code, code, mk) + if minute_ticks: + try: + from kis_trader.backtest.shared_ticks import TickColumnView + if isinstance(minute_ticks, TickColumnView): + last_i = None + for i in minute_ticks.iter_idx(): + last_i = i + if last_i is not None: + px = float(minute_ticks.owner._price[last_i]) + if px > 0: + return px + else: + for tick in reversed(list(minute_ticks)): + px = float(tick.get("price") or 0) + if px > 0: + return px + except Exception: + for tick in reversed(list(minute_ticks)): + try: + px = float(tick.get("price") or 0) + except (TypeError, ValueError): + continue + if px > 0: + return px + if bar is not None: + try: + px = float(bar.get("close") or 0) + if px > 0: + return px + except (TypeError, ValueError): + pass + cached = position.get("_bt_last_px") + if cached is not None: + try: + px = float(cached) + if px > 0: + return px + except (TypeError, ValueError): + pass + try: + px = float(position.get("entry_price") or 0) + return px if px > 0 else None + except (TypeError, ValueError): + return None + + +def try_momentum_sell_wallclock_last( + position: Dict[str, Any], + last_px: float, + candle_time: str, + params: Dict[str, Any], + *, + is_eod: bool = False, +) -> Optional[Tuple[str, float, str, float]]: + """실매 ``check_sell_signals`` 와 동일 — last 1가 + 벽시계 candle_time. + + Returns: + (reason, fill_price, sell_time, hold_min) 또는 None + """ + try: + px = float(last_px) + except (TypeError, ValueError): + return None + if px <= 0: + return None + ct = str(candle_time or "")[:12] + if len(ct) < 12: + return None + + mp = max(float(position.get("max_price", position.get("entry_price") or px)), px) + position["max_price"] = mp + candle = { + "high": mp, + "low": px, + "close": px, + "candle_time": ct, + } + res = check_sell_signal_momentum_live(position, candle, params, is_eod=is_eod) + if not res: + return None + reason, _theoretical = res + slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0))) + fill_px = px * (1.0 - slip_pct / 100.0) if slip_pct > 0 else px + entry_time = str(position.get("entry_time") or "") + 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(fill_px), ct, hold_min + + def momentum_backtest_tick_only_codes(params: Optional[Dict[str, Any]] = None) -> bool: """틱재생 시 **틱 데이터가 전혀 없는 종목을 백테/파람서치에서 제외** (기본 ON). @@ -403,11 +544,11 @@ def resolve_momentum_sell_for_bar( code: str = "", ) -> Optional[Tuple[str, float, str, float, str]]: """ - 한 분봉 청산 — 틱 우선, 없으면 OHLC intrabar 폴백. + 한 분봉 청산 — 틱 우선 → (옵션) OHLC intrabar → last-price 벽시계. Returns: (reason, fill_price, sell_time, hold_min, exit_source) - exit_source: ws_ticks | ohlc_bar + exit_source: ws_ticks | ohlc_bar | wallclock_last """ from kis_trader.engine.momentum_engine import check_sell_signal_momentum_backtest_bar @@ -424,18 +565,51 @@ def resolve_momentum_sell_for_bar( if tick_res: reason, fill_px, sell_time, hold_min = tick_res return reason, fill_px, sell_time, hold_min, "ws_ticks" + # 틱은 있었으나 미청산 → last 갱신 후 벽시계 경로에서 시간컷 등 재검사 + try: + from kis_trader.backtest.shared_ticks import TickColumnView + if isinstance(minute_ticks, TickColumnView): + last_i = None + for i in minute_ticks.iter_idx(): + last_i = i + if last_i is not None: + update_momentum_bt_last_px( + position, float(minute_ticks.owner._price[last_i]), ct, + ) + else: + for tick in reversed(list(minute_ticks)): + px = float(tick.get("price") or 0) + if px > 0: + update_momentum_bt_last_px(position, px, ct) + break + except Exception: + pass - if not momentum_backtest_tick_fallback_ohlc(params): + if momentum_backtest_tick_fallback_ohlc(params): + res = check_sell_signal_momentum_backtest_bar(position, bar, params, is_eod=is_eod) + if res: + 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" + + if not momentum_backtest_wallclock_last_price(params): return None - res = check_sell_signal_momentum_backtest_bar(position, bar, params, is_eod=is_eod) - if not res: + last_px = resolve_momentum_wallclock_last_px( + position, bar, ticks_by_code=ticks_by_code, code=code, minute_key=ct, + ) + if last_px is None: 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" + update_momentum_bt_last_px(position, last_px, ct) + wall_res = try_momentum_sell_wallclock_last( + position, last_px, ct, params, is_eod=is_eod, + ) + if not wall_res: + return None + reason, fill_px, sell_time, hold_min = wall_res + return reason, fill_px, sell_time, hold_min, "wallclock_last" diff --git a/kis_trader/engine/post_sell_candle_backfill.py b/kis_trader/engine/post_sell_candle_backfill.py index 710a3bf..dae9bc3 100644 --- a/kis_trader/engine/post_sell_candle_backfill.py +++ b/kis_trader/engine/post_sell_candle_backfill.py @@ -23,7 +23,8 @@ from kis_trader.utils.logger import get_logger logger = get_logger("kis_trader.post_sell_candle_backfill") -_INSERT_SQL = """ +# 존재 행 OHLCV 덮어쓰기(큰 volume 우선) — freeze OFF 일 때만 +_INSERT_SQL_OVERWRITE = """ INSERT INTO ws_candles (code, timeframe, candle_time, `open`, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) @@ -37,11 +38,31 @@ _INSERT_SQL = """ source=IF(VALUES(volume) > volume, VALUES(source), source) """ +# freeze ON: 없는 분만 INSERT. 확정·미확정 행이 있으면 OHLCV 유지 (구멍 메우기 전용) +_INSERT_SQL_FREEZE = """ + 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 + candle_time=candle_time +""" + def post_sell_candle_backfill_enabled() -> bool: return get_env_bool("POST_SELL_CANDLE_BACKFILL", True) +def _candle_freeze_on_confirm() -> bool: + """docs/정합성.md — 확정 후 REST/백필이 봉을 키우지 않음. 기본 true.""" + return get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True) + + +def _insert_sql() -> str: + return _INSERT_SQL_FREEZE if _candle_freeze_on_confirm() else _INSERT_SQL_OVERWRITE + + def post_sell_candle_rollup_3m_enabled() -> bool: """1분 채운 뒤 3분 롤업 UPSERT (꼬리 TF 정합).""" return get_env_bool("POST_SELL_CANDLE_ROLLUP_3M", True) @@ -154,10 +175,11 @@ def _upsert_df_rows(db: Any, code: str, tf_min: int, rows: List[Dict[str, Any]]) continue if not payload: return 0 + sql = _insert_sql() with db.conn._lock: db.conn._ensure_connected() cur = db.conn._conn.cursor() - cur.executemany(_INSERT_SQL, payload) + cur.executemany(sql, payload) db.conn._conn.commit() return len(payload) diff --git a/kis_trader/engine/scalping_engine.py b/kis_trader/engine/scalping_engine.py index 6cfb320..bff4813 100644 --- a/kis_trader/engine/scalping_engine.py +++ b/kis_trader/engine/scalping_engine.py @@ -205,6 +205,11 @@ def get_scalping_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) - 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)) + # EOD — 실매 기존 15:25 장마감청산과 동일 (strategy_eod SCALP) + eod_enabled = _to_bool(r.get("SCALP_EOD_ENABLED"), True) + if r.get("SCALP_EOD_ENABLED") in (None, "", "None") and r.get("force_eod_exit") not in (None, "", "None"): + eod_enabled = _to_bool(r.get("force_eod_exit"), True) + eod_hm = str(r.get("SCALP_EOD_HM") or "15:25").strip() or "15:25" 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 @@ -224,6 +229,7 @@ def get_scalping_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) - require_reversal_candle = True vol_mult = 0.0 time_start_hm, time_end_hm, max_daily = 900, 1530, 3 + eod_enabled, eod_hm = True, "15:25" except Exception: cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0 @@ -244,6 +250,7 @@ def get_scalping_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) - require_reversal_candle = True vol_mult = 0.0 time_start_hm, time_end_hm, max_daily = 900, 1530, 3 + eod_enabled, eod_hm = True, "15:25" return { "cooldown_min": cooldown_min, @@ -287,6 +294,10 @@ def get_scalping_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) - "max_stocks": max_stocks, "total_budget_krw": total_budget_krw, "portfolio_mode": portfolio_mode, + "eod_enabled": eod_enabled, + "eod_hm": eod_hm, + # 레거시 별칭 — 포트폴리오/단건 엔진 force_eod 경로 호환 + "force_eod_exit": eod_enabled, } @@ -668,11 +679,8 @@ def run_scalping_backtest( min_margin = float(params.get("min_margin", 0.002)) use_defense_filters = _to_bool(params.get("use_defense_filters", True), True) use_macd_cross = _to_bool(params.get("use_macd_cross", False), False) - # 백테스트 EOD 강제청산 여부: - # - True : 기존 동작 유지(당일 마지막 봉에서 청산) - # - False: 실매와 유사하게 포지션 오픈 유지(미청산은 결과 미기록) - force_eod_exit = _to_bool(params.get("force_eod_exit"), False) + from kis_trader.engine.strategy_eod import is_strategy_eod_bar from kis_trader.engine.tick_exit_common import ( backtest_sell_slip_pct, backtest_tick_poll_ms, @@ -728,8 +736,8 @@ def run_scalping_backtest( running_high = max(running_high, hi) running_low = min(running_low, lo) - is_eod_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day) - is_eod = is_eod_raw and force_eod_exit + # 실매 SCALP_EOD_HM(기본 15:25) 과 동일 — force_eod 마지막봉만 의존 금지 + is_eod = is_strategy_eod_bar(c["candle_time"], params, "SCALP") # ── 포지션 보유 중: 청산 체크 ── if position is not None: diff --git a/kis_trader/engine/strategy_eod.py b/kis_trader/engine/strategy_eod.py index f01c19f..8ade15a 100644 --- a/kis_trader/engine/strategy_eod.py +++ b/kis_trader/engine/strategy_eod.py @@ -51,6 +51,8 @@ _STRATEGY_EOD_SPEC: Dict[str, Tuple[str, str, bool, str, str]] = { "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"), + # 실매 scalping.py 기존 하드코딩 15:25 와 동일 (장마감청산) + "SCALP": ("SCALP_EOD_ENABLED", "SCALP_EOD_HM", True, "15:25", "force_eod_exit"), } @@ -116,6 +118,20 @@ def is_backtest_eod_bar( return (bar_hh > eod_hh) or (bar_hh == eod_hh and bar_mm >= eod_mm) +def eod_bar_time_key( + day_yyyymmdd: str, + eod_hm: str, + *, + default_hm: str = "15:20", +) -> str: + """당일 EOD 시각을 봉 키(YYYYMMDDHHMM)로. 백테 벽시계 EOD sell_time 용.""" + day = str(day_yyyymmdd or "").strip()[:8] + if len(day) != 8 or not day.isdigit(): + return "" + hh, mm = parse_eod_hm(eod_hm, default_hm) + return "%s%02d%02d" % (day, hh, mm) + + def is_strategy_eod_bar( candle_time: str, params: Dict[str, Any], diff --git a/kis_trader/engine/tail_engine.py b/kis_trader/engine/tail_engine.py index cf35f73..d71fb2f 100644 --- a/kis_trader/engine/tail_engine.py +++ b/kis_trader/engine/tail_engine.py @@ -18,12 +18,14 @@ kis_trader/engine/tail_engine.py — 꼬리잡기 백테스트·실매매 공통 B/C 동일 축 — ``ConditionSearchManager`` / ``tail`` → ``target_candidates_history`` (strategy_id=SHORT) 에 스냅샷 저장. - [TRIGGER — 본 엔진, 엄격하게 — HTS A/B/C 는 조건검색에서 이미 통과] + [TRIGGER — 본 엔진, 엄격하게 — HTS SCAN 통과 후보에 타점·리스크 재검사] 반전 패턴 OR(망치·핀바·장악·관통·하라미·도지·샛별) + 당일 회복률·3분봉 회복 위치, (선택) 신호봉 거래량 폭증(``TAIL_VOL_MULT``×N봉평균, 0=OFF) + RSI·MA20, 고점추격·피뢰침, 시간대/쿨다운. 패턴별 ``TAIL_PATTERN_*`` env 로 ON/OFF. - ``TAIL_SKIP_HTS_SCAN_DUPES=true`` (기본) 이면 TRIGGER 에서 HTS A 와 겹치는 - **당일 시가→저점 낙폭** 은 건너뜀 → 이중 필터로 거래가 사라지는 현상 방지. + ``TAIL_SKIP_HTS_SCAN_DUPES=false`` (운영 기본) 이면 TRIGGER 에서 + **3분봉 직전대비 등락**(``TAIL_BAR_CHG_MIN/MAX_PCT``) 을 추가 검사. + (HTS 일봉 A·1분 G 와 **축이 다름** — 라벨을 HTS A 로 부르지 말 것. + true 이면 이 3분 등락 재검사만 생략. 시가→저점 낙폭은 ``TAIL_USE_INTRADAY_DROP``.) ■ 엔진 공통 로직 (백테·실매 동일) 매도 우선순위 (V4): @@ -111,7 +113,10 @@ def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: def resolve_tail_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool: """ - HTS tail SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부. + TRIGGER 의 **3분봉 직전대비 등락**(BAR_CHG) 재검사 생략 여부. + + 이름에 HTS 가 들어가지만, 끄는 대상은 HTS 일봉 A/1분 G 가 아니라 + ``TAIL_BAR_CHG_*`` (신호 3분봉 vs 직전 3분봉) 이다. - ``TAIL_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로 - 미설정 → ``SHORT_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True @@ -922,7 +927,7 @@ def _eval_tail_buy_at_index( max_daily_change = float(params.get("max_daily_change", 20.0)) ma20_max_above = float(params.get("ma20_max_above", 3.0)) - # SCAN/TRIGGER 분리 — 조건검색 유니버스 사용 시 HTS A/B/C 중복 필터 생략 + # SCAN/TRIGGER — skip_hts=true 이면 아래 3분 봉등락(BAR_CHG) 재검사만 생략 if params.get("skip_hts_scan_dupes") is None: skip_hts = resolve_tail_skip_hts_scan_dupes() else: @@ -982,7 +987,7 @@ def _eval_tail_buy_at_index( if range_change_pct > max_daily_change: return ("탈락-피뢰침 급등주", f"일일 변동폭 {range_change_pct:.1f}% > {max_daily_change:.0f}%", None) - # HTS A (봉 등락률) — 조건검색 미사용(전종목) 백테 시에만 TRIGGER 에서 재검사 + # 3분봉 직전대비 등락 (BAR_CHG) — HTS 일봉A/1분G 와 축이 다름. skip_hts=true 면 생략 if not skip_hts and i >= 1: prev_cl = float(candles[i - 1]["close"]) if prev_cl > 0: @@ -990,11 +995,11 @@ def _eval_tail_buy_at_index( if bar_chg < bar_chg_min_pct or bar_chg > bar_chg_max_pct: return ( "탈락-봉등락", - f"봉등락 {bar_chg:.2f}% (HTS A: {bar_chg_min_pct:.1f}~{bar_chg_max_pct:.1f}%)", + f"봉등락 {bar_chg:.2f}% (3분 직전대비: {bar_chg_min_pct:.1f}~{bar_chg_max_pct:.1f}%)", None, ) - # 당일 시가→저점 낙폭 — HTS A 와 다른 축; ``TAIL_USE_INTRADAY_DROP=true`` 일 때만 + # 당일 시가→저점 낙폭 — BAR_CHG 와 다른 축; ``TAIL_USE_INTRADAY_DROP=true`` 일 때만 if use_intraday_drop: drop = (running_open - running_low) / running_open if drop < min_drop_rate: diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py index 5b550e0..6b1556f 100644 --- a/kis_trader/execution/order_manager.py +++ b/kis_trader/execution/order_manager.py @@ -30,6 +30,12 @@ kis_trader/execution/order_manager.py — Master Executor ``inquire-daily-ccld`` 1회로 체결 복구 → active_trades 반영. * ``DUPLICATE_ORDER_RECOVERY_WAIT_SEC`` (기본 ORDER_FILL_WAIT_SEC): 중복복구 체결 대기. * ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문. + * ``SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY`` (기본 True): 익절 지정가 미확인/부분체결 시 + **시장가 보강 전에 지정가 취소 → ODNO 재조회**. 취소 실패·체결 미확인이면 시장가 금지 + (PENDING). 지정가+시장가 이중체결로 타전략 몫까지 파는 사고 방지 (전 전략 공통). + * ``SELL_LIMIT_RECHECK_WAIT_SEC`` (기본 1): 취소 후 지정가 ODNO 재조회 대기. + * ``GHOST_PURGE_RECORD_HISTORY`` (기본 True): 유령잔고 삭제 시 trade_history 에 + ``ghost_purge`` 기록 (미기록 유령 방지). * ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만 ``ORDER_CASH_PCT`` 로 수량 축소 (매수체크 루프에서는 REST 미호출). """ @@ -888,10 +894,70 @@ class OrderManager: return handled + def _record_ghost_purge_history( + self, + code: str, + strategy_id: str, + *, + sell_reason: str = "ghost_purge", + ) -> bool: + """ + 유령(브로커 0주) 정리 시 trade_history 기록. + 실제 매도 체결이 아니므로 realized_pnl=0 (사유=ghost_purge/broker_zero). + """ + if not get_env_bool("GHOST_PURGE_RECORD_HISTORY", True): + self.db.delete_active_trade(code=code, strategy=strategy_id) + return False + try: + from ..utils.strategy_ids import canonical_strategy_id + + sid = canonical_strategy_id(strategy_id) + row = None + matched_sid = strategy_id + for try_sid in (sid, strategy_id): + row = self.db.conn.execute( + "SELECT avg_buy_price, current_price FROM active_trades " + "WHERE code=%s AND strategy=%s LIMIT 1", + (code, try_sid), + ).fetchone() + if row: + matched_sid = try_sid + break + buy_px = 0.0 + mark = 0.0 + if row: + d = dict(row) + buy_px = float(d.get("avg_buy_price") or 0) + cur = float(d.get("current_price") or 0) + # 장부용 매도가: 마지막 시세 있으면 사용, 없으면 매수가 (PnL은 0 고정) + mark = cur if cur > 0 else buy_px + if mark <= 0: + mark = buy_px if buy_px > 0 else 1.0 + ok = self.db.close_trade( + code=code, + sell_price=mark, + sell_reason=sell_reason, + strategy=matched_sid, + realized_pnl_override=0.0, + ) + if not ok: + self.db.delete_active_trade(code=code, strategy=matched_sid) + return bool(ok) + except Exception as e: + logger.warning( + "유령 trade_history 기록 실패 %s [%s]: %s → delete만", + code, strategy_id, e, + ) + try: + self.db.delete_active_trade(code=code, strategy=strategy_id) + except Exception: + pass + return False + def _purge_ghost_position(self, req: OrderRequest, log_tag: str) -> OrderResult: """ 브로커 0주인데 로컬만 남은 포지션 정리. - DB 삭제 + 전략 holdings.pop 트리거(extra.purge_holdings). + DB 삭제(+ 선택적 trade_history) + 전략 holdings.pop 트리거(extra.purge_holdings). 동일 (전략, 종목) 은 GHOST_POSITION_COOLDOWN_SEC 동안 재로그·재API 방지. """ key = (req.strategy_id, req.code) @@ -910,17 +976,24 @@ class OrderManager: request=req, ) self._ghost_purged_at[key] = now + reason_tag = ( + "ghost_purge:broker_response" + if log_tag == "broker_response" + else "ghost_purge:broker_zero" + ) if log_tag == "broker_response": logger.warning( - "%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 active_trades 삭제%s", - LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET, + "%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 정리 (%s)%s", + LOG_YELLOW, req.strategy_id, req.name, req.code, reason_tag, LOG_RESET, ) else: logger.warning( - "%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 active_trades 삭제%s", - LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET, + "%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 정리 (%s)%s", + LOG_YELLOW, req.strategy_id, req.name, req.code, reason_tag, LOG_RESET, ) - self.db.delete_active_trade(code=req.code, strategy=req.strategy_id) + self._record_ghost_purge_history( + req.code, req.strategy_id, sell_reason=reason_tag, + ) self.invalidate_holdings_cache() return OrderResult( False, @@ -929,6 +1002,138 @@ class OrderManager: extra={"purge_holdings": True}, ) + def _after_limit_sell_need_market( + self, + *, + req: OrderRequest, + ord_no: str, + sell_qty: int, + filled_so_far: int, + wait_sec: float, + ) -> tuple: + """ + 익절 지정가 미확인·부분체결 후 시장가 보강. + + Returns: + (fill_dict|None, market_ord_no|None, pending:bool) + fill_dict: {"filled_qty", "avg_price"} — 지정가(+시장가) 합산 체결 + pending=True 이면 호출부가 PENDING_FILL 로 두고 시장가 금지 + """ + remain = max(0, int(sell_qty) - int(filled_so_far)) + if remain <= 0: + return None, None, False + + use_cancel = get_env_bool("SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY", True) + recheck_sec = float(get_env_int("SELL_LIMIT_RECHECK_WAIT_SEC", 1)) + + if not use_cancel: + # 레거시: 즉시 시장가 (이중체결 위험 — 기본 OFF 경로) + logger.warning( + "%s⚠️ [익절지정가→시장가] %s 잔여 %d주 (취소가드 OFF)%s", + LOG_YELLOW, req.code, remain, LOG_RESET, + ) + mkt_no = self.client.sell_market_order(req.code, remain) + if not mkt_no: + return None, None, False + mfill = self.client.get_execution_by_odno( + mkt_no, code=req.code, wait_sec=wait_sec, + ) + return mfill, mkt_no, False + + # ── 근본: 잔여 지정가 취소 → ODNO 재조회 → 필요할 때만 시장가 ── + try: + cancel_ok = bool( + self.client.cancel_order(str(ord_no), qty=remain) + ) + except Exception as e: + cancel_ok = False + logger.warning( + "익절지정가 취소 예외 %s ODNO=%s: %s", req.code, ord_no, e, + ) + logger.info( + "%s🛑 [익절지정가취소] %s ODNO=%s 잔여=%d 결과=%s%s", + LOG_CYAN, req.code, ord_no, remain, + "OK" if cancel_ok else "FAIL", LOG_RESET, + ) + + # 취소 직전·직후 체결됐을 수 있음 → 같은 ODNO 재조회 + refill = self.client.get_execution_by_odno( + ord_no, code=req.code, wait_sec=recheck_sec, + ) + refill_qty = int((refill or {}).get("filled_qty", 0) or 0) + refill_px = float((refill or {}).get("avg_price", 0) or 0) + if refill_qty >= sell_qty and refill_px > 0: + logger.info( + "%s✅ [익절지정가 재확인체결] %s ODNO=%s × %d주 @ %.0f — 시장가 생략%s", + LOG_GREEN, req.code, ord_no, refill_qty, refill_px, LOG_RESET, + ) + return ( + {"filled_qty": refill_qty, "avg_price": refill_px}, + None, + False, + ) + + still_need = max(0, sell_qty - max(filled_so_far, refill_qty)) + if still_need <= 0 and refill_qty > 0 and refill_px > 0: + return ( + {"filled_qty": refill_qty, "avg_price": refill_px}, + None, + False, + ) + + if not cancel_ok: + # 취소 실패 = 이미 체결·처리 중 가능 → 시장가 금지 (이중매도 방지) + logger.warning( + "%s⏸ [익절시장가보류] %s ODNO=%s — 취소실패·체결미확정 → PENDING " + "(시장가 재시도 금지)%s", + LOG_YELLOW, req.code, ord_no, LOG_RESET, + ) + if refill_qty > 0 and refill_px > 0: + return ( + {"filled_qty": refill_qty, "avg_price": refill_px}, + None, + refill_qty < sell_qty, # 부분만 보이면 pending + ) + return None, None, True + + # 취소 성공 + 아직 잔여 → 시장가 + logger.warning( + "%s⚠️ [익절지정가 미체결→시장가] %s 잔여 %d주%s", + LOG_YELLOW, req.code, still_need, LOG_RESET, + ) + mkt_no = self.client.sell_market_order(req.code, still_need) + if not mkt_no: + if refill_qty > 0 and refill_px > 0: + return ( + {"filled_qty": refill_qty, "avg_price": refill_px}, + None, + True, + ) + return None, None, True + mfill = self.client.get_execution_by_odno( + mkt_no, code=req.code, wait_sec=wait_sec, + ) + if not mfill or int(mfill.get("filled_qty", 0) or 0) <= 0: + if refill_qty > 0 and refill_px > 0: + return ( + {"filled_qty": refill_qty, "avg_price": refill_px}, + mkt_no, + True, + ) + return None, mkt_no, True + + add_q = int(mfill["filled_qty"]) + add_p = float(mfill["avg_price"]) + prev_q = max(int(filled_so_far), int(refill_qty)) + prev_p = refill_px if refill_qty > 0 and refill_px > 0 else 0.0 + if prev_q > 0 and prev_p > 0: + tot_q = prev_q + add_q + tot_p = (prev_p * prev_q + add_p * add_q) / tot_q if tot_q > 0 else add_p + else: + tot_q = add_q + tot_p = add_p + return {"filled_qty": tot_q, "avg_price": tot_p}, mkt_no, False + # ------------------------------------------------------------------ # 공개 API # ------------------------------------------------------------------ @@ -1345,51 +1550,46 @@ class OrderManager: filled_avg_price=sell_price, status="SUBMITTED", ) - # 익절 지정가 미체결 잔량 → 시장가로 잔여 청산 (손절은 처음부터 시장가) - if use_limit and fill and int(fill.get("filled_qty", 0) or 0) < sell_qty: - remain = sell_qty - int(fill["filled_qty"]) - logger.warning( - "%s⚠️ [익절지정가 부분체결] %s 잔여 %d주 시장가 보완%s", - LOG_YELLOW, req.code, remain, LOG_RESET, + # 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가 + # (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지) + if use_limit and ( + not fill + or int(fill.get("filled_qty", 0) or 0) < sell_qty + ): + filled_so_far = int((fill or {}).get("filled_qty", 0) or 0) + merged, _mkt_no, pending = self._after_limit_sell_need_market( + req=req, + ord_no=ord_no, + sell_qty=sell_qty, + filled_so_far=filled_so_far, + wait_sec=wait_sec, ) - mkt_no = self.client.sell_market_order(req.code, remain) - if mkt_no: - mfill = self.client.get_execution_by_odno( - mkt_no, code=req.code, wait_sec=wait_sec, + if merged and int(merged.get("filled_qty", 0) or 0) > 0: + filled_qty = int(merged["filled_qty"]) + sell_price = float(merged["avg_price"]) + 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", ) - if mfill: - add_q = int(mfill.get("filled_qty", 0) or 0) - add_p = float(mfill.get("avg_price", 0) or 0) - if add_q > 0 and add_p > 0: - prev_q = int(fill["filled_qty"]) - prev_p = float(fill["avg_price"]) - filled_qty = prev_q + add_q - sell_price = ( - (prev_p * prev_q + add_p * add_q) / filled_qty - if filled_qty > 0 else add_p - ) - 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", - ) - elif use_limit and not fill: - logger.warning( - "%s⚠️ [익절지정가 미확인] %s 시장가 재시도%s", - LOG_YELLOW, req.code, LOG_RESET, - ) - ord_no2 = self.client.sell_market_order(req.code, sell_qty) - if ord_no2: - fill = self.client.get_execution_by_odno( - ord_no2, code=req.code, wait_sec=wait_sec, + if pending or filled_qty <= 0 or sell_price <= 0: + 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 — " + "지정가 미확정·시장가보류, heartbeat 재조회%s", + LOG_YELLOW, req.strategy_id, req.name, req.code, + ord_no, LOG_RESET, + ) + return OrderResult( + False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) - if fill: - sell_price = float(fill["avg_price"]) - filled_qty = int(fill["filled_qty"]) if filled_qty <= 0 or sell_price <= 0: if self._strict_fill_required(): diff --git a/kis_trader/execution/orphan_reconcile.py b/kis_trader/execution/orphan_reconcile.py index 0fbc3dd..8365612 100644 --- a/kis_trader/execution/orphan_reconcile.py +++ b/kis_trader/execution/orphan_reconcile.py @@ -222,16 +222,29 @@ def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None if _broker_qty(broker, code) > 0: continue try: - db.delete_active_trade(code=code, strategy=strategy or None) + # OrderManager 와 동일: trade_history 에 ghost_purge 남긴 뒤 정리 + recorded = False + if hasattr(order_mgr, "_record_ghost_purge_history"): + recorded = bool( + order_mgr._record_ghost_purge_history( + code, + strategy or "", + sell_reason="ghost_purge:broker_zero", + ) + ) + else: + 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), + "history_recorded": recorded, }) logger.warning( - "🧹 [유령잔고삭제] [%s] %s %s — 브로커 0주 → active_trades 삭제", + "🧹 [유령잔고삭제] [%s] %s %s — 브로커 0주 → 정리%s", strategy, name, code, + " (trade_history 기록)" if recorded else "", ) except Exception as e: out["failed"].append({"code": code, "name": name, "error": str(e)}) diff --git a/kis_trader/main.py b/kis_trader/main.py index ea15acf..72264f8 100644 --- a/kis_trader/main.py +++ b/kis_trader/main.py @@ -77,6 +77,7 @@ from .network.ranking_manager import VolumeRankManager from .network.ws_manager import WSManager from .strategies import ( DbBandStrategy, + DartStrategy, BreakoutStrategy, MomentumStrategy, RangeBreakStrategy, @@ -304,6 +305,12 @@ class TradingOrchestrator: else: logger.info("⏸ [전략 비활성] DBBAND") + if get_env_bool("STRATEGY_DART_ENABLED", False): + self.strategies.append(DartStrategy(**shared)) + logger.info("✅ [전략 등록] DART (%s)", self._universe_tag("DART")) + else: + logger.info("⏸ [전략 비활성] DART") + if not self.strategies: logger.error("전략이 하나도 활성화되지 않음 → 종료") sys.exit(1) diff --git a/kis_trader/network/ws_manager.py b/kis_trader/network/ws_manager.py index 6923a81..5639aa4 100644 --- a/kis_trader/network/ws_manager.py +++ b/kis_trader/network/ws_manager.py @@ -356,8 +356,8 @@ class WSManager: 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) + if code not in kw_want: + self._remove_candle_ram(code) with self._kiwoom_ws._sub_lock: kw_now2 = set(self._kiwoom_ws._subscribed) @@ -366,8 +366,8 @@ class WSManager: 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 code not in kis_want: + self._remove_candle_ram(code) with self._lock: is_perm = code in self._permanent_codes if self.tick_recorder and code not in kw_want and not is_perm: @@ -544,8 +544,7 @@ class WSManager: if not still_refs and not is_permanent and self.ws_cache: self.ws_cache.unsubscribe(code) - if self.candle_agg: - self.candle_agg.remove_code(code) + self._remove_candle_ram(code) if self.tick_recorder: self.tick_recorder.remove_code(code) if self.trigger_snapshot_recorder: @@ -651,13 +650,44 @@ class WSManager: except Exception: return [] - def fill_gap(self, codes: Optional[Iterable[str]] = None) -> None: - """외부에서 수동으로 갭 보정 트리거 (비동기: 큐 등록 후 즉시 리턴).""" + def fill_gap( + self, + codes: Optional[Iterable[str]] = None, + *, + force: bool = False, + ) -> None: + """외부에서 수동으로 갭 보정 트리거 (비동기: 큐 등록 후 즉시 리턴). + + force=True: 이미 ``_gap_filled`` 여도 재큐 (RAM 삭제 후 재ENTER·봉부족 복구). + """ if codes is None: self._trigger_bulk_refill_async() else: for c in codes: - self._enqueue_gap_fill(c) + self._enqueue_gap_fill(c, force=bool(force), priority=bool(force)) + + def _clear_gap_fill_state(self, code: str) -> None: + """RAM 봉 삭제와 짝 — 갭보정 완료 마커 해제. + + EXIT→remove_code 후 ``_gap_filled`` 가 남으면 재ENTER 시 갭보정이 스킵되고 + WS 실시간 몇 봉만으로 '봉부족'이 난다 (전 전략 공통). + """ + if not code: + return + with self._gap_lock: + self._gap_filled.discard(code) + self._gap_tf_ok.pop(code, None) + + def _remove_candle_ram(self, code: str) -> None: + """구독 해제 시 RAM 봉 정리 + 갭보정 재실행 가능하도록 상태 리셋.""" + if not code: + return + if self.candle_agg: + try: + self.candle_agg.remove_code(code) + except Exception: + pass + self._clear_gap_fill_state(code) # ------------------------------------------------------------------ # 내부: 갭 보정 — 백그라운드 워커 파이프라인 diff --git a/kis_trader/scan/__init__.py b/kis_trader/scan/__init__.py new file mode 100644 index 0000000..80c1124 --- /dev/null +++ b/kis_trader/scan/__init__.py @@ -0,0 +1 @@ +"""kis_trader.scan — HTS 외 SCAN 소스 (DART 공시 등).""" diff --git a/kis_trader/scan/dart_order_tracker.py b/kis_trader/scan/dart_order_tracker.py new file mode 100644 index 0000000..d2bc240 --- /dev/null +++ b/kis_trader/scan/dart_order_tracker.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +""" +DART 수주(단일판매·공급계약) 공시 SCAN +==================================== +역할 (1단계): + Open DART ``list.json`` 을 폴링 → 신규 공시만 DB 저장 → Mattermost 알림. + (선택) ``DART_SAVE_CANDIDATES=true`` 이면 ``target_candidates_history`` + (strategy_id=DART) 에 후보만 적재. + +품질필터 (기본 ON): + 1) 건설·재개발·아파트 등 제외 (법인명·공시명·원문) + 2) 공시 원문 매출액대비(%) ≥ DART_MIN_SALES_PCT + 3) 수출·해외·USD·반도체·방산 등 테마 키워드 필수(DART_REQUIRE_THEME) + +하지 않는 것: + - 공시 직후 시장가 매수 (즉시매수 금지 — TRIGGER 전략은 후속) + - Gemini 식 독립 SafeRequest / JSON 파일 원자저장 (기존 SafeRequest + MariaDB) + +실행: + cd ~/kis_bot + nohup python3 -u -m kis_trader.scan.dart_order_tracker \\ + >> logs/dart_order_tracker.log 2>&1 & + # 1회만: + python3 -m kis_trader.scan.dart_order_tracker --once +""" +from __future__ import annotations + +import argparse +import datetime as dt +import io +import random +import re +import sys +import time +import zipfile +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from database import TradeDB +from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int +from kis_trader.utils.logger import get_logger, msg_mm, msg_tg +from kis_trader.utils.request_handler import SafeRequest + +logger = get_logger("kis_trader.dart_order") + +DART_LIST_URL = "https://opendart.fss.or.kr/api/list.json" +DART_DOCUMENT_URL = "https://opendart.fss.or.kr/api/document.xml" +DART_VIEW_URL = "https://dart.fss.or.kr/dsaf001/main.do?rcpNo={rcept_no}" +STRATEGY_ID = "DART" + +# 매출액대비(%) — 정식공시 / 자율공시 표기 차이 +_RE_SALES_PCT = re.compile( + r"매출액\s*대비\s*\(%\)\s*([0-9]+(?:\.[0-9]+)?)", + re.IGNORECASE, +) +_RE_CONTRACT_AMT = re.compile( + r"계약금액(?:\s*총액)?\s*\(원\)\s*([0-9,\.]+)", +) + + +def _now_str() -> str: + return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _mm_channel() -> str: + ch = (get_env_from_db("DART_MM_CHANNEL", "") or "").strip() + if ch: + return ch + ch = (get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "") or "").strip() + if ch: + return ch + return (get_env_from_db("MATTERMOST_CHANNEL", "stock") or "stock").strip() + + +def _split_keywords(raw: str) -> List[str]: + parts = [p.strip() for p in (raw or "").replace("ㆍ", ",").split(",")] + return [p for p in parts if p] + + +def _keywords() -> List[str]: + raw = get_env_from_db("DART_REPORT_NAME_KEYWORDS", "단일판매,공급계약") or "" + return _split_keywords(raw) + + +def _exclude_corp_keywords() -> List[str]: + # 재개발·아파트·건설 수주 — 테마 선반영 후 공시되는 경우가 많아 SCAN에서 제외 + raw = get_env_from_db( + "DART_EXCLUDE_CORP_KEYWORDS", + "건설,재개발,아파트,주택,토건,시공,하우징,도시정비,디벨로퍼", + ) or "" + return _split_keywords(raw) + + +def _exclude_content_keywords() -> List[str]: + raw = get_env_from_db( + "DART_EXCLUDE_CONTENT_KEYWORDS", + "아파트,재개발,주택분양,도시정비,건축공사,도급공사,시공계약,분양", + ) or "" + return _split_keywords(raw) + + +def _theme_keywords() -> List[str]: + # 수출·해외·제조/반도체·방산 쪽 — 국내 아파트 수주와 구분 + raw = get_env_from_db( + "DART_THEME_KEYWORDS", + "수출,해외,USD,달러,반도체,방산,우주,이차전지,배터리,디스플레이," + "부품,제조,항공,국방,전자,소재,장비", + ) or "" + return _split_keywords(raw) + + +def _plain_from_html(text: str) -> str: + plain = re.sub(r"<[^>]+>", " ", text or "") + plain = re.sub(r"&[a-zA-Z]+;", " ", plain) + plain = re.sub(r"\s+", " ", plain) + return plain.strip() + + +def _contains_any(hay: str, needles: Sequence[str]) -> Optional[str]: + if not hay or not needles: + return None + for n in needles: + if n and n in hay: + return n + return None + + +def ensure_dart_disclosure_columns(db: TradeDB) -> None: + """웹/백테/스캐너 공용 — filter_ok·sales_pct 컬럼 보장.""" + db.conn.execute( + """ + CREATE TABLE IF NOT EXISTS dart_disclosures ( + rcept_no VARCHAR(32) NOT NULL PRIMARY KEY, + corp_code VARCHAR(16) NOT NULL DEFAULT '', + corp_name VARCHAR(100) NOT NULL DEFAULT '', + stock_code VARCHAR(20) NOT NULL DEFAULT '', + report_nm VARCHAR(255) NOT NULL DEFAULT '', + rcept_dt VARCHAR(8) NOT NULL DEFAULT '', + url VARCHAR(255) NOT NULL DEFAULT '', + first_seen_at VARCHAR(30) NOT NULL, + mm_sent TINYINT NOT NULL DEFAULT 0, + filter_ok TINYINT NULL, + sales_pct DOUBLE NULL, + filter_reason VARCHAR(64) NULL, + KEY idx_dart_seen (first_seen_at), + KEY idx_dart_stock (stock_code) + ) CHARACTER SET utf8mb4 + """ + ) + cols = { + str(r["Field"]) + for r in db.conn.execute("SHOW COLUMNS FROM dart_disclosures").fetchall() + } + for col, ddl in ( + ("filter_ok", "ADD COLUMN filter_ok TINYINT NULL"), + ("sales_pct", "ADD COLUMN sales_pct DOUBLE NULL"), + ("filter_reason", "ADD COLUMN filter_reason VARCHAR(64) NULL"), + ): + if col not in cols: + try: + db.conn.execute(f"ALTER TABLE dart_disclosures {ddl}") + except Exception as e: + logger.debug("dart_disclosures alter skip %s: %s", col, e) + + +class DartOrderTracker(SafeRequest): + """ + Open DART 공시검색(list.json) → 수주성 보고서명 필터 → 품질필터 → DB/알림. + SafeRequest 상속: 429/5xx 재시도 + 클라이언트 스로틀. + """ + + def __init__(self, db: Optional[TradeDB] = None): + # DART 일일 호출 한도 여유 — 폴링은 분 단위라 간격 여유 있게 + super().__init__(min_interval_sec=0.35, max_retries=4, timeout_sec=15.0) + self.db = db or TradeDB() + self._ensure_table() + + def _ensure_table(self) -> None: + ensure_dart_disclosure_columns(self.db) + self._backfill_corp_exclude() + + def _backfill_corp_exclude(self) -> None: + """이미 적재된 건설·재개발 법인명은 filter_ok=0 으로 표시 (원문 재다운로드 없음).""" + kws = _exclude_corp_keywords() + if not kws: + return + try: + rows = self.db.conn.execute( + """ + SELECT rcept_no, corp_name, report_nm FROM dart_disclosures + WHERE filter_ok IS NULL + """ + ).fetchall() + except Exception: + return + for r in rows: + blob = f"{r.get('corp_name') or ''} {r.get('report_nm') or ''}" + hit = _contains_any(blob, kws) + if hit: + try: + self.db.conn.execute( + """ + UPDATE dart_disclosures + SET filter_ok=0, filter_reason=%s + WHERE rcept_no=%s + """, + (f"exclude_corp:{hit}"[:64], r["rcept_no"]), + ) + except Exception: + pass + else: + # 품질필터 도입 전 건 — 백테는 filter_ok=1 만 쓰므로 일단 통과 표시 + try: + self.db.conn.execute( + """ + UPDATE dart_disclosures + SET filter_ok=1, filter_reason=%s + WHERE rcept_no=%s + """, + ("legacy_pass", r["rcept_no"]), + ) + except Exception: + pass + + def _api_key(self) -> str: + return (get_env_from_db("DART_API_KEY", "") or "").strip() + + def fetch_list(self, *, bgn_de: str, end_de: str) -> List[Dict[str, Any]]: + """공시검색 list.json (페이지 순회, 최대 10페이지).""" + key = self._api_key() + if not key: + logger.error("DART_API_KEY 가 비어 있습니다.") + return [] + + out: List[Dict[str, Any]] = [] + page_count = 100 + max_pages = max(1, min(50, get_env_int("DART_LIST_MAX_PAGES", 20))) + for page_no in range(1, max_pages + 1): + params = { + "crtfc_key": key, + "bgn_de": bgn_de, + "end_de": end_de, + "page_no": str(page_no), + "page_count": str(page_count), + } + resp = self.get(DART_LIST_URL, params=params) + if resp is None or resp.status_code != 200: + logger.warning( + "DART list HTTP 실패 status=%s page=%s", + getattr(resp, "status_code", None), + page_no, + ) + break + try: + body = resp.json() + except Exception as e: + logger.warning("DART list JSON 파싱 실패: %s", e) + break + + status = str(body.get("status") or "") + # 013 = 조회된 데이터가 없습니다 (정상 empty) + if status == "013": + break + if status != "000": + logger.warning( + "DART list status=%s msg=%s page=%s", + status, body.get("message"), page_no, + ) + break + + rows = body.get("list") or [] + if isinstance(rows, dict): + rows = [rows] + if not rows: + break + out.extend(rows) + total_page = int(body.get("total_page") or 1) + if page_no >= total_page: + break + return out + + def fetch_document_plain(self, rcept_no: str) -> str: + """공시 원문 ZIP(XML) → 텍스트. 실패 시 빈 문자열.""" + key = self._api_key() + rcept_no = str(rcept_no or "").strip() + if not key or not rcept_no: + return "" + resp = self.get( + DART_DOCUMENT_URL, + params={"crtfc_key": key, "rcept_no": rcept_no}, + timeout=30.0, + ) + if resp is None or resp.status_code != 200 or not resp.content: + return "" + try: + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + names = zf.namelist() + if not names: + return "" + raw = zf.read(names[0]) + text = raw.decode("utf-8", errors="replace") + return _plain_from_html(text) + except Exception as e: + logger.debug("DART document 파싱 실패 %s: %s", rcept_no, e) + return "" + + def filter_reports(self, rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: + """보고서명 키워드 + (선택) 기재정정 스킵 + 상장만.""" + kws = _keywords() + skip_corr = get_env_bool("DART_SKIP_CORRECTION", True) + listed_only = get_env_bool("DART_LISTED_ONLY", True) + hits: List[Dict[str, Any]] = [] + for row in rows: + name = str(row.get("report_nm") or "").strip() + if not name: + continue + if skip_corr and ("기재정정" in name or name.startswith("[기재정정]")): + continue + if kws and not any(k in name for k in kws): + continue + stock = str(row.get("stock_code") or "").strip() + if listed_only and not stock: + continue + hits.append(row) + return hits + + def quality_check( + self, item: Dict[str, Any] + ) -> Tuple[bool, Dict[str, Any]]: + """ + 건설 제외 · 매출대비% · 테마 키워드. + 반환: (통과여부, meta{sales_pct, filter_reason, theme_hit, plain_len}) + """ + meta: Dict[str, Any] = { + "sales_pct": None, + "filter_reason": "pass", + "theme_hit": None, + "plain_len": 0, + } + if not get_env_bool("DART_QUALITY_FILTER_ENABLED", True): + return True, meta + + corp = str(item.get("corp_name") or "") + report = str(item.get("report_nm") or "") + blob_head = f"{corp} {report}" + + ex_corp = _exclude_corp_keywords() + hit = _contains_any(blob_head, ex_corp) + if hit: + meta["filter_reason"] = f"exclude_corp:{hit}" + return False, meta + + plain = "" + if get_env_bool("DART_FETCH_DOCUMENT", True): + plain = self.fetch_document_plain(str(item.get("rcept_no") or "")) + meta["plain_len"] = len(plain) + + ex_content = _exclude_content_keywords() + if plain: + hit = _contains_any(plain, ex_content) + if hit: + meta["filter_reason"] = f"exclude_content:{hit}" + return False, meta + + min_pct = float(get_env_float("DART_MIN_SALES_PCT", 5.0) or 0.0) + sales_pct: Optional[float] = None + if plain: + m = _RE_SALES_PCT.search(plain) + if m: + try: + sales_pct = float(m.group(1)) + except Exception: + sales_pct = None + meta["sales_pct"] = sales_pct + + if min_pct > 0: + if sales_pct is None: + if get_env_bool("DART_SKIP_IF_SALES_PCT_UNKNOWN", True): + meta["filter_reason"] = "sales_pct_unknown" + return False, meta + elif sales_pct < min_pct: + meta["filter_reason"] = f"low_sales:{sales_pct}" + return False, meta + + if get_env_bool("DART_REQUIRE_THEME", True): + themes = _theme_keywords() + theme_blob = f"{blob_head} {plain}" + th = _contains_any(theme_blob, themes) + meta["theme_hit"] = th + if not th: + meta["filter_reason"] = "no_theme" + return False, meta + + meta["filter_reason"] = "pass" + return True, meta + + def _known_rcept_nos(self, rcept_nos: Sequence[str]) -> Set[str]: + nos = [str(x).strip() for x in rcept_nos if str(x).strip()] + if not nos: + return set() + # pymysql IN — 플레이스홀더 + placeholders = ",".join(["%s"] * len(nos)) + rows = self.db.conn.execute( + f"SELECT rcept_no FROM dart_disclosures WHERE rcept_no IN ({placeholders})", + tuple(nos), + ).fetchall() + return {str(r["rcept_no"]) for r in rows} + + def _insert_disclosure( + self, + item: Dict[str, Any], + *, + mm_sent: bool, + filter_ok: bool, + sales_pct: Optional[float], + filter_reason: str, + ) -> bool: + """신규 공시 1건 INSERT. 이미 있으면 False.""" + rcept_no = str(item.get("rcept_no") or "").strip() + if not rcept_no: + return False + stock = str(item.get("stock_code") or "").strip() + corp_name = str(item.get("corp_name") or "").strip()[:100] + report_nm = str(item.get("report_nm") or "").strip()[:255] + url = DART_VIEW_URL.format(rcept_no=rcept_no) + try: + self.db.conn.execute( + """ + INSERT INTO dart_disclosures + (rcept_no, corp_code, corp_name, stock_code, report_nm, + rcept_dt, url, first_seen_at, mm_sent, + filter_ok, sales_pct, filter_reason) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + rcept_no, + str(item.get("corp_code") or "").strip()[:16], + corp_name, + stock[:20], + report_nm, + str(item.get("rcept_dt") or "").strip()[:8], + url[:255], + _now_str(), + 1 if mm_sent else 0, + 1 if filter_ok else 0, + sales_pct, + (filter_reason or "")[:64], + ), + ) + return True + except Exception as e: + # PK 중복 등 + logger.debug("dart insert skip %s: %s", rcept_no, e) + return False + + def _notify(self, item: Dict[str, Any], meta: Optional[Dict[str, Any]] = None) -> bool: + stock = str(item.get("stock_code") or "").strip() or "-" + corp = str(item.get("corp_name") or "").strip() + report = str(item.get("report_nm") or "").strip() + rcept_no = str(item.get("rcept_no") or "").strip() + url = DART_VIEW_URL.format(rcept_no=rcept_no) + meta = meta or {} + pct = meta.get("sales_pct") + pct_s = f"{pct:.2f}%" if isinstance(pct, (int, float)) else "-" + theme = meta.get("theme_hit") or "-" + body = ( + f"📢 **[DART 수주/공급계약]**\n" + f"- 종목: `{stock}` {corp}\n" + f"- 공시: {report}\n" + f"- 매출대비: {pct_s} · 테마: {theme}\n" + f"- 접수: {item.get('rcept_dt') or '-'}\n" + f"- [원문]({url})\n" + f"⚠️ 즉시 시장가 매수 금지 — SCAN 후보/알림 전용" + ) + ok_mm = msg_mm(body, channel_alias=_mm_channel(), jitter=True) + # 텔레그램 키가 있으면 병행 (없으면 내부에서 no-op) + msg_tg(body, jitter=False) + return bool(ok_mm) + + def _save_candidate(self, item: Dict[str, Any]) -> None: + if not get_env_bool("DART_SAVE_CANDIDATES", False): + return + stock = str(item.get("stock_code") or "").strip() + if not stock: + return + try: + from kis_trader.database.db_manager import get_db + kdb = get_db() + et = _now_str() + code = stock.zfill(6) if stock.isdigit() else stock + kdb.insert_universe_snapshot( + strategy_id=STRATEGY_ID, + event_time=et, + items=[{ + "code": code, + "name": str(item.get("corp_name") or stock), + }], + ) + except Exception as e: + logger.warning("DART 후보 적재 실패: %s", e) + + def poll_once(self) -> int: + """ + 1회 폴링. 품질필터 통과 신규 건수 반환. + lookback: 오늘 포함 DART_LOOKBACK_DAYS 일. + """ + if not get_env_bool("DART_ENABLED", True): + logger.info("DART_ENABLED=false — 스킵") + return 0 + # SCAN 스위치 (신규 키 우선) + if not get_env_bool("DART_SCAN_ENABLED", get_env_bool("DART_ENABLED", True)): + logger.info("DART_SCAN_ENABLED=false — 스킵") + return 0 + if not self._api_key(): + logger.error("DART_API_KEY 없음 — 중단") + return 0 + + lookback = max(0, get_env_int("DART_LOOKBACK_DAYS", 1)) + end = dt.datetime.now().date() + bgn = end - dt.timedelta(days=lookback) + bgn_de = bgn.strftime("%Y%m%d") + end_de = end.strftime("%Y%m%d") + + rows = self.fetch_list(bgn_de=bgn_de, end_de=end_de) + hits = self.filter_reports(rows) + if not hits: + logger.info( + "DART 폴링: list=%d hits=0 (%s~%s)", + len(rows), bgn_de, end_de, + ) + return 0 + + known = self._known_rcept_nos([str(h.get("rcept_no") or "") for h in hits]) + new_count = 0 + pass_count = 0 + for item in hits: + rcept_no = str(item.get("rcept_no") or "").strip() + if not rcept_no or rcept_no in known: + continue + + ok, meta = self.quality_check(item) + inserted = self._insert_disclosure( + item, + mm_sent=False, + filter_ok=ok, + sales_pct=meta.get("sales_pct"), + filter_reason=str(meta.get("filter_reason") or ""), + ) + if not inserted: + continue + new_count += 1 + known.add(rcept_no) + + if not ok: + logger.info( + "⛔ DART 필터 %s %s | %s | reason=%s sales=%s", + item.get("stock_code"), + item.get("corp_name"), + str(item.get("report_nm") or "").strip()[:40], + meta.get("filter_reason"), + meta.get("sales_pct"), + ) + continue + + pass_count += 1 + mm_ok = self._notify(item, meta) + if mm_ok: + try: + self.db.conn.execute( + "UPDATE dart_disclosures SET mm_sent=1 WHERE rcept_no=%s", + (rcept_no,), + ) + except Exception: + pass + self._save_candidate(item) + # 임시 워치 (구독 스위치 ON 시) + try: + from kis_trader.scan.dart_watchlist import upsert_watch + upsert_watch( + self.db, + stock_code=str(item.get("stock_code") or "").strip(), + corp_name=str(item.get("corp_name") or ""), + rcept_no=rcept_no, + report_nm=str(item.get("report_nm") or ""), + ) + except Exception as e: + logger.debug("DART 워치 적재 스킵: %s", e) + logger.info( + "🆕 DART 통과 %s %s | sales=%s%% theme=%s | %s", + item.get("stock_code"), + item.get("corp_name"), + meta.get("sales_pct"), + meta.get("theme_hit"), + str(item.get("report_nm") or "").strip()[:60], + ) + logger.info( + "DART 폴링: list=%d hits=%d new=%d pass=%d (%s~%s)", + len(rows), len(hits), new_count, pass_count, bgn_de, end_de, + ) + return pass_count + + def run_forever(self) -> None: + poll = max(15, get_env_int("DART_POLL_SEC", 60)) + logger.info( + "DART 수주 감시 시작 poll=%ss lookback=%sd channel=%s quality=%s min_sales=%s theme=%s", + poll, + get_env_int("DART_LOOKBACK_DAYS", 1), + _mm_channel(), + get_env_bool("DART_QUALITY_FILTER_ENABLED", True), + get_env_float("DART_MIN_SALES_PCT", 5.0), + get_env_bool("DART_REQUIRE_THEME", True), + ) + while True: + try: + self.poll_once() + except Exception as e: + logger.exception("DART 폴링 예외: %s", e) + # 일반 루프: 기본 간격 + 1~3초 지터 (서버 부하·패턴 고정 방지) + time.sleep(float(poll) + random.uniform(1.0, 3.0)) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description="DART 수주/공급계약 공시 SCAN") + parser.add_argument( + "--once", action="store_true", + help="1회 폴링 후 종료 (스모크/cron용)", + ) + args = parser.parse_args(list(argv) if argv is not None else None) + + if not get_env_bool("DART_ENABLED", True): + logger.warning("DART_ENABLED=false") + return 0 + tracker = DartOrderTracker() + if args.once: + n = tracker.poll_once() + print(f"dart_order_tracker --once new_pass={n}") + return 0 + tracker.run_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/kis_trader/scan/dart_watchlist.py b/kis_trader/scan/dart_watchlist.py new file mode 100644 index 0000000..516749f --- /dev/null +++ b/kis_trader/scan/dart_watchlist.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +kis_trader/scan/dart_watchlist.py — DART 임시 워치 (영구구독 아님) +""" +from __future__ import annotations + +import datetime as dt +from typing import Any, Dict, List, Optional + +from kis_trader.utils.env import get_env_bool, get_env_int +from kis_trader.utils.logger import get_logger + +logger = get_logger("kis_trader.dart_watch") + + +def _now() -> dt.datetime: + return dt.datetime.now() + + +def _fmt(ts: dt.datetime) -> str: + return ts.strftime("%Y-%m-%d %H:%M:%S") + + +def ensure_watchlist_table(db: Any) -> None: + db.conn.execute( + """ + CREATE TABLE IF NOT EXISTS dart_watchlist ( + stock_code VARCHAR(20) NOT NULL PRIMARY KEY, + corp_name VARCHAR(100) NOT NULL DEFAULT '', + rcept_no VARCHAR(32) NOT NULL DEFAULT '', + report_nm VARCHAR(255) NOT NULL DEFAULT '', + added_at VARCHAR(30) NOT NULL, + expires_at VARCHAR(30) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + KEY idx_dart_watch_exp (expires_at) + ) CHARACTER SET utf8mb4 + """ + ) + + +def purge_expired(db: Any) -> int: + ensure_watchlist_table(db) + now = _fmt(_now()) + cur = db.conn.execute( + "DELETE FROM dart_watchlist WHERE expires_at < %s OR enabled=0", + (now,), + ) + try: + return int(cur.rowcount or 0) + except Exception: + return 0 + + +def list_active_watch(db: Any) -> List[Dict[str, Any]]: + ensure_watchlist_table(db) + purge_expired(db) + now = _fmt(_now()) + rows = db.conn.execute( + """ + SELECT stock_code, corp_name, rcept_no, report_nm, added_at, expires_at + FROM dart_watchlist + WHERE enabled=1 AND expires_at >= %s + ORDER BY added_at DESC + """, + (now,), + ).fetchall() + return [dict(r) for r in rows] + + +def watch_count(db: Any) -> int: + return len(list_active_watch(db)) + + +def upsert_watch( + db: Any, + *, + stock_code: str, + corp_name: str = "", + rcept_no: str = "", + report_nm: str = "", + ttl_hours: Optional[int] = None, + watch_max: Optional[int] = None, +) -> bool: + """ + 구독 스위치 ON일 때만 호출. 상한 초과 시 가장 오래된 것 제거 후 삽입. + """ + if not get_env_bool("DART_SUBSCRIBE_ENABLED", False): + return False + code = (stock_code or "").strip() + if not code: + return False + ensure_watchlist_table(db) + purge_expired(db) + ttl = int(ttl_hours if ttl_hours is not None else get_env_int("DART_WATCH_TTL_HOURS", 24)) + cap = int(watch_max if watch_max is not None else get_env_int("DART_WATCH_MAX", 15)) + now = _now() + exp = now + dt.timedelta(hours=max(1, ttl)) + + active = list_active_watch(db) + if code not in {a["stock_code"] for a in active} and len(active) >= cap: + # 가장 오래된 1건 제거 + oldest = sorted(active, key=lambda x: x.get("added_at") or "")[:1] + for o in oldest: + db.conn.execute( + "DELETE FROM dart_watchlist WHERE stock_code=%s", + (o["stock_code"],), + ) + logger.info("DART 워치 상한 — 제거 %s", o["stock_code"]) + + db.conn.execute( + """ + INSERT INTO dart_watchlist + (stock_code, corp_name, rcept_no, report_nm, added_at, expires_at, enabled) + VALUES (%s, %s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE + corp_name=VALUES(corp_name), + rcept_no=VALUES(rcept_no), + report_nm=VALUES(report_nm), + added_at=VALUES(added_at), + expires_at=VALUES(expires_at), + enabled=1 + """, + ( + code[:20], + (corp_name or "")[:100], + (rcept_no or "")[:32], + (report_nm or "")[:255], + _fmt(now), + _fmt(exp), + ), + ) + return True + + +def event_time_for_code(db: Any, code: str) -> Optional[str]: + """워치/최신 공시 시각 → 봉키 YYYYMMDDHHMM 근사.""" + ensure_watchlist_table(db) + code = (code or "").strip() + row = db.conn.execute( + """ + SELECT added_at, rcept_no FROM dart_watchlist + WHERE stock_code=%s AND enabled=1 + ORDER BY added_at DESC LIMIT 1 + """, + (code,), + ).fetchone() + if row and row.get("added_at"): + s = str(row["added_at"]).replace("-", "").replace(":", "").replace(" ", "") + if len(s) >= 12: + return s[:12] + # disclosures fallback + d = db.conn.execute( + """ + SELECT first_seen_at, rcept_dt FROM dart_disclosures + WHERE stock_code=%s ORDER BY first_seen_at DESC LIMIT 1 + """, + (code,), + ).fetchone() + if not d: + return None + if d.get("first_seen_at"): + s = str(d["first_seen_at"]).replace("-", "").replace(":", "").replace(" ", "") + if len(s) >= 12: + return s[:12] + rd = str(d.get("rcept_dt") or "") + if len(rd) == 8: + return rd + "0900" + return None diff --git a/kis_trader/scripts/fill_kiwoom_candles.py b/kis_trader/scripts/fill_kiwoom_candles.py index 6e1e2d1..c80645b 100644 --- a/kis_trader/scripts/fill_kiwoom_candles.py +++ b/kis_trader/scripts/fill_kiwoom_candles.py @@ -160,7 +160,18 @@ def _load_target_codes( # ─── DB UPSERT 헬퍼 ──────────────────────────────────────────────────── -_INSERT_SQL = """ +# WS_CANDLE_FREEZE_ON_CONFIRM(기본 true): 존재 행 OHLCV 유지 — docs/정합성.md +_INSERT_SQL_FREEZE = """ + 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 + candle_time=candle_time +""" + +_INSERT_SQL_OVERWRITE = """ INSERT INTO ws_candles (code, timeframe, candle_time, `open`, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) @@ -173,6 +184,14 @@ _INSERT_SQL = """ """ +def _insert_sql() -> str: + from kis_trader.utils.env import get_env_bool + + if get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True): + return _INSERT_SQL_FREEZE + return _INSERT_SQL_OVERWRITE + + def _upsert_candles( db: TradeDB, code: str, @@ -214,7 +233,7 @@ def _upsert_candles( with db.conn._lock: db.conn._ensure_connected() cur = db.conn._conn.cursor() - cur.executemany(_INSERT_SQL, rows) + cur.executemany(_insert_sql(), rows) db.conn._conn.commit() return len(rows) diff --git a/kis_trader/strategies/__init__.py b/kis_trader/strategies/__init__.py index 35a5a84..ac6db8c 100644 --- a/kis_trader/strategies/__init__.py +++ b/kis_trader/strategies/__init__.py @@ -12,6 +12,7 @@ from .scalping import ScalpingStrategy from .tail_catch import TailCatchStrategy from .updow_strategy import UpdowStrategy from .dbband_strategy import DbBandStrategy +from .dart_strategy import DartStrategy __all__ = [ "BaseStrategy", @@ -22,5 +23,6 @@ __all__ = [ "MomentumStrategy", "UpdowStrategy", "DbBandStrategy", + "DartStrategy", "updow_buy", ] diff --git a/kis_trader/strategies/breakout.py b/kis_trader/strategies/breakout.py index ed059cd..99d5f43 100644 --- a/kis_trader/strategies/breakout.py +++ b/kis_trader/strategies/breakout.py @@ -1645,6 +1645,10 @@ class BreakoutStrategy(BaseStrategy): 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: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass self.logger.info( "🔍 [캔들부족] %s(%s) need>=%d have=%d", name, code, need_n - 1, len(confirmed), diff --git a/kis_trader/strategies/dart_strategy.py b/kis_trader/strategies/dart_strategy.py new file mode 100644 index 0000000..e3e3a0a --- /dev/null +++ b/kis_trader/strategies/dart_strategy.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +kis_trader/strategies/dart_strategy.py — DART 수주 공시 실매 전략 +================================================================ +유니버스 = dart_watchlist (구독 스위치) 또는 당일 disclosures. +매매 = STRATEGY_DART_ENABLED + DART_TRADE_ENABLED. +""" +from __future__ import annotations + +from datetime import datetime as dt +from typing import Dict, List, Optional + +from ..engine import dart_engine as de +from ..scan.dart_watchlist import event_time_for_code, list_active_watch +from ..utils.env import get_env_bool, get_env_int +from .base import BaseStrategy + + +class DartStrategy(BaseStrategy): + strategy_id = "DART" + loop_min_sleep = 1.0 + loop_max_sleep = 2.5 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.candle_tf = 1 + self._engine_params: Optional[Dict] = None + self.reload_config() + + def reload_config(self) -> None: + try: + base = de.get_dart_defaults_from_db() + self._engine_params = base + self.min_price = float(base.get("min_price", 1000.0)) + self.stop_loss_pct = -abs(float(base.get("sl_pct", 0.02))) + self.take_profit_pct = abs(float(base.get("tp_pct", 0.04))) + self.slot_money = int(base.get("slot_money", 300_000)) + self.time_start_hm = int(base.get("time_start_hm", 930)) + self.time_end_hm = int(base.get("time_end_hm", 1520)) + except Exception as e: + self.logger.debug("dart defaults 실패: %s", e) + self._engine_params = {} + + def _trade_on(self) -> bool: + return get_env_bool("DART_TRADE_ENABLED", False) + + def _in_trade_window(self) -> bool: + now = dt.now() + hm = now.hour * 100 + now.minute + return self.time_start_hm <= hm < self.time_end_hm + + def _db_raw(self): + return self.db.raw if hasattr(self.db, "raw") else self.db + + def _load_candidates(self) -> List[Dict]: + out: List[Dict] = [] + lim = get_env_int("DART_CAND_LIMIT", 20) or 20 + try: + conn_db = self._db_raw() + watches = list_active_watch(conn_db) + for w in watches: + code = (w.get("stock_code") or "").strip() + if not code: + continue + out.append({ + "code": code.zfill(6) if code.isdigit() else code, + "name": w.get("corp_name") or code, + "score": 0.0, + }) + if out: + return out[:lim] + today0 = dt.now().strftime("%Y-%m-%d") + " 00:00:00" + rows = conn_db.conn.execute( + """ + SELECT stock_code, corp_name FROM dart_disclosures + WHERE first_seen_at >= %s AND stock_code <> '' + ORDER BY first_seen_at DESC LIMIT %s + """, + (today0, lim), + ).fetchall() + for r in rows: + code = (r.get("stock_code") or "").strip() + if not code: + continue + out.append({ + "code": code.zfill(6) if code.isdigit() else code, + "name": r.get("corp_name") or code, + "score": 0.0, + }) + except Exception as e: + self.logger.debug("DART 후보 로드 실패: %s", e) + return out + + def _candidate_filter(self, candidate: Dict) -> bool: + if not self._trade_on(): + return False + if not candidate.get("code"): + return False + if not self._in_trade_window(): + return False + return True + + def _norm_candle(self, c: dict) -> dict: + ct = c.get("candle_time") or c.get("candle_time_str", "") + if isinstance(ct, str) and len(ct) == 19 and " " in ct: + ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12] + return { + "candle_time": ct, + "open": float(c.get("open", 0)), + "high": float(c.get("high", 0)), + "low": float(c.get("low", 0)), + "close": float(c.get("close", 0)), + "volume": float(c.get("volume", 0)), + "is_confirmed": c.get("is_confirmed", 1), + } + + def _force_buy_test(self, code: str, name: str) -> Optional[Dict]: + wsd = self.ws.get_price(code) + px = 0.0 + if wsd: + try: + px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", ""))) + except Exception: + px = 0.0 + if px <= 0: + return None + qty, rej = self._resolve_buy_qty_live(px) + if rej or qty < 1: + return None + return { + "code": code, + "name": name, + "price": px, + "qty": qty, + "stop_price": px * (1 + self.stop_loss_pct), + "target_price": px * (1 + self.take_profit_pct), + "atr_entry": 0.0, + "size_class": "", + "entry_features": {"force_test": True}, + } + + def check_buy(self, code: str, name: str) -> Optional[Dict]: + if not self._trade_on() or not self._in_trade_window(): + return None + try: + if get_env_bool("FORCE_BUY_TEST", False): + return self._force_buy_test(code, name) + + params = self._engine_params or de.get_dart_defaults_from_db() + ev = event_time_for_code(self._db_raw(), code) + if not ev: + return None + + need = de.dart_min_bars_required(params) + candles_raw = self.ws.get_candles(code, self.candle_tf, n=need + 30) + if len(candles_raw) < need: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass + return None + candles = [self._norm_candle(c) for c in candles_raw] + reject, msg, sig = de.check_buy_signal_dart_live( + candles, params, event_candle_time=ev, + ) + if reject: + self.logger.info("🔍 [%s] %s(%s) %s", reject, name, code, msg or "") + return None + if not sig: + return None + + curr_price = float(sig.get("entry_price") or 0) + if curr_price <= 0 or curr_price < self.min_price: + return None + hard_cap = get_env_int("DART_MAX_BUY_AMOUNT", 0) or get_env_int( + "MAX_BUY_AMOUNT_PER_STOCK", 0, + ) + qty, rej = self._resolve_buy_qty_live(curr_price, hard_cap=hard_cap) + if rej or qty < 1: + return None + return { + "code": code, + "name": name, + "price": curr_price, + "qty": qty, + "stop_price": curr_price * (1 + self.stop_loss_pct), + "target_price": curr_price * (1 + self.take_profit_pct), + "atr_entry": 0.0, + "size_class": "", + "entry_features": { + "rsi": sig.get("rsi"), + "event_bars": sig.get("event_bars"), + "strategy": "DART", + }, + } + except Exception as e: + self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e) + return None + + def check_sell_signals(self) -> List[Dict]: + if not self.holdings: + return [] + signals: List[Dict] = [] + params = self._engine_params or de.get_dart_defaults_from_db() + now = dt.now() + hm = now.hour * 100 + now.minute + for code, holding in list(self.holdings.items()): + try: + name = holding.get("name", code) + buy_price = float(holding.get("buy_price", 0)) + qty = int(holding.get("qty", 0)) + max_price = float(holding.get("max_price", buy_price)) + if qty <= 0 or buy_price <= 0: + continue + current_price = 0.0 + wsd = self.ws.get_price(code) + if wsd: + try: + current_price = abs( + float(str(wsd.get("stck_prpr", 0)).replace(",", "")) + ) + except Exception: + current_price = 0.0 + if current_price <= 0: + continue + if current_price > max_price: + max_price = current_price + holding["max_price"] = max_price + bars = int(holding.get("bars_held") or 0) + sell, reason = de.check_sell_signal_dart_live( + buy_price=buy_price, + highest=max_price, + last_price=current_price, + bars_held=bars, + params=params, + now_hm=hm, + ) + if sell: + signals.append({ + "code": code, + "name": name, + "qty": qty, + "reason": reason, + "price": current_price, + }) + except Exception as e: + self.logger.error("DART 매도 체크 %s: %s", code, e) + return signals diff --git a/kis_trader/strategies/dbband_strategy.py b/kis_trader/strategies/dbband_strategy.py index 67a6cbb..e9b149e 100644 --- a/kis_trader/strategies/dbband_strategy.py +++ b/kis_trader/strategies/dbband_strategy.py @@ -96,6 +96,10 @@ class DbBandStrategy(BaseStrategy): ) candles_raw = self.ws.get_candles(code, tf_code, n=min_len + 10) if len(candles_raw) < min_len: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass return None candles = [self._norm_candle(c) for c in candles_raw] diff --git a/kis_trader/strategies/momentum.py b/kis_trader/strategies/momentum.py index bf2388b..0dbf108 100644 --- a/kis_trader/strategies/momentum.py +++ b/kis_trader/strategies/momentum.py @@ -95,7 +95,8 @@ class MomentumStrategy(BaseStrategy): candles_raw.append(cur_d) if len(candles_raw) < 6: try: - self.ws.fill_gap([code]) + # force: EXIT 후 _gap_filled 잔존 시에도 재채움 (봉부족 복구) + self.ws.fill_gap([code], force=True) except Exception: pass return None @@ -133,13 +134,16 @@ 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: + # 갭보정 워밍업 중 — 전일시가 없음·봉부족 시 force 재큐 (로그 스팸 전에 복구) + if reject in ("탈락-전일시가없음", "탈락-봉부족"): try: - self.ws.fill_gap([code]) + self.ws.fill_gap([code], force=True) except Exception: pass - return None + if reject == "탈락-전일시가없음" and len(candles_raw) < min_need: + return None + if reject == "탈락-봉부족": + return None self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: diff --git a/kis_trader/strategies/range_break.py b/kis_trader/strategies/range_break.py index 5a0327e..dfc0ca2 100644 --- a/kis_trader/strategies/range_break.py +++ b/kis_trader/strategies/range_break.py @@ -73,6 +73,10 @@ class RangeBreakStrategy(BaseStrategy): need_n = rbe.range_break_min_bars_required(self._engine_params or {}) candles_raw = self.ws.get_candles(code, self.candle_tf, n=need_n + 10) if len(candles_raw) < need_n - 2: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass return None candles = [self._norm_candle(c) for c in candles_raw] diff --git a/kis_trader/strategies/scalping.py b/kis_trader/strategies/scalping.py index c27c89c..d4b9742 100644 --- a/kis_trader/strategies/scalping.py +++ b/kis_trader/strategies/scalping.py @@ -29,8 +29,8 @@ try: except ImportError: se = None -from ..utils.env import get_env_bool, get_env_float, get_env_int -from .base import BaseStrategy +from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int +from .base import BaseStrategy, is_live_eod_now class ScalpingStrategy(BaseStrategy): @@ -63,6 +63,9 @@ class ScalpingStrategy(BaseStrategy): self.rsi_oversold = get_env_float("SCALP_RSI_OVERSOLD", 25.0) self.rsi_overbought = get_env_float("SCALP_RSI_OVERBOUGHT", 75.0) self.slot_money = get_env_int("SLOT_MONEY_DEFAULT", 3000000) + # 실매↔BT 공통 EOD (기존 하드코딩 15:25 → env) + self.eod_enabled = get_env_bool("SCALP_EOD_ENABLED", True) + self.eod_hm = get_env_from_db("SCALP_EOD_HM", "15:25") if se is not None: try: @@ -126,6 +129,10 @@ class ScalpingStrategy(BaseStrategy): candles_raw = self.ws.get_candles(code, self.candle_tf, n=50) if len(candles_raw) < 5: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass return None candles = [self._norm_candle(c) for c in candles_raw] @@ -254,7 +261,12 @@ class ScalpingStrategy(BaseStrategy): signals: List[Dict] = [] now = dt.now() - is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15 + is_eod = is_live_eod_now( + getattr(self, "eod_enabled", True), + getattr(self, "eod_hm", "15:25"), + now, + default_hm="15:25", + ) try: params = se.get_scalping_defaults_from_db() @@ -274,6 +286,8 @@ class ScalpingStrategy(BaseStrategy): "shoulder_min_high": float(params.get("shoulder_min_high", 0.005)), "shoulder_cut_pct": float(params.get("shoulder_cut_pct", 0.003)), "min_hold_sec": float(get_env_int("SCALP_MIN_HOLD_SEC", 30)), + "eod_enabled": getattr(self, "eod_enabled", True), + "eod_hm": getattr(self, "eod_hm", "15:25"), }) for code, holding in list(self.holdings.items()): diff --git a/kis_trader/strategies/tail_catch.py b/kis_trader/strategies/tail_catch.py index 08cf5a5..a9fb82d 100644 --- a/kis_trader/strategies/tail_catch.py +++ b/kis_trader/strategies/tail_catch.py @@ -158,6 +158,10 @@ class TailCatchStrategy(BaseStrategy): min_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14) candles_raw = self.ws.get_candles(code, self.candle_tf, n=50) if len(candles_raw) < min_len: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass return None candles = [self._norm_candle(c) for c in candles_raw] if len(candles) < 10: diff --git a/kis_trader/strategies/updow_strategy.py b/kis_trader/strategies/updow_strategy.py index ac00713..63cf57c 100644 --- a/kis_trader/strategies/updow_strategy.py +++ b/kis_trader/strategies/updow_strategy.py @@ -558,6 +558,10 @@ class UpdowStrategy(BaseStrategy): tf_code = self._candle_tf_for_code(code) candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch) if len(candles_raw) < min_len: + try: + self.ws.fill_gap([code], force=True) + except Exception: + pass self.logger.info( "🔍 [탈락-봉부족] %s %s: WS확정봉 %d개 (최소 %d, tf=%d)", name, code, len(candles_raw), min_len, tf_code, diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py index 3125ff8..d5944fa 100644 --- a/kis_trader/web/live_config_schema.py +++ b/kis_trader/web/live_config_schema.py @@ -26,6 +26,7 @@ LIVE_STRATEGY_LABELS: Dict[str, str] = { "RANGE_BREAK": "박스권돌파", "UPDOW": "UPDOWN 박스", "DBBAND": "더블BB", + "DART": "DART 수주", } # 전략 ID → 실제 env 키 (SHORT 는 TAIL_* 가 canonical — tail_engine·config_short) @@ -476,6 +477,24 @@ def build_live_config_groups() -> List[GroupDef]: _f("PENDING_SELL_STOP_MAX_AGE_SEC", "손절 매도 미체결 만료(초)", "int", default=15), _f("SELL_PENDING_REORDER_ON_EXPIRE", "손절 만료 후 즉시 재매도", "bool", default=True), _f("AUTO_CANCEL_PARTIAL_BUY_REMAINDER", "부분체결 잔량 자동취소", "bool", default=True), + _f( + "SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY", + "익절지정가→시장가 전 취소", + "bool", + default=True, + hint=( + "ON=지정가 미확인/부분체결 시 잔여 취소 후 ODNO 재조회, " + "취소실패·미확정이면 시장가 금지(PENDING) · " + "OFF=레거시 즉시 시장가(이중매도 위험) · 전전략 OrderManager 공통" + ), + ), + _f( + "SELL_LIMIT_RECHECK_WAIT_SEC", + "지정가 취소 후 재조회(초)", + "int", + default=1, + hint="SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY=ON 일 때 ODNO 재조회 대기", + ), ], }) @@ -595,6 +614,21 @@ def build_live_config_groups() -> List[GroupDef]: ], }) + groups.append({ + "id": "scalp_exit", + "title": "스캘핑(SCALP) — EOD", + "hint": ( + "config_scalp · 저장 후 다음 매도 루프부터 반영(재시작 권장). " + "실매 기존 하드코딩 15:25 장마감청산과 동일 기본값." + ), + "fields": [ + _f("SCALP_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True, + hint="ON=설정 시각 이후 보유 전량 장마감청산 · OFF=익절·손절·어깨만"), + _f("SCALP_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:25", + hint="실매 기존 15:25 · 정규장 15:30 마감 직전 순차매도"), + ], + }) + groups.append({ "id": "breakout_exit", "title": "돌파(BREAKOUT) — EOD·시간컷", @@ -685,6 +719,16 @@ def build_live_config_groups() -> List[GroupDef]: 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( + "GHOST_PURGE_RECORD_HISTORY", + "유령정리 시 거래내역 기록", + "bool", + default=True, + hint=( + "ON=유령 삭제 시 trade_history 에 sell_reason=ghost_purge " + "(realized_pnl=0) · OFF=삭제만 · 전전략 공통" + ), + ), _f("INTRADAY_HOLDINGS_DRIFT_ENABLED", "장중 qty 드리프트 감시", "bool", default=False, hint="기본 OFF · ON=N분마다 잔고 REST · 알림/자동복구는 별도 키"), _f("INTRADAY_HOLDINGS_DRIFT_INTERVAL_SEC", "드리프트 조회주기(초)", "int", default=300, diff --git a/kis_trader/ws/kis_ws.py b/kis_trader/ws/kis_ws.py index 0fc01d5..c3be380 100644 --- a/kis_trader/ws/kis_ws.py +++ b/kis_trader/ws/kis_ws.py @@ -1124,6 +1124,91 @@ class CandleAggregator: self._flush_batch(batch) logger.info("CandleDBWriter 스레드 종료") + @staticmethod + def _ws_candle_freeze_on_confirm() -> bool: + """확정봉(is_confirmed=1) OHLCV 동결 — docs/정합성.md. 기본 true.""" + return get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True) + + def _load_confirmed_ohlcv_from_db( + self, code: str, tf: int, candle_times: list, + ) -> Dict[str, Dict]: + """ + freeze 재시작 정합: RAM 이 비어도 DB 에 이미 확정된 봉은 REST 로 덮지 않고 + DB 값을 RAM 에 시드한다. (실매 RAM ≠ DB 재발 방지) + """ + out: Dict[str, Dict] = {} + if not self.db or not candle_times: + return out + times = sorted({str(t)[:12] for t in candle_times if str(t)[:12]}) + if not times: + return out + # IN 절 길이 제한 — 청크 + chunk_n = max(50, int(get_env_int("WS_CANDLE_FREEZE_DB_LOOKUP_CHUNK", 200))) + try: + for i in range(0, len(times), chunk_n): + chunk = times[i : i + chunk_n] + ph = ",".join(["%s"] * len(chunk)) + rows = self.db.conn.execute( + f""" + SELECT candle_time, `open`, high, low, close, volume, + rsi_2, rsi_3, rsi_5, source, holding_peak + FROM ws_candles + WHERE code=%s AND timeframe=%s AND is_confirmed=1 + AND candle_time IN ({ph}) + """, + (code, int(tf), *chunk), + ).fetchall() + for r in rows or []: + ct = str(r.get("candle_time") or "")[:12] + if not ct: + continue + out[ct] = dict(r) + except Exception as e: + logger.debug("freeze DB lookup 실패(%s %dM): %s", code, tf, e) + return out + + @staticmethod + def _ws_candles_upsert_sql(*, freeze: bool) -> str: + """ + freeze ON: 이미 확정된 행의 OHLCV/RSI/volume/source 유지. + 미확정(is_confirmed=0)→확정·갱신은 허용. holding_peak 만 항상 GREATEST. + freeze OFF: 기존처럼 덮어쓰기. + """ + if freeze: + dup = """ + ON DUPLICATE KEY UPDATE + `open`=IF(is_confirmed=1, `open`, VALUES(`open`)), + high=IF(is_confirmed=1, high, GREATEST(high, VALUES(high))), + low=IF(is_confirmed=1, low, VALUES(low)), + close=IF(is_confirmed=1, close, VALUES(close)), + volume=IF(is_confirmed=1, volume, VALUES(volume)), + rsi_2=IF(is_confirmed=1, rsi_2, VALUES(rsi_2)), + rsi_3=IF(is_confirmed=1, rsi_3, VALUES(rsi_3)), + rsi_5=IF(is_confirmed=1, rsi_5, VALUES(rsi_5)), + is_confirmed=IF(is_confirmed=1, 1, VALUES(is_confirmed)), + source=IF(is_confirmed=1, source, VALUES(source)), + holding_peak=GREATEST(COALESCE(holding_peak,0), COALESCE(VALUES(holding_peak),0)), + updated_at=IF(is_confirmed=1, updated_at, VALUES(updated_at)) + """ + else: + dup = """ + ON DUPLICATE KEY UPDATE + `open`=VALUES(`open`), high=GREATEST(high, VALUES(high)), + low=VALUES(low), close=VALUES(close), volume=VALUES(volume), + rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5), + is_confirmed=VALUES(is_confirmed), + holding_peak=GREATEST(COALESCE(holding_peak,0), COALESCE(VALUES(holding_peak),0)), + updated_at=VALUES(updated_at) + """ + return f""" + 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 + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + {dup} + """ + def _flush_batch(self, batch: list) -> None: """ 배치 리스트를 DB에 한 번의 executemany 로 INSERT. @@ -1154,22 +1239,12 @@ class CandleAggregator: item.get("holding_peak"), now_str, )) + sql = self._ws_candles_upsert_sql( + freeze=self._ws_candle_freeze_on_confirm(), + ) # ws_candles 테이블이 존재하면 배치 INSERT (없으면 조용히 skip) self.db.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 - (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - `open`=VALUES(`open`), high=GREATEST(high, VALUES(high)), - low=VALUES(low), close=VALUES(close), volume=VALUES(volume), - rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5), - is_confirmed=VALUES(is_confirmed), - holding_peak=GREATEST(COALESCE(holding_peak,0), COALESCE(VALUES(holding_peak),0)), - updated_at=VALUES(updated_at) - """, + sql, rows[0], ) if len(rows) == 1 else self._executemany_batch(rows) logger.debug("💾 [배치저장] %d봉 → DB", len(rows)) @@ -1178,22 +1253,10 @@ class CandleAggregator: def _executemany_batch(self, rows: list) -> None: """여러 봉을 executemany 로 한 번에 INSERT.""" - sql = """ - 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 - (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - `open`=VALUES(`open`), high=GREATEST(high, VALUES(high)), - low=VALUES(low), close=VALUES(close), volume=VALUES(volume), - rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5), - is_confirmed=VALUES(is_confirmed), - holding_peak=GREATEST(COALESCE(holding_peak,0), COALESCE(VALUES(holding_peak),0)), - updated_at=VALUES(updated_at) - """ + sql = self._ws_candles_upsert_sql( + freeze=self._ws_candle_freeze_on_confirm(), + ) # pymysql executemany: cursor.executemany(sql, list_of_tuples) - import pymysql with self.db.conn._lock: self.db.conn._ensure_connected() cur = self.db.conn._conn.cursor() @@ -1230,6 +1293,16 @@ class CandleAggregator: import datetime as _dt return _dt.datetime.now().strftime("%Y%m%d%H%M") + @staticmethod + def _open_bucket_ctime(tf: int, now=None) -> str: + """현재 시각 기준 진행 중(미완성) 봉의 candle_time (YYYYMMDDHHMM).""" + import datetime as _dt + from kis_trader.engine.candle_rollup import floor_candle_time_to_tf + + dt0 = now if now is not None else _dt.datetime.now() + raw = dt0.strftime("%Y%m%d%H%M") + return floor_candle_time_to_tf(raw, int(tf) or 1) + # ------------------------------------------------------------------ # RSI 계산 (확정 봉 close 리스트 기반) # ------------------------------------------------------------------ @@ -1344,8 +1417,9 @@ class CandleAggregator: [트랙 1] RSI 계산 + ``_confirmed`` RAM 버퍼 적재 (매수 루프 즉시 참조용) [트랙 2] DB 기록 Queue 적재 (논블로킹) - 동일 ``candle_time`` 이 이미 있으면 **append 하지 않고 upsert**. - volume 은 더 큰 쪽을 유지 (갭보정 불완전봉 < WS 누적 < REST 완전봉). + 동일 ``candle_time`` 이 이미 있으면: + - ``WS_CANDLE_FREEZE_ON_CONFIRM``(기본 true): OHLCV 유지(첫 확정 승), holding_peak 만 갱신 + - freeze OFF: volume 더 큰 쪽 upsert (레거시) """ code, tf = key ctime = str(cur.get("candle_time") or "")[:12] @@ -1362,20 +1436,24 @@ class CandleAggregator: ) hp = self._holding_peak_for(code) + freeze = self._ws_candle_freeze_on_confirm() if idx >= 0: old = buf[idx] old_vol = int(old.get("volume") or 0) - # 이미 더 완전한 volume 이 있으면(예: REST 완전봉) WS 부분봉으로 덮지 않음 - if new_vol < old_vol: + # freeze: 이미 확정된 봉은 OHLCV 고정 (REST/재확정이 키우지 않음) + if freeze or 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), - ) + # freeze 시 high 도 동결 — peak 만 메타로 보관 + if not freeze: + confirmed_candle["high"] = max( + float(confirmed_candle.get("high") or 0), float(hp), + ) + buf[idx] = confirmed_candle return confirmed_candle low_cands = [ @@ -1512,11 +1590,19 @@ class CandleAggregator: if rest_df is None or rest_df.empty: return 0 + # 진행 중 분봉은 confirmed 에 넣지 않음 — merge_confirmed_bars 에서도 재필터. + skip_incomplete = get_env_bool("WS_GAP_FILL_SKIP_INCOMPLETE_BUCKET", True) + open_bucket = self._open_bucket_ctime(tf) if skip_incomplete else "" + rows: list = [] + skipped_open = 0 for _, row in rest_df.iterrows(): ctime = str(row.get("time", ""))[:12] if not ctime or len(ctime) < 12: continue + if open_bucket and ctime >= open_bucket: + skipped_open += 1 + continue close = float(row.get("close", 0) or 0) if close <= 0: continue @@ -1529,6 +1615,11 @@ class CandleAggregator: "volume": int(float(row.get("volume", 0) or 0)), "source": "rest", }) + if skipped_open: + logger.info( + "⏭ [갭보정] %s %dM 진행분(>=%s) %d봉 confirmed 제외 (매수 직전봉 왜곡 방지)", + code, tf, open_bucket, skipped_open, + ) return self.merge_confirmed_bars(code, tf, rows, log_tag="REST") def merge_confirmed_bars( @@ -1538,20 +1629,68 @@ class CandleAggregator: bars: list, *, log_tag: str = "merge", + skip_incomplete_bucket: Optional[bool] = None, + now=None, ) -> int: """ 확정봉 리스트를 RAM(+DB 큐)에 병합. - 신규 candle_time → insert - - 기존 candle_time → volume 이 더 클 때만 OHLCV upsert - (갭보정 불완전봉을 REST/완전 롤업이 덮어쓰도록) + - 기존 candle_time → ``WS_CANDLE_FREEZE_ON_CONFIRM``(기본 true) 이면 skip + (freeze OFF: volume 더 클 때만 OHLCV upsert — 레거시) + - ``WS_GAP_FILL_SKIP_INCOMPLETE_BUCKET``(기본 true): 진행 중 버킷 + (``candle_time >= 현재 봉시작``) 은 insert/update 하지 않고, + 이미 RAM 에 있으면 제거. 장초 REST 미완성봉 → 직전봉% 몸통 왜곡 방지. """ if not bars: - return 0 + # bars 비어도 진행분 purge 는 수행 (재갭보정 정리) + pass + + if skip_incomplete_bucket is None: + skip_incomplete_bucket = get_env_bool( + "WS_GAP_FILL_SKIP_INCOMPLETE_BUCKET", True, + ) + freeze = self._ws_candle_freeze_on_confirm() + open_bucket = ( + self._open_bucket_ctime(tf, now=now) if skip_incomplete_bucket else "" + ) + + # lock 밖에서 DB 조회 (재시작 후 RAM 공백 → REST 가 DB 확정봉을 덮는 것 방지) + db_frozen: Dict[str, Dict] = {} + if freeze and bars and self.db is not None: + want_times = [] + for row in bars: + ct = str(row.get("candle_time") or row.get("time") or "")[:12] + if ct and len(ct) >= 12: + want_times.append(ct) + db_frozen = self._load_confirmed_ohlcv_from_db(code, tf, want_times) + with self._lock: key = (code, tf) closes = self._closes.setdefault(key, []) conf_buf = self._confirmed.setdefault(key, []) + + # 이미 들어온 진행분 confirmed 제거 (갭보정 직후·롤업 공통) + purged = 0 + if open_bucket and conf_buf: + kept = [] + for c in conf_buf: + ct = str(c.get("candle_time") or "")[:12] + if ct and ct >= open_bucket: + purged += 1 + continue + kept.append(c) + if purged: + conf_buf[:] = kept + closes[:] = [float(c.get("close") or 0) for c in conf_buf] + logger.info( + "🧹 [갭보정] %s %dM 진행분 confirmed %d봉 제거 (>=%s)", + code, tf, purged, open_bucket, + ) + + if not bars: + return 0 + by_time = { str(c.get("candle_time", ""))[:12]: i for i, c in enumerate(conf_buf) @@ -1559,10 +1698,16 @@ class CandleAggregator: } inserted = 0 updated = 0 + skipped_freeze = 0 + seeded_db = 0 + skipped_open = 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 + if open_bucket and ctime >= open_bucket: + skipped_open += 1 + continue close = float(row.get("close", 0) or 0) if close <= 0: continue @@ -1571,6 +1716,10 @@ class CandleAggregator: idx = by_time.get(ctime) if idx is not None: + # freeze-on-confirm: 첫 확정본 유지 (갭보정/백필이 lookback 시리즈를 키우지 않음) + if freeze: + skipped_freeze += 1 + continue old = conf_buf[idx] old_vol = int(old.get("volume") or 0) if new_vol <= old_vol: @@ -1614,6 +1763,53 @@ class CandleAggregator: updated += 1 continue + # RAM 에 없고 DB 에 확정봉이 있으면 → DB 값으로만 시드 (REST로 덮지 않음) + if freeze and ctime in db_frozen: + dr = db_frozen[ctime] + d_close = float(dr.get("close") or 0) + if d_close <= 0: + d_close = close + closes.append(d_close) + if len(closes) > self._ram_buffer_max: + closes.pop(0) + rsi2 = dr.get("rsi_2") + rsi3 = dr.get("rsi_3") + rsi5 = dr.get("rsi_5") + if rsi2 is None and rsi3 is None and rsi5 is None: + rsi2, rsi3, rsi5 = self._compute_rsi_set(closes) + candle = { + "code": code, + "tf": tf, + "candle_time": ctime, + "open": float(dr.get("open") or d_close), + "high": float(dr.get("high") or d_close), + "low": float(dr.get("low") or d_close), + "close": d_close, + "volume": int(float(dr.get("volume") or 0)), + "rsi_2": rsi2, + "rsi_3": rsi3, + "rsi_5": rsi5, + "is_confirmed": 1, + "source": str(dr.get("source") or "db")[:10], + } + if dr.get("holding_peak") is not None: + candle["holding_peak"] = dr.get("holding_peak") + conf_buf.append(candle) + 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] + } + # DB 이미 있음 → 쓰기 큐 불필요 + seeded_db += 1 + continue + closes.append(close) if len(closes) > self._ram_buffer_max: closes.pop(0) @@ -1650,16 +1846,21 @@ class CandleAggregator: except queue.Full: pass inserted += 1 - if inserted or updated: + if inserted or updated or seeded_db: conf_buf.sort(key=lambda x: str(x.get("candle_time", ""))) closes[:] = [float(c["close"]) for c in conf_buf] - if inserted or updated: - logger.info( - "🔧 [갭보정] %s %dM → %s insert=%d update=%d RAM+DB큐", - code, tf, log_tag, inserted, updated, + if skipped_open and log_tag: + logger.debug( + "⏭ [갭보정] %s %dM %s 진행분 %d봉 skip (>=%s)", + code, tf, log_tag, skipped_open, open_bucket, ) - return inserted + updated + if inserted or updated or skipped_freeze or seeded_db: + logger.info( + "🔧 [갭보정] %s %dM → %s insert=%d update=%d freeze_skip=%d db_seed=%d RAM+DB큐", + code, tf, log_tag, inserted, updated, skipped_freeze, seeded_db, + ) + return inserted + updated + seeded_db def rollup_tf_from_1m(self, code: str, target_tf: int = 3) -> int: """ diff --git a/kis_trader/ws/kis_ws_overseas.py b/kis_trader/ws/kis_ws_overseas.py index e1bbfb8..64f383a 100644 --- a/kis_trader/ws/kis_ws_overseas.py +++ b/kis_trader/ws/kis_ws_overseas.py @@ -208,6 +208,9 @@ class KISOverseasWebSocket: if self._running: return True if force_cleanup: + # 원래 동작: 재기동 시 메모리 approval 비우고 재발급 (국내 공유 매니저 미사용) + self._approval_key = None + self._approval_key_ts = 0.0 with self._cache_lock: self._cache.clear() if not self._get_approval_key(): @@ -237,24 +240,28 @@ class KISOverseasWebSocket: # 내부: approval_key / 구독 메시지 / 파싱 # ================================================================== def _get_approval_key(self) -> Optional[str]: - """국내 WS 와 동일 approval_key 파일 캐시 공유 (6h/24h KIS 정책).""" + """해외 전용 REST approval 발급 (국내 공유 매니저·정합성 freeze 와 무관).""" + now = time.time() + if self._approval_key and (now - self._approval_key_ts) < self.APPROVAL_KEY_CACHE_SEC: + return self._approval_key try: - 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 + 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 def _build_sub_payload(self, tr_key: str, subscribe: bool) -> str: return json.dumps({ diff --git a/logs/apply_optuna_fine_71516_latest.logpath b/logs/apply_optuna_fine_71516_latest.logpath new file mode 100644 index 0000000..fe9a5b6 --- /dev/null +++ b/logs/apply_optuna_fine_71516_latest.logpath @@ -0,0 +1 @@ +logs/apply_optuna_fine_71516_20260717_133420.log diff --git a/logs/optuna_3strat_apply_0720_nohup.out b/logs/optuna_3strat_apply_0720_nohup.out new file mode 100644 index 0000000..7c47861 --- /dev/null +++ b/logs/optuna_3strat_apply_0720_nohup.out @@ -0,0 +1,42 @@ +======== Optuna 3전략 순차+apply-best 시작 2026-07-21T02:30:12+09:00 ======== +START=2026-07-20 END=2026-07-20 MODE=fine TRIALS=200 +STRATEGIES=momentum breakout scalp apply-best=ON orderbook=off n_jobs=1 +master_log=logs/optuna_3strat_apply_20260720_20260720_20260721_023012_master.log + total used free shared buff/cache available +Mem: 13Gi 4.4Gi 1.7Gi 64Mi 7.9Gi 9.2Gi + +-------- 2026-07-21T02:30:12+09:00 START momentum study=momentum_fine_apply_20260720_20260721_023012 -------- +LOG=logs/optuna_seq_momentum_fine_apply_20260721_023012.log +-------- 2026-07-21T02:35:46+09:00 END momentum rc=0 -------- +✅ momentum 완료(+apply-best 시도). log=logs/optuna_seq_momentum_fine_apply_20260721_023012.log +[02:30:12] [param_search_optuna] 📌 호가필터: OFF (스킵 — 코어 파라미터 순수 탐색) +[02:35:46] [param_search_optuna] ⚠️ 적용할 best trial 없음 +Mem: 13Gi 4.4Gi 1.7Gi 64Mi 7.9Gi 9.2Gi + +-------- 2026-07-21T02:35:46+09:00 START breakout study=breakout_fine_apply_20260720_20260721_023546 -------- +LOG=logs/optuna_seq_breakout_fine_apply_20260721_023546.log +-------- 2026-07-21T02:37:42+09:00 END breakout rc=0 -------- +✅ breakout 완료(+apply-best 시도). log=logs/optuna_seq_breakout_fine_apply_20260721_023546.log +[02:35:47] [param_search_optuna] 📌 호가필터: OFF (스킵 — 코어 파라미터 순수 탐색) +[02:37:42] [param_search_optuna] ⚠️ 적용할 best trial 없음 +Mem: 13Gi 4.4Gi 1.7Gi 64Mi 7.9Gi 9.2Gi + +-------- 2026-07-21T02:37:42+09:00 START scalp study=scalp_fine_apply_20260720_20260721_023742 -------- +LOG=logs/optuna_seq_scalp_fine_apply_20260721_023742.log +-------- 2026-07-21T02:40:23+09:00 END scalp rc=0 -------- +✅ scalp 완료(+apply-best 시도). log=logs/optuna_seq_scalp_fine_apply_20260721_023742.log +[02:37:43] [param_search_optuna] 📌 호가필터: OFF (스킵 — 코어 파라미터 순수 탐색 (실매 ORDERBOOK도 OFF 권장 정합)) +[02:40:22] [param_search_optuna] 📐 [mode vs #1] best_pnl=13330.0 (9건) | mode_pnl=26188.0 (10건) | Δ=+12858 +✅ config_scalp + env_config INSERT id=682: +[02:40:22] [param_search_optuna] 🚀 [Optuna apply-best] scalp trial #174 → env_config +Mem: 13Gi 4.4Gi 1.7Gi 64Mi 7.9Gi 9.2Gi + +======== 전부 종료 2026-07-21T02:40:23+09:00 ======== +master=logs/optuna_3strat_apply_20260720_20260720_20260721_023012_master.log + momentum logpath=logs/optuna_seq_momentum_fine_apply_20260721_023012.log + momentum study=momentum_fine_apply_20260720_20260721_023012 + breakout logpath=logs/optuna_seq_breakout_fine_apply_20260721_023546.log + breakout study=breakout_fine_apply_20260720_20260721_023546 + scalp logpath=logs/optuna_seq_scalp_fine_apply_20260721_023742.log + scalp study=scalp_fine_apply_20260720_20260721_023742 +※ apply-best: 총손익≤0 이면 코드가 DB 적용 스킵할 수 있음 — 각 로그 grep apply diff --git a/logs/optuna_3strat_apply_latest_master.logpath b/logs/optuna_3strat_apply_latest_master.logpath new file mode 100644 index 0000000..19c8c2c --- /dev/null +++ b/logs/optuna_3strat_apply_latest_master.logpath @@ -0,0 +1 @@ +logs/optuna_3strat_apply_20260720_20260720_20260721_023012_master.log diff --git a/logs/optuna_4strat_seq_latest_master.logpath b/logs/optuna_4strat_seq_latest_master.logpath new file mode 100644 index 0000000..9d17d6c --- /dev/null +++ b/logs/optuna_4strat_seq_latest_master.logpath @@ -0,0 +1 @@ +logs/optuna_4strat_seq_2026-07-15_2026-07-16_20260717_031853_master.log diff --git a/logs/optuna_4strat_seq_nohup.out b/logs/optuna_4strat_seq_nohup.out new file mode 100644 index 0000000..7d9c855 --- /dev/null +++ b/logs/optuna_4strat_seq_nohup.out @@ -0,0 +1,43 @@ +======== Optuna 4전략 순차 시작 2026-07-17T03:18:53+09:00 ======== +START=2026-07-15 END=2026-07-16 MODE=fine TRIALS=200 +STRATEGIES=tail momentum breakout scalp +apply-best=OFF orderbook=off n_jobs=1 +master_log=logs/optuna_4strat_seq_2026-07-15_2026-07-16_20260717_031853_master.log + total used free shared buff/cache available +Mem: 13Gi 5.5Gi 1.9Gi 65Mi 6.7Gi 8.2Gi +/dev/mapper/ubuntu--vg-ubuntu--lv 47G 39G 5.6G 88% / + +-------- 2026-07-17T03:18:53+09:00 START tail study=tail_fine_20260715_20260716_20260717_031853 -------- +LOG=logs/optuna_seq_tail_fine_20260717_031853.log +-------- 2026-07-17T03:56:29+09:00 END tail rc=0 -------- +✅ tail 완료. log=logs/optuna_seq_tail_fine_20260717_031853.log +Mem: 13Gi 3.7Gi 3.9Gi 64Mi 6.5Gi 9Gi + +-------- 2026-07-17T03:56:29+09:00 START momentum study=momentum_fine_20260715_20260716_20260717_035629 -------- +LOG=logs/optuna_seq_momentum_fine_20260717_035629.log +-------- 2026-07-17T04:07:04+09:00 END momentum rc=0 -------- +✅ momentum 완료. log=logs/optuna_seq_momentum_fine_20260717_035629.log +Mem: 13Gi 3.7Gi 3.8Gi 64Mi 6.5Gi 9.9Gi + +-------- 2026-07-17T04:07:04+09:00 START breakout study=breakout_fine_20260715_20260716_20260717_040704 -------- +LOG=logs/optuna_seq_breakout_fine_20260717_040704.log +-------- 2026-07-17T04:12:46+09:00 END breakout rc=0 -------- +✅ breakout 완료. log=logs/optuna_seq_breakout_fine_20260717_040704.log +Mem: 13Gi 3.7Gi 3.8Gi 64Mi 6.5Gi 9.9Gi + +-------- 2026-07-17T04:12:46+09:00 START scalp study=scalp_fine_20260715_20260716_20260717_041246 -------- +LOG=logs/optuna_seq_scalp_fine_20260717_041246.log +-------- 2026-07-17T04:20:58+09:00 END scalp rc=0 -------- +✅ scalp 완료. log=logs/optuna_seq_scalp_fine_20260717_041246.log +Mem: 13Gi 3.7Gi 3.8Gi 64Mi 6.5Gi 9.9Gi + +======== 전부 종료 2026-07-17T04:20:58+09:00 ======== +master=logs/optuna_4strat_seq_2026-07-15_2026-07-16_20260717_031853_master.log + tail logpath=logs/optuna_seq_tail_fine_20260717_031853.log + tail study=tail_fine_20260715_20260716_20260717_031853 + momentum logpath=logs/optuna_seq_momentum_fine_20260717_035629.log + momentum study=momentum_fine_20260715_20260716_20260717_035629 + breakout logpath=logs/optuna_seq_breakout_fine_20260717_040704.log + breakout study=breakout_fine_20260715_20260716_20260717_040704 + scalp logpath=logs/optuna_seq_scalp_fine_20260717_041246.log + scalp study=scalp_fine_20260715_20260716_20260717_041246 diff --git a/logs/optuna_breakout_fine_latest.jsonpath b/logs/optuna_breakout_fine_latest.jsonpath index a9f2d11..138fa38 100644 --- a/logs/optuna_breakout_fine_latest.jsonpath +++ b/logs/optuna_breakout_fine_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260716_030605.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260721_023742.json diff --git a/logs/optuna_breakout_latest.jsonpath b/logs/optuna_breakout_latest.jsonpath index a9f2d11..138fa38 100644 --- a/logs/optuna_breakout_latest.jsonpath +++ b/logs/optuna_breakout_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260716_030605.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260721_023742.json diff --git a/logs/optuna_latest.jsonpath b/logs/optuna_latest.jsonpath index a9f2d11..50fb653 100644 --- a/logs/optuna_latest.jsonpath +++ b/logs/optuna_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_fine_20260716_030605.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_fine_20260721_024021.json diff --git a/logs/optuna_momentum_fine_latest.jsonpath b/logs/optuna_momentum_fine_latest.jsonpath index dee958d..94d74c6 100644 --- a/logs/optuna_momentum_fine_latest.jsonpath +++ b/logs/optuna_momentum_fine_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_fine_20260716_023221.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_fine_20260721_023546.json diff --git a/logs/optuna_momentum_latest.jsonpath b/logs/optuna_momentum_latest.jsonpath index dee958d..94d74c6 100644 --- a/logs/optuna_momentum_latest.jsonpath +++ b/logs/optuna_momentum_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_fine_20260716_023221.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_fine_20260721_023546.json diff --git a/logs/optuna_scalp_fine_latest.jsonpath b/logs/optuna_scalp_fine_latest.jsonpath new file mode 100644 index 0000000..50fb653 --- /dev/null +++ b/logs/optuna_scalp_fine_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_fine_20260721_024021.json diff --git a/logs/optuna_scalp_latest.jsonpath b/logs/optuna_scalp_latest.jsonpath new file mode 100644 index 0000000..50fb653 --- /dev/null +++ b/logs/optuna_scalp_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_fine_20260721_024021.json diff --git a/logs/optuna_seq_breakout_latest.logpath b/logs/optuna_seq_breakout_latest.logpath new file mode 100644 index 0000000..0013015 --- /dev/null +++ b/logs/optuna_seq_breakout_latest.logpath @@ -0,0 +1 @@ +logs/optuna_seq_breakout_fine_apply_20260721_023546.log diff --git a/logs/optuna_seq_breakout_latest.study b/logs/optuna_seq_breakout_latest.study new file mode 100644 index 0000000..419619e --- /dev/null +++ b/logs/optuna_seq_breakout_latest.study @@ -0,0 +1 @@ +breakout_fine_apply_20260720_20260721_023546 diff --git a/logs/optuna_seq_momentum_latest.logpath b/logs/optuna_seq_momentum_latest.logpath new file mode 100644 index 0000000..1826e0f --- /dev/null +++ b/logs/optuna_seq_momentum_latest.logpath @@ -0,0 +1 @@ +logs/optuna_seq_momentum_fine_apply_20260721_023012.log diff --git a/logs/optuna_seq_momentum_latest.study b/logs/optuna_seq_momentum_latest.study new file mode 100644 index 0000000..6c94b8d --- /dev/null +++ b/logs/optuna_seq_momentum_latest.study @@ -0,0 +1 @@ +momentum_fine_apply_20260720_20260721_023012 diff --git a/logs/optuna_seq_scalp_latest.logpath b/logs/optuna_seq_scalp_latest.logpath new file mode 100644 index 0000000..7e82d69 --- /dev/null +++ b/logs/optuna_seq_scalp_latest.logpath @@ -0,0 +1 @@ +logs/optuna_seq_scalp_fine_apply_20260721_023742.log diff --git a/logs/optuna_seq_scalp_latest.study b/logs/optuna_seq_scalp_latest.study new file mode 100644 index 0000000..38c2bac --- /dev/null +++ b/logs/optuna_seq_scalp_latest.study @@ -0,0 +1 @@ +scalp_fine_apply_20260720_20260721_023742 diff --git a/logs/optuna_seq_tail_latest.logpath b/logs/optuna_seq_tail_latest.logpath new file mode 100644 index 0000000..33259aa --- /dev/null +++ b/logs/optuna_seq_tail_latest.logpath @@ -0,0 +1 @@ +logs/optuna_tail_fine_noapply_20260721_074448.log diff --git a/logs/optuna_seq_tail_latest.study b/logs/optuna_seq_tail_latest.study new file mode 100644 index 0000000..dd1accb --- /dev/null +++ b/logs/optuna_seq_tail_latest.study @@ -0,0 +1 @@ +tail_fine_noapply_20260720_20260721_074448 diff --git a/logs/optuna_tail_fine_latest.jsonpath b/logs/optuna_tail_fine_latest.jsonpath new file mode 100644 index 0000000..93011cb --- /dev/null +++ b/logs/optuna_tail_fine_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_fine_20260717_035620.json diff --git a/logs/optuna_tail_latest.jsonpath b/logs/optuna_tail_latest.jsonpath new file mode 100644 index 0000000..93011cb --- /dev/null +++ b/logs/optuna_tail_latest.jsonpath @@ -0,0 +1 @@ +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_fine_20260717_035620.json diff --git a/scripts/_diag_scalp_live_bt_20260716.py b/scripts/_diag_scalp_live_bt_20260716.py new file mode 100644 index 0000000..94e5947 --- /dev/null +++ b/scripts/_diag_scalp_live_bt_20260716.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""SCALP 2026-07-16 실매↔웹BT 진입 괴리 진단 (adhoc).""" +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple + +from database import TradeDB +from kis_trader.backtest.scalping_backtest_common import resolve_scalp_universe +from kis_trader.engine.scalping_engine import ( + _apply_buy_state_filters, + _eval_scalp_buy_at_index, + get_scalping_defaults_from_db, +) + + +def _z(code: Any) -> str: + s = str(code or "").strip() + return s.zfill(6) if s.isdigit() else s + + +def _hm_from_buy(buy_date: str) -> Tuple[str, str]: + """'2026-07-16 09:39:03' → day=20260716, entry_key≈202607160939""" + raw = str(buy_date or "").strip().replace("-", "").replace(":", "").replace(" ", "") + day = raw[:8] + hm = raw[8:12] if len(raw) >= 12 else "" + return day, day + hm + + +def load_candles(db: TradeDB, code: str, day: str) -> List[Dict]: + rows = db.conn.execute( + """SELECT candle_time, open, high, low, close, volume, is_confirmed + FROM ws_candles + WHERE code=%s AND timeframe=1 AND candle_time LIKE %s + ORDER BY candle_time""", + (code, day + "%"), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["candle_time"] = str(d["candle_time"]) + out.append(d) + return out + + +def find_index(candles: List[Dict], key12: str) -> Optional[int]: + k = str(key12)[:12] + for i, c in enumerate(candles): + if str(c["candle_time"])[:12] == k: + return i + return None + + +def nearest_index(candles: List[Dict], key12: str) -> Optional[int]: + k = str(key12)[:12] + best = None + best_d = 10**9 + for i, c in enumerate(candles): + ct = str(c["candle_time"])[:12] + if len(ct) < 12: + continue + try: + d = abs(int(ct) - int(k)) + except ValueError: + continue + if d < best_d: + best_d = d + best = i + return best + + +def eval_at(candles: List[Dict], i: int, params: Dict) -> Tuple[str, str]: + if i is None or i < 1: + return "no_idx", "봉인덱스 없음" + state = {"daily_cnt": 0, "last_exit_dt": None} + st = _apply_buy_state_filters(candles, i, params, state) + if st[2] is None: + return "state", str(st[0] or st[1] or "state_reject") + rej, msg, sig = _eval_scalp_buy_at_index(candles, i, params) + if rej: + return str(rej), str(msg or "") + if not sig: + return "no_sig", "신호없음" + rsi = sig.get("rsi") + return "PASS", "rsi=%s mode=%s" % (rsi, sig.get("entry_mode")) + + +def slot_has(univ: Dict[str, List[str]], slot: str, code: str) -> bool: + if not univ: + return False + return code in (univ.get(slot) or []) + + +def first_slots(univ: Dict[str, List[str]], code: str, day: str, limit: int = 8) -> List[str]: + out = [] + for sk in sorted(univ.keys()): + if not sk.startswith(day): + continue + if code in (univ.get(sk) or []): + out.append(sk) + if len(out) >= limit: + break + return out + + +def main() -> None: + db = TradeDB() + live_rows = db.conn.execute( + """SELECT code, name, buy_date, sell_date, buy_price, sell_reason, realized_pnl + FROM trade_history WHERE strategy=%s AND buy_date LIKE %s + ORDER BY buy_date""", + ("SCALP", "2026-07-16%"), + ).fetchall() + active_rows = db.conn.execute( + """SELECT code, name, buy_date, avg_buy_price + FROM active_trades WHERE strategy=%s AND buy_date LIKE %s""", + ("SCALP", "2026-07-16%"), + ).fetchall() + + # 사용자 웹 BT 9건 (스크린샷) + bt_user = [ + ("001130", "2026-07-16 14:55:00"), + ("067830", "2026-07-16 14:25:00"), + ("035000", "2026-07-16 09:55:00"), + ("047770", "2026-07-16 14:27:00"), + ("226400", "2026-07-16 12:36:00"), + ("330350", "2026-07-16 12:42:00"), + ("460930", "2026-07-16 09:21:00"), + ("007540", "2026-07-16 09:36:00"), + ("439090", "2026-07-16 09:52:00"), + ] + bt_codes = {_z(c) for c, _ in bt_user} + live_codes = {_z(r["code"]) for r in list(live_rows) + list(active_rows)} + + params = get_scalping_defaults_from_db() + params.update({ + "rsi_period": 3, + "rsi_oversold": 23.0, + "rsi_overbought": 75.0, + "sl_pct": 0.035, + "tp_pct": 0.03, + "tp_max_pct": 0.04, + "drop_rate": 0.01, + "vol_mult": 1.5, + "use_defense_filters": False, + "use_macd_cross": False, + "skip_hts_scan_dupes": False, + "require_reversal_candle": False, + "min_price": 6000.0, + "high_chase_thr": 0.99, + "max_daily_chg": 50.0, + "cooldown_min": 5, + "time_start_hm": 900, + "time_end_hm": 1530, + "max_daily": 100, + }) + + univ, usrc, nslots, _ = resolve_scalp_universe( + "2026-07-16", "2026-07-16", use_saved_history=True, strategy_id="SCALP", + ) + univ = univ or {} + print("UNIVERSE", usrc, "slots", nslots) + + print("\n" + "=" * 72) + print("A) 실매 ONLY — 왜 BT가 못 샀나 (매수시각 기준 신호봉=진입직전봉)") + print("=" * 72) + + for r in live_rows: + code = _z(r["code"]) + if code in bt_codes: + tag = "BOTH" + else: + tag = "LIVE_ONLY" + day, entry_key = _hm_from_buy(r["buy_date"]) + candles = load_candles(db, code, day) + # ALIGN: 진입봉=entry_key, 신호봉=직전 확정봉 + entry_i = find_index(candles, entry_key) + if entry_i is None: + entry_i = nearest_index(candles, entry_key) + signal_i = (entry_i - 1) if entry_i is not None and entry_i >= 1 else None + + slots = first_slots(univ, code, day, 5) + in_entry_slot = slot_has(univ, entry_key, code) if entry_key else False + sig_key = str(candles[signal_i]["candle_time"])[:12] if signal_i is not None else "" + in_sig_slot = slot_has(univ, sig_key, code) if sig_key else False + + status, detail = ("no_candle", "분봉0") + if signal_i is not None: + status, detail = eval_at(candles, signal_i, params) + # vol detail if reject + vol_info = "" + if signal_i is not None and candles: + c = candles[signal_i] + vols = [float(x.get("volume") or 0) for x in candles] + win = max(1, min(20, signal_i)) + avg = sum(vols[signal_i - win : signal_i]) / win if win else 0 + vol = vols[signal_i] + vol_info = "vol=%.0f avg20=%.0f need>=%.0f" % ( + vol, avg, avg * float(params["vol_mult"]), + ) + + print( + f"\n[{tag}] {code} {r['name']} live_buy={r['buy_date']} @{r['buy_price']}" + ) + print(f" candles={len(candles)} entry_key={entry_key} signal_i={signal_i} sig_key={sig_key}") + print(f" universe: in_entry_slot={in_entry_slot} in_sig_slot={in_sig_slot} first_slots={slots}") + print(f" TRIGGER@signal: {status} | {detail} | {vol_info}") + + # also scan morning for first PASS in BT conditions + first_pass = None + for i in range(1, len(candles)): + st, det = eval_at(candles, i, params) + if st == "PASS": + ck = str(candles[i]["candle_time"])[:12] + if slot_has(univ, ck, code) or not univ: + first_pass = (ck, det) + break + print(f" first PASS+univ day: {first_pass}") + + print("\n" + "=" * 72) + print("B) BT ONLY — 실매는 왜 안 샀나 (BT 매수시각 기준)") + print("=" * 72) + for code, buy_t in bt_user: + code = _z(code) + if code in live_codes: + continue + day, entry_key = _hm_from_buy(buy_t) + candles = load_candles(db, code, day) + entry_i = find_index(candles, entry_key) + signal_i = (entry_i - 1) if entry_i is not None and entry_i >= 1 else None + sig_key = str(candles[signal_i]["candle_time"])[:12] if signal_i is not None else "" + status, detail = eval_at(candles, signal_i, params) if signal_i is not None else ("no", "") + slots = first_slots(univ, code, day, 5) + # history presence count + n_hist = db.conn.execute( + "SELECT COUNT(*) n FROM target_candidates_history WHERE strategy_id=%s AND code=%s AND slot_key LIKE %s", + ("SCALP", code, day + "%"), + ).fetchone()["n"] + print(f"\n[BT_ONLY] {code} bt_buy={buy_t}") + print(f" hist_rows={n_hist} first_slots={slots}") + print(f" TRIGGER@BT_signal {sig_key}: {status} | {detail}") + print(f" in_sig_slot={slot_has(univ, sig_key, code)} in_entry_slot={slot_has(univ, entry_key, code)}") + + print("\n" + "=" * 72) + print("C) BOTH — 시각 차이") + print("=" * 72) + live_by = {_z(r["code"]): r for r in live_rows} + for code, buy_t in bt_user: + code = _z(code) + if code not in live_by: + continue + lr = live_by[code] + print(f" {code}: LIVE {lr['buy_date']} @{lr['buy_price']} | BT {buy_t}") + + # vol_mult ON/OFF sensitivity for LIVE_ONLY + print("\n" + "=" * 72) + print("D) LIVE_ONLY — vol_mult=0 이면 PASS 되나?") + print("=" * 72) + p0 = dict(params) + p0["vol_mult"] = 0.0 + for r in live_rows: + code = _z(r["code"]) + if code in bt_codes: + continue + day, entry_key = _hm_from_buy(r["buy_date"]) + candles = load_candles(db, code, day) + entry_i = find_index(candles, entry_key) or nearest_index(candles, entry_key) + signal_i = (entry_i - 1) if entry_i and entry_i >= 1 else None + s1, d1 = eval_at(candles, signal_i, params) if signal_i is not None else ("?", "") + s0, d0 = eval_at(candles, signal_i, p0) if signal_i is not None else ("?", "") + print(f" {code}: vol1.5={s1}({d1[:40]}) | vol0={s0}({d0[:40]})") + + # min_price with defense OFF should not block — confirm + print("\n" + "=" * 72) + print("E) 슬롯 경쟁 가설 — 실매 매수 시각에 BT 후보가 몇 개?") + print("=" * 72) + # for each live buy minute, count how many codes PASS in that signal slot among univ + for r in live_rows[:5]: + day, entry_key = _hm_from_buy(r["buy_date"]) + # signal approx entry-1min + try: + sig_num = int(entry_key) - 1 + sig_key = str(sig_num) + except Exception: + sig_key = entry_key + # fix minute borrow + if entry_key.endswith("00"): + # 0900 -> 0859 not valid; use find + pass + hh = int(entry_key[8:10]) + mm = int(entry_key[10:12]) + if mm == 0: + hh -= 1 + mm = 59 + else: + mm -= 1 + sig_key = "%s%02d%02d" % (day, hh, mm) + codes_in = list(univ.get(sig_key) or []) + passes = [] + for c in codes_in[:80]: + candles = load_candles(db, _z(c), day) + si = find_index(candles, sig_key) + if si is None: + continue + st, det = eval_at(candles, si, params) + if st == "PASS": + passes.append((_z(c), det)) + print( + f" live={_z(r['code'])} @{r['buy_date']} sig={sig_key} " + f"univ={len(codes_in)} PASS={len(passes)} sample={passes[:6]}" + ) + + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/run_optuna_3strat_apply_20260720.sh b/scripts/run_optuna_3strat_apply_20260720.sh new file mode 100755 index 0000000..e7068d7 --- /dev/null +++ b/scripts/run_optuna_3strat_apply_20260720.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# 모멘텀·돌파·스캘핑 Optuna 순차 + --apply-best (2026-07-20 1일) +# 동시 실행 금지(RAM). 장전 적용용. +# +# nohup bash scripts/run_optuna_3strat_apply_20260720.sh >> logs/optuna_3strat_apply_0720_master.log 2>&1 & +# tail -f logs/optuna_3strat_apply_0720_master.log + +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs kis_trader/backtest/results + +START="${START:-2026-07-20}" +END="${END:-2026-07-20}" +MODE="${MODE:-fine}" +TRIALS="${TRIALS:-200}" +MIN_TRADES="${MIN_TRADES:-1}" +STRATEGIES="${STRATEGIES:-momentum breakout scalp}" +TS0="$(date +%Y%m%d_%H%M%S)" +MASTER="logs/optuna_3strat_apply_${START//-/}_${END//-/}_${TS0}_master.log" + +{ + echo "======== Optuna 3전략 순차+apply-best 시작 $(date -Is) ========" + echo "START=$START END=$END MODE=$MODE TRIALS=$TRIALS" + echo "STRATEGIES=$STRATEGIES apply-best=ON orderbook=off n_jobs=1" + echo "master_log=$MASTER" + free -h | sed -n '1,2p' +} | tee -a "$MASTER" +echo "$MASTER" > logs/optuna_3strat_apply_latest_master.logpath + +run_one() { + local strat="$1" + local ts study log sort_by + ts="$(date +%Y%m%d_%H%M%S)" + study="${strat}_${MODE}_apply_${START//-/}_${ts}" + log="logs/optuna_seq_${strat}_${MODE}_apply_${ts}.log" + sort_by="pnl" + if [[ "$strat" == "momentum" || "$strat" == "scalp" ]]; then + sort_by="score" + fi + + { + echo "" + echo "-------- $(date -Is) START $strat study=$study --------" + echo "LOG=$log" + } | tee -a "$MASTER" + echo "$log" > "logs/optuna_seq_${strat}_latest.logpath" + echo "$study" > "logs/optuna_seq_${strat}_latest.study" + + set +e + python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy "$strat" \ + --mode "$MODE" \ + --start "$START" \ + --end "$END" \ + --trials "$TRIALS" \ + --min_trades "$MIN_TRADES" \ + --sort-by "$sort_by" \ + --orderbook-filter off \ + --no-progress \ + --n-jobs 1 \ + --study-name "$study" \ + --apply-best \ + > "$log" 2>&1 + local rc=$? + set -e + + { + echo "-------- $(date -Is) END $strat rc=$rc --------" + if [[ $rc -ne 0 ]]; then + echo "⚠️ $strat 실패(rc=$rc) — 다음 전략 계속. tail: $log" + else + echo "✅ $strat 완료(+apply-best 시도). log=$log" + grep -E "apply-best|DB 적용|env_config|스킵|best|순익|PnL" "$log" | tail -20 || true + fi + free -h | sed -n '2p' + } | tee -a "$MASTER" + + return 0 +} + +for s in $STRATEGIES; do + run_one "$s" +done + +{ + echo "" + echo "======== 전부 종료 $(date -Is) ========" + echo "master=$MASTER" + for s in $STRATEGIES; do + echo " $s logpath=$(cat logs/optuna_seq_${s}_latest.logpath 2>/dev/null || echo '?')" + echo " $s study=$(cat logs/optuna_seq_${s}_latest.study 2>/dev/null || echo '?')" + done + echo "※ apply-best: 총손익≤0 이면 코드가 DB 적용 스킵할 수 있음 — 각 로그 grep apply" +} | tee -a "$MASTER" diff --git a/scripts/run_optuna_4strat_seq_20260715_16.sh b/scripts/run_optuna_4strat_seq_20260715_16.sh new file mode 100755 index 0000000..ff65119 --- /dev/null +++ b/scripts/run_optuna_4strat_seq_20260715_16.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# 4전략 Optuna 순차 실행 (동시 X — RAM 13G + 틱 2일 로딩 시 병렬은 OOM/스왑 위험) +# 기간: 2026-07-15 ~ 2026-07-16 (거래일) · apply-best 없음 · 호가 OFF +# +# 사용: +# nohup bash scripts/run_optuna_4strat_seq_20260715_16.sh >> logs/optuna_4strat_seq_master.log 2>&1 & +# tail -f logs/optuna_4strat_seq_master.log +# # 전략별: tail -f logs/optuna_seq__*.log +# +# 환경변수 오버라이드 예: +# MODE=coarse TRIALS=100 STRATEGIES="tail scalp" bash scripts/run_optuna_4strat_seq_20260715_16.sh + +set -euo pipefail +cd /home/hoon/kis_bot +mkdir -p logs kis_trader/backtest/results + +START="${START:-2026-07-15}" +END="${END:-2026-07-16}" +MODE="${MODE:-fine}" +TRIALS="${TRIALS:-200}" +MIN_TRADES="${MIN_TRADES:-1}" +# 공백 구분: tail momentum breakout scalp +STRATEGIES="${STRATEGIES:-tail momentum breakout scalp}" +TS0="$(date +%Y%m%d_%H%M%S)" +MASTER="logs/optuna_4strat_seq_${START}_${END}_${TS0}_master.log" + +{ + echo "======== Optuna 4전략 순차 시작 $(date -Is) ========" + echo "START=$START END=$END MODE=$MODE TRIALS=$TRIALS" + echo "STRATEGIES=$STRATEGIES" + echo "apply-best=OFF orderbook=off n_jobs=1" + echo "master_log=$MASTER" + free -h | sed -n '1,2p' + df -h / | tail -1 +} | tee -a "$MASTER" +echo "$MASTER" > logs/optuna_4strat_seq_latest_master.logpath + +run_one() { + local strat="$1" + local ts study log sort_by + ts="$(date +%Y%m%d_%H%M%S)" + study="${strat}_${MODE}_${START//-/}_${END//-/}_${ts}" + log="logs/optuna_seq_${strat}_${MODE}_${ts}.log" + sort_by="pnl" + if [[ "$strat" == "momentum" || "$strat" == "scalp" ]]; then + sort_by="score" + fi + + { + echo "" + echo "-------- $(date -Is) START $strat study=$study --------" + echo "LOG=$log" + } | tee -a "$MASTER" + echo "$log" > "logs/optuna_seq_${strat}_latest.logpath" + echo "$study" > "logs/optuna_seq_${strat}_latest.study" + + # --apply-best 없음 (기본 미적용) + set +e + python3 -u kis_trader/backtest/param_search_optuna.py \ + --strategy "$strat" \ + --mode "$MODE" \ + --start "$START" \ + --end "$END" \ + --trials "$TRIALS" \ + --min_trades "$MIN_TRADES" \ + --sort-by "$sort_by" \ + --orderbook-filter off \ + --no-progress \ + --n-jobs 1 \ + --study-name "$study" \ + > "$log" 2>&1 + local rc=$? + set -e + + { + echo "-------- $(date -Is) END $strat rc=$rc --------" + if [[ $rc -ne 0 ]]; then + echo "⚠️ $strat 실패(rc=$rc) — 다음 전략 계속. tail: $log" + else + echo "✅ $strat 완료. log=$log" + fi + free -h | sed -n '2p' + } | tee -a "$MASTER" + + return 0 +} + +for s in $STRATEGIES; do + run_one "$s" +done + +{ + echo "" + echo "======== 전부 종료 $(date -Is) ========" + echo "master=$MASTER" + for s in $STRATEGIES; do + echo " $s logpath=$(cat logs/optuna_seq_${s}_latest.logpath 2>/dev/null || echo '?')" + echo " $s study=$(cat logs/optuna_seq_${s}_latest.study 2>/dev/null || echo '?')" + done +} | tee -a "$MASTER" diff --git a/scripts/smoke_candle_upsert_rollup.py b/scripts/smoke_candle_upsert_rollup.py index 5ae98a1..a159c63 100644 --- a/scripts/smoke_candle_upsert_rollup.py +++ b/scripts/smoke_candle_upsert_rollup.py @@ -106,6 +106,37 @@ def main() -> None: }) assert agg2._confirmed[key][0]["volume"] == 800 + # 진행 중 분봉은 REST/merge confirmed 에 넣지 않음 (장초 직전봉% 왜곡 방지) + import datetime as _dt + agg3 = CandleAggregator(db=None, timeframes=[1]) + code2 = "333050" + frozen = _dt.datetime(2026, 7, 16, 9, 0, 34) + # 전일 + 미완성 당일 09:00 을 넣으려 할 때 → 09:00 만 skip + n = agg3.merge_confirmed_bars( + code2, 1, + [ + bar("202607151530", 5280, 5280, 5280, 5280, 960, "rest"), + bar("202607160900", 5220, 5250, 5200, 5220, 10, "rest"), # 진행분 + ], + log_tag="smoke_skip_open", + skip_incomplete_bucket=True, + now=frozen, + ) + assert n == 1 + buf3 = agg3._confirmed[(code2, 1)] + assert len(buf3) == 1 and buf3[0]["candle_time"] == "202607151530" + # 이미 들어간 진행분 purge + agg3._confirmed[(code2, 1)].append( + bar("202607160900", 5220, 5250, 5200, 5220, 10, "rest") + ) + agg3.merge_confirmed_bars( + code2, 1, [], log_tag="smoke_purge", skip_incomplete_bucket=True, now=frozen, + ) + assert all( + str(c["candle_time"])[:12] < "202607160900" + for c in agg3._confirmed[(code2, 1)] + ) + print("SMOKE_OK candle_upsert_rollup") diff --git a/static/js/backtest.js b/static/js/backtest.js index 5496ee0..6eca332 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -387,18 +387,118 @@ document.querySelectorAll('[data-tab]').forEach(el => { $('tab-holding').style.display = tab === 'holding' ? '' : 'none'; $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none'; $('tab-updow').style.display = tab === 'updow' ? '' : 'none'; + $('tab-dart').style.display = tab === 'dart' ? '' : 'none'; $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none'; if (tab === 'dashboard') loadDashboard(); if (tab === 'liveconfig') lcOnTabShow(); if (tab === 'portfolio') pfLoad(true); if (tab === 'holding') hdLoadStocks(); if (tab === 'updow') permLoad(); + if (tab === 'dart') dartOnTabShow(); if (tab === 'updownbox') ubxOnTabShow(); if (tab === 'dbband') dbLoadStocks(); if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors(); }); }); +// ════════════════════════════════════════════════════════════════ +// DART 수주 공시 +// ════════════════════════════════════════════════════════════════ +let _dartPollTimer = null; +function dartOnTabShow() { + dartLoad(); + if (_dartPollTimer) clearInterval(_dartPollTimer); + _dartPollTimer = setInterval(() => { + const t = document.querySelector('[data-tab].active'); + if (t && t.dataset.tab === 'dart') dartLoad(true); + }, 10000); +} +async function dartLoad(quiet) { + try { + const r = await fetch('/api/dart/disclosures?limit=80'); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || 'fail'); + const f = j.flags || {}; + if ($('dart_scan_en')) $('dart_scan_en').checked = !!f.scan; + if ($('dart_sub_en')) $('dart_sub_en').checked = !!f.subscribe; + if ($('dart_trade_en')) $('dart_trade_en').checked = !!f.trade; + if ($('dart_strat_en')) $('dart_strat_en').checked = !!f.strategy; + if ($('dart_watch_max') && f.watch_max != null) $('dart_watch_max').value = f.watch_max; + if ($('dart_watch_ttl') && f.watch_ttl_hours != null) $('dart_watch_ttl').value = f.watch_ttl_hours; + if ($('dart_quality_en')) $('dart_quality_en').checked = f.quality_filter !== false; + if ($('dart_min_sales') && f.min_sales_pct != null) $('dart_min_sales').value = f.min_sales_pct; + if ($('dart_require_theme')) $('dart_require_theme').checked = f.require_theme !== false; + const wb = $('dart_watch_tbody'); + if (wb) { + const w = j.watch || []; + wb.innerHTML = w.length ? w.map(x => ` + ${x.stock_code||''}${x.corp_name||''} + ${(x.report_nm||'').slice(0,40)} + ${x.added_at||''}${x.expires_at||''} + `).join('') : '워치 없음 (구독 스위치 OFF 또는 공시 대기)'; + } + const tb = $('dart_disc_tbody'); + if (tb) { + const rows = j.rows || []; + tb.innerHTML = rows.length ? rows.map(x => { + const ok = x.filter_ok == null ? null : Number(x.filter_ok) === 1; + const badge = ok === true ? '통과' + : ok === false ? `${(x.filter_reason||'제외').slice(0,24)}` + : '-'; + const pct = (x.sales_pct != null && x.sales_pct !== '') ? Number(x.sales_pct).toFixed(1) : '-'; + return ` + ${x.first_seen_at||''} + ${x.stock_code||''}${x.corp_name||''} + ${pct} + ${badge} + ${(x.report_nm||'').trim()} + ${x.url ? `원문` : ''} + `; + }).join('') : '공시 없음'; + } + } catch (e) { + if (!quiet) console.error('dartLoad', e); + } +} +async function dartSaveConfig() { + const body = { + DART_SCAN_ENABLED: $('dart_scan_en') && $('dart_scan_en').checked ? 'true' : 'false', + DART_SUBSCRIBE_ENABLED: $('dart_sub_en') && $('dart_sub_en').checked ? 'true' : 'false', + DART_TRADE_ENABLED: $('dart_trade_en') && $('dart_trade_en').checked ? 'true' : 'false', + STRATEGY_DART_ENABLED: $('dart_strat_en') && $('dart_strat_en').checked ? 'true' : 'false', + DART_WATCH_MAX: $('dart_watch_max') ? String($('dart_watch_max').value || 15) : '15', + DART_WATCH_TTL_HOURS: $('dart_watch_ttl') ? String($('dart_watch_ttl').value || 24) : '24', + DART_QUALITY_FILTER_ENABLED: $('dart_quality_en') && $('dart_quality_en').checked ? 'true' : 'false', + DART_MIN_SALES_PCT: $('dart_min_sales') ? String($('dart_min_sales').value || 5) : '5', + DART_REQUIRE_THEME: $('dart_require_theme') && $('dart_require_theme').checked ? 'true' : 'false', + }; + const r = await fetch('/api/dart/config', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + const j = await r.json(); + alert(j.ok ? '저장됨: ' + (j.saved||[]).join(', ') : ('실패: ' + (j.error||''))); + dartLoad(); +} +async function dartRunBacktest() { + const s = $('dart_bt_start') && $('dart_bt_start').value; + const e = $('dart_bt_end') && $('dart_bt_end').value; + if (!s || !e) { alert('시작/종료일 필요'); return; } + $('dart_bt_out').textContent = '실행 중…'; + try { + const r = await fetch(`/api/backtest/dart?start=${encodeURIComponent(s)}&end=${encodeURIComponent(e)}`); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || 'fail'); + $('dart_bt_out').textContent = + `이벤트 ${j.events} · 거래 ${j.trade_count} · 승률 ${Number(j.win_rate||0).toFixed(1)}% · PnL ${fmtKrw(j.total_pnl)}\n` + + (j.trades||[]).slice(0, 20).map(t => + `${t.code} ${t.entry_time}→${t.exit_time} ${t.reason} pnl=${Math.round(t.pnl||0)}` + ).join('\n'); + } catch (err) { + $('dart_bt_out').textContent = '오류: ' + err; + } +} + // ════════════════════════════════════════════════════════════════ // 영구구독 관리 (permanent_subscriptions: KR 국내WS / US 해외WS) // ════════════════════════════════════════════════════════════════ @@ -1095,6 +1195,11 @@ function initDefaultDates(datesPayload) { const rv = s.require_reversal_candle; $('bt_require_reversal').checked = rv !== false && rv !== 0 && rv !== '0' && rv !== 'false'; } + if (s.eod_enabled !== undefined && $('bt_eod_enabled')) { + const ev = String(s.eod_enabled).trim().toLowerCase(); + $('bt_eod_enabled').checked = (ev === '1' || ev === 'true' || ev === 'y' || ev === 'yes' || ev === 'on' || s.eod_enabled === true); + } + if (s.eod_hm != null) set('bt_eod_hm', s.eod_hm); // 꼬리잡기 탭 — config_short + env_config (실매·파라서치와 동일) fillTailFormFromApi(d.tail || {}); @@ -2181,10 +2286,13 @@ function saveScalpConfig() { 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'), + eod_enabled: !!($('bt_eod_enabled')?.checked), + eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25', }; if (!confirm(`💾 스캘핑 봇에 아래 파라미터를 저장합니까?\n\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` + + `EOD: ${body.eod_enabled ? 'ON' : 'OFF'} ${body.eod_hm}\n` + `\n⚠️ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return; fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -2890,7 +2998,8 @@ function runBacktest() { 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 + eod_enabled: $('bt_eod_enabled')?.checked ? 1 : 0, + eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25', env_timeline: envTimelineParam('bt_env_timeline'), }; params.universe = $('bt_use_univ_history')?.checked ? 'history' : 'sim'; @@ -2913,7 +3022,7 @@ function renderBacktest(d) { `${universeLabel} ` + `방어:${p.use_defense_filters ? 'ON' : 'OFF'} ` + `MACD:${p.use_macd_cross ? 'ON' : 'OFF'} ` + - `EOD강제:${p.force_eod_exit ? 'ON' : 'OFF'} ` + + `EOD:${p.eod_enabled ? 'ON' : 'OFF'} ${p.eod_hm || ''} ` + `RSI(${p.rsi_period}) <${p.rsi_oversold} / >${p.rsi_overbought ?? 75} 과열차단 | ` + `손절-${tr(p.sl_pct)}% 익절+${tr(p.effective_tp_pct ?? p.tp_pct)}%(상한${tr(p.tp_max_pct)}%) | ` + `낙폭≥${tr(p.drop_rate)}% | 쿨다운${p.cooldown_min}분 | ` + diff --git a/templates/backtest.html b/templates/backtest.html index 3d740f6..5150c21 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -37,6 +37,7 @@ + @@ -512,6 +513,16 @@ +
+
+ + +
+
+
+ + +
@@ -961,7 +972,7 @@
- +
@@ -2883,6 +2894,83 @@
+ + +