feat: Add new files and enhance backtesting functionality
Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
This commit is contained in:
@@ -61,6 +61,128 @@ def _is_non_stock(name: str, code: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def parse_eod_hm(raw: str, default: str = "15:25") -> Tuple[int, int]:
|
||||
"""EOD 시각 문자열 → (시, 분). ``1515`` / ``15:15`` 모두 허용."""
|
||||
s = str(raw or default).strip()
|
||||
if not s or s.lower() == "none":
|
||||
s = default
|
||||
if ":" in s:
|
||||
parts = s.split(":", 1)
|
||||
try:
|
||||
return int(parts[0]), int(parts[1])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if len(s) == 4 and s.isdigit():
|
||||
return int(s[:2]), int(s[2:])
|
||||
try:
|
||||
hh, mm = [int(x) for x in s.split(":")]
|
||||
return hh, mm
|
||||
except Exception:
|
||||
return 15, 25
|
||||
|
||||
|
||||
def is_live_eod_now(
|
||||
enabled: bool,
|
||||
eod_hm: str,
|
||||
now: dt,
|
||||
*,
|
||||
default_hm: str = "15:25",
|
||||
) -> bool:
|
||||
"""실매 EOD 당일청산 시각 도달 여부."""
|
||||
if not enabled:
|
||||
return False
|
||||
eod_hh, eod_mm = parse_eod_hm(eod_hm, default_hm)
|
||||
return (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm)
|
||||
|
||||
|
||||
# 전략별 EOD env 키 — 실매·백테·파라서치 공통
|
||||
_STRATEGY_EOD_SPEC: Dict[str, Tuple[str, str, bool, str, str]] = {
|
||||
"BREAKOUT": ("BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", True, "15:15", ""),
|
||||
"MOMENTUM": ("MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", True, "15:25", "MOMENTUM_FORCE_EOD_EXIT"),
|
||||
"TAIL": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"),
|
||||
"SHORT": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"),
|
||||
}
|
||||
|
||||
|
||||
def _params_truthy_bool(val: Any, default: bool) -> bool:
|
||||
if val is None or val == "" or val == "None":
|
||||
return default
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
return str(val).strip().lower() in ("1", "true", "t", "y", "yes", "on")
|
||||
|
||||
|
||||
def resolve_strategy_eod_params(
|
||||
params: Dict[str, Any],
|
||||
strategy_id: str,
|
||||
) -> Tuple[bool, str]:
|
||||
"""params → (eod_enabled, eod_hm). UI ``eod_enabled``/``eod_hm`` 우선, 없으면 env 키."""
|
||||
sid = str(strategy_id or "").strip().upper()
|
||||
if sid == "SHORT":
|
||||
sid = "TAIL"
|
||||
spec = _STRATEGY_EOD_SPEC.get(sid)
|
||||
if spec is None:
|
||||
return False, "15:25"
|
||||
en_key, hm_key, def_en, def_hm, leg_key = spec
|
||||
|
||||
if "eod_enabled" in params:
|
||||
enabled = _params_truthy_bool(params.get("eod_enabled"), def_en)
|
||||
elif en_key in params:
|
||||
enabled = _params_truthy_bool(params.get(en_key), def_en)
|
||||
elif leg_key and leg_key in params:
|
||||
enabled = _params_truthy_bool(params.get(leg_key), def_en)
|
||||
else:
|
||||
enabled = def_en
|
||||
|
||||
raw_hm = params.get("eod_hm")
|
||||
if raw_hm not in (None, "", "None"):
|
||||
eod_hm = str(raw_hm).strip()
|
||||
elif params.get(hm_key) not in (None, "", "None"):
|
||||
eod_hm = str(params.get(hm_key)).strip()
|
||||
else:
|
||||
eod_hm = def_hm
|
||||
return enabled, eod_hm
|
||||
|
||||
|
||||
def is_backtest_eod_bar(
|
||||
candle_time: str,
|
||||
enabled: bool,
|
||||
eod_hm: str,
|
||||
*,
|
||||
default_hm: str = "15:25",
|
||||
) -> bool:
|
||||
"""백테 1분봉/스캔키 — 실매 ``is_live_eod_now`` 와 동일 시각 기준."""
|
||||
if not enabled:
|
||||
return False
|
||||
eod_hh, eod_mm = parse_eod_hm(eod_hm, default_hm)
|
||||
t = str(candle_time).strip()
|
||||
if len(t) < 12:
|
||||
return False
|
||||
try:
|
||||
bar_hh = int(t[8:10])
|
||||
bar_mm = int(t[10:12])
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return (bar_hh > eod_hh) or (bar_hh == eod_hh and bar_mm >= eod_mm)
|
||||
|
||||
|
||||
def is_strategy_eod_bar(
|
||||
candle_time: str,
|
||||
params: Dict[str, Any],
|
||||
strategy_id: str,
|
||||
) -> bool:
|
||||
"""전략 params + 봉시각 → EOD 청산 여부 (실매와 동일 키·시각)."""
|
||||
sid = str(strategy_id or "").strip().upper()
|
||||
if sid == "SHORT":
|
||||
sid = "TAIL"
|
||||
spec = _STRATEGY_EOD_SPEC.get(sid)
|
||||
if spec is None:
|
||||
return False
|
||||
_, _, _, def_hm, _ = spec
|
||||
enabled, eod_hm = resolve_strategy_eod_params(params, sid)
|
||||
return is_backtest_eod_bar(candle_time, enabled, eod_hm, default_hm=def_hm)
|
||||
|
||||
|
||||
class BaseStrategy(ABC, threading.Thread):
|
||||
"""
|
||||
모든 전략의 공통 부모 클래스. threading.Thread 상속 → start() 시 독립 쓰레드.
|
||||
@@ -126,8 +248,10 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
self.universe_source = default
|
||||
|
||||
self._running = False
|
||||
# 보유 종목 (DB active_trades 로부터 로드 — 전략별 필터)
|
||||
# 보유 종목 — 매 루프 DB active_trades 와 동기화 (진실의 원천 = DB)
|
||||
self.holdings: Dict[str, dict] = {}
|
||||
# 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지)
|
||||
self._runtime: Dict[str, dict] = {}
|
||||
# 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프)
|
||||
self.recently_sold: Dict[str, float] = {}
|
||||
# 당일 매매불가 종목 (다음 후보로 넘어감)
|
||||
@@ -136,7 +260,7 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
# 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF)
|
||||
self.daily_profit_halt: Any = None
|
||||
|
||||
self._load_holdings_from_db()
|
||||
self._sync_holdings_from_db(log_restore=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 외부 인터페이스
|
||||
@@ -235,6 +359,9 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
# 설정 리로드 (DB env_config 실시간 반영)
|
||||
self.reload_config()
|
||||
|
||||
# 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
|
||||
self._sync_holdings_from_db()
|
||||
|
||||
# ── [1] 매도 먼저 ────────────────────────────────
|
||||
sell_signals = self.check_sell_signals()
|
||||
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
|
||||
@@ -258,6 +385,9 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
if candidates and active_cnt < max_stocks and self.check_buy_allowed():
|
||||
self._scan_and_buy(candidates, max_stocks, active_cnt)
|
||||
|
||||
# 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
|
||||
self._capture_runtime_overlay()
|
||||
|
||||
time.sleep(self._scan_sleep("loop"))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -470,6 +600,7 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
"name": req.name,
|
||||
"size_class": req.size_class or "",
|
||||
}
|
||||
self._capture_runtime_overlay()
|
||||
elif result.success and signal.get("use_limit_buy"):
|
||||
self.on_limit_buy_submitted(signal, result)
|
||||
else:
|
||||
@@ -496,16 +627,14 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
result = self.order_mgr.place(req)
|
||||
if result.success:
|
||||
self.recently_sold[req.code] = time.time()
|
||||
self.holdings.pop(req.code, None)
|
||||
elif result.reason in ("broker_no_position", "ghost_cooldown") or (
|
||||
result.extra and result.extra.get("purge_holdings")
|
||||
):
|
||||
self._drop_local_position(req.code)
|
||||
elif result.reason == "broker_no_position":
|
||||
if req.code in self.holdings:
|
||||
self.logger.info(
|
||||
"🧹 [유령정리] %s %s — 로컬 holdings 제거 (%s)",
|
||||
req.name, req.code, result.reason,
|
||||
)
|
||||
self.holdings.pop(req.code, None)
|
||||
self._drop_local_position(req.code)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -599,19 +728,84 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
return tail_cd
|
||||
return get_env_int("REENTRY_COOLDOWN_SEC", 300)
|
||||
|
||||
def _load_holdings_from_db(self) -> None:
|
||||
"""DB active_trades 에서 본 전략 소유 포지션 로드.
|
||||
# DB sync 시 holdings 에 합치지 않고 _runtime 만 유지하는 장중 오버레이 키
|
||||
_RUNTIME_OVERLAY_KEYS: Tuple[str, ...] = (
|
||||
"max_price", "session_low",
|
||||
"updow_entry_bar_key", "box_low", "box_high",
|
||||
)
|
||||
|
||||
ETN/ETF/스팩 등 비본주는 KIS·키움 API에서 가격 조회 자체가 막혀 매분
|
||||
``[매도-가격없음]`` 로그를 무한 반복하므로 holdings 에서 자동 제외한다.
|
||||
(사용자가 한투 HTS 에서 직접 처분 — 봇은 매수/매도 시도 없음.)
|
||||
def _load_holdings_from_db(self, *, log_restore: bool = False) -> None:
|
||||
"""DB → holdings 동기화 (지정가 체결 등 이벤트 시 호출)."""
|
||||
self._sync_holdings_from_db(log_restore=log_restore)
|
||||
|
||||
def _drop_local_position(self, code: str) -> None:
|
||||
"""매도·유령정리 후 메모리 보유·런타임 오버레이 제거."""
|
||||
self.holdings.pop(code, None)
|
||||
self._runtime.pop(code, None)
|
||||
|
||||
def _merge_runtime_overlay(
|
||||
self, code: str, avg_bp: float, db_max: float, db_sess_low: float,
|
||||
) -> Tuple[float, float]:
|
||||
"""DB 행 + _runtime → max_price/session_low (퇴행 방지)."""
|
||||
rt = self._runtime.get(code) or {}
|
||||
max_p = max(
|
||||
avg_bp,
|
||||
float(db_max or 0),
|
||||
float(rt.get("max_price") or 0),
|
||||
)
|
||||
sess_candidates = [
|
||||
v for v in (
|
||||
avg_bp,
|
||||
float(db_sess_low or 0),
|
||||
float(rt.get("session_low") or 0),
|
||||
) if v > 0
|
||||
]
|
||||
sess_low = min(sess_candidates) if sess_candidates else avg_bp
|
||||
return max_p, sess_low
|
||||
|
||||
def _apply_runtime_extra_fields(self, code: str, holding: Dict[str, Any]) -> None:
|
||||
"""UPDOW 등 전략 부가 필드를 _runtime → holdings 로 복원."""
|
||||
rt = self._runtime.get(code) or {}
|
||||
for k in self._RUNTIME_OVERLAY_KEYS:
|
||||
if k in ("max_price", "session_low"):
|
||||
continue
|
||||
if k in rt and rt[k] is not None:
|
||||
holding[k] = rt[k]
|
||||
|
||||
def _capture_runtime_overlay(self) -> None:
|
||||
"""매도 판단 루프가 갱신한 고점·저점을 _runtime 에 저장."""
|
||||
for code, h in self.holdings.items():
|
||||
rt = self._runtime.setdefault(code, {})
|
||||
mp = float(h.get("max_price") or 0)
|
||||
if mp > float(rt.get("max_price") or 0):
|
||||
rt["max_price"] = mp
|
||||
sl = float(h.get("session_low") or 0)
|
||||
if sl > 0:
|
||||
prev = float(rt.get("session_low") or 0)
|
||||
rt["session_low"] = sl if prev <= 0 else min(prev, sl)
|
||||
for k in self._RUNTIME_OVERLAY_KEYS:
|
||||
if k in ("max_price", "session_low"):
|
||||
continue
|
||||
if k in h and h[k] is not None:
|
||||
rt[k] = h[k]
|
||||
|
||||
def _after_holdings_sync(self) -> None:
|
||||
"""서브클래스 훅 — DB sync 직후 (UPDOW entry_bar_key 등)."""
|
||||
return None
|
||||
|
||||
def _sync_holdings_from_db(self, *, log_restore: bool = False) -> None:
|
||||
"""DB active_trades → holdings (진실의 원천). 장중 고점은 _runtime 과 merge.
|
||||
|
||||
- DB에 없는 종목은 holdings·_runtime 에서 제거 (양방향 정합)
|
||||
- poll_pending 매수 체결·재시작 후에도 다음 루프에 자동 반영
|
||||
- ETN/ETF/스팩 등 비본주는 가격 API 불가 → 자동 제외
|
||||
"""
|
||||
try:
|
||||
prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
|
||||
rows = self.db.get_active_trades(strategy_prefix=prefix)
|
||||
skipped_non_stock: list[str] = []
|
||||
new_holdings: Dict[str, dict] = {}
|
||||
for code, t in rows.items():
|
||||
# 같은 prefix 라도 정확한 strategy 매칭만 가져감 (SCALP* 과 SHORT* 충돌 방지)
|
||||
if t.get("strategy") and t["strategy"] != self.strategy_id:
|
||||
continue
|
||||
if get_env_bool("EXCLUDE_NON_STOCK", True):
|
||||
@@ -619,29 +813,64 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
if _is_non_stock(name, code):
|
||||
skipped_non_stock.append(f"{code}({name})")
|
||||
continue
|
||||
avg_bp = float(t.get("avg_buy_price", 0) or 0)
|
||||
self.holdings[code] = {
|
||||
avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0)
|
||||
qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0)
|
||||
if qty <= 0 or avg_bp <= 0:
|
||||
continue
|
||||
db_max = float(t.get("max_price") or 0)
|
||||
db_sess = float(t.get("session_low") or 0)
|
||||
max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
|
||||
holding = {
|
||||
"buy_price": avg_bp,
|
||||
"qty": t.get("current_qty", 0),
|
||||
"qty": qty,
|
||||
"stop_price": t.get("stop_price", 0),
|
||||
"target_price": t.get("target_price", 0),
|
||||
"max_price": float(t.get("max_price") or avg_bp or 0),
|
||||
"session_low": float(t.get("session_low") or avg_bp or 0),
|
||||
"max_price": max_p,
|
||||
"session_low": sess_low,
|
||||
"atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
|
||||
"buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
"name": t.get("name", code),
|
||||
"size_class": t.get("size_class", ""),
|
||||
}
|
||||
if self.holdings:
|
||||
self.logger.info("📂 [DB 복원] 보유 %d종목 (%s)",
|
||||
len(self.holdings), self.strategy_id)
|
||||
self._apply_runtime_extra_fields(code, holding)
|
||||
new_holdings[code] = holding
|
||||
|
||||
db_codes = set(new_holdings.keys())
|
||||
for code in list(self._runtime.keys()):
|
||||
if code not in db_codes:
|
||||
del self._runtime[code]
|
||||
|
||||
prev_codes = set(self.holdings.keys())
|
||||
self.holdings.clear()
|
||||
self.holdings.update(new_holdings)
|
||||
self._after_holdings_sync()
|
||||
|
||||
if log_restore and self.holdings:
|
||||
self.logger.info(
|
||||
"📂 [DB 복원] 보유 %d종목 (%s)",
|
||||
len(self.holdings), self.strategy_id,
|
||||
)
|
||||
elif not log_restore:
|
||||
added = db_codes - prev_codes
|
||||
if added:
|
||||
self.logger.info(
|
||||
"📂 [DB동기화] +%d종목 (%s) poll/체결 반영: %s",
|
||||
len(added), self.strategy_id, ",".join(sorted(added)[:5]),
|
||||
)
|
||||
removed = prev_codes - db_codes
|
||||
if removed:
|
||||
self.logger.debug(
|
||||
"📂 [DB동기화] -%d종목 (%s) 청산 반영: %s",
|
||||
len(removed), self.strategy_id, ",".join(sorted(removed)[:5]),
|
||||
)
|
||||
|
||||
if skipped_non_stock:
|
||||
self.logger.warning(
|
||||
"⚠️ ETN/ETF 보유 자동 제외(매수/매도 모두 봇이 안 건드림 — 한투 HTS에서 직접 처분 권장): %s",
|
||||
", ".join(skipped_non_stock),
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("DB holdings 로드 실패: %s", e)
|
||||
self.logger.warning("DB holdings 동기화 실패: %s", e)
|
||||
|
||||
def _load_candidates(self) -> List[Dict]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user