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:
2026-07-06 19:11:34 +09:00
parent 336d637b72
commit 78edb75e01
33 changed files with 1479 additions and 303 deletions

View File

@@ -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]:
"""