From 5e44b86f8bf2978525f87faeabf3b7bdee260c78 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 27 Aug 2026 15:21:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EC=8B=9C=EC=84=B8):=20LS=20spill-only?= =?UTF-8?q?=C2=B7=ED=86=A0=ED=81=B0=20=ED=8C=8C=EC=9D=BC=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=C2=B7=ED=98=B8=EA=B0=80=20snap=5Ftime=203=EC=B4=88=EC=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합 - LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음) - 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지 - 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기 Co-authored-by: Cursor --- database.py | 21 +- docs/LS_WS_관측_예약.md | 139 +++++++++ docs/like_mcp.md/MODIFICATION_GUIDE.md | 18 +- docs/증권사_토큰_시세.md | 16 +- docs/호가.md | 52 +++- kis_trader/engine/feed_fallback.py | 68 +++++ kis_trader/engine/orderbook_env.py | 9 +- kis_trader/execution/order_manager.py | 5 +- kis_trader/execution/orderbook_sell.py | 24 +- kis_trader/main.py | 19 +- kis_trader/network/ls_token.py | 233 ++++++++++++++- kis_trader/network/ws_manager.py | 242 ++++++++++++++-- kis_trader/strategies/base.py | 11 + kis_trader/web/live_config_schema.py | 49 ++-- kis_trader/ws/kis_ws.py | 152 ++++++---- kis_trader/ws/kis_ws_overseas.py | 20 +- kis_trader/ws/kiwoom_ws.py | 18 +- kis_trader/ws/ls_ws.py | 327 ++++++++++++++++++++-- kis_trader/ws/orderbook_cache.py | 127 ++++++++- kis_trader/ws/ws_reconnect_backoff.py | 50 ++++ permanent_subs.py | 5 +- scripts/observe_ls_ws_journal.py | 313 +++++++++++++++++++++ scripts/observe_ls_ws_journal.sh | 22 ++ scripts/test_live_execution_validation.py | 186 ++++++++++-- 24 files changed, 1923 insertions(+), 203 deletions(-) create mode 100644 docs/LS_WS_관측_예약.md create mode 100644 kis_trader/ws/ws_reconnect_backoff.py create mode 100755 scripts/observe_ls_ws_journal.py create mode 100755 scripts/observe_ls_ws_journal.sh diff --git a/database.py b/database.py index d571c36..f35587e 100644 --- a/database.py +++ b/database.py @@ -427,6 +427,7 @@ ENV_CONFIG_KEYS = ( # KIS WS 재연결·구독 (키움 CNSRREQ_GAP 과 동일 — 구독 연속 폭주 시 세션 즉시 종료 방지) "KIS_WS_SUBSCRIBE_GAP_MIN_SEC", "KIS_WS_SUBSCRIBE_GAP_MAX_SEC", "KIS_WS_RECONNECT_SESSION_WAIT_SEC", "KIS_WS_RECONNECT_REFRESH_KEY", + "WS_RECONNECT_DELAY_SECS", "KIS_WS_APPROVAL_MIN_REISSUE_SEC", "KIS_WS_APPROVAL_MAX_VALID_SEC", # invalid approval 연속 시 6h 가드 우회 REST 재발급 (죽은 키 루프 방지) "KIS_WS_INVALID_APPROVAL_REISSUE_AFTER", "KIS_WS_INVALID_APPROVAL_BYPASS_6H", @@ -577,13 +578,21 @@ ENV_CONFIG_KEYS = ( "WS_PRICE_MAX_AGE_SEC", # 실매·옵투나 읽기 폴백 나이(초). 메인 체결시각 vs 지금. 넘기면 그 벤더 실패→2차→3차. "LIVE_FEED_FALLBACK_MAX_AGE_SEC", + # 실매 틱/호가 1차 벤더 (LS는 1차 아님 · 구독 spill 3차). 호가 kis=2키 OB 세션. + "LIVE_TICK_PROVIDER", + "LIVE_OB_PROVIDER", + # 벤더별 호가 DB 적재. KIS=2키(H0STASP0)만. 메인 시세 41 합산 금지. + "WS_ORDERBOOK_SAVE_KIS", + "WS_ORDERBOOK_SAVE_KIWOOM", "TRIGGER_FEED_DETAIL_LOG", # 옵투나/백테 피드 추적 (틱·호가 벤더 비율 + TRIGGER 샘플 상한) "BT_FEED_DETAIL_LOG", "BT_FEED_DETAIL_LOG_MAX", "CANDLE_GARBAGE_FALLBACK", - # LS에 메인 틱/호가 유니버스(후보∪보유∪grace) 구독. 영구와 별개 owner. + # LS에 메인 틱/호가 유니버스(후보∪보유∪grace) 전체 미러. 기본 OFF — spill/permanent만. "LS_FEED_FALLBACK_SUBSCRIBE", + # hold reopen 시 RAM 유지: 영구 + (기본) 보유. OFF=영구만. + "LS_WS_HOLD_RAM_KEEP_HOLDINGS", # 후보 LS 갭 REST(t8412). 기본 OFF — 구독만. 영구 갭은 LS_GAP_FILL_ENABLED. "LS_GAP_FILL_CANDIDATES", # 필터 ON + 호가 사진 한 장도 없으면 거절(기본 true). 만료 Fail-Open 과 별개. @@ -1368,7 +1377,7 @@ ENV_CONFIG_KEYS = ( "WS_OB_SUBSCRIBE_CHAIN", # LS WS 마스터 기동 (spill 3차·영구 RAM). false면 VALIDATION/HISTORY/ls_condition 만으로 기동. "LS_WS_ENABLED", - # LS 로컬 구독 상한. 0=로컬한도 없음(연결 여부만). spill 3차용. + # LS 로컬 구독 상한. 0=로컬한도 없음. spill 3차용 (기본 100). "LS_WS_MAX_SUBSCRIPTIONS", # 검증 비교 주기(초) — 너무 짧으면 부하, 너무 길면 표본 부족. 기본 5. "WS_VALIDATION_INTERVAL_SEC", @@ -1388,9 +1397,11 @@ ENV_CONFIG_KEYS = ( # true → 조건 구독 시 6자리 숫자 종목만 (우선주/특이코드 스킵) "LS_WS_CONDITION_PLAIN6_ONLY", "LS_WS_FORCE_REAL", - # 후보 틱 DB 기본 OFF. 영구 ETF 틱은 persist 게이트(영구만) 유지. + # 후보∪보유∪영구∪grace 구독 중이면 ls_ws_ticks INSERT (호가와 동일 게이트). # INSERT 만 게이트. 호가 틱동기(LS_WS_ORDERBOOK_SAVE) 콜백을 같이 끄지 말 것. "LS_WS_TICK_SAVE", + # 백테/옵투나: ws_ticks 후 빈 초에 ls_ws_ticks 3차 (나이=LIVE_FEED_FALLBACK) + "BT_TICK_LS_THIRD_FALLBACK", "LS_WS_CANDLE_SAVE", # true=LS 틱을 ws_ticks(source=ls) 에도 이중 저장 (용량↑). 기본 false — ls_ws_ticks 만 "LS_WS_TICK_MIRROR_WS_TICKS", @@ -1422,6 +1433,10 @@ ENV_CONFIG_KEYS = ( "LS_WS_PING_TIMEOUT_SEC", "LS_WS_OPEN_REG_DELAY_MS", "LS_WS_REG_GAP_MS", + # Bye/조기 CLOSE: 안정 OPEN(초) 미만이면 early. streak 도달 시 토큰 갱신+회로 대기 + "LS_WS_STABLE_OPEN_SEC", + "LS_WS_EARLY_BYE_STREAK", + "LS_WS_BYE_CIRCUIT_SLEEP_SEC", # 봇 종료 시 UNREG 동기 전송 (재시작 세션 꼬임 완화). 타임아웃 후 TCP close "LS_WS_STOP_UNREG_TIMEOUT_SEC", "LS_WS_STOP_UNREG_GAP_MS", diff --git a/docs/LS_WS_관측_예약.md b/docs/LS_WS_관측_예약.md new file mode 100644 index 0000000..f5b028d --- /dev/null +++ b/docs/LS_WS_관측_예약.md @@ -0,0 +1,139 @@ +# LS WS 저널 관측 (Bye / sends≈1 / abort_why / RSP) + +8/25형 **불완전 REG** (`OPEN sends≈1` → Bye 반복) 와 관측 패치 필드 +(`abort_why`, `ws_same`, `LS WS RSP`) 를 **장중 hold** 구간에 자동 수집한다. + +장외(hold 외)에는 LS 소켓이 없어 OPEN/Bye 가 없는 것이 **정상**이다. + +--- + +## 1. 파일 목록 + +| 경로 | 역할 | +|------|------| +| `scripts/observe_ls_ws_journal.py` | journalctl 수집·집계 (once / watch) | +| `scripts/observe_ls_ws_journal.sh` | cron용 래퍼 (stdout → 일별 래퍼 로그) | +| `docs/LS_WS_관측_예약.md` | 본 문서 | +| `logs/ls_ws_observe_YYYYMMDD_HHMMSS.log` | 매칭된 저널 **원문** | +| `logs/ls_ws_observe_YYYYMMDD_HHMMSS_summary.md` | **집계 요약** (sends 분포, abort_why, …) | +| `logs/ls_ws_observe_cron_YYYYMMDD.log` | cron/래퍼 실행 로그 (그날 누적 append) | + +관련 코드(관측 필드 출력): + +| 경로 | 내용 | +|------|------| +| `kis_trader/ws/ls_ws.py` | `LS WS OPEN … abort_why=` / `CLOSE … ws_same=` / `LS WS RSP …` | +| `kis_trader/utils/ls_ws_session_windows.py` | KR hold 기본 **07:00~20:00** | + +유닛: `kis_trader_main.service` + +--- + +## 2. 수동 실행 + +```bash +cd /home/hoon/kis_bot + +# 당일 00:00~지금 1회 집계 +python3 -u scripts/observe_ls_ws_journal.py --once + +# hold 시각부터 지정 +python3 -u scripts/observe_ls_ws_journal.py --once --since "2026-08-26 07:00:00" + +# 90분 폴링(2분 간격) 후 최종 집계 — 아침 재현용 +python3 -u scripts/observe_ls_ws_journal.py --watch --minutes 90 --interval 120 + +# 래퍼 (cron과 동일, 로그 append) +bash scripts/observe_ls_ws_journal.sh watch +bash scripts/observe_ls_ws_journal.sh once --since "2026-08-26 07:00:00" +``` + +실행 후 stdout 에 `raw=` / `summary=` 경로가 찍힌다. + +```bash +# 최신 요약 보기 +ls -lt logs/ls_ws_observe_*_summary.md | head -3 +tail -f logs/ls_ws_observe_cron_$(date +%Y%m%d).log +``` + +--- + +## 3. cron 예약 (평일 아침) + +KR hold 기본 **07:00** → **07:05** 에 90분 watch 시작 (약 08:35 종료). + +설치 (사용자 `hoon` crontab): + +```cron +# LS WS Bye/REG 관측 — 평일 07:05 KST, 90분 watch +5 7 * * 1-5 /home/hoon/kis_bot/scripts/observe_ls_ws_journal.sh watch +``` + +장 마감 후 하루 요약(선택): + +```cron +# LS WS 당일 전체 once 요약 — 평일 20:10 (hold 종료 직후) +10 20 * * 1-5 /home/hoon/kis_bot/scripts/observe_ls_ws_journal.sh once --since "$(date +\%Y-\%m-\%d) 07:00:00" +``` + +확인: + +```bash +crontab -l | grep observe_ls_ws +``` + +제거: + +```bash +crontab -l | grep -v observe_ls_ws_journal | crontab - +``` + +--- + +## 4. 요약 파일이 보는 것 + +| 집계 | 의미 | +|------|------| +| `LS WS OPEN` + `sends≈N` 분포 | N=1 다수 → 불완전 REG 의 | +| `abort_why` | `opened_cleared` / `not_running` (REG 루프 중단 사유) | +| `ws_same` | CLOSE/ERROR 가 **현재 소켓**인지 | +| `LS WS RSP` / `rsp_cd` | REG ACK (body 없는 응답 포함) | +| Bye / opcode=8 | 서버/연결 종료 시그니처 | +| hold 관련 | 장외 대기 중이면 OPEN=0 이 정상 | + +힌트 문구는 **가설**이다. `abort_why`·`ws_same` 없이 “고쳤다”고 단정하지 말 것. + +--- + +## 5. 8/25 참고 시그니처 (이미 확인된 것) + +- 장중 `sends≈1` OPEN 다수 (예: 373) + Bye/CLOSE 반복 (~1400대) +- 정상급 `sends≈15` 이상은 소수 +- 당시 프로세스에는 `abort_why` / `ws_same` / `LS WS RSP` **없음** (관측 패치 전) +- **재시작 후 hold 진입분**부터 새 필드가 찍힘 + +--- + +## 6. 운영 체크리스트 + +1. 전날/당일 `kis_trader_main` 재시작으로 관측 패치·틱 구독전체 적재 코드 반영 확인 +2. 평일 07:05 cron → `logs/ls_ws_observe_cron_YYYYMMDD.log` append 확인 +3. `*_summary.md` 에서 `sends≈1` 비율·`abort_why`·`ws_same` 확인 +4. 원인 확정 후에만 행동 패치(REG 재시도 / stale close 무시 등) — **선보고·승인** +5. `LS_WS_TICK_SAVE=true` 여도 **Bye로 구독이 죽으면** `ls_ws_ticks` 는 0 + +--- + +## 7. 의존 + +- `journalctl` 읽기 권한 (동일 사용자 유닛 또는 systemd user) +- Python 3, 레포 루트 `/home/hoon/kis_bot` +- DB/증권사 API **호출 없음** (저널만) + +--- + +## 8. 변경 이력 + +| 일자 | 내용 | +|------|------| +| 2026-08-26 | 스크립트·cron 문서 최초. 재시작 후 장외 hold 대기로 당일 새벽 재현 불가 → 07:05 watch 예약 | diff --git a/docs/like_mcp.md/MODIFICATION_GUIDE.md b/docs/like_mcp.md/MODIFICATION_GUIDE.md index b2fe212..24c2833 100644 --- a/docs/like_mcp.md/MODIFICATION_GUIDE.md +++ b/docs/like_mcp.md/MODIFICATION_GUIDE.md @@ -79,16 +79,20 @@ align 스터디는 `limit_atr_mult` 제외. fixed 스터디는 `atr_sl_*` 제외 시세·호가 읽기 폴백 (실매=옵투나 같은 읽기): - `LIVE_TICK_PROVIDER` / `LIVE_OB_PROVIDER` 운영설정 **셀박** (`kiwoom`|`kis`). LS는 1차 아님(3차 spill). - `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지. + - 호가 RAM도 동일 초: `snap_time`(KIS BSOP_HOUR / LS hotime / 키움 FID20) lag 초과 시 미반영·`get(max_age>0)` None. `FILTER_MAX_AGE=0`(마지막 RAM)은 snap 컷 안 함. - `TRIGGER_FEED_DETAIL_LOG`(기본 true) = 매수체크 `🔍` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리. - `BT_FEED_DETAIL_LOG`(기본 true) / `BT_FEED_DETAIL_LOG_MAX`(기본 40) = 옵투나·백테에도 동일 축: 틱/호가 로드 벤더 비율 INFO + TRIGGER 샘플. `entry_source=ws_ticks:kis` 등. - 호가 후처리(`recommend_orderbook_parameters`): 코어 TPE 호가OFF와 무관. `[호가후처리]` DB벤더·`[호가후처리샘플]` bid/ask/src 가 **후처리 구간**에 나옴. - 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 전부 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지. - 한투 호가=`WS_ORDERBOOK_SAVE_KIS`: **2번째 앱키(`KIS_APP_KEY_OB_REAL`) 전용 세션만**. 키 없거나 start 실패 시 메인에 H0STASP0 붙이지 않음(시세 41 합산 금지). -- `LS_FEED_FALLBACK_SUBSCRIBE`(기본 true) → 후보∪보유∪영구∪grace 를 LS RAM 미러. **MINIMAL ON/OFF 무관** (`sync_targets` + split reconcile 둘 다). DB 틱 `should_persist_ls`(영구만) 이번 범위 밖. + `WS_ORDERBOOK_SAVE_MODE=tick` 이면 메인 `H0STCNT0` 체결마다 2키 OB RAM을 `on_orderbook_tick_sync` → `ws_orderbook`(source=`kis_h0stasp0`). 키움 0B↔0D 틱동기와 동일. +- `LS_FEED_FALLBACK_SUBSCRIBE`(기본 false) → spill/permanent만. ON=후보∪보유∪grace LS RAM 미러(레거시). +- `LS_WS_TICK_SAVE` → `ls_ws_ticks` INSERT. 게이트=`_ls_is_subscribed`(구독 전체, 호가와 동일). 봉·VI만 `should_persist_ls`(영구). +- `BT_TICK_LS_THIRD_FALLBACK`(기본 true) → 백테/옵투나 `load_breakout_ticks_by_code` 가 같은 초 1·2차 없을 때 `ls_ws_ticks` 채움. 나이=`LIVE_FEED_FALLBACK_MAX_AGE_SEC`. - 키움 RAM/리스너/봉 skip = 읽기 나이. `ws_ticks` 적재는 기본 전부(`KIWOOM_TICK_LIVE_MAX_LAG_SEC=0`). 같이 내리지 말 것. `ws_ticks.channel`(기본 ws)은 봉과 같은 경로 라벨. ka10007을 틱 INSERT 하지 말 것. - 시세 REST 키 = `KIWOOM_WS_FORCE_REAL`(기본 true) 실키. `_get_kiwoom_creds` 가 `KIS_MOCK` 모의키로 가면 안 됨 (8001). - `LS_GAP_FILL_CANDIDATES` 기본 OFF. -- `LS_WS_TICK_SAVE` 기본 false = `ls_ws_ticks` INSERT 만 OFF. `_tick_recorder` 를 같이 끄면 호가 틱동기도 0건. `LS_WS_ALSO_HOGA` 기본 true(UH1 구독). `LS_WS_ORDERBOOK_SAVE` 기본 true(구독 종목). 호가필터 `FILTER_MAX_AGE=0` 을 저장 TTL 과 다시 합치지 말 것. +- `LS_WS_TICK_SAVE` 기본 false 가능(운영 ON 권장 검증기간). `_tick_recorder` 를 같이 끄면 호가 틱동기도 0건. `LS_WS_ALSO_HOGA` 기본 true(UH1 구독). `LS_WS_ORDERBOOK_SAVE` 기본 true(구독 종목). 호가필터 `FILTER_MAX_AGE=0` 을 저장 TTL 과 다시 합치지 말 것. - 매수 루프 전 종목 REST 금지. 매도: 3초 체인 → last-RAM(`SELL_WS_LAST_RAM_MAX_AGE_SEC`) → 직전가 캐시 → 4차 키움 `ka10007`+쿨다운. 한투 60초 캐시 금지. 보유 KIS는 41슬롯 pin + `WS_TICK_GRACE_SEC`(후보 KIS grace 아님). - MM 체결 알림: `시세: kiwoom(1차) | 호가: ls(3차)`. @@ -278,6 +282,16 @@ Optuna 후처리(실매 엔진 비영향): OPTUNA_POST_TOP_N / INCLUDE_MODE / IN · optuna_rerun_postprocess.py (구 JSON 「이 잡 후처리 재실행」. 호가스냅<3 `not_enough_trades` 는 구JSON이 아님 — 재실행해도 스냅 없으면 8방 동일) 후처리 앵커: gated TopN + stable TopN(표/적용) + mode + live(참고). 합의·과적합 가점은 gated+mode만. 웹 Optuna: 학습/gated/stable Top5 행에 안정점수(↑·만점없음·원) + 과적합%(↓·0~100). 1위 비교표·mode_combo 실측행은 표시 안 함. + **본 TPE 호가·휩쏘 (2026-08-23 A):** `OPTUNA_TPE_INCLUDE_ORDERBOOK`/`WHIPSAW` 기본 true → + trial suggest 에 `_orderbook_filter_enabled`·spread/ratio/ask · `whipsaw_*`(돌파 제외). + CLI `--orderbook-filter off` 여도 TPE 시 호가 스냅 로드. 사후 8방「필터후」는 + `OPTUNA_POST_FORCE_OB_WHIPSAW` 없으면 OFF (`_run_ob_whipsaw_full`). 축 변경 시 **새 study-name**. + **웹 스캘 진입 스냅 (2026-08-23):** `run_scalping_backtest_web_aligned` 도 모멘텀/돌파/꼬리처럼 + `resolve_trigger_snapshots_for_backtest` → `_bt_orderbook_by_code` (표시용 enrich와 별개). + 미로드+호가ON+REJECT_IF_EMPTY 가 웹 0건 구멍이었음. + **호가 UI=DB (2026-08-23):** 모멘텀 `mom_ob_filter` HTML `checked`+「ON」고정 금지. + 꼬리/돌파 `tl_/bo_ob_filter` 도 기본 unchecked → `/api/env/params` 실효값으로만 체크. + 스캘은 `bt_ob_ro_on` 읽기전용(원래 DB 반영). 진입 격자(2026-08-15): OPTUNA_OB_ENTRY_SPREAD 0.1~8 / RATIO 0.05~1.5 / ASK_MULT 1~80 / LOOKBACK 30분. 8방 스택(2026-08-16/17): OPTUNA_OB_COMBO_TRIALS_SINGLE=150 / DOUBLE=200 / TRIPLE=250. 적용=방 단위(build_combo_env_patch). 합의=8방 중 median PnL 최고 방(_consensus_from_anchors). diff --git a/docs/증권사_토큰_시세.md b/docs/증권사_토큰_시세.md index 58da4b8..4009984 100644 --- a/docs/증권사_토큰_시세.md +++ b/docs/증권사_토큰_시세.md @@ -154,12 +154,22 @@ env 스위치: | TR | 시장 | 내용 | |----|------|------| -| `US3` (기본) / `S3_`·`K3_` | 국내 | 체결 틱 | -| `UH1` 등 | 국내 | 호가 | +| `US3` (기본) / `S3_`·`K3_` | 국내 | **체결 틱** (호가 DB `MODE=tick` 트리거 = 한투 CNT0·키움 0B 와 동일 역할) | +| `UH1` / `H1_`·`HA_` | 국내 | **호가 → RAM** (`also_hoga`). **UH3 없음** | | `UVI` 등 | 국내 | VI | | `JIF` | 국내 | 장운영정보 (워치독 보조) | | `GSC` | 해외 | 해외 체결 (`82{symbol}` 키) | +**호가 수집 (LS = 한투·키움과 같은 두 갈래)** + +| 갈래 | TR | 역할 | +|------|-----|------| +| 실시간 호가 | UH1 | RAM 갱신 (실매 필터) | +| 틱동기 DB | US3 체결 때 | `LS_WS_ORDERBOOK_SAVE_MODE=tick` 이면 RAM 호가 1장 → `ls_ws_orderbook` | + +`MODE=interval` 이면 UH1 경로에서 DB 저장. 세 증권사 공통 개념. +상세·8/25 Bye/`sends≈1` 사고: `docs/호가.md` §0-0b. + 소켓 hold (벽시계): `ls_ws_session_windows` - 국내 `07:00~20:00` / 해외 `21:00~06:00`(해외 구독 있을 때) - 갭·장외: **소켓 close + 대기**, 토큰 캐시 유지 @@ -204,7 +214,7 @@ env 스위치: 1. 만료·서버 거부 코드 확인 전에는 **force 발급 금지** 2. KIS approval: 6h 가드 · 국내/해외 **공유 캐시만** · 세션 전환은 **close/open** 3. LS: `expires_in` 캐시 · 세션 hold 전환 시 **oauth 호출 금지** -4. 키움: LOGIN 거부 시에만 invalidate · 재연결 폭주 쿨다운 +4. 키움: LOGIN 거부 시에만 invalidate · 재연결 단계 대기 (`WS_RECONNECT_DELAY_SECS`, 기본 1,3,5,7,10초) 5. 진단 스크립트도 실매와 **동일 한도** --- diff --git a/docs/호가.md b/docs/호가.md index 371d071..aa59386 100644 --- a/docs/호가.md +++ b/docs/호가.md @@ -6,6 +6,56 @@ --- +## 0-0b. 벤더 공통: 실시간 호가(RAM) vs 틱동기 호가(DB) — 2026-08-25 + +**UH3 없음.** LS 체결=`US3`, 호가=`UH1`. (한투=`H0STCNT0`/`H0STASP0`, 키움=`0B`/`0D`) + +| | RAM (실매 필터가 봄) | DB 호가 (`*_ORDERBOOK_SAVE_MODE=tick` 기본) | +|--|----------------------|-----------------------------------------------| +| **한투** | H0STASP0 올 때마다 | **H0STCNT0(체결) 1건** → 그때 RAM 1장 → `ws_orderbook` | +| **키움** | 0D 올 때마다 | **0B(체결) 1건** → 그때 RAM 1장 → `ws_orderbook` | +| **LS** | UH1 올 때마다 | **US3(체결) 1건** → 그때 RAM 1장 → `ls_ws_orderbook` | + +- `MODE=interval` = 호가 메시지(UH1/0D/ASP0) 쪽에서 간격 저장. **세 증권사 공통 스위치**이지 LS 전용 신기능이 아님. +- `LS_WS_TICK_SAVE` = `ls_ws_ticks` INSERT. **구독 전체**(영구∪후보∪보유) — 호가와 동일. 봉·VI만 영구. +- `BT_TICK_LS_THIRD_FALLBACK`(기본 true) = 백테/옵투나 틱: 같은 초 1·2차 없으면 `ls_ws_ticks` (나이=폴백 3초). +- 실매는 DB를 안 보고 **RAM**. DB는 백테/옵투나 재현용 샘플. + +### 2026-08-25 LS `ls_ws_orderbook` 0건 — 원인 (코드) + +의도(US3 틱동기)는 한투·키움과 **같음**. 문제는: + +1. 장중 **Bye ≈ 매분** → OPEN 직후 구독 복구가 `sends≈1`(JIF만)로 **미완료**. +2. 서버에 **US3/UH1 REG가 안 붙음** → 체결·호가 수신 없음 → tick 모드라 DB 0. +3. 미완료인데도 `recovering=False` 로 풀리던 버그 → 워치독이 틱없음으로 또 끊음 (악순환). + +**수정(동작) 2026-08-27:** `ls_ws.py` + `ls_token.py` + +1. **OPEN마다 `_ws_reconnect_step=0` 리셋 제거** — Bye 플랩 시 1초 연타 원인. +2. **조기 CLOSE/Bye 서킷** (`LS_WS_STABLE_OPEN_SEC`/`EARLY_BYE_STREAK`/`BYE_CIRCUIT_SLEEP_SEC`) — streak 도달 시 로컬 토큰 무효화+1회 재발급 후 대기. +3. **REG 미완료면 `recovering` 유지** — `reg_ok`일 때만 해제·step/streak 리셋. +4. adhoc에서 `fetch_ls_access_token` 강제 발급 **금지** (봇 프로세스 토큰 무효화). + +`LS_WS_ENABLED` / TICK·ORDERBOOK_SAVE / 3차 spill **끄지 않음** (옵션1). + +#### Bye ≈ 매분 — 로그·git으로 좁힌 것 (2026-08-25 journal) + +| 비교 | Aug24 (정상 수집) | Aug25 (DB 0) | +|------|-------------------|--------------| +| 시간대당 `LSWebSocket.*Bye` | **0** (09~15시) | **~54~59**/시간 (≈매분) | +| 같은 시간 `LS WS watchdog` | 드묾(장초 1회 등) | **장중 시간대 0** | +| OPEN 복구 | `sends≈15~339` 완료 | 거의 항상 `sends≈1` (JIF만) | + +- Bye 페이로드: `opcode=8` + `\x03\xe8Bye` = WebSocket **정상종료(1000) + 서버가 보낸 Bye**. 우리 워치독 문구(`틱 없음 → 강제 재연결`)와 **무관**. +- 패턴: `OPEN(sends≈1)` → 약 **60초** 유지 → `Bye`+`CLOSE` → **같은 초~1초 안**에 다시 `OPEN(sends≈1)`. 침묵 워치독(grace30+silence45≈75초)보다 **먼저** 서버가 끊음. +- 05:07 봇 재시작 이후 **07:00부터** 이미 동일 루프(장 시작 전). Aug24 18:37은 `KR=169 sends≈339`로 복구 성공 이력 있음. +- `sends≈1` + KR 수백: REG 루프가 JIF 직후 `_opened` 해제되어 abort된 **형태**(가설). + 검증 키: CLOSE `ws_same=False`가 OPEN/REG 중 끼는지, 복구 로그 `abort_why=opened_cleared`. **미확정**. + +**아직 단정 금지:** 이중세션(조건식 AFR vs 시세)·계정 한도·서버 idle 정책만의 단독 원인. 위는 “워치독 매분 킥”은 **아님**까지 확정. + +--- + ## 0-0. 「호가 쌓기」는 설계가 아니다 (헷갈림 방지) **DB에 호가 한 장씩 넣는 것(저장)은 이미 돌아가는 수집 기능이다.** @@ -157,7 +207,7 @@ AND라서 하나라도 깨지면 탈락 → 체감이 셈. ### 키움 조건 vs LS 조건 (진입·수익구간 호가매도 공통) - `kiwoom_condition` / `condition` → 호가 **키움 0D RAM** · DB `ws_orderbook` - `ls_condition` → 호가 **LS UH1 RAM** · DB `ls_ws_orderbook` (키움으로 메우지 않음) -수익구간·손절호가도 같은 스냅샷 API. **2026-08 운영:** 4전략 유니버스는 키움 조건이 본체. LS는 틱/호가 **구독 spill 3차**이지 “LS 전용 종목”이 아님 (`LIVE_OB_PROVIDER=kiwoom`). +수익구간·손절호가도 같은 스냅샷 API. **2026-08 운영:** 4전략 유니버스는 키움 조건이 본체. 호가 구독/읽기 체인 **kis(2키 OB 41)→kiwoom→ls(3차)**. LS는 spill 3차지 “LS 전용 종목”이 아님 (`LIVE_OB_PROVIDER=kis`, `WS_OB_SUBSCRIBE_CHAIN=kis,kiwoom,ls`). --- diff --git a/kis_trader/engine/feed_fallback.py b/kis_trader/engine/feed_fallback.py index 90cf200..08e5b62 100644 --- a/kis_trader/engine/feed_fallback.py +++ b/kis_trader/engine/feed_fallback.py @@ -51,6 +51,21 @@ def is_feed_read_stale( return False +def is_orderbook_snap_time_stale( + snap_time: Any, + *, + max_age_sec: Optional[float] = None, +) -> bool: + """호가 거래소 시각(snap_time) vs 지금 — 틱 ``packet_lag`` + ``is_feed_read_stale`` 와 동일. + + snap_time 없거나 파싱 실패 → False(버리지 않음). LIVE_FEED_FALLBACK 0 이하면 OFF. + """ + return is_feed_read_stale( + packet_lag_seconds(str(snap_time or "").strip()), + max_age_sec=max_age_sec, + ) + + def live_feed_fallback_max_age_sec() -> float: """읽기 유효 나이(초). 기본 3. 0 이하면 나이로 벤더를 버리지 않음(레거시).""" try: @@ -361,6 +376,59 @@ def merge_ticks_time_axis_fallback( return out +def merge_ls_ticks_third_fallback( + ticks_by_code: Dict[str, Dict[str, List[Dict[str, Any]]]], + ls_ticks_by_code: Dict[str, Dict[str, List[Dict[str, Any]]]], + *, + max_lag_sec: Optional[float] = None, +) -> int: + """실매 틱 3차: 같은 초에 kis/kiwoom 이 이미 있으면 LS 안 넣음. + + 메인·2차가 비었거나(또는 나이컷으로 비움) 그 초만 ``ls_ws_ticks`` 로 채움. + ``LIVE_FEED_FALLBACK_MAX_AGE_SEC``(기본 3) — lag 모르면 버리지 않음(실매와 동일). + 반환=추가한 틱 수. + """ + if not ls_ticks_by_code: + return 0 + age = live_feed_fallback_max_age_sec() if max_lag_sec is None else float(max_lag_sec) + added = 0 + for code, ls_minutes in ls_ticks_by_code.items(): + code_s = str(code or "").strip() + if not code_s or not isinstance(ls_minutes, dict): + continue + main_minutes = ticks_by_code.setdefault(code_s, {}) + occupied: set = set() + for _mk, ticks in main_minutes.items(): + for t in ticks or []: + if not isinstance(t, dict): + continue + sk = _tick_second_key(t) + if sk: + occupied.add(sk) + for mk, ls_ticks in ls_minutes.items(): + mk_s = str(mk or "").strip() + if not mk_s: + continue + bucket = main_minutes.setdefault(mk_s, []) + for t in ls_ticks or []: + if not isinstance(t, dict): + continue + sk = _tick_second_key(t) + if not sk or sk in occupied: + continue + lag = t.get("_lag_sec") + if is_feed_read_stale(lag, age): + continue + row = dict(t) + row["source"] = "ls" + bucket.append(row) + occupied.add(sk) + added += 1 + if bucket: + bucket.sort(key=lambda x: str(x.get("tick_time") or "")) + return added + + def orderbook_row_lag_seconds(row: Dict[str, Any]) -> Optional[int]: """recv_ts vs snap_time 초 차이. 파싱 실패면 None(유효로 봄).""" recv_ts = str(row.get("recv_ts") or "").strip() diff --git a/kis_trader/engine/orderbook_env.py b/kis_trader/engine/orderbook_env.py index ddf181f..90c6e5f 100644 --- a/kis_trader/engine/orderbook_env.py +++ b/kis_trader/engine/orderbook_env.py @@ -123,10 +123,13 @@ def orderbook_params_to_env_patch(strategy: str, params: Dict[str, Any]) -> Dict out[_key(pfx, "ENTRY_BID_DEPTH_MULT")] = str(float(params["bid_depth_mult"])) if "bid_levels" in params and params.get("bid_levels") not in (None, ""): out[_key(pfx, "ENTRY_BID_LEVELS")] = str(int(float(params["bid_levels"]))) - # 호가축이 섞인 apply 면 필터 ON (탐색 결과를 실매에 쓰려면 필요) - if out and params.get("ob_filter_enabled") not in (None, ""): + # 호가축 apply → FILTER_ENABLED (_orderbook_filter_enabled / ob_filter_enabled) + fe = params.get("ob_filter_enabled") + if fe is None: + fe = params.get("_orderbook_filter_enabled") + if fe is not None and str(fe).strip() != "": out[_key(pfx, "FILTER_ENABLED")] = ( - "true" if str(params.get("ob_filter_enabled")).lower() in ("1", "true", "yes", "on") + "true" if str(fe).lower() in ("1", "true", "yes", "on") else "false" ) return out diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py index 043a8cb..9d4b4a4 100644 --- a/kis_trader/execution/order_manager.py +++ b/kis_trader/execution/order_manager.py @@ -2418,6 +2418,7 @@ class OrderManager: ob_raw, ob_src = resolve_orderbook_raw( req.code, ws_get=self.orderbook_provider, + ob_feed_label=self.ob_feed_label_provider, ) except Exception: ob_raw = None @@ -2441,8 +2442,8 @@ class OrderManager: if use_limit: ord_no = self.client.sell_limit_order(req.code, sell_qty, limit_price) logger.info( - "📤 [익절지정가] %s %s × %d주 @ %s원 (매수1호가)", - req.name, req.code, sell_qty, f"{limit_price:,}", + "📤 [익절지정가] %s %s × %d주 @ %s원 (매수1호가·호가=%s)", + req.name, req.code, sell_qty, f"{limit_price:,}", ob_src, ) else: ord_no = self.client.sell_market_order(req.code, sell_qty) diff --git a/kis_trader/execution/orderbook_sell.py b/kis_trader/execution/orderbook_sell.py index 0322e16..c59a2b5 100644 --- a/kis_trader/execution/orderbook_sell.py +++ b/kis_trader/execution/orderbook_sell.py @@ -7,7 +7,7 @@ kis_trader.execution.orderbook_sell — 익절(수익) 매도 시 호가 잔량 from __future__ import annotations import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from ..utils.env import get_env_bool, get_env_float, get_env_int @@ -70,14 +70,30 @@ def resolve_orderbook_raw( code: str, *, ws_get=None, + ob_feed_label: Optional[Callable[[str], str]] = None, rest_client=None, max_age_sec: float = 3.0, ) -> Tuple[Optional[Dict[str, Any]], str]: """ - 호가 조회 — 키움 WS 캐시 우선, 없으면 KIS REST. - Returns: (raw_dict, source_tag) source_tag: kiwoom_ws | kis_rest | none + 호가 조회 — WSManager.get_orderbook(1·2·3차 체인) 우선, 없으면 KIS REST. + + Returns: (raw_dict, source_tag) + - WS 성공: ``ob_feed_label(code)`` — 예 ``kis(1차)``, ``ls(3차,spill)`` + - REST: ``kis_rest`` + - 실패: ``none`` """ c = str(code or "").strip() + + def _ws_source_tag() -> str: + if ob_feed_label is not None: + try: + lab = (ob_feed_label(c) or "").strip() + if lab: + return lab + except Exception: + pass + return "ws" + if ws_get and c: try: raw = ws_get(c, max_age_sec=max_age_sec) @@ -89,7 +105,7 @@ def resolve_orderbook_raw( except Exception: raw = None if raw: - return raw, "kiwoom_ws" + return raw, _ws_source_tag() if rest_client and c: try: raw = rest_client.get_orderbook(c) diff --git a/kis_trader/main.py b/kis_trader/main.py index 8714f71..343c4d8 100644 --- a/kis_trader/main.py +++ b/kis_trader/main.py @@ -1159,8 +1159,9 @@ class TradingOrchestrator: self._ls_perm_ttl = float(get_env_int("PERM_SUB_UI_REFRESH_SEC", 15)) def _on_tick(code: str, payload: dict) -> None: - # 1) LS 전용 테이블 — 틱은 영구구독 enabled 만 (후보 틱 DB OFF) - if get_env_bool("LS_WS_TICK_SAVE", False) and self._ls_should_persist(code): + # 1) LS 전용 테이블 — 호가와 동일: 구독 중이면 적재 (영구만이 아님). + # 봉·VI 는 아래 _ls_should_persist(영구) 유지. + if get_env_bool("LS_WS_TICK_SAVE", False) and self._ls_is_subscribed(code): self.db.insert_ls_ws_tick( code=code, ts=payload.get("ts"), @@ -1289,7 +1290,7 @@ class TradingOrchestrator: self.ls_ws_validator = None def _ls_should_persist(self, code: str) -> bool: - """LS DB 적재: 영구구독 enabled 코드만 (마스터와 무관, 행 기준).""" + """LS 봉·VI DB: 영구구독 enabled 코드만 (마스터와 무관, 행 기준).""" now = time.time() ttl = float(getattr(self, "_ls_perm_ttl", 15.0) or 15.0) if now - float(getattr(self, "_ls_perm_codes_ts", 0) or 0) >= ttl: @@ -1302,10 +1303,8 @@ class TradingOrchestrator: from permanent_subs import should_persist_ls return should_persist_ls(code, getattr(self, "_ls_perm_codes", set())) - def _ls_should_persist_orderbook(self, code: str) -> bool: - """LS 호가 DB: 구독 중인 종목 (영구∪폴백 유니버스). 틱/봉과 게이트 분리.""" - if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True): - return False + def _ls_is_subscribed(self, code: str) -> bool: + """LS WS 구독 중인가 (영구∪폴백 유니버스). 틱/호가 DB 공통 게이트.""" c = str(code or "").strip() if not c: return False @@ -1318,6 +1317,12 @@ class TradingOrchestrator: except Exception: return False + def _ls_should_persist_orderbook(self, code: str) -> bool: + """LS 호가 DB: SAVE ON 이고 구독 중. 봉/VI(영구)와 분리.""" + if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True): + return False + return self._ls_is_subscribed(code) + def _maybe_save_ls_orderbook_on_tick(self, code: str, payload: dict) -> None: """LS 체결 1건당 호가 RAM 스냅 1장 → ls_ws_orderbook (틱 동기). diff --git a/kis_trader/network/ls_token.py b/kis_trader/network/ls_token.py index a2e9ca1..ddbad8b 100644 --- a/kis_trader/network/ls_token.py +++ b/kis_trader/network/ls_token.py @@ -3,7 +3,8 @@ 운영 준수: - 응답 ``expires_in``/``expire_in``(초) 로 만료 시각을 잡고 **만료 전 재사용** - 만료·IGW00121/123(무효/기간만료) 시에만 재발급 - - 프로세스 공유 캐시 + 최소 재발급 간격(한도·폭주 방지) + - 프로세스 RAM + **파일 캐시** (``.ls_token_cache_{real|mock}.json``) — KIS REST 토큰과 동일 패턴 + - 최소 재발급 간격(한도·폭주 방지) - ``/oauth2/revoke`` 는 정상 폐기용 — 매 루프 강제 발급에 쓰지 않음 - **국내↔해외 세션 전환·WS 재연결에서 force 재발급 금지** (LS 토큰은 조건검색 REST·국내/해외 WS 공용 — 재발급 시 상대 경로 토큰 무효화. @@ -14,9 +15,12 @@ from __future__ import annotations +import json import logging +import os import threading import time +from pathlib import Path from typing import Any, Dict, Optional, Tuple import requests @@ -28,6 +32,13 @@ logger = logging.getLogger("kis_trader.ls_token") LS_REST_BASE = "https://openapi.ls-sec.co.kr:8080" LS_TOKEN_URL = f"{LS_REST_BASE}/oauth2/token" +# kis_token_manager.py · kis_approval_manager.py 와 동일 — repo 루트 +ROOT = Path(__file__).resolve().parents[2] +CACHE_REAL = ROOT / ".ls_token_cache_real.json" +CACHE_MOCK = ROOT / ".ls_token_cache_mock.json" +LOCK_FILE = ROOT / ".ls_token_manager.lock" +LOCK_TIMEOUT_S = 60 + # 스펙 응답 예시 기본 유효기간(초) _DEFAULT_EXPIRES_IN = 86400 @@ -78,8 +89,124 @@ def _parse_expires_in(body: Dict[str, Any]) -> int: return max(60, n) +def _is_mock_app_key(app_key: str) -> bool: + """모의/실전 파일 분리 — env ``LS_APP_KEY_MOCK`` 과 일치하면 mock.""" + k = (app_key or "").strip() + mock = (os.environ.get("LS_APP_KEY_MOCK") or "").strip() + return bool(mock and k == mock) + + +def _cache_file_path(app_key: str) -> Path: + return CACHE_MOCK if _is_mock_app_key(app_key) else CACHE_REAL + + +def _acquire_file_lock() -> bool: + deadline = time.time() + LOCK_TIMEOUT_S + while time.time() < deadline: + try: + fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + try: + if time.time() - LOCK_FILE.stat().st_mtime > 300: + LOCK_FILE.unlink(missing_ok=True) + continue + except Exception: + pass + time.sleep(0.5) + except Exception as e: + logger.warning("LS 토큰 파일 잠금 실패: %s", e) + return False + logger.warning("LS 토큰 파일 잠금 타임아웃 → 파일 캐시 건너뜀") + return False + + +def _release_file_lock() -> None: + try: + LOCK_FILE.unlink(missing_ok=True) + except Exception: + pass + + +def _read_file_cache(app_key: str, app_secret: str) -> Optional[Dict[str, Any]]: + path = _cache_file_path(app_key) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as e: + logger.warning("LS 토큰 파일 읽기 실패 (%s): %s", path.name, e) + return None + if not isinstance(data, dict): + return None + fk = f"{(app_key or '').strip()}|{(app_secret or '')[:8]}" + stored_key = str(data.get("cache_key") or "") + if stored_key and stored_key != fk: + return None + tok = str(data.get("access_token") or data.get("token") or "") + if not tok: + return None + try: + expire_at = float(data.get("expire_at") or 0) + issued_at = float(data.get("issued_at") or 0) + expires_in = int(data.get("expires_in") or 0) + except (TypeError, ValueError): + return None + return { + "token": tok, + "expire_at": expire_at, + "expires_in": expires_in, + "issued_at": issued_at, + } + + +def _write_file_cache(app_key: str, app_secret: str, ent: Dict[str, Any]) -> None: + path = _cache_file_path(app_key) + fk = f"{(app_key or '').strip()}|{(app_secret or '')[:8]}" + payload = { + "cache_key": fk, + "access_token": str(ent.get("token") or ""), + "expire_at": float(ent.get("expire_at") or 0), + "expires_in": int(ent.get("expires_in") or 0), + "issued_at": float(ent.get("issued_at") or 0), + "saved_at": time.time(), + } + locked = _acquire_file_lock() + try: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info("💾 LS 토큰 파일 저장 | %s | 만료까지 %.1fh", path.name, max(0.0, payload["expire_at"] - time.time()) / 3600.0) + except Exception as e: + logger.warning("LS 토큰 파일 저장 실패 (%s): %s", path.name, e) + finally: + if locked: + _release_file_lock() + + +def _clear_file_cache(app_key: str, app_secret: str) -> None: + path = _cache_file_path(app_key) + if not path.is_file(): + return + locked = _acquire_file_lock() + try: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + data = {} + fk = f"{(app_key or '').strip()}|{(app_secret or '')[:8]}" + if isinstance(data, dict) and str(data.get("cache_key") or "") == fk: + path.unlink(missing_ok=True) + logger.info("🗑 LS 토큰 파일 삭제 | %s", path.name) + except Exception as e: + logger.warning("LS 토큰 파일 삭제 실패 (%s): %s", path.name, e) + finally: + if locked: + _release_file_lock() + + class LSTokenCache: - """appkey 단위 공유 접근토큰 캐시.""" + """appkey 단위 공유 접근토큰 캐시 (RAM + JSON 파일).""" def __init__(self) -> None: self._lock = threading.RLock() @@ -97,7 +224,28 @@ class LSTokenCache: # 연속 재발급 최소 간격 — 폭주 방지 (기본 60초) return float(get_env_float("LS_TOKEN_MIN_REISSUE_SEC", 60.0) or 60.0) + def _hydrate_from_file(self, app_key: str, app_secret: str) -> None: + """RAM 비었을 때 재시작 직후 파일 캐시 복구.""" + ck = self._cache_key(app_key, app_secret) + with self._lock: + if self._by_key.get(ck, {}).get("token"): + return + ent = _read_file_cache(app_key, app_secret) + if not ent: + return + with self._lock: + cur = self._by_key.get(ck) or {} + if cur.get("token"): + return + self._by_key[ck] = dict(ent) + logger.info( + "📂 LS 토큰 파일 캐시 로드 | %s | 만료까지 %.1fh", + _cache_file_path(app_key).name, + max(0.0, float(ent.get("expire_at") or 0) - time.time()) / 3600.0, + ) + def peek(self, app_key: str, app_secret: str) -> Optional[str]: + self._hydrate_from_file(app_key, app_secret) with self._lock: ent = self._by_key.get(self._cache_key(app_key, app_secret)) if not ent: @@ -123,6 +271,8 @@ class LSTokenCache: if not (app_key and app_secret): raise RuntimeError("LS appkey/appsecret 필요") + self._hydrate_from_file(app_key, app_secret) + ck = self._cache_key(app_key, app_secret) with self._lock: ent = self._by_key.get(ck) or {} @@ -145,6 +295,38 @@ class LSTokenCache: ) return tok + # invalidate 후 빈 토큰: 최소간격 안이면 대기 후 발급(한도 준수) + if (not still_ok) and last_iss and (now - last_iss) < min_gap: + wait = min_gap - (now - last_iss) + logger.info( + "LS 토큰 재발급 대기 %.1fs (최소간격, reason=%s)", + wait, reason or "reissue", + ) + # lock 밖에서 sleep — 아래 재진입 + pass + else: + wait = 0.0 + + if wait > 0: + time.sleep(wait) + return self.get( + app_key, app_secret, force=force, reason=reason, timeout=timeout, + ) + + with self._lock: + ent = self._by_key.get(ck) or {} + tok = str(ent.get("token") or "") + exp = float(ent.get("expire_at") or 0) + last_iss = float(ent.get("issued_at") or 0) + now = time.time() + margin = self._margin_sec() + still_ok = bool(tok) and now < (exp - margin) + if still_ok and not force: + return tok + min_gap = self._min_reissue_sec() + if still_ok and force and (now - last_iss) < min_gap: + return tok + # 만료 직전·만료·강제 — 발급 if still_ok and force: logger.info("LS 토큰 재발급 요청 reason=%s (캐시 유효하나 force)", reason or "force") @@ -162,18 +344,53 @@ class LSTokenCache: raise RuntimeError(f"LS token empty: {body}") expires_in = _parse_expires_in(body) expire_at = now + float(expires_in) - self._by_key[ck] = { + new_ent = { "token": token, "expire_at": expire_at, "expires_in": expires_in, "issued_at": now, } + self._by_key[ck] = new_ent logger.info( "✅ LS 접근토큰 발급 | expires_in=%ds | 만료까지 %.1fh | reason=%s", expires_in, expires_in / 3600.0, reason or "ok", ) + _write_file_cache(app_key, app_secret, new_ent) return token + def invalidate( + self, + app_key: str, + app_secret: str, + *, + reason: str = "", + ) -> None: + """서버가 토큰을 무효화했을 때 RAM + 파일 캐시 비운다. + + ``issued_at`` 은 남겨 두어 다음 ``get()`` 이 최소 재발급 간격을 + 존중할 수 있게 한다. (빈 token → still_ok=False → 간격 후 재발급) + """ + app_key = (app_key or "").strip() + app_secret = (app_secret or "").strip() + if not (app_key and app_secret): + return + ck = self._cache_key(app_key, app_secret) + with self._lock: + ent = self._by_key.get(ck) or {} + last_iss = float(ent.get("issued_at") or 0) + if not ent and not last_iss: + logger.info("LS 토큰 캐시 무효화(이미 비어 있음) reason=%s", reason or "-") + _clear_file_cache(app_key, app_secret) + return + self._by_key[ck] = { + "token": "", + "expire_at": 0.0, + "expires_in": 0, + "issued_at": last_iss, + } + logger.warning("LS 토큰 캐시 무효화 reason=%s", reason or "-") + _clear_file_cache(app_key, app_secret) + @staticmethod def _issue(app_key: str, app_secret: str, *, timeout: float) -> Dict[str, Any]: resp = requests.post( @@ -217,6 +434,16 @@ def fetch_ls_access_token( ) +def invalidate_ls_access_token( + app_key: str, + app_secret: str, + *, + reason: str = "", +) -> None: + """로컬 RAM·파일 캐시 무효화 (서버 Bye/auth 복구용). OAuth 호출 없음.""" + _CACHE.invalidate(app_key, app_secret, reason=reason) + + def fetch_ls_access_token_info( app_key: str, app_secret: str, diff --git a/kis_trader/network/ws_manager.py b/kis_trader/network/ws_manager.py index 94f83e6..66e7c80 100644 --- a/kis_trader/network/ws_manager.py +++ b/kis_trader/network/ws_manager.py @@ -287,21 +287,40 @@ class WSManager: try: if TriggerSnapshotRecorder is not None: self.trigger_snapshot_recorder = TriggerSnapshotRecorder(db=self.db) - # Orderbook Snapshot Recorder 부착 (호가용, kis_ws_ob 기준) - if ( - self.trigger_snapshot_recorder - and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) - and self.kis_ws_ob is not None - ): - self.kis_ws_ob.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder) - logger.info("✅ KIS 호가 WS(OB)에 TriggerSnapshotRecorder 부착 완료 (KIS 호가 → kis_ws_orderbook 적재)") - elif get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) and self.kis_ws_ob is None: - logger.warning( - "WS_ORDERBOOK_SAVE_KIS=ON 이나 2키 OB 세션 없음 → 적재 생략" - ) except Exception as ts_ex: logger.warning("TriggerSnapshotRecorder 기동 실패: %s", ts_ex) self.trigger_snapshot_recorder = None + # Orderbook Snapshot Recorder 부착 (호가용, kis_ws_ob 기준) + # attach 실패해도 recorder 자체는 유지 (키움 main 쪽 부착용) + if ( + self.trigger_snapshot_recorder is not None + and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) + and self.kis_ws_ob is not None + ): + try: + self.kis_ws_ob.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder) + logger.info("✅ KIS 호가 WS(OB)에 TriggerSnapshotRecorder 부착 완료 (KIS 호가 → kis_ws_orderbook 적재)") + except Exception as att_ex: + logger.warning("KIS OB TriggerSnapshotRecorder 부착 실패: %s", att_ex) + elif get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) and self.kis_ws_ob is None: + logger.warning( + "WS_ORDERBOOK_SAVE_KIS=ON 이나 2키 OB 세션 없음 → 적재 생략" + ) + # 메인 틱 세션: 체결(H0STCNT0)마다 2키 OB RAM 틱동기 DB (키움 0B↔0D 와 동일) + if ( + self.trigger_snapshot_recorder is not None + and self.ws_cache is not None + and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) + ): + try: + self.ws_cache.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder) + if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache: + self.ws_cache.attach_orderbook_peer(self.kis_ws_ob) + logger.info( + "✅ KIS 메인 tick→OB peer 틱동기 부착 (H0STCNT0→H0STASP0 RAM→ws_orderbook)" + ) + except Exception as peer_ex: + logger.warning("KIS tick↔OB 틱동기 부착 실패: %s", peer_ex) ok = self.ws_cache.start() if not ok: @@ -533,11 +552,11 @@ class WSManager: logger.debug("LS permanent sync 실패: %s", e) def _sync_feed_fallback_to_ls(self, codes: "Set[str]") -> None: - """메인 틱/호가 유니버스를 LS 3차 RAM(시세+UH1)에 붙임. 갭 REST 아님. + """(레거시) 메인 유니버스 전체 미러 — 기본 OFF. spill/permanent 경로만 사용. - owner=_feed_fallback. 영구는 _permanent 와 합집합. 마스터 OFF 여도 폴백 구독은 독립. + owner=_feed_fallback. ``LS_FEED_FALLBACK_SUBSCRIBE=false`` 이면 항상 빈 집합 sync. """ - if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", True): + if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False): codes = set() ls_ws = self._get_ls_ws() if ls_ws is None: @@ -547,6 +566,112 @@ class WSManager: except Exception as e: logger.debug("LS feed-fallback sync 실패: %s", e) + def _bind_ls_ws_hooks(self) -> None: + """LS hold RAM 유지·headroom 등 WSManager 연동.""" + ls_ws = self._get_ls_ws() + if ls_ws is None: + return + if getattr(ls_ws, "_ws_manager_bound", False): + return + ls_ws._hold_ram_extra_codes_fn = self._ls_hold_ram_extra_codes + ls_ws._ws_manager_bound = True + + def _ls_hold_ram_extra_codes(self) -> Set[str]: + """hold reopen 시 RAM 유지: 영구 + (옵션) 보유.""" + with self._lock: + out = set(self._permanent_codes) + if get_env_bool("LS_WS_HOLD_RAM_KEEP_HOLDINGS", True): + out |= self._all_holdings() + return {str(c).strip() for c in out if str(c).strip()} + + def _ls_ephemeral_allowed_codes( + self, + kw_want: "Set[str]", + perm: "Set[str]", + ) -> Set[str]: + """LS spill/_feed_fallback 이 유지할 수 있는 종목 (grace·ls_condition 포함).""" + allowed = set(kw_want) | set(perm) + grace = self._purge_and_get_grace_codes() + if grace: + allowed |= set(grace) + with self._lock: + allowed |= self._pure_ls_codes_locked() + return {str(c).strip() for c in allowed if str(c).strip()} + + def _prune_ls_ephemeral_subscriptions( + self, + kw_want: "Set[str]", + perm: "Set[str]", + ) -> None: + """LS spill·_feed_fallback 정리 — KIS/키움 want/grace와 동일 수명.""" + self._bind_ls_ws_hooks() + allowed = self._ls_ephemeral_allowed_codes(kw_want, perm) + if get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False): + self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes()) + else: + self._sync_feed_fallback_to_ls(set()) + ls = self._get_ls_ws() + if ls is None: + return + spill_drop: List[str] = [] + with ls._sub_lock: + for code, ow in list(ls._sub_owners.items()): + if "spill" not in ow: + continue + if code not in allowed: + spill_drop.append(code) + for code in spill_drop: + try: + ls.unsubscribe(code, owner="spill") + except Exception: + pass + + def _finalize_ls_subscriptions( + self, + kw_want: "Set[str]", + perm: "Set[str]", + ) -> None: + """reconcile/sync 끝: 영구 sync + spill-only 정리.""" + self._sync_permanent_to_ls(perm) + self._prune_ls_ephemeral_subscriptions(kw_want, perm) + + def _ensure_ls_headroom_for_new(self, need: int, allowed: Set[str]) -> None: + """LS 로컬 한도(기본 100) − headroom. grace 밖 spill 부터 해제.""" + if need <= 0: + return + ls = self._get_ls_ws() + if ls is None: + return + try: + limit = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 100) or 0) + except Exception: + limit = 100 + if limit <= 0: + return + headroom = self._ws_grace_headroom() + soft_cap = max(1, limit - headroom) + with ls._sub_lock: + n_now = len(ls._subscribed) + len(getattr(ls, "_us_subscribed", set())) + free = soft_cap - n_now + if free >= need: + return + drop_n = need - max(0, free) + with ls._sub_lock: + candidates = [ + c for c in ls._subscribed + if "spill" in (ls._sub_owners.get(c) or set()) + and c not in allowed + ] + dropped = 0 + for code in candidates: + if dropped >= drop_n: + break + try: + ls.unsubscribe(code, owner="spill") + except Exception: + pass + dropped += 1 + def _ls_ram_universe_codes(self) -> Set[str]: """LS RAM 미러 대상: 후보∪보유∪영구∪grace. MINIMAL 여부와 무관.""" with self._lock: @@ -756,6 +881,20 @@ class WSManager: ls = self._get_ls_ws() if ls is None: return False + self._bind_ls_ws_hooks() + if spill: + with self._lock: + perm = set(self._permanent_codes) + kw_want: Set[str] = set() + if self._split_feed_active: + for s in self._owner_candidates.values(): + kw_want |= s + for s in self._owner_holdings.values(): + kw_want |= s + else: + kw_want = set(self._code_refs.keys()) + allowed = self._ls_ephemeral_allowed_codes(kw_want, perm) + self._ensure_ls_headroom_for_new(1, allowed) # spill 경로: DB recorder 붙이지 않음 — RAM만 (기존 LS 적재 설정 변경 없음) owner = "spill" if spill else "default" return bool(ls.subscribe(code, owner=owner)) @@ -876,7 +1015,9 @@ class WSManager: return None with self._lock: cur = self._ob_home.get(code) - if cur: + # 이미 prefer 벤더에 붙어 있으면 재전송 스킵. + # cur≠prefer(예: 키움 틱 선마킹)면 LIVE_OB 1차로 업그레이드 시도. + if cur and cur == prefer: if cur == "kis": ob = self.kis_ws_ob if ob is not None and ob is not self.ws_cache: @@ -889,7 +1030,9 @@ class WSManager: elif self._code_subscribed_on(cur, code): return cur - if self._vendor_object_present(prefer): + if self._vendor_object_present(prefer) or ( + prefer == "kis" and self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache + ): if self._try_subscribe_vendor_ob(prefer, code, spill=False): self._mark_ob_home(code, prefer, spilled=False) return prefer @@ -999,7 +1142,11 @@ class WSManager: return self._label_from_read(rec, kind="ob", code=code) def get_trigger_feed_trace(self, code: str) -> str: - """매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄.""" + """매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄. + + 조기탈락이 get_price/get_orderbook 을 안 타면 _last_*_read 가 비어 + 「틱실제=없음」이 과대계상됨 → 상세로그 ON 일 때 RAM 1회 프로브. + """ code = (code or "").strip() if not code: return "" @@ -1009,12 +1156,36 @@ class WSManager: live_ob_primary, live_tick_primary, trigger_feed_detail_log_enabled, + vendor_read_max_age_sec, ) if not trigger_feed_detail_log_enabled(): return "" with self._feed_read_lock: tick = dict(self._last_tick_read.get(code) or {}) ob = dict(self._last_ob_read.get(code) or {}) + need_tick = not (tick.get("label") or tick.get("vendor")) + need_ob = not (ob.get("label") or ob.get("vendor")) + if need_tick or need_ob: + # 0 → LIVE_FEED_FALLBACK 나이(기본 3초). None=나이무시는 프로브에 쓰지 않음. + try: + va = vendor_read_max_age_sec(0.0) + except Exception: + va = 3.0 + if va is None: + va = 3.0 + if need_tick: + try: + self.get_price(code) + except Exception: + pass + if need_ob: + try: + self.get_orderbook_snapshot(code, max_age_sec=float(va)) + except Exception: + pass + with self._feed_read_lock: + tick = dict(self._last_tick_read.get(code) or {}) + ob = dict(self._last_ob_read.get(code) or {}) return format_trigger_feed_trace( tick, ob, tick_primary=live_tick_primary(), @@ -1185,18 +1356,19 @@ class WSManager: if self._kiwoom_ws.subscribe(code): added_kw.append(code) added_set = set(added_kw or []) + ob_primary = self._normalize_feed_vendor( + get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom", + "kiwoom", + ) for code in to_kw: if code in added_set or self._code_subscribed_on("kiwoom", code): self._mark_tick_home(code, "kiwoom", spilled=False) - self._mark_ob_home(code, "kiwoom", spilled=False) + # 호가 home은 LIVE_OB 체인(kis→kiwoom→ls). 틱=키움이어도 호가1차를 키움 강제 금지. + self._subscribe_ob_prefer_or_spill(code, ob_primary) else: # 한도/실패 → 즉시 kis→ls spill (sleep 없음) self._subscribe_tick_prefer_or_spill(code, "kiwoom") - if get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True) or ( - (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() - == "kiwoom" - ): - self._subscribe_ob_prefer_or_spill(code, "kiwoom") + self._subscribe_ob_prefer_or_spill(code, ob_primary) with self._lock: owner_cands = { str(owner): set(codes) @@ -1289,9 +1461,7 @@ class WSManager: self._sync_tick_record_codes() - # 영구구독 코드를 LS WS에 sync (KIS/키움 슬롯 절약 — LS는 RAM 전용) - self._sync_permanent_to_ls(perm) - self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes()) + self._finalize_ls_subscriptions(kw_want, perm) def _ws_grace_sec(self) -> int: # 후보/보유 이탈 후에도 시세 피드 유지 (기본 180초, 3분) @@ -1525,6 +1695,10 @@ class WSManager: get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom", "kiwoom", ) + ob_primary = self._normalize_feed_vendor( + get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom", + "kiwoom", + ) # 기존: KIS+키움 동시 구독 유지(한도 내일 때). 실패분만 spill. kis_ok = False kw_ok = False @@ -1537,10 +1711,15 @@ class WSManager: (prefer == "kis" and kis_ok) or (prefer == "kiwoom" and kw_ok) ) else ("kiwoom" if kw_ok else "kis") self._mark_tick_home(code, home, spilled=False) - if kw_ok: - self._mark_ob_home(code, "kiwoom", spilled=False) else: self._subscribe_tick_prefer_or_spill(code, prefer) + # 호가: LIVE_OB 1차(kis=2키 OB) → kiwoom → ls. 틱=키움이어도 호가 home 키움 강제 금지. + if ( + get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) + or get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True) + or ob_primary in ("kis", "kiwoom", "ls") + ): + self._subscribe_ob_prefer_or_spill(code, ob_primary) # 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/KIS 경로 self._enqueue_gap_fill(code) self._sync_tick_record_codes() @@ -1616,7 +1795,10 @@ class WSManager: for code in added_hold + added_rest: self.subscribe(code, owner) self._sync_tick_record_codes() - self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes()) + with self._lock: + perm = set(self._permanent_codes) + kw_want = set(self._code_refs.keys()) | self._all_holdings() + self._finalize_ls_subscriptions(kw_want, perm) def get_recent_ticks(self, code: str, limit: int = 100) -> list: """RAM 링버퍼 최근 체결 틱 (B안 봉 내 돌파 등).""" diff --git a/kis_trader/strategies/base.py b/kis_trader/strategies/base.py index 3048fa3..7f6e080 100644 --- a/kis_trader/strategies/base.py +++ b/kis_trader/strategies/base.py @@ -48,6 +48,17 @@ class FeedPrefixLoggerAdapter(logging.LoggerAdapter): TRIGGER_FEED_DETAIL_LOG(기본 true) 이면 틱가·틱타임·호가·1/2/3차 체인 꼬리 추가. """ + def log(self, level, msg, *args, **kwargs): + if self.isEnabledFor(level): + if args: + try: + msg = str(msg) % args + args = () + except Exception: + pass + msg, kwargs = self.process(msg, kwargs) + self.logger.log(level, msg, *args, **kwargs) + def process(self, msg, kwargs): if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra: try: diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py index 8db877a..f0c360d 100644 --- a/kis_trader/web/live_config_schema.py +++ b/kis_trader/web/live_config_schema.py @@ -199,25 +199,24 @@ def build_live_config_groups() -> List[GroupDef]: "한투 60초 캐시는 안 씀. MINIMAL 이면 한투 RAM 은 보유만. " "저장 후 kis_trader_main 재시작" )), - _f("LIVE_OB_PROVIDER", "📊 실매매 호가(OB) 소스 (재시작 필요)", "select", default="kiwoom", + _f("LIVE_OB_PROVIDER", "📊 실매매 호가(OB) 소스 (재시작 필요)", "select", default="kis", choices=[ - {"value": "kiwoom", "label": "키움 (kiwoom)"}, {"value": "kis", "label": "한투 (kis)"}, + {"value": "kiwoom", "label": "키움 (kiwoom)"}, ], hint=( - "호가 1차. 키움=kiwoom / 한투=kis. LS는 여기 없음(3차 UH1). " + "호가 1차. 한투=kis(2키 OB 세션 41) / 키움=kiwoom. LS는 여기 없음(3차 UH1). " "폴백 나이 넘으면 그 벤더는 없는 것과 같고 2차→3차. " "만료로 None 만 만들어서 필터 ON 통과 구멍을 만들지 않음. " "kis 선택 시 아래 'KIS 실시간 호가 수집' ON + 2번째 앱키(KIS_APP_KEY_OB_REAL). " - "저장 후 kis_trader_main 재시작" + "기본 체인: kis→kiwoom→ls. 저장 후 kis_trader_main 재시작" )), _f("LIVE_FEED_FALLBACK_MAX_AGE_SEC", "⏱ 시세·호가 읽기 폴백 나이(초)", "float", default=3.0, hint=( - "비교 = 그 틱의 체결시각(키움 FID20 / 한투 chetime) vs 이 서버 지금. " - "1차·2차·3차 모두 같음. 보조가 늦으면 3차, 매도는 REST. " - "키움 시각에서 한투 시각을 빼서 늦다고 하지 않음. " - "넘기면 그 벤더 실패. 적재(공책)와 분리. 기본 3. " - "분봉 쓰레기 판정 나이도 이 값(봉 끝시각 대비)." + "틱: 체결시각(키움 FID20 / 한투·LS chetime) vs 서버 지금 → 초과면 RAM 미반영. " + "호가: snap_time(한투 BSOP_HOUR / LS hotime / 키움 FID20)도 동일 초로 " + "RAM 미반영·읽기 체인 실패(필터 max_age=0=마지막 RAM 은 예외). " + "1·2·3차 같음. 0이면 나이 컷 OFF. 기본 3. 적재(공책)와 분리." )), _f("TRIGGER_FEED_DETAIL_LOG", "📝 트리거 로그에 틱·호가·벤더 상세", "bool", default=True, hint=( @@ -263,12 +262,12 @@ def build_live_config_groups() -> List[GroupDef]: hint="ON = 키움 호가를 ws_orderbook 테이블에 저장 (기본값: ON)"), _f("WS_TICK_SAVE_KIS", "📈 KIS 체결 틱 수집", "bool", default=True, hint="ON = KIS 체결 틱을 ws_ticks 테이블에 저장 (기본값: ON)"), - _f("WS_ORDERBOOK_SAVE_KIS", "📋 KIS 실시간 호가 수집", "bool", default=False, + _f("WS_ORDERBOOK_SAVE_KIS", "📋 KIS 실시간 호가 수집", "bool", default=True, danger=True, hint=( "한투 호가는 2번째 앱키(KIS_APP_KEY_OB_REAL) 전용 세션만. " "메인 시세 41과 합산 금지. 2키 없으면 ON 해도 호가 구독·적재 안 함. " - "2키 세션 한도는 호가 전용 41." + "2키 세션 한도는 호가 전용 41. LIVE_OB_PROVIDER=kis 일 때 ON 권장." )), _f("WS_SUBSCRIBE_KIS_MINIMAL", "📉 KIS 최소 구독 모드", "bool", default=False, hint=( @@ -284,24 +283,36 @@ def build_live_config_groups() -> List[GroupDef]: )), _f("WS_TICK_SUBSCRIBE_CHAIN", "📡 틱 구독 spill 체인(csv)", "text", default="", hint="비우면 LIVE_TICK_PROVIDER→나머지→ls. 예: kiwoom,kis,ls"), - _f("WS_OB_SUBSCRIBE_CHAIN", "📡 호가 구독 spill 체인(csv)", "text", default="", - hint="비우면 LIVE_OB_PROVIDER→나머지→ls(3차 RAM). 예: kis,kiwoom,ls"), + _f("WS_OB_SUBSCRIBE_CHAIN", "📡 호가 구독 spill 체인(csv)", "text", default="kis,kiwoom,ls", + hint="기본 kis,kiwoom,ls. 비우면 LIVE_OB_PROVIDER→나머지→ls(3차 RAM)."), _f("LS_WS_ENABLED", "📡 LS WS 기동 (재시작 필요)", "bool", default=False, hint=( "ON = LS증권 실시간 WS 기동 — 구독 spill 3차·영구구독·읽기 폴백 RAM. " "OFF(기본)=VALIDATION/조건이력/ls_condition 전략이 있을 때만 기동. " "키: env_config LS_APP_KEY_REAL / LS_APP_SECRET_REAL 필요." )), - _f("LS_FEED_FALLBACK_SUBSCRIBE", "📡 LS 3차 폴백 구독 (메인 유니버스)", "bool", default=True, + _f("LS_FEED_FALLBACK_SUBSCRIBE", "📡 LS 유니버스 RAM 미러 (레거시)", "bool", default=False, hint=( - "ON(기본)=영구 외에 메인 틱/호가 구독 집합(후보∪보유∪grace)을 LS에 시세+UH1 붙임. " - "3차가 실제로 있게 함. 후보 갭 REST 와 별개" + "OFF(기본)=KIS·키움과 동일 spill 3차만. ON=후보∪보유∪grace 전체를 LS에 " + "벌크 미러(_feed_fallback) — 200+ REG·07:00 Bye 위험. 영구는 별도(_permanent)" )), + _f("LS_WS_HOLD_RAM_KEEP_HOLDINGS", "📡 LS hold RAM 보유 유지", "bool", default=True, + hint=( + "ON(기본)=장마감 hold 시 영구+보유 종목만 LS RAM 유지(07:00 replay 축소). " + "OFF=영구(_permanent)만 유지" + )), + _f("LS_WS_MAX_SUBSCRIPTIONS", "📡 LS 로컬 구독 한도", "int", default=100, + hint="spill 3차 LS 종목 상한. 0=무제한(비권장). 키움 100과 유사."), _f("LS_WS_TICK_SAVE", "📈 LS 체결 틱 DB 적재", "bool", default=False, hint=( - "OFF(기본)=후보 틱은 ls_ws_ticks 에 안 쌓음(옵투나 3차 틱 없음). RAM 은 구독 ON. " - "영구 ETF 틱은 persist 게이트(영구만) + 이 스위치. " - "OFF 여도 아래 LS 호가 DB 적재는 별개로 동작" + "ON=구독 중 종목(영구∪후보∪보유∪grace) US3 → ls_ws_ticks. " + "호가 DB 게이트와 동일. OFF=테이블 미적재(RAM·3차 폴백은 구독 ON이면 유지). " + "봉·VI 는 영구만. 옵투나 틱 3차는 BT_TICK_LS_THIRD_FALLBACK" + )), + _f("BT_TICK_LS_THIRD_FALLBACK", "📈 백테/옵투나 틱 3차 LS", "bool", default=True, + hint=( + "ON(기본)=ws_ticks 로드 후 같은 초에 1·2차 없으면 ls_ws_ticks 채움. " + "나이=LIVE_FEED_FALLBACK_MAX_AGE_SEC(기본3). history_source=ls 본체와 별개" )), _f("LS_WS_ALSO_HOGA", "📋 LS 호가(UH1) 구독 (재시작 필요)", "bool", default=True, hint=( diff --git a/kis_trader/ws/kis_ws.py b/kis_trader/ws/kis_ws.py index 4777799..1247937 100644 --- a/kis_trader/ws/kis_ws.py +++ b/kis_trader/ws/kis_ws.py @@ -18,7 +18,7 @@ KIS 공식 스펙: KIS 2026-02-24 경고 준수 (무한 재연결 차단 정책): - 재연결 횟수 제한: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 자동 대기 - 최대 총 재연결 횟수: MAX_RECONNECT_ATTEMPTS 회 초과 시 WebSocket 종료 → REST fallback - - 지수 백오프: 5초 → 10초 → 20초 ... 최대 300초 + - 지수 백오프 대신 단계 대기: env WS_RECONNECT_DELAY_SECS (기본 1,3,5,7,10초) - 정상 종료/세션 양보: 연결 종료 전 구독 해제(tr_type=2). 소켓만 닫지 않음. 모의투자(VTS): @@ -45,6 +45,11 @@ import requests logger = logging.getLogger("KISWebSocket") +try: + from .ws_reconnect_backoff import ws_reconnect_delay_for_attempt +except ImportError: + from kis_trader.ws.ws_reconnect_backoff import ws_reconnect_delay_for_attempt + # ------------------------------------------------------------------ # 모듈 수준에서 get_env 함수를 참조 (kis_long_ver1 공용 함수 재사용) @@ -111,8 +116,7 @@ class KISWebSocketPriceCache: # 재연결 정책 (KIS 2026-02-24 경고 준수) MAX_RECONNECT_ATTEMPTS = 10 # 총 재연결 최대 횟수 (STABLE_CONN_RESET_SEC 이상 안정 연결 후 끊기면 초기화) MAX_RECONNECTS_PER_HOUR = 6 # 1시간 내 재연결 허용 횟수 - RECONNECT_BASE_DELAY_SEC = 5.0 # 초기 재연결 대기(초) - RECONNECT_MAX_DELAY_SEC = 300.0 # 최대 재연결 대기(초) + # 재연결 sleep 간격 — ws_reconnect_backoff (기본 1,3,5,7,10초) # 이 시간(초) 이상 안정적으로 연결이 유지됐다가 끊기면 카운터를 초기화. # 예: 5분 이상 정상 운영 후 네트워크 일시 장애 → '버스트 차단'이 아닌 '정상 재연결'로 간주. STABLE_CONN_RESET_SEC = 300.0 # 5분 @@ -178,7 +182,6 @@ class KISWebSocketPriceCache: # ── 재연결 관리 ──────────────────────────────────────────── self._reconnect_count = 0 self._reconnect_times: list = [] # 최근 재연결 타임스탬프 목록 - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._last_connect_time: float = 0.0 # 마지막 연결 성공 시각 (안정 연결 판단용) self._last_subscribe_error: str = "" # H0STCNT0 JSON rt_cd≠0 (즉시 끊김 원인 추적) self._subscribe_resend_lock = threading.Lock() @@ -190,6 +193,18 @@ class KISWebSocketPriceCache: self._candle_agg: Optional["CandleAggregator"] = None # ── TickRecorder (C안: RAM 링버퍼 + ws_ticks 배치) ──────── self._tick_recorder: Optional[Any] = None + # ── 호가 RAM (H0STASP0 · 2키 OB 세션) + TriggerSnapshotRecorder ── + self._orderbook_cache: Optional[Any] = None + self._trigger_snapshot_recorder: Optional[Any] = None + # 메인 tick 세션이 호가 틱동기 시 참조할 2키 OB 세션 (kis_ws_ob) + self._orderbook_peer: Optional[Any] = None + try: + from kis_trader.ws.orderbook_cache import OrderbookCache as _ObCache + + self._orderbook_cache = _ObCache() + except Exception as _ob_ex: + logger.warning("KIS OrderbookCache 초기화 실패: %s", _ob_ex) + self._orderbook_cache = None # ── WS 연결 성공 시 갭보정 콜백 ─────────────────────────── # set_on_connected_callback(fn) 으로 등록. @@ -228,6 +243,16 @@ class KISWebSocketPriceCache: self._tick_recorder = recorder logger.info("✅ TickRecorder 연결 완료 (KIS H0STCNT0)") + def attach_trigger_snapshot_recorder(self, recorder: Any) -> None: + """TriggerSnapshotRecorder 연결 — H0STASP0 호가 → kis_ws_orderbook / filter_eval.""" + self._trigger_snapshot_recorder = recorder + logger.info("✅ TriggerSnapshotRecorder 연결 완료 (KIS H0STASP0)") + + def attach_orderbook_peer(self, peer: Any) -> None: + """메인 tick 세션 → 2키 OB 세션 연결. 체결 1건당 호가 RAM 틱동기 DB용.""" + self._orderbook_peer = peer + logger.info("✅ KIS orderbook peer 연결 (틱동기 → 2키 OB RAM)") + def set_on_connected_callback(self, fn) -> None: """ WS 연결 성공(_on_open) 시 호출할 콜백 등록. @@ -493,15 +518,22 @@ class KISWebSocketPriceCache: return data def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]: - """메모리에 저장된 최신 KIS 호가를 반환합니다.""" - with self._cache_lock: - entry = self._ob_cache.get(code) - if not entry: + """메모리에 저장된 최신 KIS 호가(dict). OrderbookSnapshot → bid dict.""" + if self._orderbook_cache is not None: + try: + return self._orderbook_cache.get_kis_bid_dict(code, max_age_sec=max_age_sec) + except Exception: + return None + return None + + def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0): + """H0STASP0 RAM 호가 스냅샷 (실매 필터·폴백 체인용).""" + if self._orderbook_cache is None: return None - age = time.time() - entry.get("ts", 0) - if age > max_age_sec: + try: + return self._orderbook_cache.get(code, max_age_sec=max_age_sec) + except Exception: return None - return entry.get("data") @property def is_active(self) -> bool: @@ -779,31 +811,38 @@ class KISWebSocketPriceCache: return # parts[0]=암호화구분, parts[1]=TR_ID, parts[2]=건수, parts[3]=데이터 if parts[1] == "H0STASP0": - # 실시간 호가 (H0STASP0) + # 실시간 호가 (H0STASP0) — RAM 스냅샷 + TriggerSnapshotRecorder try: raw_data = parts[3] - fields = raw_data.split('^') - if len(fields) >= 42: - code_val = fields[0] - snap = { - "BSOP_HOUR": fields[1], - "ASKP1": fields[3], "BIDP1": fields[13], - "TOTAL_ASKP_RSQN": fields[43], - "TOTAL_BIDP_RSQN": fields[44], - } - - levels = [] - for i in range(10): - ask = float(fields[3 + i] or 0) - bid = float(fields[13 + i] or 0) - ask_vol = int(fields[23 + i] or 0) - bid_vol = int(fields[33 + i] or 0) - if ask > 0 or bid > 0: - levels.append({"a": ask, "av": ask_vol, "b": bid, "bv": bid_vol}) - snap["levels"] = levels - - if hasattr(self.db, "insert_kis_ws_orderbook"): - self.db.insert_kis_ws_orderbook(code=code_val, snap=snap, market="KR") + fields = raw_data.split("^") + if len(fields) < 43: + return + code_val = str(fields[0] or "").strip() + if not code_val: + return + snap = None + if self._orderbook_cache is not None: + snap = self._orderbook_cache.update_from_kis_h0stasp0(code_val, fields) + if snap is not None and self._trigger_snapshot_recorder is not None: + try: + self._trigger_snapshot_recorder.on_orderbook( + snap, snap_time=getattr(snap, "snap_time", None) or None, + ) + except Exception as _rec_ex: + logger.debug("H0STASP0 recorder 실패: %s", _rec_ex) + # 레거시 DB 직접 INSERT (db 주입 시에만) + if hasattr(self, "db") and self.db is not None and hasattr(self.db, "insert_kis_ws_orderbook"): + try: + legacy = { + "BSOP_HOUR": fields[1] if len(fields) > 1 else "", + "ASKP1": fields[3] if len(fields) > 3 else "", + "BIDP1": fields[13] if len(fields) > 13 else "", + "TOTAL_ASKP_RSQN": fields[43] if len(fields) > 43 else "", + "TOTAL_BIDP_RSQN": fields[44] if len(fields) > 44 else "", + } + self.db.insert_kis_ws_orderbook(code=code_val, snap=legacy, market="KR") + except Exception: + pass except Exception as e: logger.debug("H0STASP0 호가 파싱 오류: %s", e) return @@ -964,6 +1003,29 @@ class KISWebSocketPriceCache: except Exception as ex: logger.debug("H0STCNT0→TickRecorder 실패 %s: %s", code, ex) + # ── 호가 틱동기: 체결 1건당 2키 OB RAM 스냅 1장 (키움 0B↔0D 와 동일) + # skip_ram 이면 밀린 체결시각에 지금 호가를 붙이지 않음(미래 호가 오염 방지). + if self._trigger_snapshot_recorder is not None and not skip_ram: + try: + max_age = float( + getattr( + self._trigger_snapshot_recorder, + "orderbook_tick_max_age_sec", + 3.0, + ) + or 3.0 + ) + peer = self._orderbook_peer if self._orderbook_peer is not None else self + snap = None + if peer is not None and hasattr(peer, "get_orderbook_snapshot"): + snap = peer.get_orderbook_snapshot(code, max_age_sec=max_age) + if snap is not None: + self._trigger_snapshot_recorder.on_orderbook_tick_sync( + code, snap, snap_time=tick_time, + ) + except Exception as ex: + logger.debug("H0STCNT0→호가틱동기 실패 %s: %s", code, ex) + logger.debug("H0STCNT0 수신: %s → %s원", code, int(price)) except (ValueError, IndexError) as e: @@ -1057,7 +1119,6 @@ class KISWebSocketPriceCache: # 재연결 카운터·백오프 초기화 (장 시작 시 깨끗하게 재접속) self._reconnect_count = 0 self._reconnect_times = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC _instant_drop_streak = 0 # 60초 단위로 쪼개서 슬립 (stop() 신호 빠르게 감지) for _ in range(int(wait_sec // 60)): @@ -1095,7 +1156,6 @@ class KISWebSocketPriceCache: ) self._reconnect_count = 0 self._reconnect_times = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC _instant_drop_streak = 0 self._last_subscribe_error = "" for _ in range(int(wait_sec // 60)): @@ -1121,7 +1181,6 @@ class KISWebSocketPriceCache: ) self._reconnect_count = 0 self._reconnect_times = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC # ── 1시간 내 재연결 횟수 점검 ──────────────────────────── self._reconnect_times = [t for t in self._reconnect_times if now - t < 3600] @@ -1158,30 +1217,28 @@ class KISWebSocketPriceCache: force_refresh=get_env_bool("KIS_WS_RECONNECT_REFRESH_KEY", False), ) if not approval_key: + _attempt = max(1, self._reconnect_count) + _delay = ws_reconnect_delay_for_attempt(_attempt) logger.warning( "WebSocket approval_key 발급 불가 → %ds 대기 후 재시도", - int(self._reconnect_delay), - ) - time.sleep(self._reconnect_delay) - self._reconnect_delay = min( - self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC + int(_delay), ) + time.sleep(_delay) continue # ── 재연결 카운트 및 대기 ───────────────────────────────── self._reconnect_count += 1 if self._reconnect_count > 1: self._reconnect_times.append(time.time()) + _attempt = self._reconnect_count - 1 + _delay = ws_reconnect_delay_for_attempt(_attempt) logger.info( "🔄 WebSocket 재연결 시도 %d/%d (대기 %ds 완료)", self._reconnect_count, self.MAX_RECONNECT_ATTEMPTS, - int(self._reconnect_delay), - ) - time.sleep(self._reconnect_delay) - self._reconnect_delay = min( - self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC + int(_delay), ) + time.sleep(_delay) # ── WebSocketApp 생성 및 실행 ───────────────────────────── _conn_start = time.time() # 연결 시작 시각 (즉시 종료 감지용) @@ -1225,7 +1282,6 @@ class KISWebSocketPriceCache: def _on_open(self, ws) -> None: """연결 성공: 등록된 모든 종목 구독 (간격 두고 1회만 — 이중 전송 금지).""" self._connected = True - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._last_connect_time = time.time() self._last_subscribe_error = "" # 연결당 invalid approval 1회 처리 플래그 리셋 diff --git a/kis_trader/ws/kis_ws_overseas.py b/kis_trader/ws/kis_ws_overseas.py index 62285d4..d550b5b 100644 --- a/kis_trader/ws/kis_ws_overseas.py +++ b/kis_trader/ws/kis_ws_overseas.py @@ -22,7 +22,7 @@ KIS 공식 스펙: '해외주식 실시간시세' 신청이 안 돼 있으면 HDFSCNT0 는 **15분 지연**으로 옵니다. 15분봉 전략에선 신청 필수. 미신청 시에도 동작은 하나 데이터가 지연됩니다. -KIS 안티-어뷰즈 정책 준수: 재연결 횟수/시간 제한 + 지수 백오프 (국내 수신기와 동일 사상). +KIS 안티-어뷰즈 정책 준수: 재연결 횟수/시간 제한 + 단계 대기 (WS_RECONNECT_DELAY_SECS, 기본 1,3,5,7,10초). """ from __future__ import annotations @@ -37,6 +37,8 @@ import requests logger = logging.getLogger("KISWebSocketOverseas") +from .ws_reconnect_backoff import ws_reconnect_delay_for_attempt + try: from kis_trader.utils.env import get_env_from_db, get_env_bool, get_env_int, get_env_float except ImportError: # 단독 실행 fallback @@ -83,8 +85,7 @@ class KISOverseasWebSocket: MAX_SUBSCRIPTIONS = 41 MAX_RECONNECT_ATTEMPTS = 10 MAX_RECONNECTS_PER_HOUR = 6 - RECONNECT_BASE_DELAY_SEC = 5.0 - RECONNECT_MAX_DELAY_SEC = 300.0 + # 재연결 sleep — ws_reconnect_backoff (기본 1,3,5,7,10초) STABLE_CONN_RESET_SEC = 300.0 APPROVAL_KEY_CACHE_SEC = 82800 @@ -124,7 +125,6 @@ class KISOverseasWebSocket: self._reconnect_count = 0 self._reconnect_times: list = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._last_connect_time = 0.0 self._candle_agg: Optional[Any] = None @@ -511,7 +511,6 @@ class KISOverseasWebSocket: ) self._reconnect_count = 0 self._reconnect_times = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC _instant_streak = 0 for _ in range(int(wait_sec // 60)): if not self._running: @@ -542,7 +541,6 @@ class KISOverseasWebSocket: and self._reconnect_count > 0): self._reconnect_count = 0 self._reconnect_times = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._reconnect_times = [t for t in self._reconnect_times if now - t < 3600] if len(self._reconnect_times) >= self.MAX_RECONNECTS_PER_HOUR: @@ -557,8 +555,8 @@ class KISOverseasWebSocket: # 캐시 approval 재사용만 (force_refresh 금지 — 국내 세션 무효화 방지) if not self._get_approval_key(force_refresh=False): - time.sleep(self._reconnect_delay) - self._reconnect_delay = min(self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC) + _attempt = max(1, self._reconnect_count) + time.sleep(ws_reconnect_delay_for_attempt(_attempt)) continue # 국내가 아직 붙잡고 있으면 짧게 양보 대기 (재발급 없음) @@ -579,8 +577,9 @@ class KISOverseasWebSocket: 0.0, float(get_env_float("KIS_WS_RECONNECT_SESSION_WAIT_SEC", 2.0) or 2.0), ) - time.sleep(max(self._reconnect_delay, sess_wait)) - self._reconnect_delay = min(self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC) + _attempt = self._reconnect_count - 1 + _delay = ws_reconnect_delay_for_attempt(_attempt) + time.sleep(max(_delay, sess_wait)) _conn_start = time.time() try: @@ -607,7 +606,6 @@ class KISOverseasWebSocket: def _on_open(self, ws) -> None: self._connected = True - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._last_connect_time = time.time() logger.info("✅ KIS 해외 WebSocket 연결 성공 (HDFSCNT0)") with self._sym_lock: diff --git a/kis_trader/ws/kiwoom_ws.py b/kis_trader/ws/kiwoom_ws.py index 3c7889d..d84d4a9 100644 --- a/kis_trader/ws/kiwoom_ws.py +++ b/kis_trader/ws/kiwoom_ws.py @@ -58,6 +58,8 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set logger = logging.getLogger("KiwoomWebSocket") +from .ws_reconnect_backoff import ws_reconnect_delay_for_attempt + # ── 환경변수 헬퍼 (KIS WS 와 동일 패턴, fallback 포함) ──────────────────── try: @@ -130,9 +132,7 @@ class KiwoomWebSocketPriceCache: SUB_TYPE_ORDERBOOK = "0D" # 주식호가잔량 SUB_TYPE_PROGRAM = "0w" # 종목프로그램매매 - # 재연결 정책 - RECONNECT_BASE_DELAY_SEC = 5.0 - RECONNECT_MAX_DELAY_SEC = 300.0 + # 재연결 sleep — ws_reconnect_backoff (기본 1,3,5,7,10초) MAX_RECONNECTS_PER_HOUR = 6 MAX_RECONNECT_ATTEMPTS = 10 STABLE_CONN_RESET_SEC = 300.0 # 5분 안정 연결 후 끊기면 카운터 초기화 @@ -194,7 +194,6 @@ class KiwoomWebSocketPriceCache: # 재연결 추적 self._reconnect_count = 0 self._reconnect_times: list = [] - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._last_connect_time: float = 0.0 # REG 레이트리밋 회피: 단건 subscribe 는 디바운스 후 묶어 전송 @@ -683,7 +682,6 @@ class KiwoomWebSocketPriceCache: self._authenticated = True # 안정 LOGIN 성공 → 재연결 카운터 리셋 (주말 Bye 후 소모분 복구) self._reconnect_count = 0 - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC logger.info("✅ 키움 WS LOGIN OK") # 디바운스 타이머 취소 — LOGIN 직후 일괄 REG 와 이중 전송 방지 with self._reg_timer_lock: @@ -756,7 +754,7 @@ class KiwoomWebSocketPriceCache: elif sub_type == self.SUB_TYPE_ORDERBOOK and self._orderbook_cache: try: snap = self._orderbook_cache.update_from_kiwoom_0d(code, values) - if self._trigger_snapshot_recorder is not None: + if snap is not None and self._trigger_snapshot_recorder is not None: tt = str(values.get("20") or values.get(self.FID_TICK_TIME) or "").strip() self._trigger_snapshot_recorder.on_orderbook(snap, snap_time=tt or None) except Exception as ex: @@ -1075,7 +1073,6 @@ class KiwoomWebSocketPriceCache: if (self._last_connect_time > 0 and now - self._last_connect_time >= self.STABLE_CONN_RESET_SEC): self._reconnect_count = 0 - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC # 시간당 한도 초과 → 1시간 대기 cutoff = now - 3600 @@ -1105,14 +1102,13 @@ class KiwoomWebSocketPriceCache: self._invalidate_token_cache("max reconnect cooldown") time.sleep(cool) self._reconnect_count = 0 - self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC self._reconnect_times.clear() return - delay = min(self._reconnect_delay, self.RECONNECT_MAX_DELAY_SEC) + attempt = self._reconnect_count + 1 + delay = ws_reconnect_delay_for_attempt(attempt) logger.info("⏳ 키움 WS %ds 후 재연결 시도 (#%d)", - int(delay), self._reconnect_count + 1) + int(delay), attempt) time.sleep(delay) self._reconnect_times.append(time.time()) self._reconnect_count += 1 - self._reconnect_delay = min(self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC) diff --git a/kis_trader/ws/ls_ws.py b/kis_trader/ws/ls_ws.py index 71e4921..8ba40fd 100644 --- a/kis_trader/ws/ls_ws.py +++ b/kis_trader/ws/ls_ws.py @@ -39,6 +39,8 @@ import requests logger = logging.getLogger("LSWebSocket") +from .ws_reconnect_backoff import ws_reconnect_delay_for_attempt + try: from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int except ImportError: @@ -153,6 +155,8 @@ class LSWebSocketPriceCache: self._sub_lock = threading.Lock() self._send_lock = threading.Lock() # SSL write 직렬화 (BAD_LENGTH 레이스 방지) self._force_closing = False # UNREG→close 재진입 방지 + self._hold_ram_extra_codes_fn: Optional[Any] = None # WSManager → 보유 pin + self._ws_manager_bound: bool = False self._subscribed: Set[str] = set() # 6자리 KR 코드 (소켓 실구독) # code → {"permanent","condition","default",...} — 해제는 owner 전부일 때만 self._sub_owners: Dict[str, Set[str]] = {} @@ -168,6 +172,13 @@ class LSWebSocketPriceCache: self._last_tick_mono: float = 0.0 self._opened_mono: float = 0.0 self._watchdog_empty_streak: int = 0 + self._ws_reconnect_step: int = 0 + # Bye/조기 CLOSE 서킷 — OPEN 직후 끊김 시 백오프 리셋 금지·토큰 1회 갱신 + self._early_close_streak: int = 0 + self._last_close_lived_sec: float = 0.0 + self._last_close_early: bool = False + self._pending_bye: bool = False + self._stable_open: bool = False # JIF 장운영정보 (앱 하트비트 아님 — 세션 판정용) self._jif_lock = threading.Lock() @@ -319,12 +330,18 @@ class LSWebSocketPriceCache: ) return True - def _graceful_unreg_all(self, *, clear_ram: bool = True) -> int: + def _graceful_unreg_all( + self, + *, + clear_ram: bool = True, + ram_keep_codes: Optional[Set[str]] = None, + ) -> int: """종료 직전 서버 구독 UNREG — 재시작 시 세션 꼬임/한도 거부 완화. REG 워커 큐가 아니라 동기 전송(갭 준수). 타임아웃 초과 시 남은 종목은 TCP close 에 맡긴다 (systemd stop 지연·폭주 방지). - ``clear_ram=False``: 장 양보 시 서버만 해지, RAM 은 재접속 REG 용으로 유지. + ``clear_ram=True`` + ``ram_keep_codes``: 서버 UNREG 후 RAM 은 keep 만 유지 + (hold reopen 시 07:00 mass REG 방지 — permanent·보유 pin). """ if self._ws is None or not self._opened.is_set(): return 0 @@ -389,11 +406,26 @@ class LSWebSocketPriceCache: break if clear_ram: + keep = { + str(c).strip() + for c in (ram_keep_codes or set()) + if str(c).strip() + } with self._sub_lock: - self._subscribed.clear() - self._sub_owners.clear() - self._us_subscribed.clear() - self._us_sub_owners.clear() + if keep: + for c in list(self._subscribed): + if c not in keep: + self._subscribed.discard(c) + self._sub_owners.pop(c, None) + for c in list(self._us_subscribed): + if c not in keep: + self._us_subscribed.discard(c) + self._us_sub_owners.pop(c, None) + else: + self._subscribed.clear() + self._sub_owners.clear() + self._us_subscribed.clear() + self._us_sub_owners.clear() if timed_out: logger.warning( @@ -460,7 +492,7 @@ class LSWebSocketPriceCache: # spill 즉시성: 미연결이면 대기 없이 False (일반 owner 는 기존처럼 집합 적재 후 REG 큐) if own == "spill" and not self.is_connected(): return False - max_n = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 0) or 0) + max_n = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 100) or 0) if code.isdigit() and len(code) == 6: need_reg = False with self._sub_lock: @@ -873,6 +905,58 @@ class LSWebSocketPriceCache: self._token_at = time.time() return self._token + def _stable_open_sec(self) -> float: + return max(5.0, float(get_env_float("LS_WS_STABLE_OPEN_SEC", 30.0) or 30.0)) + + def _mark_stable_open(self, why: str) -> None: + """REG 완료·장시간 OPEN 후에만 재연결 step/Bye streak 리셋.""" + if self._stable_open and self._ws_reconnect_step == 0 and self._early_close_streak == 0: + return + self._stable_open = True + self._ws_reconnect_step = 0 + self._early_close_streak = 0 + self._pending_bye = False + logger.info( + "LS WS 안정 OPEN 확정 — reconnect/Bye streak 리셋 (%s)", + why, + ) + + def _refresh_token_after_bye(self) -> None: + """조기 Bye 서킷: 로컬 캐시 무효화 후 1회 재발급 (min_gap 존중).""" + from kis_trader.network.ls_token import invalidate_ls_access_token + + invalidate_ls_access_token( + self.app_key, self.app_secret, reason="ls_ws_early_bye", + ) + self._token = "" + self._token = fetch_ls_access_token( + self.app_key, + self.app_secret, + force=True, + reason="ls_ws_early_bye", + ) + self._token_at = time.time() + + def _hold_ram_keep_codes(self) -> Set[str]: + """hold 양보 후 RAM 유지 집합 — _permanent owner + (옵션) 보유.""" + keep: Set[str] = set() + with self._sub_lock: + for code, ow in self._sub_owners.items(): + if "_permanent" in ow: + keep.add(code) + for code, ow in self._us_sub_owners.items(): + if "_permanent" in ow: + keep.add(code) + if get_env_bool("LS_WS_HOLD_RAM_KEEP_HOLDINGS", True): + fn = getattr(self, "_hold_ram_extra_codes_fn", None) + if callable(fn): + try: + extra = fn() + keep |= {str(c).strip() for c in (extra or set()) if str(c).strip()} + except Exception: + pass + return keep + def _force_close_socket(self, reason: str) -> None: """hold 이탈·갭 — 구독 UNREG 후 소켓 양보. 토큰 revoke/재발급 없음.""" ws = self._ws @@ -883,7 +967,8 @@ class LSWebSocketPriceCache: self._force_closing = True try: try: - self._graceful_unreg_all(clear_ram=False) + keep = self._hold_ram_keep_codes() + self._graceful_unreg_all(clear_ram=True, ram_keep_codes=keep) except Exception as e: logger.warning("LS WS 세션 양보 전 UNREG 실패: %s", e) try: @@ -1150,6 +1235,8 @@ class LSWebSocketPriceCache: except Exception as e: logger.debug("LS 호가 파싱 실패 %s: %s", code, e) return + if snap is None: + return if not self._orderbook_recorder: return if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True): @@ -1299,44 +1386,72 @@ class LSWebSocketPriceCache: kr = list(self._subscribed) us = list(self._us_subscribed) n_ok = 0 + # 관측 전용(동작 동일): 루프 break 원인·기대 send 수. 복구/재REG 로직 변경 없음. + abort_why = "" + also_hoga = bool(self.also_hoga) + uvi_on = get_env_bool("LS_WS_UVI_ENABLED", True) + jif_on = get_env_bool("LS_WS_JIF_ENABLED", True) + per_kr = 1 + (1 if also_hoga else 0) + (1 if uvi_on else 0) + expected = (1 if jif_on else 0) + len(kr) * per_kr + len(us) # JIF 장운영 — 스펙 예: tr_key=0 (앱 하트비트 아님) - if get_env_bool("LS_WS_JIF_ENABLED", True): + if jif_on: jif_key = (get_env_from_db("LS_WS_JIF_TR_KEY", "0") or "0").strip() or "0" self._send_typed("3", "JIF", jif_key) n_ok += 1 time.sleep(gap_ms / 1000.0) # replay 는 직접 전송 (큐 재투입 폭주 방지) for code in kr: - if not self._running or not self._opened.is_set(): + if not self._running: + abort_why = "not_running" + break + if not self._opened.is_set(): + abort_why = "opened_cleared" break tr_cd, tr_key, hoga_cd, hoga_key = self._kr_tr_pair(code) self._send_typed("3", tr_cd, tr_key) n_ok += 1 - if self.also_hoga: + if also_hoga: self._send_typed("3", hoga_cd, hoga_key) n_ok += 1 - if get_env_bool("LS_WS_UVI_ENABLED", True): + if uvi_on: vi_cd, vi_key = self._vi_tr_pair(code) self._send_typed("3", vi_cd, vi_key) n_ok += 1 time.sleep(gap_ms / 1000.0) for sym in us: - if not self._running or not self._opened.is_set(): + if not self._running: + abort_why = abort_why or "not_running" + break + if not self._opened.is_set(): + abort_why = abort_why or "opened_cleared" break self._send_typed("3", "GSC", overseas_tr_key("82", sym)) n_ok += 1 time.sleep(gap_ms / 1000.0) ping_iv = int(get_env_int("LS_WS_PING_INTERVAL_SEC", 20) or 0) + reg_ok = (not abort_why) and self._opened.is_set() and n_ok >= expected logger.info( - "LS WS OPEN — 구독 복구 KR=%d US=%d sends≈%d (delay=%dms gap=%dms ping=%s)", + "LS WS OPEN — 구독 복구 KR=%d US=%d sends≈%d expected≈%d " + "abort_why=%s opened_now=%s also_hoga=%s reg_ok=%s " + "(delay=%dms gap=%dms ping=%s)", len(kr), len(us), n_ok, + expected, + abort_why or "-", + self._opened.is_set(), + also_hoga, + reg_ok, delay_ms, gap_ms, "off" if ping_iv <= 0 else f"{ping_iv}s", ) - self._set_recovering(False) + # 미완료 REG 인데 recovering=False → 워치독/게이트가 정상으로 착각 + if reg_ok: + self._mark_stable_open("REG replay complete") + self._set_recovering(False) + else: + self._set_recovering(True) continue # send _, tr_type, tr_cd, tr_key = item @@ -1404,7 +1519,8 @@ class LSWebSocketPriceCache: pass self._set_recovering(True) try: - self._graceful_unreg_all(clear_ram=False) + keep = self._hold_ram_keep_codes() + self._graceful_unreg_all(clear_ram=True, ram_keep_codes=keep) except Exception as e: logger.debug("LS watchdog UNREG: %s", e) try: @@ -1414,7 +1530,6 @@ class LSWebSocketPriceCache: logger.debug("LS watchdog close: %s", e) def _run_forever(self) -> None: - backoff = 1.0 while self._running: # ── hold 외 → 소켓 양보 후 다음 국내/해외 창까지 대기 (재발급 없음) ── if not self._should_hold_socket(): @@ -1436,7 +1551,7 @@ class LSWebSocketPriceCache: ) self._opened.clear() self._set_recovering(True) - backoff = 1.0 + self._ws_reconnect_step = 0 self._watchdog_empty_streak = 0 for _ in range(int(wait_sec // 60)): if not self._running: @@ -1453,8 +1568,8 @@ class LSWebSocketPriceCache: self._ensure_token() except Exception as e: logger.warning("LS 토큰 ensure 실패(캐시/한도): %s", e) - time.sleep(min(backoff, 60.0)) - backoff = min(backoff * 2, 60.0) + self._ws_reconnect_step += 1 + time.sleep(ws_reconnect_delay_for_attempt(self._ws_reconnect_step)) continue self._ws = self._websocket.WebSocketApp( self.ws_url, @@ -1474,6 +1589,11 @@ class LSWebSocketPriceCache: self._ws.run_forever(**run_kw) except Exception as e: logger.warning("LS WS run 예외: %s", e) + # 관측: run_forever 종료 후 clear (동작 동일) + logger.warning( + "LS WS run_forever 종료 → opened clear (ws_id=%s)", + id(self._ws) if self._ws is not None else None, + ) self._opened.clear() self._set_recovering(True) if not self._running: @@ -1481,35 +1601,188 @@ class LSWebSocketPriceCache: # hold 이탈로 끊긴 경우 즉시 대기 분기로 (불필요 재발급·재연결 폭주 방지) if not self._should_hold_socket(): continue - time.sleep(min(backoff, 60.0)) - backoff = min(backoff * 2, 60.0) + + # 조기 Bye/CLOSE 서킷 — OPEN마다 step=0 리셋하던 버그 보정 + 토큰 1회 갱신 + early = bool(self._last_close_early or self._pending_bye) + if early: + self._early_close_streak += 1 + elif self._last_close_lived_sec >= self._stable_open_sec(): + self._early_close_streak = 0 + + streak_need = max(2, int(get_env_int("LS_WS_EARLY_BYE_STREAK", 5) or 5)) + if early and self._early_close_streak >= streak_need: + circuit_sleep = max( + 15.0, + float(get_env_float("LS_WS_BYE_CIRCUIT_SLEEP_SEC", 60.0) or 60.0), + ) + logger.warning( + "LS WS Bye 서킷 — early_streak=%d lived=%.1fs bye=%s " + "→ 토큰 무효화·재발급 후 %.0fs 대기", + self._early_close_streak, + self._last_close_lived_sec, + self._pending_bye, + circuit_sleep, + ) + try: + self._refresh_token_after_bye() + except Exception as e: + logger.warning("LS Bye 서킷 토큰 갱신 실패: %s", e) + self._early_close_streak = 0 + self._pending_bye = False + self._ws_reconnect_step = max(self._ws_reconnect_step, 1) + time.sleep(circuit_sleep) + continue + + self._ws_reconnect_step += 1 + time.sleep(ws_reconnect_delay_for_attempt(self._ws_reconnect_step)) def _on_open(self, _ws: Any) -> None: # 콜백에서 sleep/REG 연타 금지 → 워커에 replay 위임 + # ⚠️ flapping OPEN 에서 reconnect_step=0 리셋 금지 (Bye 루프 1초 연타 원인) self._opened_mono = time.monotonic() self._last_tick_mono = time.monotonic() + self._stable_open = False + self._last_close_early = False self._opened.set() self._set_recovering(True) + # 관측 전용: 소켓 신분(레이스 가설). clear/REG 동작은 그대로. + logger.info( + "LS WS OPEN cb ws_id=%s active_id=%s same=%s reconnect_step=%d early_streak=%d", + id(_ws) if _ws is not None else None, + id(self._ws) if self._ws is not None else None, + _ws is self._ws, + self._ws_reconnect_step, + self._early_close_streak, + ) self._enqueue_replay() def _on_close(self, _ws: Any, status: Any, msg: Any) -> None: + # 관측 전용: 옛 소켓 close 여부. 동작은 기존과 동일하게 무조건 clear. + same = _ws is self._ws + msg_s = msg + try: + if isinstance(msg, (bytes, bytearray)): + msg_s = bytes(msg).decode("utf-8", errors="replace") + except Exception: + msg_s = repr(msg) + lived = 0.0 + if self._opened_mono > 0: + lived = max(0.0, time.monotonic() - self._opened_mono) + stable = self._stable_open_sec() + msg_blob = str(msg_s or "") + is_bye = ("Bye" in msg_blob) or self._pending_bye + # 강제 세션 양보(hold 외)는 early Bye 서킷에 넣지 않음 + early = (lived < stable) and (not self._force_closing) + self._last_close_lived_sec = lived + self._last_close_early = bool(early) + if is_bye: + self._pending_bye = True + logger.warning( + "LS WS CLOSE status=%s msg=%s ws_same=%s " + "close_id=%s active_id=%s opened_before=%s " + "lived=%.1fs early=%s bye=%s stable_was=%s", + status, + msg_s, + same, + id(_ws) if _ws is not None else None, + id(self._ws) if self._ws is not None else None, + self._opened.is_set(), + lived, + early, + is_bye, + self._stable_open, + ) + self._stable_open = False self._opened.clear() self._set_recovering(True) - logger.warning("LS WS CLOSE status=%s msg=%s", status, msg) def _on_error(self, _ws: Any, err: Any) -> None: - logger.warning("LS WS ERROR %s", err) + err_s = "" + try: + if isinstance(err, (bytes, bytearray)): + err_s = bytes(err).decode("utf-8", errors="replace") + else: + err_s = str(err) + except Exception: + err_s = repr(err) + if "Bye" in err_s or "opcode=8" in err_s or "\\x03\\xe8" in err_s: + self._pending_bye = True + logger.warning( + "LS WS ERROR %s ws_same=%s err_id=%s active_id=%s bye=%s", + err, + _ws is self._ws, + id(_ws) if _ws is not None else None, + id(self._ws) if self._ws is not None else None, + self._pending_bye, + ) def _on_message(self, _ws: Any, message: Any) -> None: try: data = json.loads(message) if isinstance(message, str) else message except Exception: + logger.warning( + "LS WS MSG non-json type=%s head=%r", + type(message).__name__, + (str(message)[:200] if message is not None else ""), + ) + return + if not isinstance(data, dict): + logger.warning("LS WS MSG not-dict type=%s", type(data).__name__) return header = data.get("header") or {} - body = data.get("body") or {} - if not isinstance(body, dict) or not body: - return + if not isinstance(header, dict): + header = {} + body_raw = data.get("body") + body: Dict[str, Any] = body_raw if isinstance(body_raw, dict) else {} tr_cd = str(header.get("tr_cd") or "") + tr_key = str(header.get("tr_key") or "") + tr_type = str(header.get("tr_type") or "") + rsp_cd = str( + header.get("rsp_cd") + or header.get("rspCd") + or body.get("rsp_cd") + or "" + ).strip() + rsp_msg = str( + header.get("rsp_msg") + or header.get("rspMsg") + or body.get("rsp_msg") + or "" + ).strip() + # REG/UNREG 응답은 종종 body=null + header.rsp_cd/rsp_msg. + # 예전: body 없으면 즉시 return → 서버 거절 코드를 삼킴 (관측 공백). + if rsp_cd or rsp_msg or ( + tr_type in ("1", "2", "3", "4") and not body + ): + line = ( + "LS WS RSP tr_type=%s tr_cd=%s tr_key=%s rsp_cd=%s rsp_msg=%s" + % ( + tr_type or "-", + tr_cd or "-", + tr_key or "-", + rsp_cd or "-", + rsp_msg or "-", + ) + ) + if rsp_cd and rsp_cd not in ("00000", "0"): + logger.warning(line) + try: + from kis_trader.network.ls_token import is_ls_auth_error + + if is_ls_auth_error(rsp_cd=rsp_cd, rsp_msg=rsp_msg): + self._pending_bye = True + logger.warning( + "LS WS auth RSP → Bye 서킷 후보 (rsp_cd=%s)", + rsp_cd, + ) + except Exception: + pass + else: + logger.info(line) + if not body: + return + if not body: + return if tr_cd == "JIF": self._on_jif(body) return diff --git a/kis_trader/ws/orderbook_cache.py b/kis_trader/ws/orderbook_cache.py index 15bd92c..80b3574 100644 --- a/kis_trader/ws/orderbook_cache.py +++ b/kis_trader/ws/orderbook_cache.py @@ -5,15 +5,21 @@ kis_trader/ws/orderbook_cache.py — 키움 0D 호가잔량 RAM 캐시 매도호가 1~10: 41~50, 매수호가 1~10: 51~60 매도수량 1~10: 61~70, 매수수량 1~10: 71~80 매도총잔량: 121, 매수총잔량: 125 + +RAM 적재: 틱과 동일 — 거래소 시각(snap_time)이 LIVE_FEED_FALLBACK(기본 3초)보다 +오래면 넣지 않음(ts만 새로 찍어 낡은 호가가 3초 체인을 통과하지 못하게). """ from __future__ import annotations import json +import logging import threading import time from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple +logger = logging.getLogger("kis_trader.orderbook_cache") + def _abs_int(v: Any) -> int: try: @@ -22,6 +28,30 @@ def _abs_int(v: Any) -> int: return 0 +def _normalize_ob_snap_time(raw: Any) -> str: + """FID20 / hotime / BSOP_HOUR → YYYYMMDDHHMMSS (가능하면).""" + tt = str(raw or "").strip().replace(":", "").replace("-", "").replace(" ", "") + if not tt: + return "" + if len(tt) >= 14 and tt[:14].isdigit(): + return tt[:14] + if len(tt) >= 6 and tt[-6:].isdigit(): + from datetime import datetime as _dt + + return _dt.now().strftime("%Y%m%d") + tt[-6:] + return "" + + +def _is_snap_time_stale_for_ram(snap: "OrderbookSnapshot") -> bool: + """틱 skip_ram 과 동일 — LIVE_FEED_FALLBACK 기준 snap_time lag.""" + try: + from kis_trader.engine.feed_fallback import is_orderbook_snap_time_stale + + return bool(is_orderbook_snap_time_stale(getattr(snap, "snap_time", "") or "")) + except Exception: + return False + + @dataclass class OrderbookLevel: price: int = 0 @@ -152,6 +182,8 @@ def parse_kiwoom_0d_values(code: str, values: Dict[str, Any]) -> OrderbookSnapsh total_bid = sum(b.qty for b in bids) if total_ask <= 0 and asks: total_ask = sum(a.qty for a in asks) + # FID20 체결시각(HHMMSS) — 0D에도 실리면 틱과 같은 snap 축. 없으면 "" → lag 컷 스킵. + snap_time = _normalize_ob_snap_time(values.get("20") or values.get(20)) return OrderbookSnapshot( code=str(code).strip(), asks=asks, @@ -160,6 +192,7 @@ def parse_kiwoom_0d_values(code: str, values: Dict[str, Any]) -> OrderbookSnapsh total_ask_qty=total_ask, ts=time.time(), source="kiwoom_0d", + snap_time=snap_time, ) @@ -189,6 +222,52 @@ def _ls_total_qty(body: Dict[str, Any], side: str) -> int: ) +def parse_kis_h0stasp0_fields(code: str, fields: List[Any]) -> OrderbookSnapshot: + """한투 실시간 호가 H0STASP0 ``^`` 필드 → ``OrderbookSnapshot``. + + 공식 columns (asking_price_krx / MCP): + 0 MKSC_SHRN_ISCD, 1 BSOP_HOUR, 2 HOUR_CLS_CODE, + 3~12 ASKP1~10, 13~22 BIDP1~10, + 23~32 ASKP_RSQN1~10, 33~42 BIDP_RSQN1~10, + 43 TOTAL_ASKP_RSQN, 44 TOTAL_BIDP_RSQN + """ + asks: List[OrderbookLevel] = [] + bids: List[OrderbookLevel] = [] + n = len(fields or []) + for i in range(10): + ask_px = _abs_int(fields[3 + i]) if n > 3 + i else 0 + bid_px = _abs_int(fields[13 + i]) if n > 13 + i else 0 + ask_qty = _abs_int(fields[23 + i]) if n > 23 + i else 0 + bid_qty = _abs_int(fields[33 + i]) if n > 33 + i else 0 + if ask_px > 0: + asks.append(OrderbookLevel(price=ask_px, qty=ask_qty)) + if bid_px > 0: + bids.append(OrderbookLevel(price=bid_px, qty=bid_qty)) + total_ask = _abs_int(fields[43]) if n > 43 else 0 + total_bid = _abs_int(fields[44]) if n > 44 else 0 + if total_bid <= 0 and bids: + total_bid = sum(b.qty for b in bids) + if total_ask <= 0 and asks: + total_ask = sum(a.qty for a in asks) + + snap_time = _normalize_ob_snap_time(fields[1] if n > 1 else "") + + code_val = str(code or "").strip() + if not code_val and n > 0: + code_val = str(fields[0] or "").strip() + + return OrderbookSnapshot( + code=code_val, + asks=asks, + bids=bids, + total_bid_qty=total_bid, + total_ask_qty=total_ask, + ts=time.time(), + source="kis_h0stasp0", + snap_time=snap_time, + ) + + def parse_ls_hoga_body( code: str, body: Dict[str, Any], @@ -218,13 +297,8 @@ def parse_ls_hoga_body( if total_ask <= 0 and asks: total_ask = sum(a.qty for a in asks) - hotime = str(body.get("hotime") or "").strip() # UH1 패킷 시각(HHMMSS). 틱동기 저장은 체결 chetime 으로 덮어씀(키움 FID20 과 동일 축). - snap_time = "" - if len(hotime) >= 6 and hotime[:6].isdigit(): - from datetime import datetime as _dt - - snap_time = _dt.now().strftime("%Y%m%d") + hotime[:6] + snap_time = _normalize_ob_snap_time(body.get("hotime") or "") return OrderbookSnapshot( code=str(code).strip(), @@ -245,23 +319,47 @@ class OrderbookCache: self._data: Dict[str, OrderbookSnapshot] = {} self._lock = threading.Lock() - def update_from_kiwoom_0d(self, code: str, values: Dict[str, Any]) -> OrderbookSnapshot: - snap = parse_kiwoom_0d_values(code, values) + def _commit_if_fresh(self, snap: OrderbookSnapshot) -> Optional[OrderbookSnapshot]: + """거래소 snap_time 이 LIVE_FEED_FALLBACK 초과면 RAM 미반영 (틱 skip_ram 과 동일). + + Returns: + 저장된 snap. 스킵 시 None (호출측 recorder/DB 도 생략). + """ + if snap is None or not str(getattr(snap, "code", "") or "").strip(): + return None + if _is_snap_time_stale_for_ram(snap): + logger.debug( + "호가 RAM skip (snap_time lag) %s src=%s snap=%s", + snap.code, + getattr(snap, "source", ""), + getattr(snap, "snap_time", "") or "-", + ) + return None with self._lock: self._data[snap.code] = snap return snap + def update_from_kiwoom_0d( + self, code: str, values: Dict[str, Any], + ) -> Optional[OrderbookSnapshot]: + snap = parse_kiwoom_0d_values(code, values) + return self._commit_if_fresh(snap) + def update_from_ls_hoga( self, code: str, body: Dict[str, Any], *, source: str = "ls_uh1", - ) -> OrderbookSnapshot: + ) -> Optional[OrderbookSnapshot]: snap = parse_ls_hoga_body(code, body, source=source) - with self._lock: - self._data[snap.code] = snap - return snap + return self._commit_if_fresh(snap) + + def update_from_kis_h0stasp0( + self, code: str, fields: List[Any], + ) -> Optional[OrderbookSnapshot]: + snap = parse_kis_h0stasp0_fields(code, fields) + return self._commit_if_fresh(snap) def get(self, code: str, max_age_sec: float = 3.0) -> Optional[OrderbookSnapshot]: c = str(code or "").strip() @@ -271,8 +369,13 @@ class OrderbookCache: snap = self._data.get(c) if not snap: return None + # 수신 ts 나이 (기존). max_age<=0 = 마지막 RAM(필터용) — ts·snap 컷 안 함. if max_age_sec > 0 and (time.time() - snap.ts) > max_age_sec: return None + # 읽기 폴백 나이일 때 거래소 snap 도 틱과 동일 검사 (적재 누락·재시작 잔여 방어). + # FILTER_MAX_AGE=0(마지막 RAM) 경로는 여기 안 탐 → 호가필터 구멍 방지 규칙 유지. + if max_age_sec > 0 and _is_snap_time_stale_for_ram(snap): + return None return snap def get_kis_bid_dict(self, code: str, max_age_sec: float = 3.0) -> Optional[Dict[str, Any]]: diff --git a/kis_trader/ws/ws_reconnect_backoff.py b/kis_trader/ws/ws_reconnect_backoff.py new file mode 100644 index 0000000..092ddea --- /dev/null +++ b/kis_trader/ws/ws_reconnect_backoff.py @@ -0,0 +1,50 @@ +""" +WebSocket 재연결 대기 간격 — **REST 아님** (소켓 run_forever 끊김 후 재접속 전 sleep). + +기본 시퀀스: 1 → 3 → 5 → 7 → 10초, 이후 10초 유지. +env ``WS_RECONNECT_DELAY_SECS`` (쉼표 구분, DB/env_config). +""" +from __future__ import annotations + +from typing import List, Tuple + +from ..utils.env import get_env_from_db + +_DEFAULT_DELAYS: Tuple[float, ...] = (1.0, 3.0, 5.0, 7.0, 10.0) +_cache: Tuple[float, ...] | None = None + + +def invalidate_ws_reconnect_delay_cache() -> None: + global _cache + _cache = None + + +def ws_reconnect_delays_sec() -> Tuple[float, ...]: + global _cache + if _cache is None: + raw = ( + get_env_from_db("WS_RECONNECT_DELAY_SECS", "1,3,5,7,10") + or "1,3,5,7,10" + ).strip() + parts: List[float] = [] + for piece in raw.split(","): + piece = piece.strip() + if not piece: + continue + try: + val = float(piece) + except ValueError: + continue + if val >= 0: + parts.append(val) + _cache = tuple(parts) if parts else _DEFAULT_DELAYS + return _cache + + +def ws_reconnect_delay_for_attempt(attempt: int) -> float: + """1-based 재연결 시도 번호 → 대기 초. 시퀀스 초과 시 마지막 값.""" + if attempt < 1: + attempt = 1 + delays = ws_reconnect_delays_sec() + idx = min(attempt - 1, len(delays) - 1) + return delays[idx] diff --git a/permanent_subs.py b/permanent_subs.py index 75e26ce..ab116d6 100644 --- a/permanent_subs.py +++ b/permanent_subs.py @@ -220,7 +220,10 @@ def subscribe_codes(db: Any, market_type: str) -> List[Dict[str, Any]]: def should_persist_ls(code: str, perm_codes: Optional[set] = None) -> bool: - """LS DB 적재: 영구구독 enabled 코드만 (후보 spill 제외).""" + """LS 봉·VI DB 적재: 영구구독 enabled 코드만. + + 체결틱·호가 DB 는 ``KISTrader._ls_is_subscribed``(구독 전체) — 여기 쓰지 말 것. + """ c = str(code or "").strip() if not c: return False diff --git a/scripts/observe_ls_ws_journal.py b/scripts/observe_ls_ws_journal.py new file mode 100755 index 0000000..b2cfec7 --- /dev/null +++ b/scripts/observe_ls_ws_journal.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""LS WS 저널 관측 — Bye / OPEN sends / abort_why / ws_same / RSP 집계. + +장중(hold) 재현용. 장외에는 OPEN이 없어 요약이 비는 것이 정상. + +사용: + python3 -u scripts/observe_ls_ws_journal.py --once + python3 -u scripts/observe_ls_ws_journal.py --watch --minutes 90 + python3 -u scripts/observe_ls_ws_journal.py --since "2026-08-26 07:00:00" + +출력: + logs/ls_ws_observe_YYYYMMDD_HHMMSS.log — 매칭 원문 + logs/ls_ws_observe_YYYYMMDD_HHMMSS_summary.md — 집계 +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import time +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Optional, Tuple + +ROOT = Path(__file__).resolve().parents[1] +LOG_DIR = ROOT / "logs" +SERVICE = "kis_trader_main.service" + +# 관측 패치 + 8/25형 시그니처 +PATTERNS = ( + r"LS WS OPEN", + r"LS WS CLOSE", + r"LS WS ERROR", + r"LS WS RSP", + r"abort_why=", + r"ws_same=", + r"sends≈", + r"Bye", + r"opcode=8", + r"hold 외", + r"hold까지", + r"구독 복구", + r"US3|UH1", + r"ls_ws_ticks|ls_ws_orderbook", + r"LS WS 기동|LS_WS_ENABLED", +) + + +def _now_tag() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S") + + +def _default_since(explicit: Optional[str]) -> str: + if explicit: + return explicit.strip() + # 당일 00:00 (재시작·hold 전 구간 포함) + return datetime.now().strftime("%Y-%m-%d 00:00:00") + + +def _journal_lines(since: str, until: Optional[str] = None) -> List[str]: + cmd = [ + "journalctl", + "-u", + SERVICE, + "--since", + since, + "--no-pager", + "-o", + "short-iso", + ] + if until: + cmd.extend(["--until", until]) + try: + p = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=180, + check=False, + ) + except Exception as e: + return [f"[observe] journalctl 실패: {e}"] + if p.returncode not in (0, 1): # 1 = no entries + err = (p.stderr or "").strip()[:500] + return [f"[observe] journalctl rc={p.returncode} {err}"] + return (p.stdout or "").splitlines() + + +def _match_lines(lines: List[str]) -> List[str]: + rx = re.compile("|".join(f"(?:{p})" for p in PATTERNS), re.IGNORECASE) + return [ln for ln in lines if rx.search(ln)] + + +def _summarize(matched: List[str]) -> str: + sends = Counter() + abort = Counter() + ws_same = Counter() + rsp_cd = Counter() + n_open = n_close = n_bye = n_rsp = n_err = n_hold = 0 + + re_sends = re.compile(r"sends≈(\d+)") + re_abort = re.compile(r"abort_why=([^\s]+)") + re_ws = re.compile(r"ws_same=([^\s]+)") + re_rsp = re.compile(r"rsp_cd=([^\s]+)") + + for ln in matched: + if "LS WS OPEN" in ln: + n_open += 1 + m = re_sends.search(ln) + if m: + sends[m.group(1)] += 1 + m = re_abort.search(ln) + if m: + abort[m.group(1)] += 1 + if "LS WS CLOSE" in ln: + n_close += 1 + m = re_ws.search(ln) + if m: + ws_same[m.group(1)] += 1 + if "LS WS ERROR" in ln: + n_err += 1 + m = re_ws.search(ln) + if m: + ws_same[f"err:{m.group(1)}"] += 1 + if "LS WS RSP" in ln: + n_rsp += 1 + m = re_rsp.search(ln) + if m: + rsp_cd[m.group(1)] += 1 + if re.search(r"\bBye\b|opcode=8", ln, re.IGNORECASE): + n_bye += 1 + if "hold 외" in ln or "hold까지" in ln: + n_hold += 1 + + def _top(c: Counter, n: int = 15) -> str: + if not c: + return "_없음_" + rows = [f"| `{k}` | {v} |" for k, v in c.most_common(n)] + return "\n".join(["| 값 | 건수 |", "|---|---:|"] + rows) + + # 판정 힌트 (단정 금지 — 관측용) + hint = [] + if n_open == 0 and n_hold > 0: + hint.append("OPEN 0 + hold 외 로그 → **장외 대기** (Bye 재현 구간 아님).") + if sends.get("1", 0) >= max(3, n_open // 2) and n_open >= 3: + hint.append( + f"OPEN 중 `sends≈1` 이 {sends.get('1', 0)}/{n_open} → " + "8/25형 **불완전 REG** 의심 (JIF만 등)." + ) + if n_bye >= 5 and n_open >= 3: + hint.append(f"Bye/opcode8 ≈{n_bye}, OPEN≈{n_open} → 연결 반복 끊김.") + if abort.get("opened_cleared", 0) > 0: + hint.append( + f"abort_why=opened_cleared ×{abort['opened_cleared']} → " + "REG 중 `_opened` 클리어(stale close 가설) 점검." + ) + if "False" in ws_same or "false" in {k.lower() for k in ws_same}: + hint.append("ws_same=False 존재 → CLOSE/ERROR 가 **다른/옛 소켓**일 수 있음.") + if not hint: + hint.append("특이 시그니처 부족 — 샘플·시간대 확인.") + + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return "\n".join( + [ + f"# LS WS 저널 관측 요약", + f"", + f"- 생성: `{ts}`", + f"- 유닛: `{SERVICE}`", + f"- 매칭 줄 수: **{len(matched)}**", + f"", + f"## 카운트", + f"", + f"| 항목 | 건수 |", + f"|---|---:|", + f"| LS WS OPEN | {n_open} |", + f"| LS WS CLOSE | {n_close} |", + f"| Bye / opcode=8 | {n_bye} |", + f"| LS WS RSP | {n_rsp} |", + f"| LS WS ERROR | {n_err} |", + f"| hold 관련 | {n_hold} |", + f"", + f"## OPEN `sends≈N` 분포", + f"", + _top(sends), + f"", + f"## OPEN `abort_why`", + f"", + _top(abort), + f"", + f"## CLOSE/ERROR `ws_same`", + f"", + _top(ws_same), + f"", + f"## RSP `rsp_cd`", + f"", + _top(rsp_cd), + f"", + f"## 힌트 (가설, 단정 아님)", + f"", + *[f"- {h}" for h in hint], + f"", + f"## 다음 액션", + f"", + f"- `sends≈1` 다수 + Bye → REG 중단 원인 (`abort_why`, `ws_same`) 대조", + f"- RSP non-00000 → 서버 거절 메시지 확인", + f"- hold 외만 보이면 **07:00 이후** 재실행", + f"", + ] + ) + + +def _write_outputs( + matched: List[str], + summary_md: str, + tag: str, +) -> Tuple[Path, Path]: + LOG_DIR.mkdir(parents=True, exist_ok=True) + raw_path = LOG_DIR / f"ls_ws_observe_{tag}.log" + sum_path = LOG_DIR / f"ls_ws_observe_{tag}_summary.md" + raw_path.write_text("\n".join(matched) + ("\n" if matched else ""), encoding="utf-8") + sum_path.write_text(summary_md, encoding="utf-8") + return raw_path, sum_path + + +def run_once(since: str, until: Optional[str] = None) -> Tuple[Path, Path]: + lines = _journal_lines(since, until) + matched = _match_lines(lines) + tag = _now_tag() + summary = _summarize(matched) + summary = ( + f"- since: `{since}`\n" + f"- until: `{until or 'now'}`\n" + f"- journal 총 줄: {len(lines)}\n\n" + + summary + ) + return _write_outputs(matched, summary, tag) + + +def run_watch(since0: str, minutes: float, interval_sec: float) -> Tuple[Path, Path]: + """주기적으로 스냅샷을 덮어쓰지 않고 **최종 1회** since0~now 집계. + + 중간 진행은 stdout 에 OPEN/Bye 카운트만 찍음. + """ + end = time.time() + max(60.0, float(minutes) * 60.0) + iv = max(30.0, float(interval_sec)) + print( + f"[observe] watch start since={since0!r} minutes={minutes} interval={iv}s", + flush=True, + ) + while time.time() < end: + lines = _journal_lines(since0) + matched = _match_lines(lines) + n_open = sum(1 for ln in matched if "LS WS OPEN" in ln) + n_bye = sum(1 for ln in matched if re.search(r"\bBye\b|opcode=8", ln, re.I)) + sends1 = sum(1 for ln in matched if re.search(r"sends≈1\b", ln)) + print( + f"[observe] {datetime.now():%H:%M:%S} match={len(matched)} " + f"OPEN={n_open} sends≈1={sends1} Bye={n_bye}", + flush=True, + ) + remain = end - time.time() + if remain <= 0: + break + time.sleep(min(iv, remain)) + return run_once(since0) + + +def main() -> int: + ap = argparse.ArgumentParser(description="LS WS journal 관측") + ap.add_argument("--once", action="store_true", help="1회 집계 후 종료") + ap.add_argument( + "--watch", + action="store_true", + help="지정 분간 폴링 후 최종 집계 (cron 아침용)", + ) + ap.add_argument("--minutes", type=float, default=90.0, help="--watch 지속(분)") + ap.add_argument("--interval", type=float, default=120.0, help="--watch 폴링 초") + ap.add_argument("--since", type=str, default="", help='journal --since (예: "2026-08-26 07:00:00")') + ap.add_argument("--until", type=str, default="", help="journal --until (선택)") + args = ap.parse_args() + + since = _default_since(args.since or None) + until = (args.until or "").strip() or None + + if args.watch: + # watch 시작 시각을 since 로 고정 (당일 hold 진입부터) + if not args.since: + # 오늘 07:00 (KR hold 기본). 이미 지났으면 07:00, 이전이면 since=지금-1h + today7 = datetime.now().replace(hour=7, minute=0, second=0, microsecond=0) + if datetime.now() < today7: + since = (datetime.now() - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + else: + since = today7.strftime("%Y-%m-%d %H:%M:%S") + raw, summ = run_watch(since, args.minutes, args.interval) + else: + raw, summ = run_once(since, until) + + print(f"[observe] raw={raw}", flush=True) + print(f"[observe] summary={summ}", flush=True) + # 요약 앞부분 stdout + try: + text = summ.read_text(encoding="utf-8") + print(text[:2500], flush=True) + except Exception: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/observe_ls_ws_journal.sh b/scripts/observe_ls_ws_journal.sh new file mode 100755 index 0000000..1604c3f --- /dev/null +++ b/scripts/observe_ls_ws_journal.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# LS WS 저널 관측 래퍼 — cron / 수동 공통 +# 로그: logs/ls_ws_observe_cron_YYYYMMDD.log (래퍼 stdout) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p logs +DAY="$(date +%Y%m%d)" +WRAPPER_LOG="logs/ls_ws_observe_cron_${DAY}.log" +MODE="${1:-watch}" # watch | once +shift || true + +{ + echo "======== $(date '+%F %T') start mode=${MODE} ========" + if [[ "$MODE" == "once" ]]; then + python3 -u scripts/observe_ls_ws_journal.py --once "$@" + else + # 기본: 07:00 hold 직후 90분 관측 (Bye/~1분 주기 포착) + python3 -u scripts/observe_ls_ws_journal.py --watch --minutes 90 --interval 120 "$@" + fi + echo "======== $(date '+%F %T') end ========" +} >>"$WRAPPER_LOG" 2>&1 diff --git a/scripts/test_live_execution_validation.py b/scripts/test_live_execution_validation.py index 38b1ea4..06b1945 100755 --- a/scripts/test_live_execution_validation.py +++ b/scripts/test_live_execution_validation.py @@ -562,6 +562,8 @@ def run_validation() -> bool: "WS_CANDLE_FREEZE_ON_CONFIRM", "LIVE_FEED_FALLBACK_MAX_AGE_SEC", "LS_FEED_FALLBACK_SUBSCRIBE", + "LS_WS_HOLD_RAM_KEEP_HOLDINGS", + "LS_WS_MAX_SUBSCRIPTIONS", "LS_GAP_FILL_CANDIDATES", "LS_WS_TICK_SAVE", "LS_WS_ALSO_HOGA", @@ -597,6 +599,8 @@ def run_validation() -> bool: "PERM_LS_FILL_BARS", "LIVE_FEED_FALLBACK_MAX_AGE_SEC", "LS_FEED_FALLBACK_SUBSCRIBE", + "LS_WS_HOLD_RAM_KEEP_HOLDINGS", + "LS_WS_MAX_SUBSCRIPTIONS", "LS_WS_TICK_SAVE", "LS_WS_ALSO_HOGA", "LS_WS_ORDERBOOK_SAVE", @@ -742,19 +746,43 @@ def run_validation() -> bool: else: _성공("LS 3차 폴백 구독 _sync_feed_fallback_to_ls") - st_src = inspect.getsource(WSManager.sync_targets) - if "_sync_feed_fallback_to_ls" not in st_src or "_ls_ram_universe_codes" not in st_src: - 실패목록.append("sync_targets LS RAM 합집합 없음") - _실패("sync_targets 가 _sync_feed_fallback_to_ls(_ls_ram_universe_codes) 를 안 부름") - else: - _성공("sync_targets 끝에서 LS RAM 합집합 구독 (MINIMAL OFF 경로)") - rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions) - if "_sync_feed_fallback_to_ls" not in rec_src or "_ls_ram_universe_codes" not in rec_src: - 실패목록.append("split reconcile LS RAM 합집합 없음") - _실패("_reconcile_split_subscriptions 가 LS RAM 합집합을 안 부름") + if "_finalize_ls_subscriptions" not in rec_src: + 실패목록.append("split reconcile LS finalize 없음") + _실패("_reconcile_split_subscriptions 가 _finalize_ls_subscriptions 를 안 부름") else: - _성공("split reconcile 도 LS RAM 합집합") + _성공("split reconcile → _finalize_ls_subscriptions (spill/permanent)") + + if not hasattr(WSManager, "_prune_ls_ephemeral_subscriptions"): + 실패목록.append("_prune_ls_ephemeral_subscriptions 없음") + _실패("LS spill grace 정리 헬퍼 없음") + else: + prune_src = inspect.getsource(WSManager._prune_ls_ephemeral_subscriptions) + if 'owner="spill"' not in prune_src: + 실패목록.append("LS spill prune 없음") + _실패("_prune_ls_ephemeral_subscriptions 가 spill unsubscribe 안 함") + else: + _성공("LS spill/_feed_fallback ephemeral prune") + + st_src = inspect.getsource(WSManager.sync_targets) + if "_finalize_ls_subscriptions" not in st_src: + 실패목록.append("sync_targets LS finalize 없음") + _실패("sync_targets 가 _finalize_ls_subscriptions 를 안 부름") + else: + _성공("sync_targets 끝에서 LS finalize (MINIMAL OFF 경로)") + + fb_src = inspect.getsource(WSManager._sync_feed_fallback_to_ls) + if 'LS_FEED_FALLBACK_SUBSCRIBE", False' in fb_src or "LS_FEED_FALLBACK_SUBSCRIBE', False" in fb_src: + _성공("LS_FEED_FALLBACK_SUBSCRIBE 코드 기본값=false (미러 OFF)") + else: + 실패목록.append("LS_FEED_FALLBACK 기본 false 아님") + _실패("_sync_feed_fallback_to_ls get_env_bool 기본값이 false 가 아님") + db_fb = get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False) + if db_fb: + print( + f" ⚠️ DB LS_FEED_FALLBACK_SUBSCRIBE=ON — 레거시 미러 활성. " + f"spill-only 운영 시 웹/DB에서 OFF 권장 (현재={_db원문('LS_FEED_FALLBACK_SUBSCRIBE')})" + ) start_src = inspect.getsource(WSManager.start) if "kis_ws_ob = self.ws_cache" in start_src: @@ -879,14 +907,55 @@ def run_validation() -> bool: if not ps.should_persist_ls("005930", {"005930", "069500"}): 실패목록.append("should_persist_ls True 기대") - _실패("영구구독 코드인데 LS 적재 가드 False") + _실패("영구구독 코드인데 LS 봉/VI 가드 False") else: - _성공("should_persist_ls: 영구구독 코드만 True") + _성공("should_persist_ls: 영구구독 코드만 True (봉·VI)") if ps.should_persist_ls("999999", {"005930"}): 실패목록.append("should_persist_ls False 기대") - _실패("비영구 코드인데 LS 적재 허용") + _실패("비영구 코드인데 LS 봉/VI 적재 허용") else: - _성공("should_persist_ls: 후보 spill 코드 False") + _성공("should_persist_ls: 후보 spill 코드 False (봉·VI)") + + # 체결틱 DB 게이트 = 구독 전체 (_ls_is_subscribed), 영구만이 아님 + try: + from pathlib import Path as _Path + _main_py = (_Path(__file__).resolve().parents[1] / "kis_trader" / "main.py").read_text( + encoding="utf-8", errors="replace", + ) + except Exception: + _main_py = "" + if "LS_WS_TICK_SAVE" in _main_py and "_ls_is_subscribed(code)" in _main_py: + _성공("LS 체결틱 DB: 구독 전체(_ls_is_subscribed)") + elif "LS_WS_TICK_SAVE" in _main_py and "and self._ls_should_persist(code)" in _main_py: + 실패목록.append("LS 틱이 영구게이트") + _실패("LS_WS_TICK_SAVE 가 _ls_should_persist(영구) — 구독전체여야 함") + else: + _주의("LS 틱 SAVE 게이트 패턴 확인 필요") + + from kis_trader.engine.feed_fallback import merge_ls_ticks_third_fallback + _base = { + "005930": { + "202608191200": [ + {"tick_time": "20260819120000", "source": "kis", "price": 1, "_lag_sec": 0}, + ] + } + } + _ls = { + "005930": { + "202608191200": [ + {"tick_time": "20260819120000", "source": "ls", "price": 9, "_lag_sec": 0}, + {"tick_time": "20260819120001", "source": "ls", "price": 8, "_lag_sec": 0}, + ] + } + } + _add = merge_ls_ticks_third_fallback(_base, _ls, max_lag_sec=3.0) + _sec0 = [t for t in _base["005930"]["202608191200"] if str(t.get("tick_time")) == "20260819120000"] + _sec1 = [t for t in _base["005930"]["202608191200"] if str(t.get("tick_time")) == "20260819120001"] + if _add != 1 or any(str(t.get("source")) == "ls" for t in _sec0) or not _sec1: + 실패목록.append(f"LS 틱3차 merge 이상 add={_add}") + _실패(f"같은초 LS 중복/빈초 미채움: add={_add} sec0={_sec0} sec1={_sec1}") + else: + _성공("틱 3차 LS merge: 같은초 스킵·빈초만 채움") if not hasattr(db, "insert_ls_ws_candle_if_absent"): 실패목록.append("insert_ls_ws_candle_if_absent 없음") @@ -973,7 +1042,7 @@ def run_validation() -> bool: ) _소스필수( 실패목록, LSWebSocketPriceCache._force_close_socket, - "_graceful_unreg_all", "LS 장양보 → UNREG", + "ram_keep_codes", "LS 장양보 → UNREG 후 RAM trim(영구·보유 pin)", ) _소스필수( 실패목록, LSWebSocketPriceCache._send_raw, @@ -1043,6 +1112,91 @@ def run_validation() -> bool: f"| TICK_MAX_AGE={_db원문('WS_ORDERBOOK_TICK_MAX_AGE_SEC')}" ) + # 호가 snap_time = 틱 packet_lag 와 동일 LIVE_FEED_FALLBACK 컷 + from datetime import datetime as _dt + + from kis_trader.engine.feed_fallback import ( + is_orderbook_snap_time_stale, + live_feed_fallback_max_age_sec, + ) + from kis_trader.ws.orderbook_cache import OrderbookCache, OrderbookLevel, OrderbookSnapshot + + _fb = float(live_feed_fallback_max_age_sec() or 3.0) + _fresh_st = _dt.now().strftime("%Y%m%d%H%M%S") + _stale_st = (_dt.now().replace(year=2020)).strftime("%Y%m%d") + "130248" + if _fb > 0: + if is_orderbook_snap_time_stale(_stale_st): + _성공(f"호가 snap_time 낡은 시각 → stale (LIVE_FEED_FALLBACK={_fb:g}s)") + else: + 실패목록.append("orderbook snap_time stale 미검출") + _실패("호가 snap_time 낡음인데 is_orderbook_snap_time_stale=False") + if not is_orderbook_snap_time_stale(_fresh_st): + _성공("호가 snap_time 신선 → 통과") + else: + 실패목록.append("orderbook snap_time fresh 오탐") + _실패("호가 snap_time 신선한데 stale 오탐") + if not is_orderbook_snap_time_stale(""): + _성공("호가 snap_time 빈값 → 버리지 않음(틱 lag None 과 동일)") + else: + 실패목록.append("orderbook snap_time empty 오탐") + _실패("호가 snap_time 빈값인데 stale 오탐") + + _obc = OrderbookCache() + # 강제로 매우 낡은 snap 주입 후 get(3) 거부·get(0) 유지 검증 + _inj = OrderbookSnapshot( + code="375500", + bids=[OrderbookLevel(74000, 10)], + asks=[OrderbookLevel(74100, 10)], + total_bid_qty=10, + total_ask_qty=10, + ts=time.time(), # 수신 ts 는 신선 + source="ls_uh1", + snap_time=_stale_st, + ) + with _obc._lock: + _obc._data["375500"] = _inj + if _obc.get("375500", max_age_sec=3.0) is None: + _성공("호가 get(3s): ts 신선·snap 낡음 → None (체인 실패)") + else: + 실패목록.append("orderbook get snap stale pass") + _실패("호가 get(3s) 가 snap 낡은데 통과") + if _obc.get("375500", max_age_sec=0.0) is not None: + _성공("호가 get(0)=마지막 RAM: snap 낡아도 반환 (FILTER 구멍 방지)") + else: + 실패목록.append("orderbook get(0) snap reject") + _실패("호가 get(0) 이 snap 때문에 None — FILTER_MAX_AGE=0 위반") + # 신선한 LS 적재는 성공 + _ok = _obc.update_from_ls_hoga( + "005930", + { + "offerho1": "70000", "bidho1": "69900", + "offerrem1": "5", "bidrem1": "5", + "hotime": _dt.now().strftime("%H%M%S"), + }, + source="ls_uh1", + ) + if _ok is not None and _obc.get("005930", max_age_sec=3.0) is not None: + _성공("호가 LS 신선 snap → RAM 적재·get(3s) OK") + else: + 실패목록.append("orderbook fresh ls commit fail") + _실패("호가 신선 LS 적재/조회 실패") + # stale body: hotime 가 지금과 3초 초과면 skip (장중 130248 은 거의 항상 skip) + # 단위: 명시적 stale snap_time 주입 경로로 이미 get 검증함. ingest skip 은 helper 로. + _파일필수( + 실패목록, + "kis_trader/ws/orderbook_cache.py", + "_commit_if_fresh", + "호가 RAM snap_time 컷(틱 skip_ram 정합)", + ) + _파일필수( + 실패목록, + "kis_trader/engine/feed_fallback.py", + "is_orderbook_snap_time_stale", + "호가 snap lag 헬퍼 = 틱 packet_lag", + ) + else: + _주의("LIVE_FEED_FALLBACK=0 — 호가 snap_time 컷 OFF (레거시)") + _파일필수( 실패목록, "kis_trader/backtest/param_search_optuna.py",