ㅇ 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.
381 lines
13 KiB
Python
381 lines
13 KiB
Python
#!/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
|