Files
kis_bot/kis_trader/strategies/dart_strategy.py
Your Name bc2b1b642c feat(execution): AccountOrderWorker로 매수·매도 주문 직렬화
전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다.
틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:45:26 +09:00

243 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""
kis_trader/strategies/dart_strategy.py — DART 수주 공시 실매 전략
================================================================
유니버스 = dart_watchlist (구독 스위치) 또는 당일 disclosures.
매매 = STRATEGY_DART_ENABLED + DART_TRADE_ENABLED.
"""
from __future__ import annotations
from datetime import datetime as dt
from typing import Dict, List, Optional
from ..engine import dart_engine as de
from ..scan.dart_watchlist import event_time_for_code, list_active_watch
from ..utils.env import get_env_bool, get_env_int
from .base import BaseStrategy
class DartStrategy(BaseStrategy):
strategy_id = "DART"
loop_min_sleep = 1.0
loop_max_sleep = 2.5
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.candle_tf = 1
self._engine_params: Optional[Dict] = None
self.reload_config()
def reload_config(self) -> None:
try:
base = de.get_dart_defaults_from_db()
self._engine_params = base
self.min_price = float(base.get("min_price", 1000.0))
self.stop_loss_pct = -abs(float(base.get("sl_pct", 0.02)))
self.take_profit_pct = abs(float(base.get("tp_pct", 0.04)))
self.slot_money = int(base.get("slot_money", 300_000))
self.time_start_hm = int(base.get("time_start_hm", 930))
self.time_end_hm = int(base.get("time_end_hm", 1520))
except Exception as e:
self.logger.debug("dart defaults 실패: %s", e)
self._engine_params = {}
def _trade_on(self) -> bool:
return get_env_bool("DART_TRADE_ENABLED", False)
def _in_trade_window(self) -> bool:
now = dt.now()
hm = now.hour * 100 + now.minute
return self.time_start_hm <= hm < self.time_end_hm
def _db_raw(self):
return self.db.raw if hasattr(self.db, "raw") else self.db
def _load_candidates(self) -> List[Dict]:
out: List[Dict] = []
lim = get_env_int("DART_CAND_LIMIT", 20) or 20
try:
conn_db = self._db_raw()
watches = list_active_watch(conn_db)
for w in watches:
code = (w.get("stock_code") or "").strip()
if not code:
continue
out.append({
"code": code.zfill(6) if code.isdigit() else code,
"name": w.get("corp_name") or code,
"score": 0.0,
})
if out:
return out[:lim]
today0 = dt.now().strftime("%Y-%m-%d") + " 00:00:00"
rows = conn_db.conn.execute(
"""
SELECT stock_code, corp_name FROM dart_disclosures
WHERE first_seen_at >= %s AND stock_code <> ''
ORDER BY first_seen_at DESC LIMIT %s
""",
(today0, lim),
).fetchall()
for r in rows:
code = (r.get("stock_code") or "").strip()
if not code:
continue
out.append({
"code": code.zfill(6) if code.isdigit() else code,
"name": r.get("corp_name") or code,
"score": 0.0,
})
except Exception as e:
self.logger.debug("DART 후보 로드 실패: %s", e)
return out
def _candidate_filter(self, candidate: Dict) -> bool:
if not self._trade_on():
return False
if not candidate.get("code"):
return False
if not self._in_trade_window():
return False
return True
def _norm_candle(self, c: dict) -> dict:
ct = c.get("candle_time") or c.get("candle_time_str", "")
if isinstance(ct, str) and len(ct) == 19 and " " in ct:
ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
return {
"candle_time": ct,
"open": float(c.get("open", 0)),
"high": float(c.get("high", 0)),
"low": float(c.get("low", 0)),
"close": float(c.get("close", 0)),
"volume": float(c.get("volume", 0)),
"is_confirmed": c.get("is_confirmed", 1),
}
def _force_buy_test(self, code: str, name: str) -> Optional[Dict]:
wsd = self._ws_last_quote(code)
px = 0.0
if wsd:
try:
px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
except Exception:
px = 0.0
if px <= 0:
return None
qty, rej = self._resolve_buy_qty_live(px)
if rej or qty < 1:
return None
return {
"code": code,
"name": name,
"price": px,
"qty": qty,
"stop_price": px * (1 + self.stop_loss_pct),
"target_price": px * (1 + self.take_profit_pct),
"atr_entry": 0.0,
"size_class": "",
"entry_features": {"force_test": True},
}
def check_buy(self, code: str, name: str) -> Optional[Dict]:
if not self._trade_on() or not self._in_trade_window():
return None
try:
if get_env_bool("FORCE_BUY_TEST", False):
return self._force_buy_test(code, name)
params = self._engine_params or de.get_dart_defaults_from_db()
ev = event_time_for_code(self._db_raw(), code)
if not ev:
return None
need = de.dart_min_bars_required(params)
candles_raw = self.ws.get_candles(code, self.candle_tf, n=need + 30)
if len(candles_raw) < need:
try:
self.ws.fill_gap([code], force=True)
except Exception:
pass
return None
candles = [self._norm_candle(c) for c in candles_raw]
reject, msg, sig = de.check_buy_signal_dart_live(
candles, params, event_candle_time=ev,
)
if reject:
self.logger.info("🔍 [%s] %s(%s) %s", reject, name, code, msg or "")
return None
if not sig:
return None
curr_price = float(sig.get("entry_price") or 0)
if curr_price <= 0 or curr_price < self.min_price:
return None
hard_cap = get_env_int("DART_MAX_BUY_AMOUNT", 0) or get_env_int(
"MAX_BUY_AMOUNT_PER_STOCK", 0,
)
qty, rej = self._resolve_buy_qty_live(curr_price, hard_cap=hard_cap)
if rej or qty < 1:
return None
return {
"code": code,
"name": name,
"price": curr_price,
"qty": qty,
"stop_price": curr_price * (1 + self.stop_loss_pct),
"target_price": curr_price * (1 + self.take_profit_pct),
"atr_entry": 0.0,
"size_class": "",
"entry_features": {
"rsi": sig.get("rsi"),
"event_bars": sig.get("event_bars"),
"strategy": "DART",
},
}
except Exception as e:
self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
return None
def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]:
if not self.holdings:
return []
signals: List[Dict] = []
params = self._engine_params or de.get_dart_defaults_from_db()
now = dt.now()
hm = now.hour * 100 + now.minute
for code, holding in list(self.holdings.items()):
try:
name = holding.get("name", code)
buy_price = float(holding.get("buy_price", 0))
qty = int(holding.get("qty", 0))
max_price = float(holding.get("max_price", buy_price))
if qty <= 0 or buy_price <= 0:
continue
current_price = self._resolve_sell_price(
code, is_eod=False, buy_price=buy_price,
)
if current_price <= 0:
continue
if current_price > max_price:
max_price = current_price
holding["max_price"] = max_price
bars = int(holding.get("bars_held") or 0)
sell, reason = de.check_sell_signal_dart_live(
buy_price=buy_price,
highest=max_price,
last_price=current_price,
bars_held=bars,
params=params,
now_hm=hm,
)
if sell:
signals.append({
"code": code,
"name": name,
"qty": qty,
"reason": reason,
"price": current_price,
})
except Exception as e:
self.logger.error("DART 매도 체크 %s: %s", code, e)
return signals