Files
kis_bot/kis_trader/strategies/updown_scan.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

155 lines
5.7 KiB
Python

"""
kis_trader/strategies/updown_scan.py — UPDOWN 박스권 SCAN 배선
================================================================================
[목적 — SCAN vs TRIGGER 분리 원칙]
조건검색(HTS) 결과를 받아 **박스권 필터**만 통과시켜 ``updown_watchlist`` 에
충전(membership 등록)한다. 무거운 매수 직전 검사(호가·실시간가)는 여기서 하지 않고,
"이 종목이 며칠짜리 박스권인가?"라는 **느리게 변하는 멤버십**만 판정한다.
[흐름]
조건검색 후보(code,name)
→ 종목별 15분봉(기본) 조회
→ ``evaluate_box`` (좁은범위·밴드스퀴즈·추세없음)
→ is_box 통과분만 box_low/high/score 와 함께 watchlist upsert(soft)
→ 만료/정리(expire_stale)
[설계 메모]
- 박스 판별은 15분봉 lookback 기준 → 5분 스캔마다 종목당 분봉 1회 조회.
조건검색 후보가 많아도 watchlist cap(30) 안에서만 유지되므로 호출량 제한적.
- ``get_candles_fn(code, tf, n)`` 를 주입받아 데이터 소스(WS/REST)와 분리(테스트 용이).
- 모든 임계값은 ``updown_box.get_box_cfg_from_env`` 단일 소스(하드코딩 0).
"""
from __future__ import annotations
import logging
import random
import time
from typing import Any, Callable, Dict, List, Optional
from ..engine.updown_box import evaluate_box, get_box_cfg_from_env
from ..utils.env import get_env_int
from .updown_watchlist import (
ensure_updown_watchlist_table,
expire_stale,
upsert_from_scan,
)
logger = logging.getLogger("kis_trader.updown_scan")
# 분봉 조회 콜백 시그니처: (code, tf_min, n) -> List[candle dict]
GetCandlesFn = Callable[[str, int, int], List[Dict[str, Any]]]
def scan_tf_min() -> int:
"""SCAN 박스 판별에 쓸 분봉(분). 기본 15분 (수집·백테와 동일)."""
return get_env_int("UPDOWN_SCAN_TF_MIN", 15)
def scan_fetch_n() -> int:
"""박스 판별용 분봉 조회 개수. lookback + 여유."""
cfg = get_box_cfg_from_env()
base = int(cfg.get("lookback_bars", 20)) + int(cfg.get("bb_period", 20)) + 5
return get_env_int("UPDOWN_SCAN_FETCH_N", max(base, 45))
def box_filter_candidates(
candidates: List[Dict[str, Any]],
get_candles_fn: GetCandlesFn,
*,
cfg: Optional[Dict[str, float]] = None,
tf_min: Optional[int] = None,
fetch_n: Optional[int] = None,
sleep_between: bool = True,
) -> List[Dict[str, Any]]:
"""조건검색 후보 → 박스권 통과분만 (box_low/high/score 부착) 반환.
Args:
candidates : [{"code":.., "name":..}, ...] (조건검색 결과)
get_candles_fn : (code, tf_min, n) -> 분봉 리스트(시간 오름차순)
sleep_between : REST 부하 방지용 종목 간 random.sleep (실매 루프 기본 True)
Returns: [{"code","name","box_low","box_high","box_score"}, ...]
"""
c = cfg or get_box_cfg_from_env()
tf = int(tf_min if tf_min is not None else scan_tf_min())
n = int(fetch_n if fetch_n is not None else scan_fetch_n())
passed: List[Dict[str, Any]] = []
for cand in candidates or []:
code = str(cand.get("code") or "").strip()
if not code:
continue
name = (cand.get("name") or code or "").strip() or code
try:
raw = get_candles_fn(code, tf, n) or []
except Exception as e:
logger.debug("[UPDOWN SCAN] %s 분봉조회 실패: %s", code, e)
raw = []
# 종목 간 소량 슬립 (서버 부하/429 방지) — 실시간 매매 루프 아님
if sleep_between:
time.sleep(random.uniform(0.05, 0.15))
if len(raw) < int(c.get("min_bars", 20)):
continue
box = evaluate_box(raw, c)
if not box.get("is_box"):
continue
passed.append({
"code": code,
"name": name,
"box_low": float(box.get("box_low", 0) or 0),
"box_high": float(box.get("box_high", 0) or 0),
"box_score": float(box.get("box_score", 0) or 0),
})
return passed
def run_updown_scan(
db,
condition_candidates: List[Dict[str, Any]],
get_candles_fn: GetCandlesFn,
*,
source: str = "condition",
cfg: Optional[Dict[str, float]] = None,
sleep_between: bool = True,
) -> Dict[str, Any]:
"""SCAN 1회 실행: 조건검색 후보 → 박스필터 → watchlist 충전 + 만료정리.
Returns: {"scanned":N, "box_passed":M, "upserted":K, "expired":E}
"""
ensure_updown_watchlist_table(db)
scanned = len(condition_candidates or [])
box_cands = box_filter_candidates(
condition_candidates, get_candles_fn,
cfg=cfg, sleep_between=sleep_between,
)
stats: Dict[str, int] = {"added": 0, "reactivated": 0, "updated": 0, "skipped_full": 0}
if box_cands:
try:
stats = upsert_from_scan(db, box_cands, source=source)
except Exception as e:
logger.error("[UPDOWN SCAN] watchlist upsert 실패: %s", e)
expired = 0
try:
expired = expire_stale(db)
except Exception as e:
logger.debug("[UPDOWN SCAN] expire_stale 실패: %s", e)
upserted = int(stats.get("added", 0)) + int(stats.get("reactivated", 0))
logger.info(
"🔎 [UPDOWN SCAN] 조건후보=%d → 박스통과=%d → 신규+재진입=%d 갱신=%d 만석스킵=%d (만료=%d)",
scanned, len(box_cands), upserted,
int(stats.get("updated", 0)), int(stats.get("skipped_full", 0)), expired,
)
return {
"scanned": scanned,
"box_passed": len(box_cands),
"added": int(stats.get("added", 0)),
"reactivated": int(stats.get("reactivated", 0)),
"updated": int(stats.get("updated", 0)),
"skipped_full": int(stats.get("skipped_full", 0)),
"expired": expired,
}