Files
kis_bot/kis_trader/engine/dart_engine.py
Your Name 61bec4bd1d feat: Add DART strategy and related configurations
ㅇ
Changes:
- Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework.
- Updated the database schema to include DART-specific tables for disclosures and watchlists.
- Enhanced the backtesting and parameter search functionalities to support the DART strategy.
- Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy.

Impact:
- These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
2026-07-21 07:50:24 +09:00

381 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
#!/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