#!/usr/bin/env python3 """ kis_trader/engine/dart_engine.py — DART 수주 공시 후 RSI 반등 TRIGGER ==================================================================== [SCAN] Open DART 단일판매·공급계약 → dart_disclosures / dart_watchlist [TRIGGER] 공시 이후 이벤트 창 안에서 1분봉 RSI 과매도 후 재돌파 시 진입 [EXIT] 손절·익절·트레일·보유봉·EOD 실매·웹백테·Optuna 동일 판정 함수 사용. """ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from kis_trader.engine.scalping_engine import compute_rsi_series from kis_trader.utils.env import get_env_bool DART_STRATEGY_ID = "DART" def _to_bool(v: Any, default: bool = True) -> bool: if v is None: return default if isinstance(v, bool): return v s = str(v).strip().lower() if s in ("1", "true", "t", "y", "yes", "on"): return True if s in ("0", "false", "f", "n", "no", "off", ""): return False return default def dart_scan_enabled() -> bool: """SCAN 폴링 ON — DART_SCAN_ENABLED 우선, 없으면 DART_ENABLED.""" if get_env_from_db_raw("DART_SCAN_ENABLED") not in (None, ""): return get_env_bool("DART_SCAN_ENABLED", True) return get_env_bool("DART_ENABLED", True) def get_env_from_db_raw(key: str) -> Any: try: from kis_trader.utils.env import get_env_from_db return get_env_from_db(key, "") except Exception: return "" def get_dart_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """DB/env → 엔진 params (웹·실매·Optuna 공통).""" r: Dict[str, Any] = {} if env_row: r = dict(env_row) else: try: from database import TradeDB db = TradeDB() try: r = dict(db.get_merged_env_snapshot() or {}) finally: db.close() except Exception: r = {} def _f(key: str, default: float) -> float: try: v = r.get(key) if v in (None, "", "None"): return float(default) return float(v) except Exception: return float(default) def _i(key: str, default: int) -> int: try: v = r.get(key) if v in (None, "", "None"): return int(default) return int(float(v)) except Exception: return int(default) def _b(key: str, default: bool) -> bool: v = r.get(key) if v in (None, "", "None"): return default return _to_bool(v, default) return { "slot_money": _i("DART_SLOT_MONEY", _i("SLOT_MONEY_DEFAULT", 300_000)), "max_stocks": _i("DART_MAX_STOCKS", 5), "total_budget_krw": _i("DART_TOTAL_BUDGET_KRW", 1_500_000), "short_max_buy_amount": _i("DART_MAX_BUY_AMOUNT", 0), "time_start_hm": _i("DART_TIME_START", 930), "time_end_hm": _i("DART_TIME_END", 1520), "rsi_period": _i("DART_RSI_PERIOD", 5), "rsi_oversold": _f("DART_RSI_OVERSOLD", 30.0), "rsi_reclaim": _f("DART_RSI_RECLAIM", 35.0), "sl_pct": abs(_f("DART_STOP_LOSS_PCT", 0.02)), "tp_pct": abs(_f("DART_TAKE_PROFIT_PCT", 0.04)), "trail_pct": abs(_f("DART_TRAIL_PCT", 0.015)), "trail_arm_pct": abs(_f("DART_TRAIL_ARM_PCT", 0.02)), "max_hold_bars": _i("DART_MAX_HOLD_BARS", 60), "event_window_bars": _i("DART_EVENT_WINDOW_BARS", 120), "min_price": _f("DART_MIN_PRICE", 1000.0), "vol_mult": _f("DART_VOL_MULT", 1.5), "vol_window": _i("DART_VOL_WINDOW", 10), "max_loss_krw": _i("DART_MAX_LOSS_PER_TRADE_KRW", 150_000), "force_eod_exit": _b("DART_FORCE_EOD_EXIT", True), "trade_enabled": _b("DART_TRADE_ENABLED", False), "subscribe_enabled": _b("DART_SUBSCRIBE_ENABLED", False), "watch_ttl_hours": _i("DART_WATCH_TTL_HOURS", 24), "watch_max": _i("DART_WATCH_MAX", 15), "scan_enabled": _b("DART_SCAN_ENABLED", _b("DART_ENABLED", True)), } def dart_min_bars_required(params: Optional[Dict[str, Any]] = None) -> int: p = params or {} return max( int(p.get("rsi_period", 5)) + 5, int(p.get("vol_window", 10)) + 3, 20, ) def _candle_hm(ct: str) -> Optional[int]: s = str(ct or "") if len(s) < 12: return None try: return int(s[8:12]) except Exception: return None def _bars_since_event(candles: List[Dict], i: int, event_ct: str) -> Optional[int]: """신호봉 i 기준으로 event_ct(YYYYMMDDHHMM…) 이후 경과 봉 수.""" ev = str(event_ct or "")[:12] if len(ev) < 12: return None n = 0 for j in range(i + 1): ct = str(candles[j].get("candle_time") or "")[:12] if ct >= ev: n += 1 return n def eval_dart_buy_at_index( candles: List[Dict], i: int, params: Dict[str, Any], *, event_candle_time: str, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """ DART TRIGGER — 공시 시각 이후 event_window_bars 안에서 RSI 과매도 후 rsi_reclaim 상향 돌파 + (선택) 거래량 배수. """ rsi_period = int(params.get("rsi_period", 5)) rsi_os = float(params.get("rsi_oversold", 30.0)) rsi_rc = float(params.get("rsi_reclaim", 35.0)) vol_mult = float(params.get("vol_mult", 1.5)) vol_win = int(params.get("vol_window", 10)) time_start = int(params.get("time_start_hm", 930)) time_end = int(params.get("time_end_hm", 1520)) min_price = float(params.get("min_price", 1000.0)) event_win = int(params.get("event_window_bars", 120)) need = dart_min_bars_required(params) if i < need or i >= len(candles): return ("탈락-봉부족", "need=%d i=%d" % (need, i), None) c = candles[i] ct = str(c.get("candle_time") or "") hm = _candle_hm(ct) if hm is None: return ("탈락-시간없음", ct, None) if hm < time_start or hm >= time_end: return (None, None, None) since = _bars_since_event(candles, i, event_candle_time) if since is None or since <= 0: return ("탈락-공시전이벤트", str(event_candle_time)[:12], None) if since > event_win: return ("탈락-이벤트창초과", "since=%d win=%d" % (since, event_win), None) try: cl = float(c["close"]) vol = float(c.get("volume", 0) or 0) except Exception as e: return ("탈락-캔들파싱", str(e), None) if cl < min_price: return ("탈락-최소가격", "%.0f" % cl, None) closes = [float(x["close"]) for x in candles[: i + 1]] rsis = compute_rsi_series(closes, rsi_period) rsi = rsis[i] prev = rsis[i - 1] if i >= 1 else None if rsi is None or prev is None: return ("탈락-RSI없음", None, None) # 직전 과매도 구간을 찍고, 현재봉에서 reclaim 상향 if not (prev <= rsi_os and rsi >= rsi_rc and rsi > prev): return (None, None, None) if vol_win > 0 and vol_mult > 0: vs = [ float(candles[k].get("volume", 0) or 0) for k in range(max(0, i - vol_win), i) ] avg = sum(vs) / len(vs) if vs else 0.0 if avg > 0 and vol < avg * vol_mult: return ( "탈락-거래량부족", "%.0f < %.0f×%.2f" % (vol, avg, vol_mult), None, ) entry = float(c.get("open") or cl) # align: 신호=T-1 확정 후 진입은 호출측 return ( None, None, { "entry_price": entry, "rsi": round(float(rsi), 2), "signal_time": ct[:12], "event_bars": since, }, ) def check_buy_signal_dart_live( candles: List[Dict], params: Dict[str, Any], *, event_candle_time: str, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """ 실매: 마지막 확정봉(T-1)에서 신호, 진입가는 호출측이 T 시가/틱 정렬. live_backtest_align — ±1 보정 금지. """ if not candles: return ("탈락-봉없음", None, None) # 확정봉만 conf = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] if len(conf) < 2: conf = list(candles) # 신호 = 직전 확정봉 (마지막이 진행중일 수 있음) sig_i = len(conf) - 2 if len(conf) >= 2 else len(conf) - 1 if sig_i < 0: return ("탈락-봉부족", None, None) reject, msg, sig = eval_dart_buy_at_index( conf, sig_i, params, event_candle_time=event_candle_time, ) if reject or not sig: return (reject, msg, None) # 진입 참고가: 다음 봉 시가(있으면) else 신호봉 종가 if sig_i + 1 < len(conf): nxt = conf[sig_i + 1] try: sig["entry_price"] = float(nxt.get("open") or sig["entry_price"]) sig["entry_time"] = str(nxt.get("candle_time") or "")[:12] except Exception: pass else: sig["entry_time"] = sig.get("signal_time") return (None, None, sig) def check_sell_signal_dart_live( *, buy_price: float, highest: float, last_price: float, bars_held: int, params: Dict[str, Any], now_hm: Optional[int] = None, ) -> Tuple[bool, str]: """손절·익절·트레일·보유한도·EOD.""" if buy_price <= 0 or last_price <= 0: return False, "" sl = abs(float(params.get("sl_pct", 0.02))) tp = abs(float(params.get("tp_pct", 0.04))) trail = abs(float(params.get("trail_pct", 0.015))) arm = abs(float(params.get("trail_arm_pct", 0.02))) max_hold = int(params.get("max_hold_bars", 60)) pnl = (last_price - buy_price) / buy_price if pnl <= -sl: return True, "손절" if pnl >= tp: return True, "익절" hi = max(float(highest or buy_price), last_price) peak = (hi - buy_price) / buy_price if peak >= arm and hi > 0: dd = (hi - last_price) / hi if dd >= trail: return True, "트레일" if max_hold > 0 and bars_held >= max_hold: return True, "보유한도" if _to_bool(params.get("force_eod_exit"), True) and now_hm is not None: if now_hm >= 1520: return True, "EOD" return False, "" def run_dart_backtest_code( candles: List[Dict], params: Dict[str, Any], *, event_candle_time: str, slot_money: float, fee_rate: float = 0.00015, sell_tax: float = 0.0018, ) -> List[Dict[str, Any]]: """단일 종목·단일 공시 이벤트 백테 (간단 포트 외 호출용).""" trades: List[Dict[str, Any]] = [] if not candles: return trades conf = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] if len(conf) < dart_min_bars_required(params): return trades position = None for i in range(1, len(conf)): # 신호 = i-1, 진입 = i (align) reject, _msg, sig = eval_dart_buy_at_index( conf, i - 1, params, event_candle_time=event_candle_time, ) if position is None and sig and not reject: entry = float(conf[i].get("open") or conf[i].get("close") or 0) if entry <= 0: continue qty = max(1, int(slot_money / entry)) position = { "entry_price": entry, "entry_time": str(conf[i].get("candle_time") or "")[:12], "qty": qty, "highest": entry, "bars": 0, "rsi": sig.get("rsi"), } continue if position is None: continue position["bars"] += 1 cl = float(conf[i].get("close") or 0) hi = float(conf[i].get("high") or cl) position["highest"] = max(position["highest"], hi) hm = _candle_hm(str(conf[i].get("candle_time") or "")) sell, reason = check_sell_signal_dart_live( buy_price=position["entry_price"], highest=position["highest"], last_price=cl, bars_held=position["bars"], params=params, now_hm=hm, ) if not sell: continue buy_p = position["entry_price"] sell_p = cl qty = position["qty"] gross = (sell_p - buy_p) * qty fee = (buy_p + sell_p) * qty * fee_rate tax = sell_p * qty * sell_tax pnl = gross - fee - tax trades.append({ "entry_time": position["entry_time"], "exit_time": str(conf[i].get("candle_time") or "")[:12], "buy_price": buy_p, "sell_price": sell_p, "qty": qty, "pnl": pnl, "reason": reason, "rsi_entry": position.get("rsi"), }) position = None return trades