""" kis_trader/engine/daily_profit_halt.py — 일일 익절 목표 달성 시 신규 매수 중단 (2단) ============================================================================== 1단(마스터): ``DAILY_PROFIT_*`` — 봇 당일 실현손익 총합 → 전 전략 매수 OFF 2단(서브) : ``{STRATEGY}_DAILY_PROFIT_*`` — 해당 전략만 매수 OFF 목표 판정: 원(KRW) · 운용한도 대비 %(PCT) **둘 중 하나라도** 달성 시 트리거. 분모(%) : ``DAILY_PROFIT_TARGET_BUDGET_KRW`` 또는 ON 전략 ``*_TOTAL_BUDGET_KRW`` 합. **활성화(``*_TARGET_ENABLED``)** ON + 목표 설정 → 달성 시 **무조건** 신규매수 차단. **신규매수 중단(``*_HALT_NEW_BUYS``)** = 목표와 무관한 **수동** 매수 잠금(별도 스위치). 매도(손절·익절)는 계속 — **신규 매수만** 차단. 옵션 B(잔여 리스크 버짓): 트레일/목표 hit 후 보유 최악손절합 > cushion 이면 약한(리스크 큰) 종목부터 전량 청산 — ``{SID}_DAILY_PROFIT_RISK_BUDGET_ENABLED`` (기본 OFF). 모든 임계값 env/DB — 하드코딩 금지. """ from __future__ import annotations import threading import time from datetime import datetime as dt from typing import Any, Callable, Dict, List, Optional, Tuple from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from kis_trader.utils.logger import get_logger logger = get_logger("kis_trader.daily_profit_halt") # 전략 ID → 운용한도 env 키 (bot_pct 분모와 동일) _STRATEGY_BUDGET_ENV: Dict[str, str] = { "SCALP": "SCALP_TOTAL_BUDGET_KRW", "SHORT": "TAIL_TOTAL_BUDGET_KRW", "MOMENTUM": "MOMENTUM_TOTAL_BUDGET_KRW", "BREAKOUT": "BREAKOUT_TOTAL_BUDGET_KRW", "RANGE_BREAK": "RANGE_BREAK_TOTAL_BUDGET_KRW", "UPDOW": "UPDOW_TOTAL_BUDGET_KRW", "DBBAND": "DBBAND_TOTAL_BUDGET_KRW", } def _strategy_prefix(strategy_id: str) -> str: return (strategy_id or "").strip().upper() or "STRATEGY" def _enabled_flag(common_key: str, strategy_key: str, default: bool = False) -> bool: raw = str(get_env_from_db(strategy_key, "")).strip() if raw != "": return get_env_bool(strategy_key, default) return get_env_bool(common_key, default) def _target_krw(common_key: str, strategy_key: str, default: float = 0.0) -> float: sk = strategy_key if str(get_env_from_db(sk, "")).strip(): return max(0.0, float(get_env_float(sk, default))) return max(0.0, float(get_env_float(common_key, default))) def _target_pct(common_key: str, strategy_key: str, default: float = 0.0) -> float: sk = strategy_key if str(get_env_from_db(sk, "")).strip(): return max(0.0, float(get_env_float(sk, default))) return max(0.0, float(get_env_float(common_key, default))) def _mode(common_key: str, strategy_key: str, default: str = "fixed") -> str: """익절 판정 방식 — 전략 서브값 우선 → 마스터 → 기본(fixed). fixed : 기존 동작(고정 목표 KRW/PCT 도달 시 중단) trailing: 당일 손익 고점 대비 되돌림(trail) 발동 both : 고정(하드캡) OR 트레일 — 먼저 닿는 쪽 """ raw = str(get_env_from_db(strategy_key, "")).strip() if raw != "": return raw.lower() g = str(get_env_from_db(common_key, "")).strip() return (g or default).lower() def _halt_new_buys_flag(common_key: str, strategy_key: str, default: bool = False) -> bool: """수동 신규매수 중단 — 전략 서브값 우선 → 마스터 → 기본 OFF.""" raw = str(get_env_from_db(strategy_key, "")).strip() if raw != "": return get_env_bool(strategy_key, default) return get_env_bool(common_key, default) def load_global_profit_target() -> Dict[str, Any]: return { # 글로벌 ENABLED 폐기 — 손익감시는 전략별 *_DAILY_PROFIT_TARGET_ENABLED 만. "enabled": False, "krw": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_KRW", 0.0))), "pct": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_PCT", 0.0))), "budget_krw": max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_BUDGET_KRW", 0.0))), "halt_new_buys": _halt_new_buys_flag( "DAILY_PROFIT_HALT_NEW_BUYS", "DAILY_PROFIT_HALT_NEW_BUYS", False, ), "notify_mm": get_env_bool("DAILY_PROFIT_NOTIFY_MM", True), # 트레일링 익절 (당일 손익 고점 추적) — fixed 기본이라 미설정 시 동작 불변 "mode": _mode("DAILY_PROFIT_MODE", "DAILY_PROFIT_MODE", "fixed"), # 다단계 트레일 tier(우선) — '수익원:컷%,…'. 비면 단일 drop_pct 폴백. "trail_tiers": str(get_env_from_db("DAILY_PROFIT_TRAIL_TIERS", "")).strip(), "trail_drop_pct": max(0.0, float(get_env_float("DAILY_PROFIT_TRAIL_DROP_PCT", 0.0))), "trail_arm_krw": max(0.0, float(get_env_float("DAILY_PROFIT_TRAIL_ARM_KRW", 0.0))), "trail_arm_pct": max(0.0, float(get_env_float("DAILY_PROFIT_TRAIL_ARM_PCT", 0.0))), } def load_strategy_profit_target(strategy_id: str) -> Dict[str, Any]: pfx = _strategy_prefix(strategy_id) return { # 전략별만 (글로벌 DAILY_PROFIT_TARGET_ENABLED 폴백 없음) "enabled": get_env_bool(f"{pfx}_DAILY_PROFIT_TARGET_ENABLED", False), "krw": _target_krw( "DAILY_PROFIT_TARGET_KRW", f"{pfx}_DAILY_PROFIT_TARGET_KRW", 0.0, ), "pct": _target_pct( "DAILY_PROFIT_TARGET_PCT", f"{pfx}_DAILY_PROFIT_TARGET_PCT", 0.0, ), "budget_env": _STRATEGY_BUDGET_ENV.get(pfx, f"{pfx}_TOTAL_BUDGET_KRW"), "halt_new_buys": _halt_new_buys_flag( "DAILY_PROFIT_HALT_NEW_BUYS", f"{pfx}_DAILY_PROFIT_HALT_NEW_BUYS", False, ), # 트레일링 익절 — 전략 서브값 우선 → 마스터 폴백 (fixed 기본) "mode": _mode("DAILY_PROFIT_MODE", f"{pfx}_DAILY_PROFIT_MODE", "fixed"), # 다단계 트레일 tier — 전략 서브값 우선 → 마스터 폴백 (비면 단일 drop_pct) "trail_tiers": _mode("DAILY_PROFIT_TRAIL_TIERS", f"{pfx}_DAILY_PROFIT_TRAIL_TIERS", ""), "trail_drop_pct": _target_krw( "DAILY_PROFIT_TRAIL_DROP_PCT", f"{pfx}_DAILY_PROFIT_TRAIL_DROP_PCT", 0.0, ), "trail_arm_krw": _target_krw( "DAILY_PROFIT_TRAIL_ARM_KRW", f"{pfx}_DAILY_PROFIT_TRAIL_ARM_KRW", 0.0, ), "trail_arm_pct": _target_pct( "DAILY_PROFIT_TRAIL_ARM_PCT", f"{pfx}_DAILY_PROFIT_TRAIL_ARM_PCT", 0.0, ), # B: 잔여 리스크 버짓 — 전략별만 (마스터 없음). 기본 OFF. "risk_budget_enabled": get_env_bool( f"{pfx}_DAILY_PROFIT_RISK_BUDGET_ENABLED", False, ), "risk_budget_cooldown_sec": max( 0, int(get_env_int(f"{pfx}_DAILY_PROFIT_RISK_BUDGET_COOLDOWN_SEC", 300)), ), } def resolve_strategy_budget_krw(strategy_id: str) -> float: pfx = _strategy_prefix(strategy_id) env_key = _STRATEGY_BUDGET_ENV.get(pfx, f"{pfx}_TOTAL_BUDGET_KRW") v = get_env_int(env_key, 0) if v > 0: return float(v) # SLOT × MAX_STOCKS 폴백 slot_key = f"{pfx}_SLOT_MONEY" max_key = f"{pfx}_MAX_STOCKS" slot = get_env_int(slot_key, 0) mx = get_env_int(max_key, 0) if slot > 0 and mx > 0: return float(slot * mx) return 0.0 def resolve_global_operating_budget_krw(active_strategy_ids: List[str]) -> float: explicit = max(0.0, float(get_env_float("DAILY_PROFIT_TARGET_BUDGET_KRW", 0.0))) if explicit > 0: return explicit total = 0.0 for sid in active_strategy_ids: b = resolve_strategy_budget_krw(sid) if b > 0: total += b return total def _target_configured(cfg: Dict[str, Any]) -> bool: if not cfg.get("enabled"): return False return float(cfg.get("krw") or 0) > 0 or float(cfg.get("pct") or 0) > 0 def _target_reached(pnl_krw: float, cfg: Dict[str, Any], budget_krw: float) -> bool: if not _target_configured(cfg): return False krw_tgt = float(cfg.get("krw") or 0) pct_tgt = float(cfg.get("pct") or 0) if krw_tgt > 0 and pnl_krw >= krw_tgt: return True if pct_tgt > 0 and budget_krw > 0: need = budget_krw * pct_tgt / 100.0 if pnl_krw >= need: return True return False def _guard_active(cfg: Dict[str, Any]) -> bool: """모드별 가드 작동 여부 — fixed:목표값 / trailing:trail_drop / both:둘 중 하나. (기존 _target_configured 는 fixed 전용으로 유지하고, buy_allowed 는 이 함수를 쓴다.) """ if not cfg.get("enabled"): return False mode = str(cfg.get("mode") or "fixed").lower() if mode in ("fixed", "both"): if float(cfg.get("krw") or 0) > 0 or float(cfg.get("pct") or 0) > 0: return True if mode in ("trailing", "both"): if parse_trail_tiers(cfg.get("trail_tiers")): return True if float(cfg.get("trail_drop_pct") or 0) > 0: return True return False def _trail_arm_krw(cfg: Dict[str, Any], budget_krw: float) -> float: """트레일 발동 최소수익(원) — KRW·PCT 중 큰 값. 0 이면 트레일 비활성(과민발동 방지).""" arm = float(cfg.get("trail_arm_krw") or 0) arm_pct = float(cfg.get("trail_arm_pct") or 0) if arm_pct > 0 and budget_krw > 0: arm = max(arm, budget_krw * arm_pct / 100.0) return arm def parse_trail_tiers(raw: Any) -> List[Tuple[float, float]]: """다단계 트레일 tier 파싱: '수익원:컷%,…' → [(arm_krw, drop_pct), …] (arm 오름차순). 개별포지션 ratchet_tiers 와 동일 철학을 당일 누적손익에 적용. 예) '30000:50,100000:35,300000:25' = 누적 3만↑ 고점대비 -50% / 10만↑ -35% / 30만↑ -25% (벌수록 타이트) 잘못된 토큰은 건너뜀. 빈 문자열 → []. """ if raw is None: return [] s = str(raw).strip() if s == "" or s.lower() == "off": return [] tiers: List[Tuple[float, float]] = [] for tok in s.split(","): tok = tok.strip() if not tok or ":" not in tok: continue a, _, d = tok.partition(":") try: arm = float(a.strip()) drop = float(d.strip()) except (TypeError, ValueError): continue if arm < 0 or drop <= 0: continue tiers.append((arm, drop)) tiers.sort(key=lambda x: x[0]) return tiers def _active_tier_drop(peak_krw: float, tiers: List[Tuple[float, float]]) -> float: """peak 가 도달한 가장 높은 tier 의 컷% 반환. 최저 tier 미만이면 0(비발동).""" drop = 0.0 for arm, d in tiers: # arm 오름차순 if peak_krw >= arm: drop = d else: break return drop def _trail_reached( pnl_krw: float, peak_krw: float, cfg: Dict[str, Any], budget_krw: float, ) -> bool: """당일 손익 고점(peak) 대비 되돌림 시 트리거. 우선순위: 다단계 tier(trail_tiers) → 단일 trail_drop_pct(하위호환). tier: peak 가 속한 구간의 컷% 적용 → 벌수록 타이트(이익 보존 강화). 안전장치: tier·drop 없거나 ARM 미달이면 비활성(설정 없이 과민발동 금지). peak 가 ARM 이상으로 올라간 뒤부터만 감시 → 작은 수익에 조기 종료 방지. """ # 1) 다단계 tier 우선 tiers = parse_trail_tiers(cfg.get("trail_tiers")) if tiers: drop = _active_tier_drop(peak_krw, tiers) if drop <= 0: return False # 최저 tier(=arm) 미달 cut = peak_krw * (1.0 - drop / 100.0) return pnl_krw <= cut # 2) 단일 drop_pct 폴백 (기존 동작) drop = float(cfg.get("trail_drop_pct") or 0) if drop <= 0: return False arm = _trail_arm_krw(cfg, budget_krw) if arm <= 0: return False if peak_krw < arm: return False cut = peak_krw * (1.0 - drop / 100.0) return pnl_krw <= cut def trail_cut_line( peak_krw: float, cfg: Dict[str, Any], budget_krw: float, ) -> Optional[float]: """트레일 컷라인(원). tier/drop 과 _trail_reached 동일 공식. 비발동이면 None.""" tiers = parse_trail_tiers(cfg.get("trail_tiers")) if tiers: drop = _active_tier_drop(peak_krw, tiers) if drop <= 0: return None return float(peak_krw) * (1.0 - drop / 100.0) drop = float(cfg.get("trail_drop_pct") or 0) if drop <= 0: return None arm = _trail_arm_krw(cfg, budget_krw) if arm <= 0 or peak_krw < arm: return None return float(peak_krw) * (1.0 - drop / 100.0) def fixed_target_floor(cfg: Dict[str, Any], budget_krw: float) -> Optional[float]: """고정 목표 바닥(원) — KRW·PCT 중 설정된 값들의 최대.""" floors: List[float] = [] krw_tgt = float(cfg.get("krw") or 0) if krw_tgt > 0: floors.append(krw_tgt) pct_tgt = float(cfg.get("pct") or 0) if pct_tgt > 0 and budget_krw > 0: floors.append(budget_krw * pct_tgt / 100.0) if not floors: return None return max(floors) def hit_cut_line( pnl_krw: float, peak_krw: float, cfg: Dict[str, Any], budget_krw: float, *, trail_hit: bool, fixed_hit: bool, ) -> Optional[float]: """hit 시점 보호 바닥. 트레일 우선, 없으면 고정 목표.""" if trail_hit: cut = trail_cut_line(peak_krw, cfg, budget_krw) if cut is not None: return cut if fixed_hit: return fixed_target_floor(cfg, budget_krw) return None def risk_budget_cushion(pnl_krw: float, cut_line: float) -> float: """컷라인까지 남은 여유(원). 이미 밑이면 0.""" return max(0.0, float(pnl_krw) - float(cut_line)) # 손절가 미기록 시 env 폴백 (분율, 음수 또는 양수 모두 abs 처리) _FALLBACK_SL_ENV: Dict[str, Tuple[str, float]] = { "SHORT": ("STOP_LOSS_PCT", -0.04), "SCALP": ("SCALP_STOP_LOSS_PCT", -0.015), "MOMENTUM": ("MOMENTUM_STOP_LOSS_PCT", -0.015), "US_MOMENTUM": ("US_MOMENTUM_STOP_LOSS_PCT", -0.015), "BREAKOUT": ("BREAKOUT_STOP_LOSS_PCT", -0.02), "RANGE_BREAK": ("RANGE_BREAK_STOP_LOSS_PCT", -0.03), "UPDOW": ("UPDOW_STOP_LOSS_PCT", -0.025), "DBBAND": ("DBBAND_STOP_LOSS_PCT", -0.02), } def resolve_stop_price( *, entry: float, stop_price: float, strategy_id: str, ) -> float: """유효 손절가. holdings.stop_price 우선 → 전략 SL% 폴백.""" entry = float(entry or 0) sp = float(stop_price or 0) if entry <= 0: return 0.0 if sp > 0 and sp < entry: return sp pfx = _strategy_prefix(strategy_id) key, default = _FALLBACK_SL_ENV.get(pfx, (f"{pfx}_STOP_LOSS_PCT", -0.02)) raw = float(get_env_float(key, default) or default) # STOP_LOSS 가 %단위(2.0)로 올 수 있음 → 절대값≥1 이면 /100 if abs(raw) >= 1.0: raw = raw / 100.0 sl = -abs(raw) return entry * (1.0 + sl) def position_stop_risk_krw( *, qty: int, entry: float, stop_price: float, ) -> float: """손절까지 최악 실현손실(원). 미실현 익절 가정 없음.""" qty = int(qty or 0) entry = float(entry or 0) stop = float(stop_price or 0) if qty <= 0 or entry <= 0 or stop <= 0: return 0.0 return float(qty) * max(0.0, entry - stop) def build_open_risk_rows( strategy_id: str, holdings: Dict[str, dict], ) -> List[Dict[str, Any]]: """전략 holdings → 리스크 행 목록.""" rows: List[Dict[str, Any]] = [] for code, h in (holdings or {}).items(): if not code or not isinstance(h, dict): continue entry = float(h.get("buy_price") or h.get("avg_buy_price") or 0) qty = int(h.get("qty") or h.get("current_qty") or 0) if entry <= 0 or qty <= 0: continue stop = resolve_stop_price( entry=entry, stop_price=float(h.get("stop_price") or 0), strategy_id=strategy_id, ) risk = position_stop_risk_krw(qty=qty, entry=entry, stop_price=stop) rows.append({ "code": str(code), "name": str(h.get("name") or code), "qty": qty, "buy_price": entry, "stop_price": stop, "risk_krw": risk, "current_price": float(h.get("current_price") or h.get("max_price") or entry), }) return rows def select_closes_for_risk_budget( positions: List[Dict[str, Any]], deficit: float, ) -> List[Dict[str, Any]]: """부족분(deficit)만큼 리스크 큰 종목부터 전량 청산 대상 선정.""" need = float(deficit or 0) if need <= 0: return [] ordered = sorted( [p for p in (positions or []) if float(p.get("risk_krw") or 0) > 0], key=lambda p: -float(p.get("risk_krw") or 0), ) picked: List[Dict[str, Any]] = [] reduced = 0.0 for p in ordered: if reduced >= need: break picked.append(p) reduced += float(p.get("risk_krw") or 0) return picked def plan_risk_budget_trim( *, pnl_krw: float, peak_krw: float, cfg: Dict[str, Any], budget_krw: float, positions: List[Dict[str, Any]], trail_hit: bool, fixed_hit: bool, ) -> Dict[str, Any]: """B 판정 순수함수 — 실매·백테 공유.""" cut = hit_cut_line( pnl_krw, peak_krw, cfg, budget_krw, trail_hit=trail_hit, fixed_hit=fixed_hit, ) if cut is None: return { "action": "skip", "reason": "cut_line 없음", "closes": [], "cushion": 0.0, "worst": 0.0, "deficit": 0.0, "cut_line": None, } cushion = risk_budget_cushion(pnl_krw, cut) worst = sum(float(p.get("risk_krw") or 0) for p in (positions or [])) deficit = max(0.0, worst - cushion) if deficit <= 0: return { "action": "ok", "reason": "worst ≤ cushion", "closes": [], "cushion": cushion, "worst": worst, "deficit": 0.0, "cut_line": cut, } closes = select_closes_for_risk_budget(positions, deficit) return { "action": "trim", "reason": "worst > cushion", "closes": closes, "cushion": cushion, "worst": worst, "deficit": deficit, "cut_line": cut, } def _format_hit_detail( pnl_krw: float, cfg: Dict[str, Any], budget_krw: float, ) -> str: parts = [f"실현 {pnl_krw:+,.0f}원"] krw_tgt = float(cfg.get("krw") or 0) pct_tgt = float(cfg.get("pct") or 0) if krw_tgt > 0: parts.append(f"목표 {krw_tgt:,.0f}원") if pct_tgt > 0 and budget_krw > 0: parts.append(f"목표 {pct_tgt:.2f}% (한도 {budget_krw:,.0f}원)") return " · ".join(parts) def describe_profit_guard_startup(cfg: Dict[str, Any], *, scope: str = "마스터") -> str: """기동 로그용 — 설정이 매수에 미치는 영향을 한 줄로.""" if cfg.get("halt_new_buys"): return ( f"⛔ [일일익절·{scope}] 수동 신규매수 중단 ON — " f"목표·손익과 무관하게 신규매수 차단 (매도·손절 유지)" ) if not cfg.get("enabled"): return ( f"ℹ️ [일일익절·{scope}] 손익 감시 OFF — " f"목표 달성 시에도 신규매수 차단 없음" ) if not _guard_active(cfg): return ( f"ℹ️ [일일익절·{scope}] 손익 감시 ON 이지만 목표 미설정 " f"(금액·%·트레일 없음) — 달성 차단 없음" ) krw = float(cfg.get("krw") or 0) pct = float(cfg.get("pct") or 0) return ( f"🎯 [일일익절·{scope}] 손익 감시 ON — 목표 {krw:,.0f}원 / {pct:.2f}% " f"· 달성 시 신규매수 차단 (매도·손절 유지)" ) class DailyProfitHaltGuard: """ Orchestrator 가 주입 — - ``buy_allowed(strategy_id)`` 신규매수 차단 - ``maybe_trim_open_risk(strategy_id)`` B안 잔여리스크 정리(전량) """ def __init__( self, *, global_pnl_fn: Callable[[str], Tuple[float, int]], strategy_pnl_fn: Callable[[str, str], Tuple[float, int]], active_strategies_fn: Callable[[], List[str]], notify_fn: Optional[Callable[[str, Optional[str]], None]] = None, open_positions_fn: Optional[Callable[[str], List[Dict[str, Any]]]] = None, force_sell_fn: Optional[ Callable[[str, List[Dict[str, Any]], str], None] ] = None, ): self._global_pnl_fn = global_pnl_fn self._strategy_pnl_fn = strategy_pnl_fn self._active_strategies_fn = active_strategies_fn self._notify_fn = notify_fn self._open_positions_fn = open_positions_fn self._force_sell_fn = force_sell_fn self._lock = threading.Lock() self._notified_keys: set = set() self._last_log_ts: Dict[str, float] = {} # 트레일링용 당일 손익 고점 추적 {scope_key: (today, peak_krw)} self._peaks: Dict[str, Tuple[str, float]] = {} # B: 당일 정리 완료 시각 {sid:today -> unix_ts} self._risk_budget_done_ts: Dict[str, float] = {} def _update_peak(self, scope_key: str, today: str, pnl_krw: float) -> float: """당일 손익 고점 갱신·반환. 날짜가 바뀌면 리셋.""" with self._lock: day, peak = self._peaks.get(scope_key, (today, pnl_krw)) if day != today: peak = pnl_krw peak = max(peak, pnl_krw) self._peaks[scope_key] = (today, peak) return peak def maybe_trim_open_risk(self, strategy_id: str) -> Optional[Dict[str, Any]]: """ B안: 전략 일일익절 hit 상태이면 cushion vs 보유 최악손절합 비교 후 부족분만큼 전량 청산. 마스터(총합) 경로에는 붙이지 않음(전략별만). """ if not self._open_positions_fn or not self._force_sell_fn: return None today = dt.now().strftime("%Y-%m-%d") sid = _strategy_prefix(strategy_id) scfg = load_strategy_profit_target(sid) if not scfg.get("risk_budget_enabled"): return None if not _guard_active(scfg): return None spnl, scnt = self._strategy_pnl_fn(today, sid) sbudget = resolve_strategy_budget_krw(sid) speak = self._update_peak(f"{sid}:{today}", today, spnl) mode = str(scfg.get("mode") or "fixed").lower() fixed_hit = mode in ("fixed", "both") and _target_reached(spnl, scfg, sbudget) trail_hit = mode in ("trailing", "both") and _trail_reached( spnl, speak, scfg, sbudget, ) if not (fixed_hit or trail_hit): return None done_key = f"{sid}:{today}" cooldown = int(scfg.get("risk_budget_cooldown_sec") or 0) now = time.time() with self._lock: last = float(self._risk_budget_done_ts.get(done_key) or 0) if last > 0 and (cooldown <= 0 or (now - last) < cooldown): return None try: positions = list(self._open_positions_fn(sid) or []) except Exception as ex: logger.warning("⚠️ [%s] 리스크버짓 보유조회 실패: %s", sid, ex) return None plan = plan_risk_budget_trim( pnl_krw=spnl, peak_krw=speak, cfg=scfg, budget_krw=sbudget, positions=positions, trail_hit=trail_hit, fixed_hit=fixed_hit, ) if plan.get("action") != "trim" or not plan.get("closes"): with self._lock: self._risk_budget_done_ts[done_key] = now if plan.get("action") == "ok": self._throttled_log( sid, f"📅 [리스크버짓·{sid}] worst={plan['worst']:,.0f} ≤ " f"cushion={plan['cushion']:,.0f} → 보유 유지", ) return plan closes = list(plan["closes"]) reason = ( f"일일익절리스크버짓(cushion={plan['cushion']:,.0f}" f"/worst={plan['worst']:,.0f}/deficit={plan['deficit']:,.0f})" ) try: self._force_sell_fn(sid, closes, reason) except Exception as ex: logger.warning("⚠️ [%s] 리스크버짓 청산 실패: %s", sid, ex) return {**plan, "error": str(ex)} with self._lock: self._risk_budget_done_ts[done_key] = now codes = ",".join(str(c.get("code")) for c in closes[:8]) msg = ( f"🧯 [리스크버짓·{sid}] 정리 {len(closes)}종목 " f"cut={plan['cut_line']:,.0f} cushion={plan['cushion']:,.0f} " f"worst={plan['worst']:,.0f} → {codes}" ) logger.info(msg) if self._notify_fn: try: body = ( f"🧯 **일일익절 리스크버짓 정리**\n" f"- 범위: **{sid}**\n" f"- 실현 {spnl:+,.0f}원 · 고점 {speak:+,.0f}원\n" f"- cut {plan['cut_line']:,.0f} · cushion {plan['cushion']:,.0f} " f"· worst {plan['worst']:,.0f}\n" f"- 전량청산 {len(closes)}종목 ({codes})\n" f"- 청산누적 {scnt}건 · 신규매수는 계속 중단" ) self._notify_fn(body, sid) except Exception as ex: logger.debug("리스크버짓 MM 실패: %s", ex) return plan def buy_allowed(self, strategy_id: str) -> Tuple[bool, str]: """신규 매수 허용 여부. (False, 사유) 이면 매수 스킵.""" today = dt.now().strftime("%Y-%m-%d") sid = _strategy_prefix(strategy_id) gcfg = load_global_profit_target() # 1) 수동 신규매수 중단 (목표·손익과 무관) if gcfg.get("halt_new_buys"): self._throttled_log( sid, "⛔ [신규매수중단·총합] 수동 중단 ON → 신규매수 차단", ) return False, "탈락-신규매수중단(총합)" # 2) 일일익절 — 활성화 ON이면 목표 달성 시 무조건 신규매수 차단 if _guard_active(gcfg): gpnl, gcnt = self._global_pnl_fn(today) gbudget = resolve_global_operating_budget_krw(self._active_strategies_fn()) gpeak = self._update_peak(f"global:{today}", today, gpnl) hit, extra = self._check_hit(gpnl, gpeak, gcfg, gbudget) if hit: detail = _format_hit_detail(gpnl, gcfg, gbudget) + extra self._notify_once(f"global:{today}", "GLOBAL", detail, gcnt) self._throttled_log(sid, f"⛔ [일일익절·총합] {detail} → 신규매수 중단") return False, "탈락-일일익절(총합)" scfg = load_strategy_profit_target(sid) if scfg.get("halt_new_buys"): self._throttled_log( sid, f"⛔ [신규매수중단·{sid}] 수동 중단 ON → 해당전략 신규매수 차단", ) return False, f"탈락-신규매수중단({sid})" if _guard_active(scfg): spnl, scnt = self._strategy_pnl_fn(today, sid) sbudget = resolve_strategy_budget_krw(sid) speak = self._update_peak(f"{sid}:{today}", today, spnl) hit, extra = self._check_hit(spnl, speak, scfg, sbudget) if hit: detail = _format_hit_detail(spnl, scfg, sbudget) + extra self._notify_once(f"{sid}:{today}", sid, detail, scnt) self._throttled_log( sid, f"⛔ [일일익절·{sid}] {detail} → 해당전략 신규매수 중단", ) # hit 직후 B 평가 (매수루프가 비어도 base 에서 재호출) try: self.maybe_trim_open_risk(sid) except Exception as ex: logger.debug("리스크버짓(buy_allowed) 예외: %s", ex) return False, f"탈락-일일익절({sid})" return True, "" def _check_hit( self, pnl_krw: float, peak_krw: float, cfg: Dict[str, Any], budget_krw: float, ) -> Tuple[bool, str]: """모드별 트리거 판정. (hit, 로그 부가설명) 반환. both: 고정(하드캡) 먼저 검사 → 트레일 — 먼저 닿는 쪽 발동. """ mode = str(cfg.get("mode") or "fixed").lower() if mode in ("fixed", "both") and _target_reached(pnl_krw, cfg, budget_krw): return True, "" if mode in ("trailing", "both") and _trail_reached(pnl_krw, peak_krw, cfg, budget_krw): tiers = parse_trail_tiers(cfg.get("trail_tiers")) drop = ( _active_tier_drop(peak_krw, tiers) if tiers else float(cfg.get("trail_drop_pct") or 0) ) tag = "트레일·다단" if tiers else "트레일" return True, ( f" · {tag}(고점 {peak_krw:+,.0f}원 대비 -{drop:.0f}% 되돌림)" ) return False, "" def _throttled_log(self, strategy_id: str, msg: str) -> None: now = time.time() key = strategy_id if now - self._last_log_ts.get(key, 0.0) < 60.0: return self._last_log_ts[key] = now logger.info(msg) def _notify_once( self, notify_key: str, scope: str, detail: str, trade_cnt: int, ) -> None: gcfg = load_global_profit_target() if not gcfg.get("notify_mm", True) or not self._notify_fn: return with self._lock: if notify_key in self._notified_keys: return self._notified_keys.add(notify_key) title = "🎯 **일일 익절 목표 달성**" if scope == "GLOBAL": body = ( f"{title}\n" f"- 범위: **전체 봇 (총합)**\n" f"- {detail}\n" f"- 청산 {trade_cnt}건\n" f"- 조치: **금일 신규 매수 중단** (보유 종목 매도·손절은 유지)" ) else: body = ( f"{title}\n" f"- 범위: **{scope}**\n" f"- {detail}\n" f"- 청산 {trade_cnt}건\n" f"- 조치: **{scope} 신규 매수만 중단** (다른 전략·보유 매도는 유지)" ) try: self._notify_fn(body, scope if scope != "GLOBAL" else None) except Exception as ex: logger.debug("일일익절 MM 알림 실패: %s", ex)