feat: Enhance trading system with new permanent subscription features and order book management
Changes: - Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically. - Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database. - Introduced a mechanism to handle master subscription states, improving the management of subscription statuses. - Updated the database schema to include new fields for managing subscription states and order book filtering. Impact: - These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies. 히스토리 align 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
This commit is contained in:
@@ -8,6 +8,11 @@ scripts/test_live_execution_validation.py
|
||||
가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다.
|
||||
(OFF 전략에 가짜 탈락을 찍지 않음 — 헷갈림 방지)
|
||||
|
||||
추가로 검증:
|
||||
· ws_candles source/channel 정규화 · 실매=Optuna 읽기쌍
|
||||
· 갭보정 키움 REST만 (KIS REST 강제 무시)
|
||||
· 영구구독 마스터 · LS DB 적재 가드 · 운영설정 UI 키
|
||||
|
||||
실행:
|
||||
python3 -u scripts/test_live_execution_validation.py
|
||||
# 로그 예: logs/test_live_execution_validation_*.log
|
||||
@@ -17,6 +22,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -86,7 +92,6 @@ def run_validation() -> bool:
|
||||
from kis_trader.execution.order_manager import OrderManager
|
||||
from kis_trader.utils.env import get_env_bool, get_env_from_db, invalidate_merged_env_cache
|
||||
from kis_trader.utils.live_portfolio_common import (
|
||||
live_universe_slot_align_enabled,
|
||||
resolve_live_universe_history_source,
|
||||
)
|
||||
from kis_trader.web.live_config_schema import build_live_config_groups
|
||||
@@ -249,30 +254,69 @@ def run_validation() -> bool:
|
||||
else:
|
||||
_성공(f"{_전략표시(sid)} 위험호가 → 차단 ({위험탈락}: {위험사유})")
|
||||
|
||||
# ── 5) WS 단절 · ALIGN (DB) ─────────────────────────────────────
|
||||
print("\n[5단계] 호가스냅 없음(None) · 유니버스 ALIGN (DB)")
|
||||
print(" hist = ALIGN 교집합용 이력테이블 (시세 LS WS 와 무관)")
|
||||
# ── 5) WS 단절 · 유니버스 소스 (DB) ────────────────────────────────
|
||||
print("\n[5단계] 호가스냅 없음(None) · 유니버스 소스 (DB)")
|
||||
print(" 실매 후보=키움 RAM. history 는 백테 재생용.")
|
||||
print(" kiwoom→target_candidates_history / ls→ls_candidates_history")
|
||||
|
||||
for sid in 전략목록:
|
||||
탈락, _ = orderbook_reject_for_entry(
|
||||
{"_backtest_orderbook_snapshot": None, "slot_money": 10_000_000},
|
||||
탈락, 사유 = orderbook_reject_for_entry(
|
||||
{"slot_money": 10_000_000},
|
||||
sid,
|
||||
current_price=10000.0,
|
||||
)
|
||||
if 탈락 is None:
|
||||
동작 = "스냅 없으면 호가탈락 스킵(Fail-Open)"
|
||||
필터켜짐 = orderbook_filter_enabled(sid)
|
||||
if not 필터켜짐:
|
||||
if 탈락:
|
||||
실패목록.append(f"{sid}: 필터 OFF인데 스냅None 탈락={탈락}")
|
||||
_실패(f"{_전략표시(sid)} 필터 OFF인데 스냅없음 탈락({탈락})")
|
||||
else:
|
||||
_성공(f"{_전략표시(sid)} 필터 OFF + 스냅없음 → 통과(막지 않음)")
|
||||
continue
|
||||
if 탈락 == "탈락-호가없음":
|
||||
_성공(f"{_전략표시(sid)} 필터 ON + 스냅없음 → 차단({탈락})")
|
||||
elif 탈락 is None:
|
||||
실패목록.append(f"{sid}: 필터 ON인데 스냅없음 Fail-Open")
|
||||
_실패(f"{_전략표시(sid)} 필터 ON인데 스냅없음 통과 — REJECT_IF_EMPTY 확인")
|
||||
else:
|
||||
동작 = f"스냅 없어도 차단({탈락})"
|
||||
print(f" {_전략표시(sid)} 스냅None → {동작}")
|
||||
실패목록.append(f"{sid}: 스냅없음 예상외 탈락={탈락}")
|
||||
_실패(f"{_전략표시(sid)} 스냅없음 예상외({탈락}: {사유})")
|
||||
|
||||
print(" B안: 저장TTL보다 오래된 RAM 호가도 필터 ON이면 검사")
|
||||
|
||||
class _오래된호가WS:
|
||||
def __init__(self, snap: OrderbookSnapshot) -> None:
|
||||
self.snap = snap
|
||||
|
||||
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
|
||||
if max_age_sec > 0 and (time.time() - float(self.snap.ts or 0)) > max_age_sec:
|
||||
return None
|
||||
return self.snap
|
||||
|
||||
_stale_ws = _오래된호가WS(위험호가)
|
||||
for sid in 전략목록:
|
||||
if not orderbook_filter_enabled(sid):
|
||||
continue
|
||||
탈락, _ = orderbook_reject_for_entry(
|
||||
{
|
||||
"_orderbook_ws": _stale_ws,
|
||||
"_orderbook_code": "005930",
|
||||
"slot_money": 10_000_000,
|
||||
},
|
||||
sid,
|
||||
current_price=10000.0,
|
||||
)
|
||||
if 탈락:
|
||||
_성공(f"{_전략표시(sid)} 오래된 RAM 호가 → 차단({탈락}) B안 OK")
|
||||
else:
|
||||
실패목록.append(f"{sid}: 오래된 호가 Fail-Open")
|
||||
_실패(f"{_전략표시(sid)} 오래된 RAM 호가인데 통과 — FILTER_MAX_AGE 확인")
|
||||
|
||||
for sid in 전략목록:
|
||||
align_on = live_universe_slot_align_enabled(sid)
|
||||
hist = resolve_live_universe_history_source(sid) # DB UNIVERSE_SOURCE
|
||||
univ = (get_env_from_db(f"{sid}_UNIVERSE_SOURCE", "") or "").strip() or "(기본)"
|
||||
print(
|
||||
f" {_전략표시(sid)} ALIGN={'ON' if align_on else 'OFF'} "
|
||||
f"| 유니버스소스={univ} | 이력테이블쪽={hist}"
|
||||
f" {_전략표시(sid)} 유니버스소스={univ} | 이력테이블쪽={hist}"
|
||||
)
|
||||
|
||||
# ── 6) 수집 스위치 (필터와 분리) ────────────────────────────────
|
||||
@@ -400,12 +444,18 @@ def run_validation() -> bool:
|
||||
return False
|
||||
|
||||
# ── 8) env / 운영설정 UI ───────────────────────────────────────
|
||||
print("\n[8단계] 운영설정·ENV 키 (LS WS / spill)")
|
||||
print("\n[8단계] 운영설정·ENV 키 (LS WS / spill / 영구구독 / 갭보정)")
|
||||
for 키 in (
|
||||
"LS_WS_ENABLED",
|
||||
"WS_SUBSCRIBE_SPILL",
|
||||
"WS_TICK_SUBSCRIBE_CHAIN",
|
||||
"WS_OB_SUBSCRIBE_CHAIN",
|
||||
"PERMANENT_SUBSCRIBE_ENABLED",
|
||||
"WS_GAP_FILL_KIS_FALLBACK",
|
||||
"PERM_LS_FILL_BARS",
|
||||
"PERM_LS_FILL_SLEEP_MIN",
|
||||
"PERM_LS_FILL_SLEEP_MAX",
|
||||
"WS_CANDLE_FREEZE_ON_CONFIRM",
|
||||
):
|
||||
if 키 not in ENV_CONFIG_KEYS:
|
||||
실패목록.append(f"ENV 누락 {키}")
|
||||
@@ -415,7 +465,10 @@ def run_validation() -> bool:
|
||||
|
||||
print(
|
||||
f" 현재 실매: 구독spill={'ON' if get_env_bool('WS_SUBSCRIBE_SPILL', True) else 'OFF'} | "
|
||||
f"LS WS기동={'ON' if get_env_bool('LS_WS_ENABLED', False) else 'OFF'}"
|
||||
f"LS WS기동={'ON' if get_env_bool('LS_WS_ENABLED', False) else 'OFF'} | "
|
||||
f"영구구독마스터={'ON' if get_env_bool('PERMANENT_SUBSCRIBE_ENABLED', True) else 'OFF'} | "
|
||||
f"틱메인={_db원문('LIVE_TICK_PROVIDER')} | "
|
||||
f"CANDLE_SOURCE={_db원문('CANDLE_SOURCE')}"
|
||||
)
|
||||
시세섹션 = next(
|
||||
(g for g in build_live_config_groups() if g.get("id") == "ws_feed_settings"),
|
||||
@@ -426,13 +479,220 @@ def run_validation() -> bool:
|
||||
_실패("운영설정「시세 소스」섹션 없음")
|
||||
else:
|
||||
ui키들 = {f.get("key") for f in 시세섹션.get("fields") or []}
|
||||
for need in ("LS_WS_ENABLED", "WS_SUBSCRIBE_SPILL"):
|
||||
for need in (
|
||||
"LS_WS_ENABLED",
|
||||
"WS_SUBSCRIBE_SPILL",
|
||||
"PERMANENT_SUBSCRIBE_ENABLED",
|
||||
"WS_GAP_FILL_KIS_FALLBACK",
|
||||
"PERM_LS_FILL_BARS",
|
||||
):
|
||||
if need not in ui키들:
|
||||
실패목록.append(f"UI 누락 {need}")
|
||||
_실패(f"운영설정 UI에 {need} 없음")
|
||||
else:
|
||||
_성공(f"운영설정 UI 필드 있음: {need}")
|
||||
|
||||
# ── 9) 봉 source/channel · 실매=Optuna 읽기 ───────────────────────
|
||||
print("\n[9단계] ws_candles source/channel · 실매=백테 읽기쌍")
|
||||
try:
|
||||
from kis_trader.ws.candle_series import (
|
||||
dedupe_by_read_pairs,
|
||||
live_read_pairs,
|
||||
normalize_source_channel,
|
||||
)
|
||||
from kis_trader.backtest.bt_candle_source import resolve_bt_read_pairs
|
||||
from kis_trader.ws.kis_ws import CandleAggregator
|
||||
|
||||
for raw, expect in (
|
||||
(("rest", ""), ("kiwoom", "rest")),
|
||||
(("kw_rest", ""), ("kiwoom", "rest")),
|
||||
(("rollup_1m", ""), ("kiwoom", "rollup")),
|
||||
(("ws", ""), ("kis", "ws")),
|
||||
(("kiwoom", ""), ("kiwoom", "ws")),
|
||||
(("kis", "ws"), ("kis", "ws")),
|
||||
):
|
||||
got = normalize_source_channel(raw[0], raw[1])
|
||||
if got != expect:
|
||||
실패목록.append(f"normalize {raw}→{got}")
|
||||
_실패(f"normalize_source_channel{raw} = {got} (기대 {expect})")
|
||||
else:
|
||||
_성공(f"정규화 {raw[0]!r}/{raw[1]!r} → {got}")
|
||||
|
||||
pairs = live_read_pairs()
|
||||
bt_pairs = resolve_bt_read_pairs()
|
||||
if pairs != bt_pairs:
|
||||
실패목록.append(f"실매≠Optuna 읽기쌍 {pairs} vs {bt_pairs}")
|
||||
_실패(f"live_read_pairs≠resolve_bt_read_pairs: {pairs} / {bt_pairs}")
|
||||
else:
|
||||
_성공(f"실매=Optuna 읽기쌍 {pairs}")
|
||||
|
||||
if pairs[0][1] != "ws" or ("kiwoom", "rest") not in pairs:
|
||||
실패목록.append(f"읽기쌍 규칙 위반 {pairs}")
|
||||
_실패(f"메인은 WS, 구멍은 kiwoom+rest 여야 함: {pairs}")
|
||||
else:
|
||||
_성공("메인 channel=ws, 구멍에 kiwoom+rest 포함")
|
||||
|
||||
# 같은 candle_time: 메인 WS 승, rest 구멍만 채움
|
||||
rows = [
|
||||
{
|
||||
"candle_time": "202608131000",
|
||||
"open": 1, "high": 1, "low": 1, "close": 100,
|
||||
"volume": 1, "source": "kiwoom", "channel": "rest",
|
||||
},
|
||||
{
|
||||
"candle_time": "202608131000",
|
||||
"open": 2, "high": 2, "low": 2, "close": 200,
|
||||
"volume": 2, "source": "kiwoom", "channel": "ws",
|
||||
},
|
||||
{
|
||||
"candle_time": "202608131001",
|
||||
"open": 3, "high": 3, "low": 3, "close": 300,
|
||||
"volume": 3, "source": "kiwoom", "channel": "rest",
|
||||
},
|
||||
]
|
||||
deduped = dedupe_by_read_pairs(
|
||||
rows, (("kiwoom", "ws"), ("kiwoom", "rest")),
|
||||
)
|
||||
by_t = {r["candle_time"]: r["close"] for r in deduped}
|
||||
if by_t.get("202608131000") != 200:
|
||||
실패목록.append("dedupe WS 우선 실패")
|
||||
_실패(f"같은 시각에 WS가 이겨야 함: {by_t}")
|
||||
elif by_t.get("202608131001") != 300:
|
||||
실패목록.append("dedupe rest 구멍 실패")
|
||||
_실패(f"구멍 rest 유지 실패: {by_t}")
|
||||
else:
|
||||
_성공("dedupe: WS 우선·rest 구멍만 채움")
|
||||
|
||||
# 갭보정 쓰기 라벨 (소스 코드)
|
||||
fill_src = inspect.getsource(CandleAggregator.fill_gap_from_rest)
|
||||
if '"source": "kiwoom"' not in fill_src or '"channel": "rest"' not in fill_src:
|
||||
실패목록.append("fill_gap source/channel 아님")
|
||||
_실패("fill_gap_from_rest 가 kiwoom+rest 로 안 씀")
|
||||
else:
|
||||
_성공("갭보정 쓰기 = source=kiwoom, channel=rest")
|
||||
|
||||
if not hasattr(CandleAggregator, "_bar_key"):
|
||||
실패목록.append("CandleAggregator._bar_key 없음")
|
||||
_실패("RAM 키가 (code,tf,source,channel) 아님")
|
||||
else:
|
||||
k = CandleAggregator._bar_key("005930", 1, "rest", "")
|
||||
if k != ("005930", 1, "kiwoom", "rest"):
|
||||
실패목록.append(f"_bar_key {k}")
|
||||
_실패(f"_bar_key 정규화 실패: {k}")
|
||||
else:
|
||||
_성공(f"RAM _bar_key 정규화 OK {k}")
|
||||
except Exception as exc:
|
||||
실패목록.append(f"봉소스 검증 오류: {exc}")
|
||||
_실패(f"봉 source/channel 검증 오류: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# ── 10) 갭보정 KIS REST 금지 · 영구구독 갭제외 ─────────────────
|
||||
print("\n[10단계] 갭보정=키움만 · 영구구독은 ls_ws_candles")
|
||||
try:
|
||||
import permanent_subs as ps
|
||||
|
||||
fill_gap_src = inspect.getsource(WSManager._fill_gap_for_code)
|
||||
if "get_minute_chart" in fill_gap_src:
|
||||
실패목록.append("갭보정에 KIS get_minute_chart 잔존")
|
||||
_실패("WSManager._fill_gap_for_code 에 KIS REST 호출이 남아 있음")
|
||||
else:
|
||||
_성공("갭보정 경로에 KIS get_minute_chart 없음")
|
||||
if "WS_GAP_FILL_KIS_FALLBACK ON 무시" not in fill_gap_src:
|
||||
실패목록.append("KIS fallback 무시 로그 없음")
|
||||
_실패("WS_GAP_FILL_KIS_FALLBACK ON 무시 가드 문구 없음")
|
||||
else:
|
||||
_성공("WS_GAP_FILL_KIS_FALLBACK ON 이어도 호출 안 함(가드 있음)")
|
||||
|
||||
sync_src = inspect.getsource(WSManager._sync_permanent_to_ls)
|
||||
if "subscribe_master_enabled" not in sync_src:
|
||||
실패목록.append("permanent sync 마스터 미적용")
|
||||
_실패("_sync_permanent_to_ls 가 마스터 스위치를 안 봄")
|
||||
else:
|
||||
_성공("영구구독→LS sync 가 마스터 OFF 시 빈 집합")
|
||||
|
||||
# 갭 집합에 perm 합집합 금지 (후보|보유만)
|
||||
recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions)
|
||||
if "_gap_refill_codes = set(kis_want) | set(kw_want) | perm" in recon_src:
|
||||
실패목록.append("갭보정에 영구구독 포함")
|
||||
_실패("_gap_refill_codes 가 영구구독(perm)을 포함함 — ls_ws_candles 전용이어야 함")
|
||||
elif "_gap_refill_codes = set(kis_want) | set(kw_want)" not in recon_src:
|
||||
실패목록.append("갭보정 집합 패턴 변경")
|
||||
_주의("_reconcile_split_subscriptions 갭보정 집합 패턴이 예상과 다름 — 수동 확인")
|
||||
else:
|
||||
_성공("자동 갭보정 집합 = 후보·보유만 (영구구독 제외)")
|
||||
|
||||
if not ps.subscribe_master_enabled():
|
||||
_주의("DB PERMANENT_SUBSCRIBE_ENABLED=OFF (행 유지·구독만 끔)")
|
||||
else:
|
||||
_성공("영구구독 마스터 ON")
|
||||
|
||||
if not ps.should_persist_ls("005930", {"005930", "069500"}):
|
||||
실패목록.append("should_persist_ls True 기대")
|
||||
_실패("영구구독 코드인데 LS 적재 가드 False")
|
||||
else:
|
||||
_성공("should_persist_ls: 영구구독 코드만 True")
|
||||
if ps.should_persist_ls("999999", {"005930"}):
|
||||
실패목록.append("should_persist_ls False 기대")
|
||||
_실패("비영구 코드인데 LS 적재 허용")
|
||||
else:
|
||||
_성공("should_persist_ls: 후보 spill 코드 False")
|
||||
|
||||
if not hasattr(db, "insert_ls_ws_candle_if_absent"):
|
||||
실패목록.append("insert_ls_ws_candle_if_absent 없음")
|
||||
_실패("영구구독 확정봉 INSERT IGNORE 헬퍼 없음")
|
||||
else:
|
||||
_성공("insert_ls_ws_candle_if_absent 존재")
|
||||
if not hasattr(ps, "fill_ls_candles_from_kiwoom"):
|
||||
실패목록.append("fill_ls_candles_from_kiwoom 없음")
|
||||
_실패("영구구독 확정봉 가져오기 헬퍼 없음")
|
||||
else:
|
||||
_성공("fill_ls_candles_from_kiwoom 존재 (키움→ls_ws_candles)")
|
||||
except Exception as exc:
|
||||
실패목록.append(f"갭/영구구독 검증 오류: {exc}")
|
||||
_실패(f"갭보정·영구구독 검증 오류: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# ── 11) DB 스키마 channel · UNIQUE ─────────────────────────────
|
||||
print("\n[11단계] DB ws_candles.channel · UNIQUE(source,channel)")
|
||||
try:
|
||||
cols = {r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_candles").fetchall()}
|
||||
if "channel" not in cols:
|
||||
실패목록.append("ws_candles.channel 없음")
|
||||
_실패("ws_candles.channel 컬럼 없음")
|
||||
else:
|
||||
_성공("ws_candles.channel 컬럼 있음")
|
||||
idx = db.conn.execute(
|
||||
"SHOW INDEX FROM ws_candles WHERE Key_name='uq_candle'"
|
||||
).fetchall()
|
||||
idx_cols = [r["Column_name"] for r in (idx or [])]
|
||||
want = ["code", "timeframe", "candle_time", "source", "channel"]
|
||||
if idx_cols != want:
|
||||
실패목록.append(f"uq_candle={idx_cols}")
|
||||
_실패(f"UNIQUE uq_candle 기대 {want}, 실제 {idx_cols}")
|
||||
else:
|
||||
_성공(f"UNIQUE uq_candle={idx_cols}")
|
||||
dist = db.conn.execute(
|
||||
"SELECT source, channel, COUNT(*) AS n FROM ws_candles "
|
||||
"GROUP BY source, channel ORDER BY n DESC"
|
||||
).fetchall()
|
||||
print(" 현재 source/channel 분포:")
|
||||
for r in dist or []:
|
||||
print(f" {r['source']}+{r['channel']}: {int(r['n']):,}")
|
||||
bad = [
|
||||
r for r in (dist or [])
|
||||
if str(r["source"]) in ("rest", "kw_rest", "ws", "rollup_1m", "rollup")
|
||||
]
|
||||
if bad:
|
||||
실패목록.append(f"레거시 source 잔존 {bad}")
|
||||
_실패(f"레거시 source 라벨 잔존: {bad}")
|
||||
else:
|
||||
_성공("레거시 source=rest/ws/rollup_1m 잔존 없음")
|
||||
except Exception as exc:
|
||||
실패목록.append(f"스키마 검증 오류: {exc}")
|
||||
_실패(f"ws_candles 스키마 검증 오류: {exc}")
|
||||
|
||||
# ── 최종 ───────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 78)
|
||||
if 실패목록:
|
||||
|
||||
Reference in New Issue
Block a user