feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가

변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,419 @@
"""
kis_trader/strategies/updown_watchlist.py — UPDOWN(박스권) sticky 관심종목 관리
================================================================================
[역할]
- 박스권(횡보) 후보를 담는 **멤버십 전용 테이블** (`updown_watchlist`).
- 파라미터는 여기 저장하지 않는다. (글로벌 env ``UPDOWN_*`` 사용, 종목별 override 는
선택적으로 ``updow_stock_config`` 핀(pin)). 이 테이블은 "누구를 들고 보느냐"만 관리.
[핵심 설계 — sticky]
- 조건식/관심그룹에서 잠깐 빠져도 **자동 삭제하지 않는다** (sticky). 박스권은 며칠~몇 주
유지되므로, 리프레시 사이 멤버십이 출렁여도 watchlist 는 흔들리면 안 된다.
- 진짜 퇴출(졸업/탈락/만료)은 ``DELETE`` 가 아니라 ``status`` 플래그(soft-delete).
재진입 대비로 보관하다가, retention 기간 초과 시에만 청소(purge) → 갈비지 방지.
[한도/교체]
- active(매수체크 대상) 행은 ``UPDOWN_WATCH_MAX`` (기본 40) 로 상한.
- 꽉 찼는데 새 박스 종목이 들어오면 **시간순 교체**: 가장 오래된(신호도 없는) active 를
밀어낸다(evicted). 단 **현재 보유 중인 종목은 절대 밀어내지 않는다**(청산 감시 필요).
[상태(status)]
- active : 매수체크 대상 (WS 구독 + 트리거)
- graduated : 익절/박스상단 돌파로 청산 성공 → 보관(쿨다운 후 재진입 가능)
- failed : 박스 하단 이탈 손절 → 보관(재진입 시 재평가)
- expired : N거래일 무신호 → 보관
- evicted : 한도 초과로 시간순 밀려남 → 보관
모든 수치는 하드코딩 금지 — ``get_env_int`` 로 DB/Env 에서 로드한다.
"""
from __future__ import annotations
import logging
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Set
from ..utils.env import get_env_int
logger = logging.getLogger("kis_trader.updown_watchlist")
# active(매수체크/구독) 로 인정하는 상태 — 나머지는 inactive(보관)
ACTIVE_STATUS = "active"
INACTIVE_STATUSES = ("graduated", "failed", "expired", "evicted")
# 현재 보유 중인 종목은 eviction/expire 에서 보호 (구·신 전략명 모두)
_HELD_STRATEGIES = ("UPDOWN", "UPDOW")
_UPDOWN_WATCHLIST_DDL = """
CREATE TABLE IF NOT EXISTS updown_watchlist (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL,
name VARCHAR(50) NOT NULL DEFAULT '',
market_type VARCHAR(8) NOT NULL DEFAULT 'KR',
exchange VARCHAR(16) NOT NULL DEFAULT 'KRX',
source VARCHAR(16) NOT NULL DEFAULT 'manual',
status VARCHAR(16) NOT NULL DEFAULT 'active',
box_low DOUBLE NOT NULL DEFAULT 0,
box_high DOUBLE NOT NULL DEFAULT 0,
box_score DOUBLE NOT NULL DEFAULT 0,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NULL,
last_signal_at DATETIME NULL,
removed_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_updown_watch_code (code),
KEY idx_updown_watch_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='UPDOWN 박스권 sticky 관심종목(멤버십)'
"""
def _trade_db_core(db: Any):
"""TradeDBExt 이면 ``.raw``, 아니면 그대로 (다른 cfg 모듈과 동일 규약)."""
return getattr(db, "raw", db)
# ----------------------------------------------------------------------
# env 파라미터 (하드코딩 금지)
# ----------------------------------------------------------------------
def watch_max(db: Any = None) -> int:
"""active 상한 — ``UPDOWN_WATCH_MAX`` (기본 30).
30 = KIS 멀티시세(intstock_multprice) **1콜 한도**. watchlist 전체를 REST 1번으로
훑을 수 있게 30 으로 맞춘다(31개 이상이면 배치가 2콜로 늘어 효율 저하).
"""
return max(1, get_env_int("UPDOWN_WATCH_MAX", 30))
def watch_expire_days(db: Any = None) -> int:
"""무신호 만료 거래일 — ``UPDOWN_WATCH_EXPIRE_DAYS`` (기본 10). 자본·슬롯 묶임 방지."""
return max(1, get_env_int("UPDOWN_WATCH_EXPIRE_DAYS", 10))
def watch_retention_days(db: Any = None) -> int:
"""inactive 보관 일수 — ``UPDOWN_WATCH_RETENTION_DAYS`` (기본 30). 초과 시 purge."""
return max(1, get_env_int("UPDOWN_WATCH_RETENTION_DAYS", 30))
# ----------------------------------------------------------------------
# 테이블 준비
# ----------------------------------------------------------------------
def ensure_updown_watchlist_table(db: Any) -> None:
"""``updown_watchlist`` 테이블 생성 (없으면)."""
raw = _trade_db_core(db)
try:
raw.conn.execute(_UPDOWN_WATCHLIST_DDL.strip())
raw.conn.commit()
logger.info("📌 updown_watchlist 테이블 확인/생성")
except Exception as e:
logger.warning("updown_watchlist 테이블 생성 경고: %s", e)
# ----------------------------------------------------------------------
# 보유 종목 (eviction/expire 보호)
# ----------------------------------------------------------------------
def _held_codes(db: Any) -> Set[str]:
"""현재 UPDOWN 으로 보유 중인 종목코드 (active_trades). eviction/expire 제외 대상."""
raw = _trade_db_core(db)
try:
ph = ", ".join(["%s"] * len(_HELD_STRATEGIES))
rows = raw.conn.execute(
f"SELECT DISTINCT code FROM active_trades WHERE strategy IN ({ph})",
tuple(_HELD_STRATEGIES),
).fetchall()
return {str(r["code"]).strip() for r in (rows or []) if r and r.get("code")}
except Exception as e:
logger.debug("held_codes 조회 실패(무시): %s", e)
return set()
# ----------------------------------------------------------------------
# 조회
# ----------------------------------------------------------------------
def list_active_watchlist(db: Any) -> List[Dict[str, Any]]:
"""active 상태 종목 (WS 구독·트리거 대상). 오래된 순(신호 우선)으로 정렬."""
raw = _trade_db_core(db)
try:
rows = raw.conn.execute(
"SELECT * FROM updown_watchlist WHERE status = %s "
"ORDER BY COALESCE(last_signal_at, added_at) ASC, id ASC",
(ACTIVE_STATUS,),
).fetchall()
return [dict(r) for r in (rows or [])]
except Exception as e:
logger.warning("list_active_watchlist 실패: %s", e)
return []
def list_all_watchlist(db: Any) -> List[Dict[str, Any]]:
"""전체(active + inactive) — 웹 UI 표시용."""
raw = _trade_db_core(db)
try:
rows = raw.conn.execute(
"SELECT * FROM updown_watchlist "
"ORDER BY (status = %s) DESC, updated_at DESC, id DESC",
(ACTIVE_STATUS,),
).fetchall()
return [dict(r) for r in (rows or [])]
except Exception as e:
logger.warning("list_all_watchlist 실패: %s", e)
return []
def _get_row(db: Any, code: str) -> Optional[Dict[str, Any]]:
raw = _trade_db_core(db)
try:
r = raw.conn.execute(
"SELECT * FROM updown_watchlist WHERE code = %s", (str(code).strip(),)
).fetchone()
return dict(r) if r else None
except Exception:
return None
def active_count(db: Any) -> int:
raw = _trade_db_core(db)
try:
r = raw.conn.execute(
"SELECT COUNT(*) c FROM updown_watchlist WHERE status = %s", (ACTIVE_STATUS,)
).fetchone()
return int(r["c"]) if r else 0
except Exception:
return 0
# ----------------------------------------------------------------------
# 추가/교체 (sticky 핵심)
# ----------------------------------------------------------------------
def _activate_row(raw: Any, code: str, name: str, source: str,
box_low: float, box_high: float, box_score: float) -> None:
"""기존 행을 active 로 되살리거나(reactivate) 박스 정보만 갱신."""
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
raw.conn.execute(
"UPDATE updown_watchlist "
"SET status=%s, name=%s, source=%s, box_low=%s, box_high=%s, box_score=%s, "
" last_seen_at=%s, removed_at=NULL "
"WHERE code=%s",
(ACTIVE_STATUS, name, source, box_low, box_high, box_score, now, code),
)
def _insert_row(raw: Any, code: str, name: str, source: str,
box_low: float, box_high: float, box_score: float) -> None:
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
raw.conn.execute(
"INSERT INTO updown_watchlist "
"(code, name, market_type, exchange, source, status, box_low, box_high, box_score, "
" added_at, last_seen_at) "
"VALUES (%s, %s, 'KR', 'KRX', %s, %s, %s, %s, %s, %s, %s)",
(code, name, source, ACTIVE_STATUS, box_low, box_high, box_score, now, now),
)
def _evict_oldest(raw: Any, held: Set[str]) -> bool:
"""한도 초과 시 시간순(가장 오래·무신호) active 1개를 밀어낸다(evicted). 보유종목 제외.
Returns True 면 1개 비웠음(빈 슬롯 생김), False 면 밀어낼 게 없음(전부 보유 중 등).
"""
rows = raw.conn.execute(
"SELECT code FROM updown_watchlist WHERE status = %s "
"ORDER BY COALESCE(last_signal_at, added_at) ASC, id ASC",
(ACTIVE_STATUS,),
).fetchall()
for r in (rows or []):
c = str(r["code"]).strip()
if c in held:
continue # 보유 종목은 청산 감시 위해 보호 → 밀어내지 않음
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
raw.conn.execute(
"UPDATE updown_watchlist SET status=%s, removed_at=%s WHERE code=%s",
("evicted", now, c),
)
logger.info("🔄 updown_watchlist 시간순 교체 → evicted: %s", c)
return True
return False
def upsert_from_scan(
db: Any,
candidates: List[Dict[str, Any]],
*,
source: str = "condition",
) -> Dict[str, int]:
"""박스권 후보(SCAN 결과)를 sticky 로 반영.
candidates: [{"code","name","box_low","box_high","box_score"}, ...]
- 이미 active → 박스정보/last_seen 갱신 (그대로 유지)
- inactive 인데 재등장 → reactivate (재진입, 박스 이력 보존)
- 신규 → 빈 슬롯 있으면 추가, 꽉 찼으면 시간순 교체 후 추가
(보유종목만 남아 못 비우면 스킵)
※ 이 함수는 절대 '교체(replace)' 가 아니라 '추가(append)' 다.
후보에서 빠진 종목을 여기서 삭제하지 않는다(sticky). 삭제는 졸업/탈락/만료/purge 만.
"""
raw = _trade_db_core(db)
cap = watch_max(db)
held = _held_codes(db)
stats = {"added": 0, "reactivated": 0, "updated": 0, "skipped_full": 0}
for cand in candidates or []:
code = str(cand.get("code") or "").strip()
if not code:
continue
name = str(cand.get("name") or code)[:50]
box_low = float(cand.get("box_low") or 0)
box_high = float(cand.get("box_high") or 0)
box_score = float(cand.get("box_score") or 0)
row = _get_row(db, code)
if row and row.get("status") == ACTIVE_STATUS:
# 이미 active → 박스 정보·last_seen 만 갱신 (sticky 유지)
_activate_row(raw, code, name, source, box_low, box_high, box_score)
stats["updated"] += 1
continue
# 신규 or 재진입 → active 슬롯 확보 필요
cnt = active_count(db)
if cnt >= cap:
if not _evict_oldest(raw, held):
stats["skipped_full"] += 1
continue # 보유종목만 남아 비울 수 없음
if row:
_activate_row(raw, code, name, source, box_low, box_high, box_score)
stats["reactivated"] += 1
logger.info("♻️ updown_watchlist 재진입: %s (%s)", code, name)
else:
_insert_row(raw, code, name, source, box_low, box_high, box_score)
stats["added"] += 1
logger.info(" updown_watchlist 추가: %s (%s) [%s]", code, name, source)
raw.conn.commit()
return stats
# ----------------------------------------------------------------------
# 수동 추가/삭제 (웹 UI)
# ----------------------------------------------------------------------
def add_manual(db: Any, code: str, name: str = "",
box_low: float = 0.0, box_high: float = 0.0) -> bool:
"""UI 수동 추가. 한도 초과면 시간순 교체 시도."""
raw = _trade_db_core(db)
code = str(code).strip()
if not code:
return False
row = _get_row(db, code)
if not (row and row.get("status") == ACTIVE_STATUS):
if active_count(db) >= watch_max(db):
if not _evict_oldest(raw, _held_codes(db)):
logger.warning("⚠️ watchlist 가득 + 전부 보유 중 → 수동추가 보류: %s", code)
return False
if row:
_activate_row(raw, code, str(name or code)[:50], "manual", box_low, box_high, 0.0)
else:
_insert_row(raw, code, str(name or code)[:50], "manual", box_low, box_high, 0.0)
raw.conn.commit()
logger.info(" updown_watchlist 수동추가: %s (%s)", code, name)
return True
def remove_manual(db: Any, code: str) -> bool:
"""UI 수동 삭제 — 명시적 사용자 행동이므로 하드 삭제."""
raw = _trade_db_core(db)
code = str(code).strip()
if not code:
return False
raw.conn.execute("DELETE FROM updown_watchlist WHERE code = %s", (code,))
raw.conn.commit()
logger.info("🗑 updown_watchlist 수동삭제: %s", code)
return True
# ----------------------------------------------------------------------
# 상태 전이 (졸업/탈락/신호) — 엔진/전략에서 호출
# ----------------------------------------------------------------------
def mark_status(db: Any, code: str, status: str) -> None:
"""graduated/failed/expired 등으로 soft-delete (보관). 재진입 대비."""
if status == ACTIVE_STATUS:
status = ACTIVE_STATUS
raw = _trade_db_core(db)
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
try:
raw.conn.execute(
"UPDATE updown_watchlist SET status=%s, removed_at=%s WHERE code=%s",
(status, now, str(code).strip()),
)
raw.conn.commit()
except Exception as e:
logger.debug("mark_status 실패(무시): %s", e)
def touch_signal(db: Any, code: str) -> None:
"""매수신호(진입봉) 발생 시 호출 — 만료 시계 리셋."""
raw = _trade_db_core(db)
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
try:
raw.conn.execute(
"UPDATE updown_watchlist SET last_signal_at=%s WHERE code=%s",
(now, str(code).strip()),
)
raw.conn.commit()
except Exception as e:
logger.debug("touch_signal 실패(무시): %s", e)
# ----------------------------------------------------------------------
# 만료/청소 (장 마감 배치 등에서 호출)
# ----------------------------------------------------------------------
def expire_stale(db: Any) -> int:
"""active 중 ``UPDOWN_WATCH_EXPIRE_DAYS`` 거래일 이상 무신호 + 미보유 → expired.
last_signal_at 없으면 added_at 기준. 보유 중 종목은 보호.
Returns 만료 처리 건수.
"""
raw = _trade_db_core(db)
days = watch_expire_days(db)
held = _held_codes(db)
try:
rows = raw.conn.execute(
"SELECT code FROM updown_watchlist WHERE status = %s "
"AND COALESCE(last_signal_at, added_at) < (NOW() - INTERVAL %s DAY)",
(ACTIVE_STATUS, days),
).fetchall()
except Exception as e:
logger.warning("expire_stale 조회 실패: %s", e)
return 0
n = 0
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
for r in (rows or []):
c = str(r["code"]).strip()
if c in held:
continue # 보유 중이면 만료 보류
raw.conn.execute(
"UPDATE updown_watchlist SET status='expired', removed_at=%s WHERE code=%s",
(now, c),
)
n += 1
if n:
raw.conn.commit()
logger.info("⌛ updown_watchlist 만료(expired) %d건 (>%d거래일 무신호)", n, days)
return n
def purge_old(db: Any) -> int:
"""inactive 중 ``UPDOWN_WATCH_RETENTION_DAYS`` 초과한 행 삭제(갈비지 방지)."""
raw = _trade_db_core(db)
days = watch_retention_days(db)
ph = ", ".join(["%s"] * len(INACTIVE_STATUSES))
try:
cur = raw.conn.execute(
f"DELETE FROM updown_watchlist WHERE status IN ({ph}) "
"AND removed_at IS NOT NULL AND removed_at < (NOW() - INTERVAL %s DAY)",
(*INACTIVE_STATUSES, days),
)
raw.conn.commit()
n = int(getattr(cur, "rowcount", 0) or 0)
if n:
logger.info("🧹 updown_watchlist purge %d건 (보관 %d일 초과)", n, days)
return n
except Exception as e:
logger.warning("purge_old 실패: %s", e)
return 0