Coverage for strategies / volume_breakout_strategy.py: 92%
91 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
1from __future__ import annotations
3from dataclasses import dataclass
4from typing import Any, Dict, List, Optional, Literal, Callable
5from strategies.base_strategy_config import BaseStrategyConfig
7# ===============================
8# 거래량 돌파 전략 설정 클래스
9# ===============================
10@dataclass
11class VolumeBreakoutConfig(BaseStrategyConfig):
12 """거래량 돌파 전략 및 백테스트용 설정"""
13 trigger_pct: float = 10.0 # 시가 대비 +10% 도달 시 매수 트리거
14 entry_push_pct: float = 2.0 # 신호 발생 후 추가 상승폭 (라이브 전략용)
15 trailing_stop_pct: float = 8.0 # 고가 대비 -8% 하락 시 익절
16 stop_loss_pct: float = -5.0 # 매수가 대비 -5% 손절
17 avg_vol_lookback_days: int = 20 # 평균 거래량 계산 기간 (거래일 기준 약 1개월)
18 avg_vol_multiplier: float = 2.0 # 평균 거래량 대비 최소 배수 (≥2배)
19 session: Literal["REGULAR", "EXTENDED"] = "REGULAR" # 거래 세션 구분
20 allow_reentry: bool = True # 당일 재진입 허용 여부 (False면 종목당 하루 1회만 매수)
22# ===============================
23# 거래량 돌파 전략 클래스
24# ===============================
25class VolumeBreakoutStrategy:
26 """
27 거래량 돌파 전략 및 단일일자 분봉 백테스트 지원:
28 - 시가 대비 trigger_pct 도달 시 매수
29 - 이후 고가 대비 trailing_stop_pct 하락 시 익절, 매수가 대비 stop_loss_pct 하락 시 손절
30 - 둘 다 도달하지 않으면 장 마감가로 청산
31 """
33 def __init__(
34 self,
35 *,
36 stock_query_service: Any,
37 market_clock: Any,
38 logger: Optional[Any] = None,
39 config: Optional[VolumeBreakoutConfig] = None,
40 ) -> None:
41 self.svc = stock_query_service # 분봉 데이터를 가져오는 서비스
42 self.market_clock = market_clock # 시간 포맷 변환 등 유틸
43 self.log = logger
44 self.cfg = config or VolumeBreakoutConfig()
46 # -------------------------
47 # 내부 유틸 함수
48 # -------------------------
49 @staticmethod
50 def _get_first_available(d: Dict[str, Any], keys: List[str], default: Any = None) -> Any:
51 """여러 키 중 첫 번째 유효한 값을 반환"""
52 for k in keys: 52 ↛ 56line 52 didn't jump to line 56 because the loop on line 52 didn't complete
53 v = d.get(k)
54 if v is not None and v != "-": 54 ↛ 52line 54 didn't jump to line 52 because the condition on line 54 was always true
55 return v
56 return default
58 def _sort_key(self, r: Dict[str, Any]) -> tuple:
59 """분봉 데이터를 날짜+시간 순으로 정렬하기 위한 키"""
60 d = str(self._get_first_available(r, ["stck_bsop_date", "bsop_date", "date"], ""))
61 t = str(self._get_first_available(r, ["stck_cntg_hour", "cntg_hour", "time"], ""))
62 return d, self.market_clock.to_hhmmss(t)
64 # -------------------------
65 # 공개 메서드: 분봉 백테스트
66 # -------------------------
67 async def backtest_open_threshold_intraday(
68 self,
69 stock_code: str,
70 *,
71 date_ymd: Optional[str] = None,
72 session: Optional[Literal["REGULAR", "EXTENDED"]] = None,
73 trigger_pct: Optional[float] = None,
74 trailing_stop_pct: Optional[float] = None,
75 sl_pct: Optional[float] = None,
76 price_getter: Optional[Callable[[Dict[str, Any]], Optional[float]]] = None,
77 ) -> Dict[str, Any]:
78 """하루치 분봉 데이터를 이용한 단일일자 백테스트 수행"""
79 session = session or self.cfg.session
80 trigger = self.cfg.trigger_pct if trigger_pct is None else trigger_pct
81 ts_pct = self.cfg.trailing_stop_pct if trailing_stop_pct is None else trailing_stop_pct
82 sl = self.cfg.stop_loss_pct if sl_pct is None else sl_pct
84 # 1) 분봉 데이터 로드
85 rows: List[Dict[str, Any]] = await self.svc.get_day_intraday_minutes_list(
86 stock_code=stock_code,
87 date_ymd=date_ymd,
88 session=session,
89 )
90 day_label = date_ymd or self.market_clock.get_current_kst_time().strftime("%Y%m%d")
91 if not rows:
92 return {"ok": False, "message": "분봉 데이터 없음", "stock_code": stock_code, "date": day_label, "trades": []}
94 rows = sorted(rows, key=self._sort_key)
96 # 2) 시가 설정 (첫 분봉의 시가 사용, 없으면 종가/가격 사용)
97 def default_price_getter(r: Dict[str, Any]) -> Optional[float]:
98 v = self._get_first_available(r, ["stck_prpr", "prpr", "close", "price"])
99 return float(v) if v not in (None, "", "-") else None
101 pg = price_getter or default_price_getter
102 open0_raw = self._get_first_available(rows[0], ["stck_oprc", "oprc", "open"]) or \
103 self._get_first_available(rows[0], ["stck_prpr", "prpr", "close", "price"])
104 try:
105 open0 = float(open0_raw)
106 except Exception:
107 return {"ok": False, "message": f"시가 파싱 실패(open0={open0_raw!r})", "stock_code": stock_code, "date": day_label, "trades": []}
109 # 3) 매수 트리거 찾기 (+trigger_pct 도달 시점)
110 entry_idx = None
111 entry_px = None
112 for i, r in enumerate(rows):
113 p = pg(r)
114 if p is None: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 continue
116 change = (p / open0 - 1.0) * 100.0
117 if change >= trigger:
118 entry_idx, entry_px = i, p
119 break
121 if entry_idx is None:
122 return {"ok": True, "message": f"트리거 {trigger}% 미발생", "stock_code": stock_code, "date": day_label, "trades": [], "equity": 1.0}
124 # 4) 익절/손절 조건 확인
125 exit_idx = None
126 exit_px = None
127 outcome = "close_exit"
128 curr_high = entry_px
129 for j in range(entry_idx + 1, len(rows)):
130 p = pg(rows[j])
131 if p is None: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 continue
133 curr_high = max(curr_high, p)
134 drop_from_high = (p / curr_high - 1.0) * 100.0
135 if drop_from_high <= -ts_pct:
136 exit_idx, exit_px, outcome = j, p, "trailing_stop"
137 break
138 pnl = (p / entry_px - 1.0) * 100.0
139 if pnl <= sl:
140 exit_idx, exit_px, outcome = j, p, "stop_loss"
141 break
143 if exit_idx is None:
144 last_price = pg(rows[-1])
145 if last_price is None: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 return {"ok": False, "message": "종가 가격 파싱 실패", "stock_code": stock_code, "date": day_label, "trades": []}
147 exit_idx, exit_px = len(rows) - 1, last_price
149 ret = (exit_px / entry_px) - 1.0
151 def fmt_ts(row: Dict[str, Any]) -> str:
152 d = str(self._get_first_available(row, ["stck_bsop_date", "bsop_date", "date"], ""))
153 t = str(self._get_first_available(row, ["stck_cntg_hour", "cntg_hour", "time"], ""))
154 return f"{d} {self.market_clock.to_hhmmss(t)}"
156 trade = {
157 "entry_time": fmt_ts(rows[entry_idx]),
158 "entry_px": float(entry_px),
159 "exit_time": fmt_ts(rows[exit_idx]),
160 "exit_px": float(exit_px),
161 "outcome": outcome,
162 "ret": ret,
163 "ret_pct": round(ret * 100.0, 3),
164 "open0": float(open0),
165 "trigger_pct": float(trigger),
166 "trailing_stop_pct": float(ts_pct),
167 "sl_pct": float(sl),
168 }
170 return {"ok": True, "message": "success", "stock_code": stock_code, "date": day_label, "equity": 1.0 + ret, "trades": [trade]}