Files
kis_bot/kis_trader/engine/daily_profit_halt.py
2026-07-30 18:05:07 +09:00

812 lines
30 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)