#!/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