""" 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, }