브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
339
tail_engine.py
339
tail_engine.py
@@ -2,28 +2,39 @@
|
||||
"""
|
||||
tail_engine.py — 꼬리잡기 백테스트·실매매 공통 엔진
|
||||
====================================================
|
||||
백테스트(backtest_web)와 실매매(kis_short_ver3)가 동일한 진입/청산 계산식을 쓰도록 공통 로직만 둠.
|
||||
백테스트(backtest_web), 파라미터 탐색(tail_param_search), 실매매(kis_short_ver3)가
|
||||
모두 동일한 진입/청산 계산식과 '고급 방어 로직'을 쓰도록 통합된 단일 소스 엔진.
|
||||
|
||||
■ 엔진 공통 로직 (백테·실매 동일)
|
||||
매수: 당일 낙폭(drop) ≥ min_drop_rate, 당일 회복률(rec_day) ≥ min_recovery_ratio,
|
||||
망치봉 꼬리(tail_ratio/tail_pct), 3분봉 회복(rec_3m) 구간, RSI < rsi_threshold,
|
||||
고점추격 방지(high_chase_thr), 시간대/쿨다운/max_daily.
|
||||
매도: 손절가(sl_pct), 익절가(tp_pct), 어깨컷(shoulder_min_high + shoulder_cut_pct), 장마감.
|
||||
|
||||
■ 엔진 밖(실매매 전용): 금액손실컷, ATR 기반 손절/목표가, 퀵프로핏, MA20/ML 필터 등.
|
||||
매수: 당일 낙폭(drop), 회복률(rec_day), 망치봉 꼬리(tail_ratio/tail_pct), RSI < rsi_threshold,
|
||||
고점추격 방지, 시간대/쿨다운, + [V3 추가] MA20 필터, 일일 변동폭(피뢰침) 방어, 최소 가격.
|
||||
매도: ATR 기반 동적 손절/목표가, 어깨컷(trailing), 금액손실컷, 최소 보유 시간, 장마감 강제 청산.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
|
||||
def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
|
||||
"""봉 시각을 N분 단위 슬롯 키로 변환.
|
||||
|
||||
신봇 기준: caller 가 ``TradeDBExt.get_universe_by_candle_time()`` 으로
|
||||
1분 캔들 시각 키 dict 를 만들어 주입하므로 기본값은 1분(= passthrough).
|
||||
``--fallback-universe`` 시뮬레이션은 caller 가 ``scan_interval_min=5`` 를
|
||||
명시해 5분 버킷팅으로 사용.
|
||||
"""
|
||||
date = candle_time[:8]
|
||||
hm = int(candle_time[8:12])
|
||||
total_min = (hm // 100) * 60 + (hm % 100)
|
||||
slot_min = (total_min // scan_interval_min) * scan_interval_min
|
||||
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
|
||||
return date + str(slot_hm).zfill(4)
|
||||
|
||||
|
||||
def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
|
||||
"""
|
||||
env_config 최신 행에서 꼬리잡기 관련 값 전부 로드.
|
||||
env_config 최신 행에서 꼬리잡기 관련 값과 고급 방어 로직 값을 전부 로드.
|
||||
백테스트·파라미터서치·실매매가 동일 DB 값을 쓰도록 단일 소스.
|
||||
db: 기존 TradeDB 인스턴스. 주면 연결 생성/종료 없이 재사용 (봇 메인 루프에서 반복 호출 시 로그 스팸·연결 낭비 방지).
|
||||
결과에 영향을 주는 env 키: MIN_DROP_RATE, MIN_RECOVERY_RATIO_SHORT, TAIL_RATIO_MIN, TAIL_PCT_MIN,
|
||||
STOP_LOSS_PCT, TAKE_PROFIT_PCT, SHOULDER_MIN_HIGH_PCT, SHOULDER_CUT_PCT, RSI_PERIOD, RSI_OVERHEAT_THRESHOLD,
|
||||
REENTRY_COOLDOWN_SEC, HIGH_PRICE_CHASE_THRESHOLD, MAX_RECOVERY_RATIO_3M, (시간/일일한도).
|
||||
"""
|
||||
own_db = None
|
||||
try:
|
||||
@@ -34,6 +45,7 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
|
||||
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
|
||||
if row:
|
||||
r = dict(row)
|
||||
# 기본 전략 파라미터
|
||||
min_drop = float(r.get("MIN_DROP_RATE") or 0.03)
|
||||
min_rec = float(r.get("MIN_RECOVERY_RATIO_SHORT") or 0.5)
|
||||
tail_ratio = float(r.get("TAIL_RATIO_MIN") or 1.5)
|
||||
@@ -50,6 +62,23 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
|
||||
time_start = int(r.get("TAIL_TIME_START") or r.get("TIME_START") or 930)
|
||||
time_end = int(r.get("TAIL_TIME_END") or r.get("TIME_END") or 1500)
|
||||
max_daily = int(r.get("MAX_DAILY_TAIL") or r.get("MAX_STOCKS") or 3)
|
||||
|
||||
# 고급 방어 필터 파라미터 (V3 통합)
|
||||
min_price = float(r.get("MIN_PRICE_TAIL") or 1000.0)
|
||||
max_daily_change = float(r.get("MAX_DAILY_CHANGE_PCT") or 20.0)
|
||||
ma20_max_above = float(r.get("MA20_MAX_ABOVE_PCT") or 3.0)
|
||||
stop_atr_mult = float(r.get("STOP_ATR_MULTIPLIER_TAIL") or 2.5)
|
||||
target_atr_mult = float(r.get("TARGET_ATR_MULTIPLIER_TAIL") or 8.0)
|
||||
max_loss_krw = int(r.get("MAX_LOSS_PER_TRADE_KRW") or 200000)
|
||||
_min_drop_loss = r.get("MIN_DROP_PCT_FOR_LOSS_CUT")
|
||||
min_drop_pct_for_loss_cut = 0.015 # 기본 1.5%: 이 하락률 미만이면 금액손실컷 미발동(흔들림 방지)
|
||||
if _min_drop_loss not in (None, ""):
|
||||
v = float(_min_drop_loss)
|
||||
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
|
||||
risk_pct = float(r.get("RISK_PCT_PER_TRADE") or 0.01)
|
||||
kelly_mult = float(r.get("KELLY_MULTIPLIER") or 0.25)
|
||||
min_hold_sec = float(r.get("MIN_HOLD_AFTER_BUY_SEC") or 30.0)
|
||||
capital = float(r.get("BACKTEST_CAPITAL") or 100000000.0) # 백테스트용 기본 자본
|
||||
else:
|
||||
min_drop, min_rec = 0.03, 0.5
|
||||
tail_ratio, tail_pct = 1.5, 0.003
|
||||
@@ -58,6 +87,11 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
|
||||
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
|
||||
max_rec_3m, high_chase = 0.8, 0.96
|
||||
time_start, time_end, max_daily = 930, 1500, 3
|
||||
min_price, max_daily_change, ma20_max_above = 1000.0, 20.0, 3.0
|
||||
stop_atr_mult, target_atr_mult = 2.5, 8.0
|
||||
max_loss_krw, risk_pct, kelly_mult = 200000, 0.01, 0.25
|
||||
min_drop_pct_for_loss_cut = 0.015
|
||||
min_hold_sec, capital = 30.0, 100000000.0
|
||||
except Exception:
|
||||
min_drop, min_rec = 0.03, 0.5
|
||||
tail_ratio, tail_pct = 1.5, 0.003
|
||||
@@ -66,6 +100,11 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
|
||||
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
|
||||
max_rec_3m, high_chase = 0.8, 0.96
|
||||
time_start, time_end, max_daily = 930, 1500, 3
|
||||
min_price, max_daily_change, ma20_max_above = 1000.0, 20.0, 3.0
|
||||
stop_atr_mult, target_atr_mult = 2.5, 8.0
|
||||
max_loss_krw, risk_pct, kelly_mult = 200000, 0.01, 0.25
|
||||
min_drop_pct_for_loss_cut = 0.015
|
||||
min_hold_sec, capital = 30.0, 100000000.0
|
||||
finally:
|
||||
if own_db is not None:
|
||||
try:
|
||||
@@ -89,11 +128,23 @@ def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
|
||||
"time_start_hm": time_start,
|
||||
"time_end_hm": time_end,
|
||||
"max_daily": max_daily,
|
||||
# 고급 방어 파라미터 반환
|
||||
"min_price": min_price,
|
||||
"max_daily_change": max_daily_change,
|
||||
"ma20_max_above": ma20_max_above,
|
||||
"stop_atr_mult": stop_atr_mult,
|
||||
"target_atr_mult": target_atr_mult,
|
||||
"max_loss_krw": max_loss_krw,
|
||||
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
|
||||
"risk_pct": risk_pct,
|
||||
"kelly_mult": kelly_mult,
|
||||
"min_hold_sec": min_hold_sec,
|
||||
"capital": capital,
|
||||
}
|
||||
|
||||
|
||||
def compute_rsi_series(closes: List[float], period: int = 14) -> List[Optional[float]]:
|
||||
"""RSI 시리즈 (Wilder 스무딩). backtest_web과 동일."""
|
||||
"""RSI 시리즈 (Wilder 스무딩)."""
|
||||
rsi_list: List[Optional[float]] = [None] * len(closes)
|
||||
if len(closes) < period + 1:
|
||||
return rsi_list
|
||||
@@ -113,6 +164,40 @@ def compute_rsi_series(closes: List[float], period: int = 14) -> List[Optional[f
|
||||
return rsi_list
|
||||
|
||||
|
||||
def compute_sma_series(closes: List[float], period: int = 20) -> List[Optional[float]]:
|
||||
"""단순 이동평균(SMA) 계산기 (엔진 내부용)."""
|
||||
sma_list: List[Optional[float]] = [None] * len(closes)
|
||||
if len(closes) < period:
|
||||
return sma_list
|
||||
running_sum = sum(closes[:period])
|
||||
sma_list[period - 1] = running_sum / period
|
||||
for i in range(period, len(closes)):
|
||||
running_sum += closes[i] - closes[i - period]
|
||||
sma_list[i] = running_sum / period
|
||||
return sma_list
|
||||
|
||||
|
||||
def compute_atr_series(candles: List[Dict], period: int = 14) -> List[Optional[float]]:
|
||||
"""ATR(Average True Range) 변동성 지표 계산기 (엔진 내부용)."""
|
||||
atr_list: List[Optional[float]] = [None] * len(candles)
|
||||
if len(candles) < period + 1:
|
||||
return atr_list
|
||||
trs = [0.0] * len(candles)
|
||||
for i in range(1, len(candles)):
|
||||
hi = float(candles[i]["high"])
|
||||
lo = float(candles[i]["low"])
|
||||
prev_cl = float(candles[i - 1]["close"])
|
||||
trs[i] = max(hi - lo, abs(hi - prev_cl), abs(lo - prev_cl))
|
||||
|
||||
# ATR = SMA of TR
|
||||
running_sum = sum(trs[1:period+1])
|
||||
atr_list[period] = running_sum / period
|
||||
for i in range(period + 1, len(candles)):
|
||||
running_sum += trs[i] - trs[i - period]
|
||||
atr_list[i] = running_sum / period
|
||||
return atr_list
|
||||
|
||||
|
||||
def _t2dt(t: str) -> datetime:
|
||||
"""candle_time 문자열 → datetime."""
|
||||
return datetime.strptime(t, "%Y%m%d%H%M")
|
||||
@@ -125,17 +210,16 @@ def check_buy_signal_live(
|
||||
) -> tuple:
|
||||
"""
|
||||
실시간: 마지막 봉이 백테스트(run_tail_backtest)와 동일한 꼬리잡기 매수 조건을 만족하는지 판단.
|
||||
로직 단일 소스: run_tail_backtest와 완전 동일한 조건·계산식.
|
||||
[V3 통합]: MA20, 피뢰침, 최소가격 필터 등 고급 방어 로직 엔진 내장.
|
||||
|
||||
candles: 3분봉 리스트 (candle_time, open, high, low, close, volume)
|
||||
state: { "last_exit_dt": datetime|None, "daily_cnt": int }
|
||||
|
||||
반환: (reject_reason, reject_msg, signal_dict)
|
||||
- 통과 시: (None, None, {"signal": True, "tail_ratio", "recovery_pos", "rsi_val"})
|
||||
- 탈락 시: ("탈락-XXX", "상세메시지(숫자포함)", None) → 호출측에서 "🔍 [탈락-XXX] name code: 상세메시지" 로그
|
||||
"""
|
||||
if len(candles) < 10:
|
||||
return ("탈락-데이터", "봉 수 부족 (len<10)", None)
|
||||
if len(candles) < 20: # MA20 계산을 위해 최소 20봉 필요
|
||||
return ("탈락-데이터", f"봉 수 부족 (len={len(candles)} < 20)", None)
|
||||
|
||||
i = len(candles) - 1
|
||||
c = candles[i]
|
||||
day = c["candle_time"][:8]
|
||||
@@ -145,6 +229,7 @@ def check_buy_signal_live(
|
||||
lo = float(c["low"])
|
||||
cl = float(c["close"])
|
||||
|
||||
# 엔진 파라미터 로드
|
||||
min_drop_rate = float(params.get("min_drop_rate", 0.03))
|
||||
min_recovery_ratio = float(params.get("min_recovery_ratio", 0.5))
|
||||
max_rec_3m = float(params.get("max_rec_3m", 0.8))
|
||||
@@ -157,9 +242,14 @@ def check_buy_signal_live(
|
||||
time_end_hm = int(params.get("time_end_hm", 1500))
|
||||
cooldown_min = float(params.get("cooldown_min", 15))
|
||||
max_daily = int(params.get("max_daily", 3))
|
||||
|
||||
# 방어 로직 파라미터
|
||||
min_price = float(params.get("min_price", 1000.0))
|
||||
max_daily_change = float(params.get("max_daily_change", 20.0))
|
||||
ma20_max_above = float(params.get("ma20_max_above", 3.0))
|
||||
|
||||
if hm < time_start_hm or hm > time_end_hm:
|
||||
return (None, None, None) # 시간대 탈락 (로그 생략 가능)
|
||||
return (None, None, None) # 시간대 탈락
|
||||
if state.get("daily_cnt", 0) >= max_daily:
|
||||
return (None, None, None)
|
||||
last_exit_dt = state.get("last_exit_dt")
|
||||
@@ -168,7 +258,10 @@ def check_buy_signal_live(
|
||||
if elapsed < cooldown_min:
|
||||
return (None, None, None)
|
||||
|
||||
# 당일 누적 OHLC (run_tail_backtest와 동일)
|
||||
if cl <= 0 or cl < min_price:
|
||||
return ("탈락-가격", f"현재가 부적절 (현재 {cl:,.0f}원, 최소 {min_price:,.0f}원)", None)
|
||||
|
||||
# 당일 누적 OHLC 및 피뢰침 검사
|
||||
running_open = op
|
||||
running_high = hi
|
||||
running_low = lo if lo > 0 else hi
|
||||
@@ -184,23 +277,32 @@ def check_buy_signal_live(
|
||||
if cl <= 0 or running_open <= 0:
|
||||
return (None, None, None)
|
||||
|
||||
# 일일 변동폭(피뢰침) 검사
|
||||
if running_low > 0:
|
||||
range_change_pct = (running_high - running_low) / running_low * 100
|
||||
if range_change_pct > max_daily_change:
|
||||
return ("탈락-피뢰침 급등주", f"일일 변동폭 {range_change_pct:.1f}% > {max_daily_change:.0f}%", None)
|
||||
|
||||
# 낙폭 검사
|
||||
drop = (running_open - running_low) / running_open
|
||||
if drop < min_drop_rate:
|
||||
return (
|
||||
"탈락-낙폭",
|
||||
f"낙폭 {drop*100:.2f}% < {min_drop_rate*100:.1f}% (당일 시가 {running_open:,.0f}원 → 저점 {running_low:,.0f}원)",
|
||||
f"낙폭 {drop*100:.2f}% < {min_drop_rate*100:.1f}% (시가 {running_open:,.0f} → 저점 {running_low:,.0f})",
|
||||
None,
|
||||
)
|
||||
|
||||
# 회복률 검사
|
||||
day_range = running_high - running_low
|
||||
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
|
||||
if rec_day < min_recovery_ratio:
|
||||
return (
|
||||
"탈락-회복률",
|
||||
f"회복률 {rec_day*100:.1f}% < {min_recovery_ratio*100:.0f}% (저점 {running_low:,.0f}원 → 현재 {cl:,.0f}원 / 범위 {day_range:,.0f}원)",
|
||||
f"회복률 {rec_day*100:.1f}% < {min_recovery_ratio*100:.0f}% (저점 {running_low:,.0f} → 현재 {cl:,.0f})",
|
||||
None,
|
||||
)
|
||||
|
||||
# 망치봉 꼬리 (run_tail_backtest와 동일: 최대 3봉 전까지 탐색)
|
||||
# 망치봉 꼬리 검사 (최대 3봉 전까지 탐색)
|
||||
body_top = max(op, cl)
|
||||
body_bot = min(op, cl)
|
||||
body_len = body_top - body_bot if body_top > body_bot else 1.0
|
||||
@@ -209,9 +311,7 @@ def check_buy_signal_live(
|
||||
if tail_len <= 0:
|
||||
for j in range(i - 1, max(i - 4, rsi_period), -1):
|
||||
prev = candles[j]
|
||||
o2 = float(prev["open"])
|
||||
l2 = float(prev["low"])
|
||||
c2 = float(prev["close"])
|
||||
o2, c2, l2 = float(prev["open"]), float(prev["close"]), float(prev["low"])
|
||||
if l2 <= 0:
|
||||
continue
|
||||
bt2, bb2 = max(o2, c2), min(o2, c2)
|
||||
@@ -229,6 +329,7 @@ def check_buy_signal_live(
|
||||
None,
|
||||
)
|
||||
|
||||
# 3분봉 내 회복 위치 상한 검사
|
||||
c_range = hi - lo if hi > lo else 0
|
||||
rec_3m = (cl - lo) / c_range if c_range > 0 else 0
|
||||
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
|
||||
@@ -238,6 +339,15 @@ def check_buy_signal_live(
|
||||
None,
|
||||
)
|
||||
|
||||
# 고점 추격 방지
|
||||
if cl >= running_high * high_chase_thr:
|
||||
return (
|
||||
"탈락-피뢰침 고점추격",
|
||||
f"현재가 {cl:,.0f} ≥ 고점대비 {high_chase_thr*100:.0f}%",
|
||||
None,
|
||||
)
|
||||
|
||||
# RSI 검사
|
||||
closes = [float(x["close"]) for x in candles]
|
||||
rsis = compute_rsi_series(closes, rsi_period)
|
||||
rsi_val = rsis[i] if i < len(rsis) else None
|
||||
@@ -247,17 +357,18 @@ def check_buy_signal_live(
|
||||
(f"RSI {rsi_val:.1f}" if rsi_val is not None else "RSI None") + f" ≥ {rsi_threshold:.0f}",
|
||||
None,
|
||||
)
|
||||
if cl >= running_high * high_chase_thr:
|
||||
return (
|
||||
"탈락-피뢰침 고점추격",
|
||||
f"현재가 {cl:,.0f} ≥ 고점대비 {high_chase_thr*100:.0f}%",
|
||||
None,
|
||||
)
|
||||
|
||||
# MA20 방어 로직 (역배열 및 이격도 과열 방지)
|
||||
ma20 = sum(closes[i-19:i+1]) / 20.0
|
||||
if cl < ma20:
|
||||
return ("탈락-MA20", f"현재가 {cl:,.0f} < MA20 {ma20:,.0f} (역배열)", None)
|
||||
if ma20 > 0 and cl > ma20 * (1 + ma20_max_above / 100):
|
||||
return ("탈락-MA20초과", f"MA20 대비 {ma20_max_above:.0f}% 이격 초과", None)
|
||||
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
{"signal": True, "tail_ratio": tail_ratio, "tail_pct": tail_pct, "recovery_pos": rec_3m, "rsi_val": rsi_val},
|
||||
{"signal": True, "tail_ratio": tail_ratio, "tail_pct": tail_pct, "recovery_pos": rec_3m, "rsi_val": rsi_val, "atr_calc_val": None},
|
||||
)
|
||||
|
||||
|
||||
@@ -268,38 +379,67 @@ def check_sell_signal_live(
|
||||
is_eod: bool = False,
|
||||
) -> Optional[tuple]:
|
||||
"""
|
||||
실시간: 백테스트와 동일한 청산 조건 (손절/익절/어깨컷/장마감).
|
||||
position: entry_price, entry_time, stop, target, max_price
|
||||
current_candle: high, low, close
|
||||
실시간 및 백테스트 공통 청산 조건.
|
||||
[V3 통합]: 금액손실컷, 어깨컷, 최소 보유 시간 검사 추가.
|
||||
|
||||
position: entry_price, entry_time(YYYYMMDDHHMM), stop, target, max_price, qty
|
||||
current_candle: high, low, close, candle_time
|
||||
반환: (reason_str, exit_price) 또는 None
|
||||
"""
|
||||
sl_pct = float(params.get("sl_pct", 0.03))
|
||||
tp_pct = float(params.get("tp_pct", 0.05))
|
||||
shoulder_min_high = float(params.get("shoulder_min_high", 0.015))
|
||||
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.03))
|
||||
max_loss_krw = int(params.get("max_loss_krw", 200000))
|
||||
min_hold_sec = float(params.get("min_hold_sec", 30.0))
|
||||
|
||||
hi = float(current_candle.get("high", current_candle["close"]))
|
||||
lo = float(current_candle.get("low", current_candle["close"]))
|
||||
cl = float(current_candle["close"])
|
||||
candle_time = current_candle.get("candle_time", "")
|
||||
|
||||
max_p = max(position["max_price"], hi)
|
||||
ep = position["entry_price"]
|
||||
stop = position.get("stop", ep * (1 - sl_pct))
|
||||
target = position.get("target", ep * (1 + tp_pct))
|
||||
stop = position["stop"]
|
||||
target = position["target"]
|
||||
qty = position.get("qty", 1) # 백테스트 시 동적 계산된 수량
|
||||
|
||||
# 최소 보유 시간 검사 (너무 짧으면 청산 무시)
|
||||
if candle_time and position["entry_time"]:
|
||||
try:
|
||||
entry_dt = _t2dt(position["entry_time"])
|
||||
curr_dt = _t2dt(candle_time)
|
||||
if (curr_dt - entry_dt).total_seconds() < min_hold_sec:
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reason = None
|
||||
exit_price = cl
|
||||
if lo > 0 and lo <= stop:
|
||||
profit_val = (lo - ep) * qty # 최악의 경우(저가) 기준 손실 평가
|
||||
drop_pct = (ep - lo) / ep if ep > 0 else 0 # 매수가 대비 하락률 (흔들림/슬리피지 수준이면 미발동 위해 사용)
|
||||
min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))
|
||||
|
||||
# 1순위: 금액 손실컷 방어 — 손실 금액이 한도 초과 **이면서** 하락률이 최소값 이상일 때만 발동
|
||||
# (슬리피지/흔들림만으로 20만원 도달 시 어깨컷 기회 전에 잘리는 것 방지)
|
||||
if profit_val <= -max_loss_krw and drop_pct >= min_drop_pct:
|
||||
reason = f"금액손실컷"
|
||||
exit_price = ep - (max_loss_krw / qty) if qty > 0 else lo
|
||||
# 2순위: 동적 손절선
|
||||
elif lo > 0 and lo <= stop:
|
||||
reason = "손절"
|
||||
exit_price = stop
|
||||
# 3순위: 동적 익절선
|
||||
elif hi >= target:
|
||||
reason = "익절"
|
||||
exit_price = target
|
||||
# 4순위: 어깨 컷 (Trailing Stop)
|
||||
elif max_p >= ep * (1 + shoulder_min_high) and cl <= max_p * (1 - shoulder_cut_pct):
|
||||
reason = "어깨컷"
|
||||
exit_price = cl
|
||||
if reason is None and is_eod:
|
||||
# 5순위: 장 마감 강제 청산
|
||||
elif reason is None and is_eod:
|
||||
reason = "장마감"
|
||||
exit_price = cl
|
||||
|
||||
if reason:
|
||||
return (reason, exit_price)
|
||||
return None
|
||||
@@ -308,20 +448,20 @@ def check_sell_signal_live(
|
||||
def run_tail_backtest(
|
||||
candles_by_code: Dict[str, List[Dict]],
|
||||
params: Dict[str, Any],
|
||||
universe_by_slot: Optional[Dict[str, List[str]]] = None,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
백테스트 1회 실행 (backtest_web과 동일 로직). API에서 호출해 단일 소스 유지.
|
||||
candles_by_code: code -> list of {candle_time, open, high, low, close, volume}
|
||||
params: get_tail_defaults_from_db() + sl_pct, tp_pct 등
|
||||
반환: all_trades 리스트
|
||||
백테스트 1회 실행. (backtest_web 및 tail_param_search 호출용)
|
||||
[V3 통합]: ATR 기반 동적 목표/손절, Kelly/리스크 비율 기반 포지션 사이징을 백테스트에 완벽 적용.
|
||||
|
||||
universe_by_slot이 주어지면 5분마다 해당 슬롯의 후보 종목에서만 매수 검사 (유니버스 히스토리 풀백).
|
||||
"""
|
||||
# 파라미터 준비
|
||||
min_drop_rate = float(params.get("min_drop_rate", 0.03))
|
||||
min_recovery_ratio = float(params.get("min_recovery_ratio", 0.5))
|
||||
max_rec_3m = float(params.get("max_rec_3m", 0.8))
|
||||
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
|
||||
tail_pct_min = float(params.get("tail_pct_min", 0.003))
|
||||
sl_pct = float(params.get("sl_pct", 0.03))
|
||||
tp_pct = float(params.get("tp_pct", 0.05))
|
||||
shoulder_min_high = float(params.get("shoulder_min_high", 0.015))
|
||||
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.03))
|
||||
rsi_period = int(params.get("rsi_period", 14))
|
||||
@@ -332,12 +472,27 @@ def run_tail_backtest(
|
||||
cooldown_min = float(params.get("cooldown_min", 15))
|
||||
max_daily = int(params.get("max_daily", 3))
|
||||
|
||||
# 방어 로직 (동적 계산용)
|
||||
stop_atr_mult = float(params.get("stop_atr_mult", 2.5))
|
||||
target_atr_mult = float(params.get("target_atr_mult", 8.0))
|
||||
max_loss_krw = int(params.get("max_loss_krw", 200000))
|
||||
risk_pct = float(params.get("risk_pct", 0.01))
|
||||
kelly_mult = float(params.get("kelly_mult", 0.25))
|
||||
capital = float(params.get("capital", 100000000.0))
|
||||
static_sl_pct = abs(float(params.get("sl_pct", 0.03)))
|
||||
|
||||
all_trades: List[Dict] = []
|
||||
|
||||
for code, candles in candles_by_code.items():
|
||||
if len(candles) < rsi_period + 5:
|
||||
continue
|
||||
|
||||
# 벡터 연산으로 지표 선행 계산 (백테스트 속도 최적화)
|
||||
closes = [float(c["close"]) for c in candles]
|
||||
rsis = compute_rsi_series(closes, rsi_period)
|
||||
ma20s = compute_sma_series(closes, 20)
|
||||
atrs = compute_atr_series(candles, 14)
|
||||
|
||||
position = None
|
||||
last_exit_dt: Dict[str, datetime] = {}
|
||||
daily_cnt: Dict[str, int] = {}
|
||||
@@ -354,6 +509,7 @@ def run_tail_backtest(
|
||||
lo = float(c["low"])
|
||||
cl = float(c["close"])
|
||||
|
||||
# 일일 변수 초기화 및 갱신
|
||||
if day != cur_day:
|
||||
cur_day = day
|
||||
running_open = op
|
||||
@@ -366,31 +522,23 @@ def run_tail_backtest(
|
||||
|
||||
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
|
||||
|
||||
# ── 1. 청산 검사 (포지션 보유 중일 때) ──
|
||||
if position is not None:
|
||||
max_p = max(position["max_price"], hi)
|
||||
position["max_price"] = max_p
|
||||
reason = None
|
||||
exit_price = cl
|
||||
if lo > 0 and lo <= position["stop"]:
|
||||
reason = "손절"
|
||||
exit_price = position["stop"]
|
||||
elif hi >= position["target"]:
|
||||
reason = "익절"
|
||||
exit_price = position["target"]
|
||||
elif max_p >= position["entry_price"] * (1 + shoulder_min_high) and cl <= max_p * (1 - shoulder_cut_pct):
|
||||
reason = "어깨컷"
|
||||
exit_price = cl
|
||||
elif is_eod:
|
||||
reason = "장마감"
|
||||
exit_price = cl
|
||||
if reason:
|
||||
|
||||
cur_c_info = {"high": hi, "low": lo, "close": cl, "candle_time": c["candle_time"]}
|
||||
res = check_sell_signal_live(position, cur_c_info, params, is_eod=is_eod)
|
||||
|
||||
if res:
|
||||
reason, exit_price = res
|
||||
all_trades.append({
|
||||
"code": code,
|
||||
"entry_time": position["entry_time"],
|
||||
"exit_time": c["candle_time"],
|
||||
"entry": round(position["entry_price"]),
|
||||
"exit": round(exit_price),
|
||||
"pnl": 0,
|
||||
"pnl": 0, # 후처리 로직(웹/서치)에서 qty와 세금 곱해서 갱신됨
|
||||
"reason": reason,
|
||||
"hold_min": 0,
|
||||
})
|
||||
@@ -400,10 +548,14 @@ def run_tail_backtest(
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if cl <= 0 or running_open <= 0:
|
||||
i += 1
|
||||
continue
|
||||
if hm < time_start_hm or hm > time_end_hm:
|
||||
# ── 2. 매수 검사 (포지션 없을 때, 유니버스 시뮬레이션 시 해당 슬롯 후보만) ──
|
||||
if universe_by_slot is not None:
|
||||
# 신봇 기본: 1분봉 == 슬롯 키 (TradeDBExt.get_universe_by_candle_time 키 포맷).
|
||||
slot_key = _slot_key(c["candle_time"], params.get("scan_interval_min", 1))
|
||||
if code not in universe_by_slot.get(slot_key, []):
|
||||
i += 1
|
||||
continue
|
||||
if cl <= 0 or running_open <= 0 or hm < time_start_hm or hm > time_end_hm:
|
||||
i += 1
|
||||
continue
|
||||
if daily_cnt.get(day, 0) >= max_daily:
|
||||
@@ -419,14 +571,15 @@ def run_tail_backtest(
|
||||
if drop < min_drop_rate:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
day_range = running_high - running_low
|
||||
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
|
||||
if rec_day < min_recovery_ratio:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
body_top = max(op, cl)
|
||||
body_bot = min(op, cl)
|
||||
# 꼬리 비율 검사
|
||||
body_top, body_bot = max(op, cl), min(op, cl)
|
||||
body_len = body_top - body_bot if body_top > body_bot else 1.0
|
||||
tail_len = body_bot - lo if lo > 0 else 0.0
|
||||
lo_use = lo
|
||||
@@ -434,8 +587,7 @@ def run_tail_backtest(
|
||||
for j in range(i - 1, max(i - 4, rsi_period), -1):
|
||||
prev = candles[j]
|
||||
o2, l2, c2 = float(prev["open"]), float(prev["low"]), float(prev["close"])
|
||||
if l2 <= 0:
|
||||
continue
|
||||
if l2 <= 0: continue
|
||||
bt2, bb2 = max(o2, c2), min(o2, c2)
|
||||
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
|
||||
tl2 = bb2 - l2
|
||||
@@ -447,19 +599,38 @@ def run_tail_backtest(
|
||||
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
c_range = hi - lo if hi > lo else 0
|
||||
rec_3m = (cl - lo) / c_range if c_range > 0 else 0
|
||||
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
|
||||
i += 1
|
||||
continue
|
||||
rsi_val = rsis[i] if i < len(rsis) else None
|
||||
|
||||
rsi_val = rsis[i]
|
||||
if rsi_val is None or rsi_val >= rsi_threshold:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if cl >= running_high * high_chase_thr:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# MA20 방어 로직 (역배열 및 이격도 과열 차단)
|
||||
ma20 = ma20s[i]
|
||||
if ma20 is None or cl < ma20:
|
||||
i += 1
|
||||
continue
|
||||
ma20_max_above = float(params.get("ma20_max_above", 3.0))
|
||||
if ma20 > 0 and cl > ma20 * (1 + ma20_max_above / 100):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 피뢰침 변동폭 방어
|
||||
if running_low > 0 and ((running_high - running_low) / running_low * 100) > float(params.get("max_daily_change", 20.0)):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── 3. 매수 실행 (다음 봉 시가 진입) ──
|
||||
if i + 1 >= len(candles):
|
||||
i += 1
|
||||
continue
|
||||
@@ -467,17 +638,31 @@ def run_tail_backtest(
|
||||
if next_c["candle_time"][:8] != day:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
entry_price = float(next_c["open"])
|
||||
if entry_price <= 0:
|
||||
entry_price = cl
|
||||
|
||||
# ATR 기반 동적 목표/손절 계산
|
||||
atr = atrs[i] if atrs[i] is not None else entry_price * 0.01
|
||||
stop_p = entry_price - (atr * stop_atr_mult)
|
||||
target_p = entry_price + (atr * target_atr_mult)
|
||||
|
||||
# 포지션 사이징 로직 (Risk % 및 Max Loss 반영)
|
||||
from_risk = (capital * risk_pct * kelly_mult) / static_sl_pct if static_sl_pct > 0 else capital
|
||||
from_cap = max_loss_krw / static_sl_pct if static_sl_pct > 0 else capital
|
||||
invest_amount = min(from_risk, from_cap)
|
||||
calc_qty = max(1, int(invest_amount / entry_price))
|
||||
|
||||
position = {
|
||||
"entry_price": entry_price,
|
||||
"entry_time": next_c["candle_time"],
|
||||
"stop": entry_price * (1 - sl_pct),
|
||||
"target": entry_price * (1 + tp_pct),
|
||||
"stop": stop_p,
|
||||
"target": target_p,
|
||||
"max_price": entry_price,
|
||||
"qty": calc_qty,
|
||||
}
|
||||
i += 1 # 진입 봉(next) 건너뜀 — 다음 봉부터 포지션 보유로 청산 체크
|
||||
i += 1 # 진입 봉 건너뜀
|
||||
continue
|
||||
|
||||
return all_trades
|
||||
return all_trades
|
||||
Reference in New Issue
Block a user