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.
This commit is contained in:
Your Name
2026-07-21 07:50:24 +09:00
parent 74db49149f
commit 61bec4bd1d
86 changed files with 4811 additions and 297 deletions

View File

@@ -0,0 +1,380 @@
#!/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