Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
481 lines
19 KiB
Python
481 lines
19 KiB
Python
"""
|
||
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``)** = 목표와 무관한 **수동** 매수 잠금(별도 스위치).
|
||
|
||
매도(손절·익절)는 계속 — **신규 매수만** 차단.
|
||
모든 임계값 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": get_env_bool("DAILY_PROFIT_TARGET_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 {
|
||
"enabled": _enabled_flag(
|
||
"DAILY_PROFIT_TARGET_ENABLED",
|
||
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,
|
||
),
|
||
}
|
||
|
||
|
||
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 _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)`` 로 신규 매수 차단 여부 판단.
|
||
"""
|
||
|
||
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,
|
||
):
|
||
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._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]] = {}
|
||
|
||
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 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} → 해당전략 신규매수 중단",
|
||
)
|
||
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)
|