""" 실매 ↔ 포트폴리오 백테 정합 — 유니버스 슬롯·총한도·매수금액. 백테 ``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