Files
kis_bot/kis_trader/strategies/updown_watchlist.py
Hwang 61c72a8a4c 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>
2026-07-06 01:27:00 +09:00

420 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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