변경 사항 ---- - _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>
261 lines
8.1 KiB
Python
261 lines
8.1 KiB
Python
"""
|
|
실매 ↔ 포트폴리오 백테 정합 — 유니버스 슬롯·총한도·매수금액.
|
|
백테 ``breakout_portfolio_backtest`` / ``backtest_portfolio_common`` 과 동일 분모.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime as dt
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
from ..backtest.backtest_portfolio_common import (
|
|
STRATEGY_PORTFOLIO_KEYS,
|
|
min_invest_ratio_of_slot,
|
|
portfolio_exposure_krw,
|
|
resolve_portfolio_params,
|
|
target_qty_and_cost,
|
|
)
|
|
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
|
|
from ..utils.position_sizing import invest_qty_for_price
|
|
|
|
# 자체 유니버스 — 기본 슬롯 필터 OFF (env 로 켤 수 있음)
|
|
_UNIVERSE_SLOT_SKIP_DEFAULT = frozenset({"UPDOW", "DBBAND"})
|
|
|
|
# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
|
|
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})
|
|
|
|
|
|
def portfolio_strategy_key(strategy_id: str) -> str:
|
|
sid = (strategy_id or "").upper()
|
|
return "TAIL" if sid == "SHORT" else sid
|
|
|
|
|
|
def _env_flag(key: str, default: bool) -> bool:
|
|
raw = get_env_from_db(key, "")
|
|
if raw in (None, "", "None"):
|
|
return default
|
|
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
|
|
|
|
|
|
def live_universe_slot_align_enabled(strategy_id: str) -> bool:
|
|
sid = (strategy_id or "").upper()
|
|
per_key = f"{sid}_LIVE_UNIVERSE_SLOT_ALIGN"
|
|
if get_env_from_db(per_key, "") not in (None, "", "None"):
|
|
return _env_flag(per_key, True)
|
|
if sid in _UNIVERSE_SLOT_SKIP_DEFAULT:
|
|
return False
|
|
return _env_flag("LIVE_UNIVERSE_SLOT_ALIGN", True)
|
|
|
|
|
|
def live_portfolio_budget_align_enabled(strategy_id: str) -> bool:
|
|
sid = (strategy_id or "").upper()
|
|
per_key = f"{sid}_LIVE_PORTFOLIO_BUDGET_ALIGN"
|
|
if get_env_from_db(per_key, "") not in (None, "", "None"):
|
|
return _env_flag(per_key, True)
|
|
if sid in _PORTFOLIO_BUDGET_SKIP_DEFAULT:
|
|
return False
|
|
return _env_flag("LIVE_PORTFOLIO_BUDGET_ALIGN", True)
|
|
|
|
|
|
def resolve_live_scan_interval_min(strategy_id: str) -> int:
|
|
sid = (strategy_id or "").upper()
|
|
v = get_env_int(f"{sid}_SCAN_INTERVAL_MIN", 0)
|
|
if v > 0:
|
|
return max(1, int(v))
|
|
return max(1, get_env_int("SCAN_INTERVAL_MIN", 1))
|
|
|
|
|
|
def slot_key_from_dt(when: dt, scan_interval_min: int = 1) -> str:
|
|
"""백테 ``_bt_slot_key`` / ``_slot_key`` 와 동일."""
|
|
iv = max(1, int(scan_interval_min))
|
|
date = when.strftime("%Y%m%d")
|
|
hm = when.hour * 100 + when.minute
|
|
total_min = (hm // 100) * 60 + (hm % 100)
|
|
slot_min = (total_min // iv) * iv
|
|
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
|
|
return date + str(slot_hm).zfill(4)
|
|
|
|
|
|
def resolve_live_total_budget_krw(
|
|
strategy_id: str,
|
|
max_stocks: int,
|
|
slot_money: float,
|
|
) -> float:
|
|
pkey = portfolio_strategy_key(strategy_id)
|
|
keys = STRATEGY_PORTFOLIO_KEYS.get(pkey, STRATEGY_PORTFOLIO_KEYS["SCALP"])
|
|
env_row: Dict[str, Any] = {}
|
|
for k in keys.get("total_budget", ()):
|
|
v = get_env_int(k, 0)
|
|
if v > 0:
|
|
env_row[k] = v
|
|
break
|
|
for k in keys.get("slot", ()):
|
|
v = get_env_int(k, 0)
|
|
if v > 0:
|
|
env_row[k] = v
|
|
for k in keys.get("max_stocks", ()):
|
|
v = get_env_int(k, 0)
|
|
if v > 0:
|
|
env_row[k] = v
|
|
pf = resolve_portfolio_params(
|
|
env_row if env_row else None,
|
|
strategy=pkey,
|
|
slot_money=float(slot_money),
|
|
max_stocks=int(max_stocks),
|
|
)
|
|
return float(pf["total_budget_krw"])
|
|
|
|
|
|
def resolve_live_min_invest_ratio(strategy_id: str) -> float:
|
|
return min_invest_ratio_of_slot({}, strategy=portfolio_strategy_key(strategy_id))
|
|
|
|
|
|
def history_universe_codes_at(
|
|
db: Any,
|
|
strategy_id: str,
|
|
when: Optional[dt] = None,
|
|
) -> Optional[Set[str]]:
|
|
"""
|
|
``target_candidates_history`` — ``at_time`` 이전 최신 스냅샷 종목 집합.
|
|
스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).
|
|
"""
|
|
when = when or dt.now()
|
|
at_time = when.strftime("%Y-%m-%d %H:%M:%S")
|
|
sid = (strategy_id or "").upper()
|
|
getter = getattr(db, "get_universe_at", None)
|
|
if getter is None:
|
|
return None
|
|
try:
|
|
rows = getter(strategy_id=sid, at_time=at_time) or []
|
|
except Exception:
|
|
return None
|
|
if not rows:
|
|
return None
|
|
codes: Set[str] = set()
|
|
for r in rows:
|
|
code = str(r.get("code") or "").strip()
|
|
if code:
|
|
codes.add(code)
|
|
return codes if codes else None
|
|
|
|
|
|
def filter_candidates_by_history_universe(
|
|
candidates: List[Dict],
|
|
db: Any,
|
|
strategy_id: str,
|
|
*,
|
|
when: Optional[dt] = None,
|
|
) -> Tuple[List[Dict], int]:
|
|
"""실시간 후보 ∩ history 스냅샷. (filtered, dropped_count)."""
|
|
if not candidates:
|
|
return [], 0
|
|
allowed = history_universe_codes_at(db, strategy_id, when)
|
|
if allowed is None:
|
|
return candidates, 0
|
|
out: List[Dict] = []
|
|
for c in candidates:
|
|
code = (c.get("code") or c.get("stk_cd") or "").strip()
|
|
if code and code in allowed:
|
|
out.append(c)
|
|
return out, len(candidates) - len(out)
|
|
|
|
|
|
def live_max_daily(strategy_id: str) -> int:
|
|
sid = (strategy_id or "").upper()
|
|
v = get_env_int(f"{sid}_MAX_DAILY", 0)
|
|
if v > 0:
|
|
return int(v)
|
|
if sid == "SHORT":
|
|
return get_env_int("TAIL_MAX_DAILY", 0)
|
|
return 0
|
|
|
|
|
|
def live_daily_entry_count(
|
|
db: Any,
|
|
code: str,
|
|
today: str,
|
|
strategy_id: str,
|
|
) -> int:
|
|
try:
|
|
trades = db.get_trades_by_date(today) or []
|
|
sid = (strategy_id or "").upper()
|
|
return len([
|
|
t for t in trades
|
|
if t.get("code") == code
|
|
and str(t.get("strategy", "")).upper().startswith(sid)
|
|
])
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def resolve_live_buy_qty(
|
|
curr_price: float,
|
|
holdings: Dict[str, Dict],
|
|
strategy_id: str,
|
|
slot_money: float,
|
|
*,
|
|
max_stocks: int,
|
|
invest_cap: Optional[float] = None,
|
|
) -> Tuple[int, float, Optional[str]]:
|
|
"""
|
|
포트폴리오 백테 ``target_qty_and_cost`` + ``min_invest_ratio`` + ``total_budget`` 정합.
|
|
Returns: (qty, invest_krw, reject_reason)
|
|
"""
|
|
if curr_price <= 0:
|
|
return 0, 0.0, "가격0"
|
|
cap = float(invest_cap if invest_cap is not None else slot_money)
|
|
if cap <= 0:
|
|
return 0, 0.0, "투자캡0"
|
|
exposure = portfolio_exposure_krw(holdings)
|
|
total_budget = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
|
|
remaining = max(0.0, total_budget - exposure)
|
|
ratio = resolve_live_min_invest_ratio(strategy_id)
|
|
target_qty, target_cost = target_qty_and_cost(curr_price, cap)
|
|
min_required = target_cost * ratio
|
|
if target_qty < 1 or remaining < min_required:
|
|
return 0, 0.0, "소액/잔여부족"
|
|
invest = min(cap, remaining, target_cost)
|
|
qty = invest_qty_for_price(curr_price, invest)
|
|
if qty < 1:
|
|
return 0, 0.0, "수량0"
|
|
cost = qty * curr_price
|
|
if cost < min_required:
|
|
return 0, 0.0, "소액"
|
|
return qty, invest, None
|
|
|
|
|
|
def live_portfolio_entry_reject(
|
|
db: Any,
|
|
holdings: Dict[str, Dict],
|
|
strategy_id: str,
|
|
code: str,
|
|
slot_money: float,
|
|
max_stocks: int,
|
|
) -> Optional[str]:
|
|
"""일일한도·총한도 — ``None`` 이면 통과."""
|
|
if not live_portfolio_budget_align_enabled(strategy_id):
|
|
return None
|
|
md = live_max_daily(strategy_id)
|
|
if md > 0:
|
|
today = dt.now().strftime("%Y%m%d")
|
|
cnt = live_daily_entry_count(db, code, today, strategy_id)
|
|
if cnt >= md:
|
|
return f"탈락-일일한도 daily={cnt}/{md}"
|
|
total = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
|
|
if total > 0 and portfolio_exposure_krw(holdings) >= total - 1e-6:
|
|
return "탈락-총한도"
|
|
return None
|
|
|
|
|
|
def live_portfolio_budget_full(
|
|
holdings: Dict[str, Dict],
|
|
strategy_id: str,
|
|
slot_money: float,
|
|
max_stocks: int,
|
|
) -> bool:
|
|
if not live_portfolio_budget_align_enabled(strategy_id):
|
|
return False
|
|
total = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
|
|
if total <= 0:
|
|
return False
|
|
return portfolio_exposure_krw(holdings) >= total - 1e-6
|