ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -11,6 +11,8 @@ kis_trader/engine/daily_profit_halt.py — 일일 익절 목표 달성 시 신
**신규매수 중단(``*_HALT_NEW_BUYS``)** = 목표와 무관한 **수동** 매수 잠금(별도 스위치).
매도(손절·익절)는 계속 — **신규 매수만** 차단.
옵션 B(잔여 리스크 버짓): 트레일/목표 hit 후 보유 최악손절합 > cushion 이면
약한(리스크 큰) 종목부터 전량 청산 — ``{SID}_DAILY_PROFIT_RISK_BUDGET_ENABLED`` (기본 OFF).
모든 임계값 env/DB — 하드코딩 금지.
"""
from __future__ import annotations
@@ -139,6 +141,14 @@ def load_strategy_profit_target(strategy_id: str) -> Dict[str, Any]:
"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)),
),
}
@@ -292,6 +302,217 @@ def _trail_reached(
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:
@@ -332,7 +553,9 @@ def describe_profit_guard_startup(cfg: Dict[str, Any], *, scope: str = "마스
class DailyProfitHaltGuard:
"""
Orchestrator 가 주입 — ``buy_allowed(strategy_id)`` 로 신규 매수 차단 여부 판단.
Orchestrator 가 주입 —
- ``buy_allowed(strategy_id)`` 신규매수 차단
- ``maybe_trim_open_risk(strategy_id)`` B안 잔여리스크 정리(전량)
"""
def __init__(
@@ -342,16 +565,24 @@ class DailyProfitHaltGuard:
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:
"""당일 손익 고점 갱신·반환. 날짜가 바뀌면 리셋."""
@@ -363,6 +594,103 @@ class DailyProfitHaltGuard:
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")
@@ -411,6 +739,11 @@ class DailyProfitHaltGuard:
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, ""