#!/usr/bin/env python3 """ tail_engine.py — 꼬리잡기 백테스트·실매매 공통 엔진 ==================================================== 백테스트(backtest_web), 파라미터 탐색(tail_param_search), 실매매(kis_short_ver3)가 모두 동일한 진입/청산 계산식과 '고급 방어 로직'을 쓰도록 통합된 단일 소스 엔진. ■ 엔진 공통 로직 (백테·실매 동일) 매수: 당일 낙폭(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 최신 행에서 꼬리잡기 관련 값과 고급 방어 로직 값을 전부 로드. 백테스트·파라미터서치·실매매가 동일 DB 값을 쓰도록 단일 소스. """ own_db = None try: if db is None: from database import TradeDB own_db = TradeDB() db = own_db 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) tail_pct = float(r.get("TAIL_PCT_MIN") or 0.003) sl_pct = abs(float(r.get("STOP_LOSS_PCT") or -0.03)) tp_pct = float(r.get("TAKE_PROFIT_PCT") or 0.05) shoulder_high = float(r.get("SHOULDER_MIN_HIGH_PCT") or 0.015) shoulder_cut = float(r.get("SHOULDER_CUT_PCT") or 0.03) cooldown_sec = int(float(r.get("REENTRY_COOLDOWN_SEC") or 900)) rsi_period = int(r.get("RSI_PERIOD") or 14) rsi_threshold = float(r.get("RSI_OVERHEAT_THRESHOLD") or 78) max_rec_3m = float(r.get("MAX_RECOVERY_RATIO_3M") or 0.8) high_chase = float(r.get("HIGH_PRICE_CHASE_THRESHOLD") or 0.96) 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 sl_pct, tp_pct = 0.03, 0.05 shoulder_high, shoulder_cut = 0.015, 0.03 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 sl_pct, tp_pct = 0.03, 0.05 shoulder_high, shoulder_cut = 0.015, 0.03 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: own_db.close() except Exception: pass return { "min_drop_rate": min_drop, "min_recovery_ratio": min_rec, "max_rec_3m": max_rec_3m, "tail_ratio_min": tail_ratio, "tail_pct_min": tail_pct, "sl_pct": sl_pct, "tp_pct": tp_pct, "shoulder_min_high": shoulder_high, "shoulder_cut_pct": shoulder_cut, "rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase, "cooldown_min": cooldown_sec // 60, "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 스무딩).""" rsi_list: List[Optional[float]] = [None] * len(closes) if len(closes) < period + 1: return rsi_list deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))] gains = [max(d, 0) for d in deltas] losses = [max(-d, 0) for d in deltas] avg_gain = sum(gains[:period]) / period avg_loss = sum(losses[:period]) / period for i in range(period, len(closes)): idx = i - 1 if i > period: avg_gain = (avg_gain * (period - 1) + gains[idx]) / period avg_loss = (avg_loss * (period - 1) + losses[idx]) / period rs = avg_gain / avg_loss if avg_loss > 0 else float("inf") rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0 rsi_list[i] = rsi_val 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") def check_buy_signal_live( candles: List[Dict], params: Dict[str, Any], state: Dict[str, Any], ) -> tuple: """ 실시간: 마지막 봉이 백테스트(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) """ 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] hm = int(c["candle_time"][8:12]) op = float(c["open"]) hi = float(c["high"]) 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)) tail_ratio_min = float(params.get("tail_ratio_min", 1.5)) tail_pct_min = float(params.get("tail_pct_min", 0.003)) rsi_period = int(params.get("rsi_period", 14)) rsi_threshold = float(params.get("rsi_threshold", 78)) high_chase_thr = float(params.get("high_chase_thr", 0.96)) time_start_hm = int(params.get("time_start_hm", 930)) 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) # 시간대 탈락 if state.get("daily_cnt", 0) >= max_daily: return (None, None, None) last_exit_dt = state.get("last_exit_dt") if last_exit_dt is not None: elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60 if elapsed < cooldown_min: return (None, None, None) 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 for j in range(i - 1, -1, -1): if candles[j]["candle_time"][:8] != day: break running_open = float(candles[j]["open"]) running_high = max(running_high, float(candles[j]["high"])) lj = float(candles[j]["low"]) if lj > 0: running_low = min(running_low, lj) 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})", 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})", None, ) # 망치봉 꼬리 검사 (최대 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 tail_len = body_bot - lo if lo > 0 else 0.0 lo_use = lo if tail_len <= 0: for j in range(i - 1, max(i - 4, rsi_period), -1): prev = candles[j] o2, c2, l2 = float(prev["open"]), float(prev["close"]), float(prev["low"]) if l2 <= 0: continue bt2, bb2 = max(o2, c2), min(o2, c2) bl2 = bt2 - bb2 if bt2 > bb2 else 1.0 tl2 = bb2 - l2 if tl2 > 0: tail_len, body_len, lo_use = tl2, bl2, l2 break tail_ratio = tail_len / body_len if body_len > 0 else 0 tail_pct = tail_len / lo_use if lo_use > 0 and tail_len > 0 else 0.0 if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min: return ( "탈락-꼬리", f"꼬리비율 {tail_ratio:.2f} (기준 {tail_ratio_min}) 또는 꼬리% {tail_pct*100:.2f}% (기준 {tail_pct_min*100:.2f}%)", 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): return ( "탈락-회복3분", f"3분봉 회복률 {rec_3m*100:.1f}% (기준 {min_recovery_ratio*100:.0f}~{max_rec_3m*100:.0f}%)", 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 if rsi_val is None or rsi_val >= rsi_threshold: return ( "탈락-RSI", (f"RSI {rsi_val:.1f}" if rsi_val is not None else "RSI None") + f" ≥ {rsi_threshold:.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, "atr_calc_val": None}, ) def check_sell_signal_live( position: Dict[str, Any], current_candle: Dict[str, Any], params: Dict[str, Any], is_eod: bool = False, ) -> Optional[tuple]: """ 실시간 및 백테스트 공통 청산 조건. [V3 통합]: 금액손실컷, 어깨컷, 최소 보유 시간 검사 추가. position: entry_price, entry_time(YYYYMMDDHHMM), stop, target, max_price, qty current_candle: high, low, close, candle_time 반환: (reason_str, exit_price) 또는 None """ 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["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 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 # 5순위: 장 마감 강제 청산 elif reason is None and is_eod: reason = "장마감" exit_price = cl if reason: return (reason, exit_price) return None 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 및 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)) 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)) rsi_threshold = float(params.get("rsi_threshold", 78)) high_chase_thr = float(params.get("high_chase_thr", 0.96)) time_start_hm = int(params.get("time_start_hm", 930)) 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)) # 방어 로직 (동적 계산용) 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] = {} cur_day = None running_open, running_high, running_low = 0.0, 0.0, 0.0 i = rsi_period + 1 while i < len(candles): c = candles[i] day = c["candle_time"][:8] hm = int(c["candle_time"][8:12]) op = float(c["open"]) hi = float(c["high"]) lo = float(c["low"]) cl = float(c["close"]) # 일일 변수 초기화 및 갱신 if day != cur_day: cur_day = day running_open = op running_high = hi running_low = lo if lo > 0 else hi else: running_high = max(running_high, hi) if lo > 0: running_low = min(running_low, lo) 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 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, # 후처리 로직(웹/서치)에서 qty와 세금 곱해서 갱신됨 "reason": reason, "hold_min": 0, }) last_exit_dt[day] = _t2dt(c["candle_time"]) daily_cnt[day] = daily_cnt.get(day, 0) + 1 position = None i += 1 continue # ── 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: i += 1 continue if day in last_exit_dt: elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60 if elapsed < cooldown_min: i += 1 continue drop = (running_open - running_low) / running_open 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, 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 if tail_len <= 0: 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 bt2, bb2 = max(o2, c2), min(o2, c2) bl2 = bt2 - bb2 if bt2 > bb2 else 1.0 tl2 = bb2 - l2 if tl2 > 0: tail_len, body_len, lo_use = tl2, bl2, l2 break tail_ratio = tail_len / body_len if body_len > 0 else 0 tail_pct = tail_len / lo_use if lo_use > 0 and tail_len > 0 else 0.0 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 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 next_c = candles[i + 1] 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": stop_p, "target": target_p, "max_price": entry_price, "qty": calc_qty, } i += 1 # 진입 봉 건너뜀 continue return all_trades