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:
154
kis_trader/strategies/updown_scan.py
Normal file
154
kis_trader/strategies/updown_scan.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user