Files
kis_bot/kis_trader/engine/tail_engine.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

2024 lines
84 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
kis_trader/engine/tail_engine.py — 꼬리잡기 백테스트·실매매 공통 엔진
====================================================
백테스트(backtest_web), 파라미터 탐색(tail_param_search), 실매매(kis_trader TailCatchStrategy)가
모두 동일한 진입/청산 계산식과 '고급 방어 로직'을 쓰도록 통합된 단일 소스 엔진.
■ SCAN vs TRIGGER (돌파 전략과 동일 원칙)
[SCAN — HTS 조건검색, 널넬하게]
**실매 권장: 키움 ``tail`` (``SHORT_UNIVERSE_SOURCE=kiwoom_condition``)**
A) [일] 시가→종가 -10% ~ -1.5% (당일 약세)
B) 체결강도 85% ~ 400%
C) 3봉전 대비 거래량 180% ~ 2000%
F) [일] 저가 대비 종가 +1% ~ +8% (꼬리 회복 구간)
→ ``KiwoomConditionSearchManager`` + ``CONDITION_SHORT_NAME=tail``
**레거시 KIS ``condition`` (REST 폴링)**
A) 1봉 등락률 -10% ~ -1.5% (직전봉 종가 대비)
B/C 동일 축 — ``ConditionSearchManager`` / ``tail``
→ ``target_candidates_history`` (strategy_id=SHORT) 에 스냅샷 저장.
[TRIGGER — 본 엔진, 엄격하게 — HTS A/B/C 는 조건검색에서 이미 통과]
반전 패턴 OR(망치·핀바·장악·관통·하라미·도지·샛별) + 당일 회복률·3분봉 회복 위치,
(선택) 신호봉 거래량 폭증(``TAIL_VOL_MULT``×N봉평균, 0=OFF) + RSI·MA20,
고점추격·피뢰침, 시간대/쿨다운. 패턴별 ``TAIL_PATTERN_*`` env 로 ON/OFF.
``TAIL_SKIP_HTS_SCAN_DUPES=true`` (기본) 이면 TRIGGER 에서 HTS A 와 겹치는
**당일 시가→저점 낙폭** 은 건너뜀 → 이중 필터로 거래가 사라지는 현상 방지.
■ 엔진 공통 로직 (백테·실매 동일)
매도 우선순위 (V4):
1) 트레일(어깨컷) — max_price 갱신 후 되돌림(저가로 터치 판정, 체결=매도선), 발동 수익% 충족 시 **손절·금액손실보다 우선**
2) ATR 캡 적용 익절 / 3) ATR 캡 적용 손절 / 4) 금액손실컷(트레일 미발동 구간만) / 5) 장마감
ATR 목표·손절: 배수 × ATR 후 ``TAIL_ATR_*_MIN/MAX_PCT`` % 상·하한 캡 (잡주 과대 목표가 방지).
백테 청산: N분 OHLC → N회 intrabar 체크 (``check_sell_signal_backtest_bar``, session_low 모드).
진입·지표는 N분봉 그대로.
■ 진입 모드 (``TAIL_ENTRY_MODE``)
- ``align``: 신호봉 확정 → 다음 3분봉 시가 시장가(레거시)
- ``limit_atr``(기본): 신호봉 확정 → anchorATR×mult 지정가 → ``TAIL_LIMIT_VALID_BARS`` 봉 내
low 터치 시 체결, 미체결 시 다음 봉부터 취소(실매) / 백테 스킵
"""
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
from kis_trader.engine.limit_entry_common import (
compute_atr_limit_price,
is_limit_atr_entry,
limit_valid_until_bar_key,
resolve_limit_anchor_price,
short_entry_mode,
tail_limit_params,
try_limit_fill_on_bar,
)
from kis_trader.engine.indicator_cache import (
attach_indicator_caches_to_params,
get_indicator_cache_from_params,
)
from kis_trader.engine.whipsaw_filter import inject_whipsaw_ticks_into_params, whipsaw_reject_for_signal
from kis_trader.backtest.trigger_snapshot_loader import inject_trigger_snapshots_into_params
from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry
from kis_trader.engine.program_filter import program_reject_for_entry
from kis_trader.engine.scalping_engine import check_sell_signal_backtest_bar
from kis_trader.engine.tail_tick_replay import (
align_entry_price_from_ticks,
collect_bar_ticks,
tail_backtest_wants_tick_replay,
tail_timeframe_min,
try_limit_fill_on_bar_with_ticks,
)
from kis_trader.engine.tail_env_keys import (
tail_env_bool,
tail_env_float,
tail_env_int,
)
def _to_bool(v: Any, default: bool = True) -> bool:
if v is None:
return default
if isinstance(v, bool):
return v
s = str(v).strip().lower()
if s in ("1", "true", "t", "y", "yes", "on"):
return True
if s in ("0", "false", "f", "n", "no", "off", ""):
return False
return default
def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
"""확정봉만 사용 (미확정 봉 제외). 없으면 원본 유지."""
confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)]
return confirmed if confirmed else list(candles)
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
r: Dict[str, Any] = {}
try:
if db is None:
from database import TradeDB
own_db = TradeDB()
db = own_db
if hasattr(db, "get_merged_env_snapshot"):
r = db.get_merged_env_snapshot()
elif hasattr(db, "get_latest_env"):
latest = db.get_latest_env()
r = dict((latest or {}).get("snapshot") or {})
else:
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
r = dict(row) if row else {}
if r:
# TAIL_* 키만 사용 (레거시 MIN_DROP_RATE 등 폴백 없음)
min_drop = tail_env_float(r, "TAIL_MIN_DROP_RATE", 0.03)
min_rec = tail_env_float(r, "TAIL_MIN_RECOVERY_RATIO", 0.5)
tail_ratio = tail_env_float(r, "TAIL_RATIO_MIN", 1.5)
tail_pct = tail_env_float(r, "TAIL_PCT_MIN", 0.003)
sl_pct = abs(tail_env_float(r, "TAIL_STOP_LOSS_PCT", -0.03))
tp_pct = tail_env_float(r, "TAIL_TAKE_PROFIT_PCT", 0.05)
shoulder_high = tail_env_float(r, "TAIL_SHOULDER_MIN_HIGH_PCT", 0.005)
shoulder_cut = tail_env_float(r, "TAIL_SHOULDER_CUT_PCT", 0.003)
cooldown_sec = tail_env_int(r, "TAIL_COOLDOWN_SEC", 900)
rsi_period = tail_env_int(r, "TAIL_RSI_PERIOD", 14)
rsi_threshold = tail_env_float(r, "TAIL_RSI_THRESHOLD", 78.0)
max_rec_3m = tail_env_float(r, "TAIL_MAX_RECOVERY_3M", 0.8)
high_chase = tail_env_float(r, "TAIL_HIGH_CHASE_THR", 0.96)
time_start = tail_env_int(r, "TAIL_TIME_START", 930)
time_end = tail_env_int(r, "TAIL_TIME_END", 1500)
max_daily = tail_env_int(r, "TAIL_MAX_DAILY", 3)
min_price = tail_env_float(r, "TAIL_MIN_PRICE", 1000.0)
max_daily_change = tail_env_float(r, "TAIL_MAX_DAILY_CHG", 20.0)
ma20_max_above = tail_env_float(r, "TAIL_MA20_MAX_ABOVE_PCT", 3.0)
stop_atr_mult = tail_env_float(r, "TAIL_STOP_ATR_MULT", 2.0)
target_atr_mult = tail_env_float(r, "TAIL_TARGET_ATR_MULT", 2.5)
atr_sl_min_pct = _read_tail_pct_from_row(r, "TAIL_ATR_SL_MIN_PCT", "TAIL_ATR_SL_MIN_PCT", 0.8) * 100.0
atr_sl_max_pct = _read_tail_pct_from_row(r, "TAIL_ATR_SL_MAX_PCT", "TAIL_ATR_SL_MAX_PCT", 6.0) * 100.0
atr_tp_min_pct = _read_tail_pct_from_row(r, "TAIL_ATR_TP_MIN_PCT", "TAIL_ATR_TP_MIN_PCT", 0.5) * 100.0
atr_tp_max_pct = _read_tail_pct_from_row(r, "TAIL_ATR_TP_MAX_PCT", "TAIL_ATR_TP_MAX_PCT", 3.0) * 100.0
max_loss_krw = tail_env_int(r, "TAIL_MAX_LOSS_KRW", 200000)
_min_drop_loss = r.get("TAIL_MIN_DROP_FOR_LOSS_CUT")
min_drop_pct_for_loss_cut = 0.015
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)
skip_hts_scan_dupes = tail_env_bool(r, "TAIL_SKIP_HTS_SCAN_DUPES", True)
use_intraday_drop = tail_env_bool(r, "TAIL_USE_INTRADAY_DROP", False)
use_ma20_filter = tail_env_bool(r, "TAIL_USE_MA20_FILTER", False)
use_rsi_filter = tail_env_bool(r, "TAIL_USE_RSI_FILTER", True)
use_daily_range_f = tail_env_bool(r, "TAIL_USE_DAILY_RANGE_FILTER", True)
use_high_chase_f = tail_env_bool(r, "TAIL_USE_HIGH_CHASE_FILTER", True)
bar_chg_min_pct = tail_env_float(r, "TAIL_BAR_CHG_MIN_PCT", -10.0)
bar_chg_max_pct = tail_env_float(r, "TAIL_BAR_CHG_MAX_PCT", -1.5)
tail_vol_mult = tail_env_float(r, "TAIL_VOL_MULT", 0.0)
tail_vol_win = tail_env_int(r, "TAIL_VOL_WIN", 5)
max_stocks = tail_env_int(r, "TAIL_MAX_STOCKS", 3)
total_budget_krw = tail_env_int(r, "TAIL_TOTAL_BUDGET_KRW", 0)
slot_money = tail_env_int(r, "TAIL_SLOT_MONEY", 3_000_000)
short_max_buy = tail_env_int(r, "TAIL_MAX_BUY_AMOUNT", 0)
from kis_trader.utils.env import get_env_from_db
ratchet_tiers = str(r.get("TAIL_RATCHET_TIERS") or get_env_from_db("TAIL_RATCHET_TIERS", "") or "").strip()
max_hold_bars = tail_env_int(r, "TAIL_MAX_HOLD_BARS", 0)
# ws_ticks 진입가 재생 기본 ON — tail_tick_replay·모멘텀·돌파와 동일 (실매 체결 정합, env=0 일 때만 OFF)
backtest_use_tick_db = tail_env_bool(r, "TAIL_BACKTEST_USE_TICK_DB", True)
backtest_tick_fallback_ohlc = tail_env_bool(r, "TAIL_BACKTEST_TICK_FALLBACK_OHLC", True)
trail_pct = abs(tail_env_float(r, "TAIL_TRAIL_PCT", 0.0))
trail_arm_pct = abs(tail_env_float(r, "TAIL_TRAIL_ARM_PCT", 0.0))
_pat = _load_tail_pattern_params_from_row(r)
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.005, 0.003
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.0, 2.5
atr_sl_min_pct, atr_sl_max_pct = 0.8, 6.0
atr_tp_min_pct, atr_tp_max_pct = 0.5, 3.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
skip_hts_scan_dupes, use_intraday_drop = True, False
use_ma20_filter, use_rsi_filter = False, True
use_daily_range_f, use_high_chase_f = True, True
bar_chg_min_pct, bar_chg_max_pct = -10.0, -1.5
tail_vol_mult, tail_vol_win = 0.0, 5
max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0
ratchet_tiers, max_hold_bars = "", 0
backtest_use_tick_db, backtest_tick_fallback_ohlc = False, True
trail_pct, trail_arm_pct = 0.0, 0.0
_pat = _load_tail_pattern_params_from_row({})
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, 5.0
atr_sl_min_pct, atr_sl_max_pct = 0.8, 6.0
atr_tp_min_pct, atr_tp_max_pct = 0.5, 5.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
skip_hts_scan_dupes, use_intraday_drop = True, False
use_ma20_filter, use_rsi_filter = False, True
use_daily_range_f, use_high_chase_f = True, True
bar_chg_min_pct, bar_chg_max_pct = -10.0, -1.5
tail_vol_mult, tail_vol_win = 0.0, 5
max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0
ratchet_tiers, max_hold_bars = "", 0
backtest_use_tick_db, backtest_tick_fallback_ohlc = False, True
trail_pct, trail_arm_pct = 0.0, 0.0
_pat = _load_tail_pattern_params_from_row({})
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,
"ratchet_tiers": ratchet_tiers,
"max_hold_bars": max_hold_bars,
"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,
"atr_sl_min_pct": atr_sl_min_pct,
"atr_sl_max_pct": atr_sl_max_pct,
"atr_tp_min_pct": atr_tp_min_pct,
"atr_tp_max_pct": atr_tp_max_pct,
"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,
# SCAN/TRIGGER 분리 플래그
"skip_hts_scan_dupes": skip_hts_scan_dupes,
"use_intraday_drop": use_intraday_drop,
"use_ma20_filter": use_ma20_filter,
"use_rsi_filter": use_rsi_filter,
"use_daily_range_filter": use_daily_range_f,
"use_high_chase_filter": use_high_chase_f,
"bar_chg_min_pct": bar_chg_min_pct,
"bar_chg_max_pct": bar_chg_max_pct,
"tail_vol_mult": tail_vol_mult,
"tail_vol_win": tail_vol_win,
"max_stocks": max_stocks,
"total_budget_krw": total_budget_krw,
"slot_money": slot_money,
"short_max_buy_amount": short_max_buy,
"portfolio_mode": True,
"entry_mode": short_entry_mode(
{"entry_mode": r.get("TAIL_ENTRY_MODE")} if r else None
),
"limit_atr_mult": tail_limit_params(
{
"limit_atr_mult": tail_env_float(r, "TAIL_LIMIT_ATR_MULT", 1.5) if r else None,
"limit_anchor": (r.get("TAIL_LIMIT_ANCHOR") if r else None),
"limit_valid_bars": tail_env_int(r, "TAIL_LIMIT_VALID_BARS", 1) if r else None,
"limit_fill_slip_pct": tail_env_float(r, "TAIL_LIMIT_FILL_SLIP_PCT", 0.0) if r else None,
}
).get("mult", 1.5),
"limit_anchor": tail_limit_params(
{
"limit_atr_mult": tail_env_float(r, "TAIL_LIMIT_ATR_MULT", 1.5) if r else None,
"limit_anchor": (r.get("TAIL_LIMIT_ANCHOR") if r else None),
}
).get("anchor", "signal_low"),
"limit_valid_bars": int(tail_limit_params(
{"limit_valid_bars": tail_env_int(r, "TAIL_LIMIT_VALID_BARS", 1) if r else None}
).get("valid_bars", 1)),
"limit_fill_slip_pct": float(tail_limit_params(
{"limit_fill_slip_pct": tail_env_float(r, "TAIL_LIMIT_FILL_SLIP_PCT", 0.0) if r else None}
).get("fill_slip_pct", 0.0)),
"backtest_use_tick_db": backtest_use_tick_db,
"backtest_tick_fallback_ohlc": backtest_tick_fallback_ohlc,
# 체결량 상한(진입봉 거래량×N%) — 0=OFF. 실매 IOC 미체결 근사 (DB·웹·CLI 공통).
"backtest_vol_fill_cap_pct": tail_env_float(r, "TAIL_BACKTEST_VOL_FILL_CAP_PCT", 0.0) if r else 0.0,
"trail_pct": trail_pct,
"trail_arm_pct": trail_arm_pct,
**_pat,
}
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) 변동성 지표 계산기 (엔진 내부용).
■ RMA(Wilder's Smoothing) 방식 — TradingView 기본과 동일 (2026-06 SMA→RMA 전환).
ATR_t = (ATR_{t-1} × (period-1) + TR_t) / period
· 첫 ATR(인덱스 period)은 SMA(TR[1..period])로 시드.
· SMA 대비: 급락(큰 TR)이 14봉 지나도 '계단식 급락' 없이 완만히 감쇠 →
급변장에서 손절/목표가가 덜 출렁임.
"""
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(인덱스 period): TR[1..period] 단순 평균으로 시드 (Wilder 초기값)
prev_atr = sum(trs[1:period + 1]) / period
atr_list[period] = prev_atr
# 이후: Wilder RMA 누적 감쇠 (이전 ATR×(n-1) + 오늘 TR) / n
for i in range(period + 1, len(candles)):
prev_atr = (prev_atr * (period - 1) + trs[i]) / period
atr_list[i] = prev_atr
return atr_list
def _t2dt(t: str) -> datetime:
"""candle_time 문자열 → datetime."""
return datetime.strptime(t, "%Y%m%d%H%M")
def _pct_to_frac(v: Any, default_pct: float) -> float:
"""퍼센트 숫자(1.5=1.5%%) 또는 소수(0.015) → 비율 소수."""
if v is None or v == "":
v = default_pct
x = float(v)
return x / 100.0 if x >= 0.2 else x
def _read_tail_pct_from_row(r: Dict[str, Any], db_key: str, env_key: str, default_pct: float) -> float:
"""env_config 행 → 비율 소수. 없으면 get_env_float 폴백."""
raw = r.get(db_key) if r else None
if raw not in (None, ""):
return _pct_to_frac(raw, default_pct)
try:
from kis_trader.utils.env import get_env_float
return _pct_to_frac(get_env_float(env_key, default_pct), default_pct)
except Exception:
return _pct_to_frac(default_pct, default_pct)
def compute_tail_atr_prices(
entry_price: float,
atr_value: Optional[float],
params: Dict[str, Any],
) -> Tuple[float, float]:
"""
꼬리잡기 ATR 손절/익절가 — 배수 적용 후 % 상·하한 캡 (UPDOW ``resolve_atr_exit_pcts`` 와 동일 철학).
:return: (stop_price, target_price)
"""
if entry_price <= 0:
return entry_price * 0.97, entry_price * 1.03
atr = float(atr_value) if atr_value and float(atr_value) > 0 else entry_price * 0.01
stop_mult = float(params.get("stop_atr_mult", 2.5))
target_mult = float(params.get("target_atr_mult", 5.0))
sl_pct = (atr * stop_mult) / entry_price
tp_pct = (atr * target_mult) / entry_price
sl_min = _pct_to_frac(params.get("atr_sl_min_pct", 0.8), 0.8)
sl_max = _pct_to_frac(params.get("atr_sl_max_pct", 6.0), 6.0)
tp_min = _pct_to_frac(params.get("atr_tp_min_pct", 0.5), 0.5)
tp_max = _pct_to_frac(params.get("atr_tp_max_pct", 5.0), 5.0)
sl_pct = min(max(sl_pct, sl_min), sl_max)
tp_pct = min(max(tp_pct, tp_min), tp_max)
stop_p = entry_price * (1.0 - sl_pct)
target_p = entry_price * (1.0 + tp_pct)
return stop_p, target_p
def _load_tail_pattern_params_from_row(r: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""반전 패턴 ON/OFF 및 형태 임계값 — env ``TAIL_PATTERN_*`` 단일 소스."""
row = r or {}
return {
"pattern_hammer": tail_env_bool(row, "TAIL_PATTERN_HAMMER", True),
"pattern_pin": tail_env_bool(row, "TAIL_PATTERN_PIN", False),
"pattern_engulfing": tail_env_bool(row, "TAIL_PATTERN_ENGULFING", False),
"pattern_piercing": tail_env_bool(row, "TAIL_PATTERN_PIERCING", False),
"pattern_harami": tail_env_bool(row, "TAIL_PATTERN_HARAMI", False),
"pattern_doji": tail_env_bool(row, "TAIL_PATTERN_DOJI", False),
"pattern_morning_star": tail_env_bool(row, "TAIL_PATTERN_MORNING_STAR", False),
"pin_close_upper_ratio": tail_env_float(row, "TAIL_PIN_CLOSE_UPPER_RATIO", 0.66),
"pin_max_upper_tail_ratio": tail_env_float(row, "TAIL_PIN_MAX_UPPER_TAIL_RATIO", 0.35),
"engulf_min_body_ratio": tail_env_float(row, "TAIL_ENGULF_MIN_BODY_RATIO", 1.0),
"piercing_penetrate_ratio": tail_env_float(row, "TAIL_PIERCING_PENETRATE_RATIO", 0.5),
"harami_max_body_ratio": tail_env_float(row, "TAIL_HARAMI_MAX_BODY_RATIO", 0.5),
"doji_body_max_ratio": tail_env_float(row, "TAIL_DOJI_BODY_MAX_RATIO", 0.15),
"morning_star_body_max_ratio": tail_env_float(row, "TAIL_MORNING_STAR_BODY_MAX_RATIO", 0.35),
"candle_lookback": tail_env_int(row, "TAIL_CANDLE_LOOKBACK", 3),
}
def _bar_ohlc(c: Dict) -> Tuple[float, float, float, float]:
return float(c["open"]), float(c["high"]), float(c["low"]), float(c["close"])
def _body_parts(op: float, hi: float, lo: float, cl: float) -> Dict[str, float]:
"""봉 몸통·꼬리 분해 (도지: 몸통 0 이면 range 기준 최소값)."""
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot
rng = hi - lo if hi > lo else 0.0
if body_len <= 0:
body_len = max(rng * 0.001, 1e-6) if rng > 0 else 1.0
lower_tail = max(0.0, body_bot - lo) if lo > 0 else 0.0
upper_tail = max(0.0, hi - body_top) if hi > 0 else 0.0
return {
"body_top": body_top,
"body_bot": body_bot,
"body_len": body_len,
"range": rng,
"lower_tail": lower_tail,
"upper_tail": upper_tail,
"is_bullish": cl >= op,
"is_bearish": cl < op,
}
def _hammer_tail_metrics(
op: float, hi: float, lo: float, cl: float, lo_ref: float,
) -> Tuple[float, float, float]:
parts = _body_parts(op, hi, lo, cl)
tail_len = parts["lower_tail"]
body_len = parts["body_len"]
lo_use = lo if lo > 0 else lo_ref
tail_ratio = tail_len / body_len if body_len > 0 else 0.0
tail_pct = tail_len / lo_use if lo_use > 0 and tail_len > 0 else 0.0
return tail_ratio, tail_pct, lo_use
def _detect_hammer_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""망치(하단 꼬리) — 신호봉 또는 lookback 봉에서 탐색."""
rsi_period = int(params.get("rsi_period", 14))
lookback = int(params.get("candle_lookback", 3))
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
tail_pct_min = float(params.get("tail_pct_min", 0.003))
lo_ref = float(candles[i]["low"]) if float(candles[i]["low"]) > 0 else float(candles[i]["high"])
for j in range(i, max(i - lookback, rsi_period) - 1, -1):
op, hi, lo, cl = _bar_ohlc(candles[j])
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 lo > 0 else lo_ref
if tail_len <= 0:
continue
tail_ratio = tail_len / body_len if body_len > 0 else 0.0
tail_pct = tail_len / lo_use if lo_use > 0 else 0.0
if tail_ratio >= tail_ratio_min and tail_pct >= tail_pct_min:
return True, {
"pattern": "hammer",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": j,
}
return False, {}
def _detect_pin_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""스트릭트 핀바 — 하단 꼬리 + 종가 상단 1/3 + 윗꼬리 짧음."""
rsi_period = int(params.get("rsi_period", 14))
lookback = int(params.get("candle_lookback", 3))
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
tail_pct_min = float(params.get("tail_pct_min", 0.003))
pin_close_upper = float(params.get("pin_close_upper_ratio", 0.66))
pin_upper_max = float(params.get("pin_max_upper_tail_ratio", 0.35))
lo_ref = float(candles[i]["low"]) if float(candles[i]["low"]) > 0 else float(candles[i]["high"])
for j in range(i, max(i - lookback, rsi_period) - 1, -1):
op, hi, lo, cl = _bar_ohlc(candles[j])
parts = _body_parts(op, hi, lo, cl)
if parts["range"] <= 0:
continue
tail_ratio, tail_pct, _ = _hammer_tail_metrics(op, hi, lo, cl, lo_ref)
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
close_pos = (cl - lo) / parts["range"]
if close_pos < pin_close_upper:
continue
if parts["body_len"] > 0 and parts["upper_tail"] / parts["body_len"] > pin_upper_max:
continue
return True, {
"pattern": "pin",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": j,
"close_pos": close_pos,
}
return False, {}
def _detect_engulfing_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""불리시 장악형 — 전봉 음봉 몸통을 현재 양봉이 완전 삼킴."""
if i < 1:
return False, {}
engulf_min = float(params.get("engulf_min_body_ratio", 1.0))
po, ph, pl, pc = _bar_ohlc(candles[i - 1])
co, ch, cl, cc = _bar_ohlc(candles[i])
prev = _body_parts(po, ph, pl, pc)
curr = _body_parts(co, ch, cl, cc)
if not prev["is_bearish"] or not curr["is_bullish"]:
return False, {}
if co > pc or cc < po:
return False, {}
if curr["body_len"] < prev["body_len"] * engulf_min:
return False, {}
tail_ratio, tail_pct, _ = _hammer_tail_metrics(co, ch, cl, cc, pl if pl > 0 else ch)
return True, {
"pattern": "engulfing",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": i,
}
def _detect_piercing_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""관통형 — 갭다운 후 전봉 몸통 중간 이상 회복(완전 장악 전)."""
if i < 1:
return False, {}
penetrate = float(params.get("piercing_penetrate_ratio", 0.5))
po, ph, pl, pc = _bar_ohlc(candles[i - 1])
co, ch, cl, cc = _bar_ohlc(candles[i])
prev = _body_parts(po, ph, pl, pc)
curr = _body_parts(co, ch, cl, cc)
if not prev["is_bearish"] or not curr["is_bullish"]:
return False, {}
if co >= pc:
return False, {}
midpoint = prev["body_bot"] + prev["body_len"] * penetrate
if cc <= midpoint:
return False, {}
if cc >= po:
return False, {}
tail_ratio, tail_pct, _ = _hammer_tail_metrics(co, ch, cl, cc, pl if pl > 0 else ch)
return True, {
"pattern": "piercing",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": i,
}
def _detect_harami_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""불리시 하라미 — 큰 음봉 안에 작은 양봉(몸통 포함)."""
if i < 1:
return False, {}
harami_max = float(params.get("harami_max_body_ratio", 0.5))
po, ph, pl, pc = _bar_ohlc(candles[i - 1])
co, ch, cl, cc = _bar_ohlc(candles[i])
prev = _body_parts(po, ph, pl, pc)
curr = _body_parts(co, ch, cl, cc)
if not prev["is_bearish"]:
return False, {}
if curr["body_top"] > prev["body_top"] or curr["body_bot"] < prev["body_bot"]:
return False, {}
if curr["body_len"] > prev["body_len"] * harami_max:
return False, {}
if not curr["is_bullish"]:
return False, {}
tail_ratio, tail_pct, _ = _hammer_tail_metrics(co, ch, cl, cc, pl if pl > 0 else ch)
return True, {
"pattern": "harami",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": i,
}
def _detect_doji_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""저점 도지 — 몸통 극소 + 하단 꼬리(망치형) + 종가 중상단."""
doji_max = float(params.get("doji_body_max_ratio", 0.15))
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
tail_pct_min = float(params.get("tail_pct_min", 0.003))
pin_close_upper = float(params.get("pin_close_upper_ratio", 0.66))
op, hi, lo, cl = _bar_ohlc(candles[i])
parts = _body_parts(op, hi, lo, cl)
if parts["range"] <= 0:
return False, {}
real_body = abs(cl - op)
if real_body / parts["range"] > doji_max:
return False, {}
tail_ratio, tail_pct, _ = _hammer_tail_metrics(op, hi, lo, cl, lo if lo > 0 else hi)
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
return False, {}
close_pos = (cl - lo) / parts["range"]
if close_pos < pin_close_upper:
return False, {}
return True, {
"pattern": "doji",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": i,
"close_pos": close_pos,
}
def _detect_morning_star_pattern(
candles: List[Dict], i: int, params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""샛별형(3봉) — 장음봉 + 작은 별 + 양봉이 1봉 몸통 중간 돌파."""
if i < 2:
return False, {}
star_max = float(params.get("morning_star_body_max_ratio", 0.35))
o0, h0, l0, c0 = _bar_ohlc(candles[i - 2])
o1, h1, l1, c1 = _bar_ohlc(candles[i - 1])
o2, h2, l2, c2 = _bar_ohlc(candles[i])
bear = _body_parts(o0, h0, l0, c0)
star = _body_parts(o1, h1, l1, c1)
bull = _body_parts(o2, h2, l2, c2)
if not bear["is_bearish"] or bear["body_len"] <= 0:
return False, {}
if star["body_len"] > bear["body_len"] * star_max:
return False, {}
midpoint = (bear["body_top"] + bear["body_bot"]) / 2.0
if not bull["is_bullish"] or c2 <= midpoint:
return False, {}
tail_ratio, tail_pct, _ = _hammer_tail_metrics(o2, h2, l2, c2, l2 if l2 > 0 else h2)
return True, {
"pattern": "morning_star",
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"pattern_bar_idx": i,
}
def eval_tail_reversal_pattern(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[bool, str, Dict[str, Any]]:
"""
TRIGGER 반전 패턴 OR 평가.
``TAIL_PATTERN_*`` 가 모두 OFF 이면 망치만(기존 동작) 검사.
"""
checks = []
if _to_bool(params.get("pattern_hammer"), True):
checks.append(("hammer", _detect_hammer_pattern))
if _to_bool(params.get("pattern_pin"), False):
checks.append(("pin", _detect_pin_pattern))
if _to_bool(params.get("pattern_engulfing"), False):
checks.append(("engulfing", _detect_engulfing_pattern))
if _to_bool(params.get("pattern_piercing"), False):
checks.append(("piercing", _detect_piercing_pattern))
if _to_bool(params.get("pattern_harami"), False):
checks.append(("harami", _detect_harami_pattern))
if _to_bool(params.get("pattern_doji"), False):
checks.append(("doji", _detect_doji_pattern))
if _to_bool(params.get("pattern_morning_star"), False):
checks.append(("morning_star", _detect_morning_star_pattern))
if not checks:
checks.append(("hammer", _detect_hammer_pattern))
for name, fn in checks:
ok, metrics = fn(candles, i, params)
if ok:
return True, name, metrics
active = ",".join(n for n, _ in checks)
return False, active, {}
def _tail_volume_spike_ok(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[bool, str]:
"""
TRIGGER — 신호봉 거래량이 직전 N봉 평균 대비 배수 이상인지 (모멘텀 mom_vol_mult 와 동일 패턴).
``tail_vol_mult`` ≤ 0 이면 OFF (기존 백테·실매 동작 유지).
"""
vol_mult = float(params.get("tail_vol_mult", 0) or 0)
vol_win = int(params.get("tail_vol_win", 5) or 5)
if vol_mult <= 0:
return True, ""
vol = float(candles[i].get("volume", 0) or 0)
win = max(1, min(vol_win, i))
vols = [float(candles[k].get("volume", 0) or 0) for k in range(i - win, i)]
if not vols or sum(vols) <= 0:
return False, "거래량창없음"
avg = sum(vols) / len(vols)
if avg <= 0 or vol < avg * vol_mult:
ratio = vol / avg if avg > 0 else 0.0
return False, "%.2fx < %.1fx" % (ratio, vol_mult)
return True, ""
def _eval_tail_buy_at_index(
candles: List[Dict],
i: int,
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""신호봉 인덱스 ``i`` 에서 꼬리잡기 매수 조건 평가 (백테스트 루프와 동일 시점)."""
if i < 19 or i >= len(candles):
return ("탈락-데이터", f"인덱스 부적절 (i={i})", None)
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))
# SCAN/TRIGGER 분리 — 조건검색 유니버스 사용 시 HTS A/B/C 중복 필터 생략
skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), True)
use_intraday_drop = _to_bool(params.get("use_intraday_drop"), False)
use_ma20 = _to_bool(params.get("use_ma20_filter"), False)
use_rsi = _to_bool(params.get("use_rsi_filter"), True)
use_daily_range = _to_bool(params.get("use_daily_range_filter"), True)
use_high_chase = _to_bool(params.get("use_high_chase_filter"), True)
bar_chg_min_pct = float(params.get("bar_chg_min_pct", -10.0))
bar_chg_max_pct = float(params.get("bar_chg_max_pct", -1.5))
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)
# 일일 변동폭(피뢰침) 검사 — TRIGGER 선택 필터
if use_daily_range and 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)
# HTS A (봉 등락률) — 조건검색 미사용(전종목) 백테 시에만 TRIGGER 에서 재검사
if not skip_hts and i >= 1:
prev_cl = float(candles[i - 1]["close"])
if prev_cl > 0:
bar_chg = (cl - prev_cl) / prev_cl * 100.0
if bar_chg < bar_chg_min_pct or bar_chg > bar_chg_max_pct:
return (
"탈락-봉등락",
f"봉등락 {bar_chg:.2f}% (HTS A: {bar_chg_min_pct:.1f}~{bar_chg_max_pct:.1f}%)",
None,
)
# 당일 시가→저점 낙폭 — HTS A 와 다른 축; ``TAIL_USE_INTRADAY_DROP=true`` 일 때만
if use_intraday_drop:
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,
)
# 반전 패턴 OR (망치·핀바·장악·관통·하라미·도지·샛별) — env TAIL_PATTERN_* 로 ON/OFF
pat_ok, pat_active, pat_metrics = eval_tail_reversal_pattern(candles, i, params)
if not pat_ok:
return (
"탈락-패턴",
f"반전패턴 미충족 (활성: {pat_active})",
None,
)
tail_ratio = float(pat_metrics.get("tail_ratio", 0.0))
tail_pct = float(pat_metrics.get("tail_pct", 0.0))
pattern_name = str(pat_metrics.get("pattern", pat_active))
# TRIGGER — 신호봉 거래량 폭증 (HTS C는 스캔 시점; 매수 직전 재확인)
vol_ok, vol_msg = _tail_volume_spike_ok(candles, i, params)
if not vol_ok:
return ("탈락-거래량", f"신호봉 거래량 {vol_msg}", 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,
)
# 고점 추격 방지 — TRIGGER 선택 필터
if use_high_chase and cl >= running_high * high_chase_thr:
return (
"탈락-피뢰침 고점추격",
f"현재가 {cl:,.0f} ≥ 고점대비 {high_chase_thr*100:.0f}%",
None,
)
# RSI 검사 — TRIGGER 선택 필터 (HTS B 체결강도와 별개)
closes = [float(x["close"]) for x in candles]
ic = params.get("_indicator_cache")
if ic is not None and hasattr(ic, "rsi_at"):
rsi_val = ic.rsi_at(i, rsi_period)
else:
rsis = compute_rsi_series(closes, rsi_period)
rsi_val = rsis[i] if i < len(rsis) else None
if use_rsi and (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 방어 — TRIGGER 선택 필터 (기본 OFF: 역배열 컷이 신호를 과하게 줄임)
if use_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)
sig = {
"signal": True,
"pattern": pattern_name,
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"recovery_pos": rec_3m,
"rsi_val": rsi_val,
"atr_calc_val": None,
"signal_candle_time": c.get("candle_time"),
}
return _tail_signal_with_whipsaw(params, candles, i, sig)
def _tail_signal_with_whipsaw(
params: Dict[str, Any],
candles: List[Dict],
i: int,
sig: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""꼬리 신호 dict 반환 직전 — 휩쏘(TRIGGER) 공통 (실매·백테·파라서치)."""
c = candles[i]
cl = float(c.get("close") or 0)
ws_rej, ws_msg = whipsaw_reject_for_signal(
params, "TAIL",
signal_bar=c,
current_price=cl,
)
if ws_rej:
return (ws_rej, ws_msg, None)
ob_rej, ob_msg = orderbook_reject_for_entry(
params, "TAIL", current_price=cl,
)
if ob_rej:
return (ob_rej, ob_msg, None)
prog_rej, prog_msg = program_reject_for_entry(
params, "TAIL", current_price=cl,
)
if prog_rej:
return (prog_rej, prog_msg, None)
return (None, None, sig)
def check_buy_signal_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> tuple:
"""
실시간 꼬리잡기 매수 신호.
- ``live_backtest_align=True`` (기본): 백테와 동일 — **직전 확정봉=신호봉**,
**현재 확정봉=진입봉** (신호봉 조건 충족 후 다음 3분봉 시가 진입).
- ``live_backtest_align=False``: 구버전 — 마지막 확정봉 1개만 검사.
candles: 3분봉 (candle_time, open, high, low, close, volume)
state: { "last_exit_dt": datetime|None, "daily_cnt": int }
"""
live_align = _to_bool(params.get("live_backtest_align", True), True)
lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
confirmed = _confirmed_candles_only(candles)
if len(confirmed) < 20:
return ("탈락-데이터", f"확정봉 부족 (len={len(confirmed)} < 20)", None)
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
None, None, None,
)
if live_align:
entry_i = len(confirmed) - 1
for k in range(lookback):
signal_i = entry_i - 1 - k
if signal_i < 19:
break
reject, msg, sig = _eval_tail_buy_at_index(
confirmed, signal_i, params, state,
)
if reject:
if k == 0:
last_reject = (reject, msg, None)
continue
if sig:
ent = confirmed[entry_i]
entry_open = float(ent.get("open", 0) or 0)
if entry_open <= 0:
entry_open = float(ent.get("close", 0) or 0)
sig["entry_price"] = entry_open
sig["entry_bar_key"] = ent.get("candle_time")
return (None, None, sig)
return last_reject
i = len(confirmed) - 1
return _eval_tail_buy_at_index(confirmed, i, params, state)
def _tail_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]:
"""래칫 단계 트레일 — 돌파 ``_breakout_ratchet_tiers`` 와 동일 형식.
env/params ``"2:1.5,5:1.0"`` → +2% 수익부터 고점 대비 1.5% 되돌림컷.
비어 있으면 [] (= OFF, 단일 어깨컷 사용).
"""
raw = params.get("ratchet_tiers")
if raw is None:
from kis_trader.utils.env import get_env_from_db
raw = get_env_from_db("TAIL_RATCHET_TIERS", "")
if isinstance(raw, (list, tuple)):
pairs = list(raw)
else:
s = str(raw or "").strip()
if not s:
return []
pairs = []
for chunk in s.split(","):
chunk = chunk.strip()
if not chunk or ":" not in chunk:
continue
g, c = chunk.split(":", 1)
pairs.append((g, c))
tiers: List[Tuple[float, float]] = []
for g, c in pairs:
try:
gain = abs(float(g)) / 100.0
cut = abs(float(c)) / 100.0
except (TypeError, ValueError):
continue
if gain <= 0 or cut <= 0:
continue
tiers.append((gain, cut))
tiers.sort(key=lambda x: x[0])
return tiers
def _tail_max_hold_minutes(params: Dict[str, Any]) -> int:
"""시간컷: 최대 보유 **분** (돌파 BREAKOUT_MAX_HOLD_BARS 와 동일 — 이름은 bars, 단위는 분). 0=OFF."""
v = params.get("max_hold_bars")
if v is None:
from kis_trader.utils.env import get_env_int
v = get_env_int("TAIL_MAX_HOLD_BARS", 0)
try:
return max(0, int(float(v)))
except (TypeError, ValueError):
return 0
def _tail_minutes_held(position: Dict[str, Any], current_candle: Dict[str, Any]) -> Optional[int]:
"""진입 이후 경과 분 — 시간컷 판정."""
et = position.get("entry_time") or position.get("buy_time")
ct = current_candle.get("candle_time")
if not et or not ct:
return None
try:
e_dt = _t2dt(str(et)[:12])
c_dt = _t2dt(str(ct)[:12])
return max(0, int((c_dt - e_dt).total_seconds() // 60))
except Exception:
return 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]:
"""
실시간 및 백테스트 공통 청산 조건.
[V4]: 트레일(어깨) 저가 기준 · 수익 구간 우선 · ATR 캡 익절/손절 · 금액손실컷(트레일 미발동만).
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.005))
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.003))
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(float(position.get("max_price", 0) or 0), hi)
ep = float(position["entry_price"])
stop = float(position["stop"])
target = float(position["target"])
qty = int(position.get("qty", 1) or 1)
# 최소 보유 시간 검사 (너무 짧으면 청산 무시)
if candle_time and position.get("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.0
min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))
# 1순위: 트레일 — 돌파와 동일: 래칫 문자열 있으면 **단일 어깨 대체**, 없으면 shoulder_min/cut
ratchet_tiers = _tail_ratchet_tiers(params)
trail_armed = False
if ratchet_tiers and ep > 0:
peak_gain = (max_p - ep) / ep
cut_ratio = 0.0
for gain_thr, cut in ratchet_tiers:
if peak_gain >= gain_thr:
cut_ratio = cut
if cut_ratio > 0.0:
trail_armed = True
ratchet_line = max_p * (1.0 - cut_ratio)
if lo > 0 and lo <= ratchet_line:
reason = "래칫컷"
exit_price = ratchet_line
else:
trail_armed = ep > 0 and max_p >= ep * (1.0 + shoulder_min_high)
trail_stop_px = max_p * (1.0 - shoulder_cut_pct) if trail_armed else 0.0
if trail_armed and lo > 0 and lo <= trail_stop_px:
reason = "어깨컷"
exit_price = trail_stop_px
# 2순위: ATR 캡 익절
if not reason and hi >= target:
reason = "익절"
exit_price = target
# 3순위: ATR 캡 손절
if not reason and lo > 0 and lo <= stop:
reason = "손절"
exit_price = stop
# 4순위: 보조 트레일링 — 래칫/어깨(1순위)와 별도. TAIL_TRAIL_PCT>0 일 때만 (돌파 BREAKOUT_TRAIL_* 와 동일)
# trail_arm_pct: 고점이 진입×(1+arm) 도달 후에만 무장 (0=즉시 무장). 어깨 미발동 구간의 팝 후 되돌림 보호.
if not reason:
trail_pct = abs(float(params.get("trail_pct", 0.0) or 0.0))
trail_arm_pct = abs(float(params.get("trail_arm_pct", 0.0) or 0.0))
if trail_pct > 0 and ep > 0:
trail_arm_line = ep * (1.0 + trail_arm_pct)
if max_p > ep and max_p >= trail_arm_line:
trail_line = max_p * (1.0 - trail_pct)
if lo > 0 and lo <= trail_line:
reason = "트레일컷"
exit_price = trail_line
# 5순위: 금액 손실컷 — 1순위 어깨/래칫 미발동(수익% 문턱 못 넘음)일 때만
if (
not reason
and not trail_armed
and profit_val <= -max_loss_krw
and drop_pct >= min_drop_pct
):
reason = "금액손실컷"
exit_price = ep - (max_loss_krw / qty) if qty > 0 else lo
# 6순위: 시간컷 — 돌파와 동일(분 단위), 기본 OFF. 어깨·손절·익절 뒤, 장마감 앞
if not reason:
max_hold_min = _tail_max_hold_minutes(params)
if max_hold_min > 0:
held = _tail_minutes_held(position, current_candle)
if held is not None and held >= max_hold_min:
reason = "시간컷"
exit_price = cl
# 7순위: 장 마감 강제 청산
if not reason and is_eod:
reason = "장마감"
exit_price = cl
if reason:
return (reason, exit_price)
return None
def resolve_tail_invest_amount_krw(params: Dict[str, Any]) -> int:
"""
실매 ``resolve_invest_amount_krw`` + ``TailCatchStrategy`` 종목당 상한과 동일.
1회 매수 금액 = slot_money (MAX_LOSS/sl 로 투자금을 줄이지 않음).
"""
slot = int(float(params.get("slot_money") or params.get("invest_amount") or 0))
if slot <= 0:
try:
from kis_trader.utils.env import get_env_int
slot = get_env_int("TAIL_SLOT_MONEY", 3_000_000)
except Exception:
slot = 3_000_000
hard_cap = int(float(params.get("short_max_buy_amount") or params.get("per_stock_cap") or 0))
if hard_cap <= 0:
try:
from kis_trader.utils.env import get_env_int
hard_cap = get_env_int("TAIL_MAX_BUY_AMOUNT", 0)
except Exception:
hard_cap = 0
if hard_cap > 0 and slot > hard_cap:
return hard_cap
return slot
def _portfolio_exposure_krw(portfolio: Dict[str, Dict[str, Any]]) -> float:
"""동시 보유 매입금 합 (실매 UPDOW 노출·SHORT 총한도와 동일 개념)."""
total = 0.0
for p in portfolio.values():
total += float(p.get("entry_price") or 0) * int(p.get("qty") or 1)
return total
def _tail_max_stocks_from_params(params: Dict[str, Any]) -> int:
for key in ("max_stocks", "short_max_stocks"):
v = params.get(key)
if v not in (None, "", 0):
return max(1, int(v))
try:
from kis_trader.utils.env import get_env_int
n = get_env_int("TAIL_MAX_STOCKS", 3)
return max(1, int(n))
except Exception:
return 3
def _tail_total_budget_from_params(params: Dict[str, Any]) -> float:
"""0 이하면 호출측에서 max_stocks×slot_money 로 유도."""
for key in ("total_budget_krw", "short_total_budget_krw"):
v = params.get(key)
if v not in (None, ""):
try:
return float(v)
except (TypeError, ValueError):
pass
try:
from kis_trader.utils.env import get_env_int
cap = get_env_int("TAIL_TOTAL_BUDGET_KRW", 0)
if cap > 0:
return float(cap)
except Exception:
pass
return 0.0
def _tail_min_invest_ratio_of_slot(params: Dict[str, Any]) -> float:
"""1회 slot 대비 최소 투입 비율 — 미만이면 매수 스킵 (실매: 풀 slot 주문 후 예수금 부족 시만 축소)."""
v = params.get("min_invest_ratio_of_slot")
if v not in (None, ""):
return max(0.01, min(1.0, float(v)))
try:
from kis_trader.utils.env import get_env_float
return max(0.01, min(1.0, get_env_float("TAIL_MIN_INVEST_RATIO_OF_SLOT", 0.90)))
except Exception:
return 0.90
def _tail_target_qty_and_cost(entry_price: float, slot_money: float) -> Tuple[int, float]:
"""실매와 동일: 1회 투자금(slot) 기준 목표 수량·매입금."""
from kis_trader.utils.position_sizing import invest_qty_for_price
qty = invest_qty_for_price(entry_price, slot_money)
if qty < 1:
return 0, 0.0
return qty, qty * entry_price
def _vol_capped_qty(qty: int, candle: Dict[str, Any], cap_pct: float) -> int:
"""진입봉 거래량×cap_pct% 로 체결 가능 주수 제한 (실매 유동성 제약 근사).
cap_pct<=0 → 제한 없음. 거래량 정보 없으면(0) 기존 동작 유지(보수적). 반환 0=미체결.
"""
if cap_pct <= 0:
return qty
try:
vol = int(float(candle.get("volume") or 0))
except (TypeError, ValueError):
vol = 0
if vol <= 0:
return qty
fillable = int(vol * cap_pct / 100.0)
return min(qty, fillable)
def _buy_priority_key(
code: str,
uni_codes: Optional[List[str]],
) -> Tuple[int, str]:
"""실매 후보 순회 순서 근사 — 유니버스 편입 순서(index), 없으면 code 정렬."""
if uni_codes is None:
return (0, code)
try:
return (uni_codes.index(code), code)
except ValueError:
return (999999, code)
def _universe_codes_at(
t: str,
slot_key: str,
universe_timeline: Optional[Any],
universe_by_slot: Optional[Dict[str, List[str]]],
) -> Optional[List[str]]:
"""그 시각(봉 마감초) 유효 유니버스 코드 리스트 (돌파·모멘텀 공통 로직).
- ``universe_timeline`` (초단위, 실매 get_universe_at 정합) 우선 — 봉 마감(HH:MM:59)
직전 최신 조건검색 스냅샷. strict lag(1분 지연) 없이 실매와 동일 시점 조회.
- 없으면 1분 슬롯(``universe_by_slot``) 폴백. 둘 다 없으면 None(전종목·필터없음).
"""
if universe_timeline is not None:
return universe_timeline.codes_at(str(t)[:12] + "59")
if universe_by_slot is not None:
return universe_by_slot.get(slot_key, [])
return None
def run_tail_backtest_portfolio(
candles_by_code: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]] = None,
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
) -> List[Dict]:
"""
시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사.
- 모든 종목 봉을 ``candle_time`` 순으로 처리 (종목별 독립 합산 아님)
- ``max_stocks``: 동시 보유 종목 수 (SHORT_MAX_STOCKS / MAX_STOCKS)
- ``total_budget_krw``: 동시 보유 매입금 합 상한 (0 → max_stocks×slot_money)
- 1시각(봉)당 신규 매수 1건 (실매 1루프 1매수)
- 1회 투자금 = ``slot_money`` (실매 resolve_invest_amount_krw)
"""
rsi_period = int(params.get("rsi_period", 14))
min_bars = rsi_period + 5
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
max_stocks = _tail_max_stocks_from_params(params)
slot_money = resolve_tail_invest_amount_krw(params)
total_budget = _tail_total_budget_from_params(params)
if total_budget <= 0:
total_budget = float(max_stocks * slot_money)
min_invest_ratio = _tail_min_invest_ratio_of_slot(params)
skipped_micro_buys = 0
# 체결량 상한(유동성 제약 근사) — 진입봉 거래량×N% 까지만 체결. 0=OFF(동작 불변).
# 실매 시장가 IOC 가 호가에 있는 만큼만 체결되는 것을 봉단위로 근사 (소형주 미체결↑).
vol_fill_cap_pct = float(params.get("backtest_vol_fill_cap_pct", 0) or 0)
skipped_vol_unfilled = 0
tick_tf = tail_timeframe_min(params)
use_ticks = bool(ticks_by_code) and tail_backtest_wants_tick_replay(params)
tick_fill_stats = {"ws_ticks": 0, "ohlc_low": 0, "ohlc_open": 0}
attach_indicator_caches_to_params(params, candles_by_code)
# 종목별 컨텍스트
ctx_by_code: Dict[str, Dict[str, Any]] = {}
all_times_set = set()
for code, raw_rows in candles_by_code.items():
if len(raw_rows) < min_bars:
continue
candles = [dict(r) for r in raw_rows]
closes = [float(c["close"]) for c in candles]
ctx_by_code[code] = {
"code": code,
"candles": candles,
"closes": closes,
"rsis": compute_rsi_series(closes, rsi_period),
"ma20s": compute_sma_series(closes, 20),
"atrs": compute_atr_series(candles, 14),
"time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)},
"last_exit_dt": {},
"daily_cnt": {},
"pending_entry": None,
"pending_limit": None,
}
for c in candles:
all_times_set.add(c["candle_time"])
all_times = sorted(all_times_set)
portfolio: Dict[str, Dict[str, Any]] = {}
all_trades: List[Dict] = []
# 초단위 유니버스 타임라인 (실매 get_universe_at 정합, 돌파·모멘텀 공통). 없으면 1분 슬롯 폴백.
universe_timeline = params.get("_universe_timeline")
for t in all_times:
slot_key = _slot_key(t, params.get("scan_interval_min", 1))
uni_codes = _universe_codes_at(t, slot_key, universe_timeline, universe_by_slot)
uni_set = set(uni_codes) if uni_codes is not None else None
# ── Phase 0a: ATR 지정가 체결 (유효 봉 low ≤ limit) ──
for code, ctx in ctx_by_code.items():
pl = ctx.get("pending_limit")
if not pl or code in portfolio:
continue
idx = ctx["time_index"].get(t)
if idx is None:
continue
sig_i = int(pl.get("signal_i", -1))
if idx <= sig_i:
continue
vu = str(pl.get("valid_until") or "")[:12]
if vu and str(t)[:12] > vu:
ctx["pending_limit"] = None
continue
c = ctx["candles"][idx]
bar_ticks = (
collect_bar_ticks(ticks_by_code, code, t, tick_tf) if use_ticks else []
)
fill, fill_src = try_limit_fill_on_bar_with_ticks(
c,
float(pl.get("limit_price") or 0),
float(pl.get("fill_slip") or 0),
ticks=bar_ticks,
params=params,
)
if not fill or fill <= 0:
continue
if fill_src in tick_fill_stats:
tick_fill_stats[fill_src] += 1
if len(portfolio) >= max_stocks:
continue
exposure = _portfolio_exposure_krw(portfolio)
remaining = max(0.0, total_budget - exposure)
target_qty, target_cost = _tail_target_qty_and_cost(fill, float(slot_money))
min_required = target_cost * min_invest_ratio
if target_qty < 1 or remaining < min_required:
skipped_micro_buys += 1
continue
invest = min(float(slot_money), remaining, target_cost)
qty = int(invest / fill)
if qty < 1:
skipped_micro_buys += 1
continue
# 유동성 제약: 진입봉 거래량×cap% 까지만 체결 (실매 IOC 미체결 근사)
capped = _vol_capped_qty(qty, c, vol_fill_cap_pct)
if capped < qty:
if capped < 1:
skipped_vol_unfilled += 1
continue
qty = capped
cost = qty * fill
if cost < min_required or exposure + cost > total_budget + 1e-6:
skipped_micro_buys += 1
continue
portfolio[code] = {
"entry_price": fill,
"entry_time": t,
"stop": pl["stop"],
"target": pl["target"],
"max_price": fill,
"session_low": fill,
"qty": qty,
}
ctx["pending_limit"] = None
break
# ── Phase 0b: 예약 진입 (align — 다음 봉 시가) ──
pending_codes = [
code for code, ctx in ctx_by_code.items()
if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t
]
pending_codes.sort(
key=lambda c: _buy_priority_key(c, uni_codes),
)
for code in pending_codes:
ctx = ctx_by_code[code]
pe = ctx.pop("pending_entry", None)
if not pe or code in portfolio:
continue
if len(portfolio) >= max_stocks:
break
entry_price = float(pe["entry_price"])
if use_ticks:
bar_ticks = collect_bar_ticks(ticks_by_code, code, t, tick_tf)
entry_price, align_src = align_entry_price_from_ticks(bar_ticks, entry_price)
if align_src in tick_fill_stats:
tick_fill_stats[align_src] += 1
if entry_price <= 0:
continue
exposure = _portfolio_exposure_krw(portfolio)
remaining = max(0.0, total_budget - exposure)
target_qty, target_cost = _tail_target_qty_and_cost(entry_price, float(slot_money))
min_required = target_cost * min_invest_ratio
if target_qty < 1 or remaining < min_required:
skipped_micro_buys += 1
continue
invest = min(float(slot_money), remaining, target_cost)
qty = int(invest / entry_price)
if qty < 1:
skipped_micro_buys += 1
continue
# 유동성 제약: 진입봉 거래량×cap% 까지만 체결 (실매 IOC 미체결 근사)
if vol_fill_cap_pct > 0:
_eidx = ctx["time_index"].get(t)
_ebar = ctx["candles"][_eidx] if _eidx is not None else {}
capped = _vol_capped_qty(qty, _ebar, vol_fill_cap_pct)
if capped < qty:
if capped < 1:
skipped_vol_unfilled += 1
continue
qty = capped
cost = qty * entry_price
if cost < min_required:
skipped_micro_buys += 1
continue
if exposure + cost > total_budget + 1e-6:
skipped_micro_buys += 1
continue
portfolio[code] = {
"entry_price": entry_price,
"entry_time": t,
"stop": pe["stop"],
"target": pe["target"],
"max_price": entry_price,
"session_low": entry_price,
"qty": qty,
}
break # 1시각 1매수
# ── Phase 1: 보유 종목 청산 ──
for code in list(portfolio.keys()):
ctx = ctx_by_code.get(code)
if ctx is None:
continue
idx = ctx["time_index"].get(t)
if idx is None:
continue
candles = ctx["candles"]
c = candles[idx]
day = t[:8]
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
op = float(c["open"])
is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day)
is_eod = is_eod_raw and force_eod_exit
pos = portfolio[code]
max_p = max(float(pos.get("max_price", 0) or 0), hi)
hp = float(c.get("holding_peak") or 0)
if hp > 0:
max_p = max(max_p, hp)
pos["max_price"] = max_p
cur_c_info = {
"open": op,
"high": hi,
"low": lo,
"close": cl,
"candle_time": t,
}
res = check_sell_signal_backtest_bar(
pos,
cur_c_info,
params,
is_eod=is_eod,
sell_fn=check_sell_signal_live,
low_mode="session_low",
)
if not res:
continue
reason, exit_price = res
all_trades.append({
"code": code,
"entry_time": pos["entry_time"],
"exit_time": t,
"entry": round(pos["entry_price"]),
"exit": round(exit_price),
"pnl": 0,
"reason": reason,
"hold_min": 0,
"peak_price": round(max_p),
"qty": pos.get("qty", 1),
})
ctx["last_exit_dt"][day] = _t2dt(t)
ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
del portfolio[code]
# ── Phase 2: 신규 매수 신호 (다음 봉 시가 진입 예약) ──
if len(portfolio) >= max_stocks:
continue
exposure = _portfolio_exposure_krw(portfolio)
if exposure >= total_budget - 1e-6:
continue
time_start_hm = int(params.get("time_start_hm", 930))
time_end_hm = int(params.get("time_end_hm", 1500))
hm = int(t[8:12])
candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
for code, ctx in ctx_by_code.items():
if code in portfolio or ctx.get("pending_entry") or ctx.get("pending_limit"):
continue
idx = ctx["time_index"].get(t)
if idx is None:
continue
candles = ctx["candles"]
c = candles[idx]
day = t[:8]
cl = float(c["close"])
if uni_set is not None:
if code not in uni_set:
continue
if cl <= 0 or hm < time_start_hm or hm > time_end_hm:
continue
if ctx["daily_cnt"].get(day, 0) >= int(params.get("max_daily", 3)):
continue
if day in ctx["last_exit_dt"]:
elapsed = (_t2dt(t) - ctx["last_exit_dt"][day]).total_seconds() / 60
if elapsed < float(params.get("cooldown_min", 15)):
continue
eval_params = dict(params)
ic = get_indicator_cache_from_params(params, code)
if ic is not None:
eval_params["_indicator_cache"] = ic
inject_whipsaw_ticks_into_params(
eval_params,
ticks_by_code=ticks_by_code,
code=code,
bar_candle_time=t,
strategy="TAIL",
tf_min=tick_tf,
)
inject_trigger_snapshots_into_params(
eval_params,
orderbook_by_code=orderbook_by_code,
program_by_code=program_by_code,
code=code,
bar_candle_time=t,
)
if universe_by_slot is not None:
eval_params.setdefault("skip_hts_scan_dupes", True)
else:
eval_params.setdefault("skip_hts_scan_dupes", False)
state = {
"daily_cnt": ctx["daily_cnt"].get(day, 0),
"last_exit_dt": ctx["last_exit_dt"].get(day),
}
reject, _msg, sig = _eval_tail_buy_at_index(candles, idx, eval_params, state)
if reject or not sig:
continue
atr = ctx["atrs"][idx] if ctx["atrs"][idx] is not None else cl * 0.01
pri = _buy_priority_key(code, uni_codes)
if is_limit_atr_entry(short_entry_mode(params)):
lp_cfg = tail_limit_params(params)
sig_bar = candles[idx]
anchor_px = resolve_limit_anchor_price(
lp_cfg["anchor"], sig_bar, candles, idx,
)
min_px = float(params.get("min_price", 1000.0))
limit_px = compute_atr_limit_price(
anchor_px, atr, lp_cfg["mult"], min_price=min_px,
)
if limit_px <= 0:
continue
stop_p, target_p = compute_tail_atr_prices(limit_px, atr, params)
vu = limit_valid_until_bar_key(candles, idx, lp_cfg["valid_bars"])
candidates.append((pri, code, {
"pending_limit": True,
"signal_i": idx,
"limit_price": limit_px,
"valid_until": vu,
"fill_slip": lp_cfg["fill_slip_pct"],
"stop": stop_p,
"target": target_p,
}))
continue
if idx + 1 >= len(candles):
continue
next_c = candles[idx + 1]
if next_c["candle_time"][:8] != day:
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params)
candidates.append((pri, code, {
"entry_time": next_c["candle_time"],
"entry_price": entry_price,
"stop": stop_p,
"target": target_p,
}))
if not candidates:
continue
candidates.sort(key=lambda x: x[0])
_pri, pick_code, pe = candidates[0]
if pe.get("pending_limit"):
ctx_by_code[pick_code]["pending_limit"] = pe
else:
ctx_by_code[pick_code]["pending_entry"] = pe
skip_stats: Dict[str, Any] = {}
if skipped_micro_buys:
skip_stats["skipped_micro_buys"] = skipped_micro_buys
if skipped_vol_unfilled:
skip_stats["skipped_vol_unfilled"] = skipped_vol_unfilled
if use_ticks:
skip_stats["tick_entry_sources"] = dict(tick_fill_stats)
if skip_stats:
params["_portfolio_skip_stats"] = skip_stats
return all_trades
def run_tail_backtest(
candles_by_code: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]] = None,
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
) -> List[Dict]:
"""
백테스트 1회 실행. (backtest_web 및 tail_param_search 호출용)
기본: ``portfolio_mode=true`` → 시각순 포트폴리오 (실매 MAX_STOCKS·총한도·slot_money).
``portfolio_mode=false`` → 레거시 종목별 독립 루프.
"""
if _to_bool(params.get("portfolio_mode"), True):
return run_tail_backtest_portfolio(
candles_by_code, params, universe_by_slot,
ticks_by_code=ticks_by_code,
orderbook_by_code=orderbook_by_code,
program_by_code=program_by_code,
)
# ── 레거시: 종목별 독립 시뮬 ──
tick_tf = tail_timeframe_min(params)
use_ticks = bool(ticks_by_code) and tail_backtest_wants_tick_replay(params)
# 파라미터 준비
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.005))
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 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))
# 백테스트 EOD 강제청산 여부:
# - True : 기존 동작 유지(당일 마지막 봉에서 청산)
# - False: 실매와 유사하게 포지션 오픈 유지(백테 결과에서 미청산은 미기록)
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
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_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
is_eod = is_eod_raw and force_eod_exit
# ── 1. 청산 검사 (포지션 보유 중일 때) ──
if position is not None:
max_p = max(position["max_price"], hi)
hp = float(c.get("holding_peak") or 0)
if hp > 0:
max_p = max(max_p, hp)
position["max_price"] = max_p
cur_c_info = {
"open": op,
"high": hi,
"low": lo,
"close": cl,
"candle_time": c["candle_time"],
}
res = check_sell_signal_backtest_bar(
position,
cur_c_info,
params,
is_eod=is_eod,
sell_fn=check_sell_signal_live,
low_mode="session_low",
)
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,
"reason": reason,
"hold_min": 0,
"peak_price": round(max_p),
"qty": position.get("qty", 1),
})
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
eval_params = dict(params)
if universe_by_slot is not None:
eval_params.setdefault("skip_hts_scan_dupes", True)
else:
eval_params.setdefault("skip_hts_scan_dupes", False)
state = {
"daily_cnt": daily_cnt.get(day, 0),
"last_exit_dt": last_exit_dt.get(day),
}
reject, _msg, sig = _eval_tail_buy_at_index(candles, i, eval_params, state)
if reject or not sig:
i += 1
continue
atr = atrs[i] if atrs[i] is not None else cl * 0.01
# ── 3. 매수 — limit_atr: 유효 봉 내 지정가 터치 / align: 다음 봉 시가 ──
if is_limit_atr_entry(short_entry_mode(params)):
lp_cfg = tail_limit_params(params)
sig_bar = candles[i]
anchor_px = resolve_limit_anchor_price(
lp_cfg["anchor"], sig_bar, candles, i,
)
min_px = float(params.get("min_price", 1000.0))
limit_px = compute_atr_limit_price(
anchor_px, atr, lp_cfg["mult"], min_price=min_px,
)
if limit_px <= 0:
i += 1
continue
vu = limit_valid_until_bar_key(candles, i, lp_cfg["valid_bars"])
filled = False
for j in range(i + 1, min(i + 1 + lp_cfg["valid_bars"], len(candles))):
if candles[j]["candle_time"][:8] != day:
break
bar_ticks = (
collect_bar_ticks(
ticks_by_code, code, candles[j]["candle_time"], tick_tf,
)
if use_ticks else []
)
fp, _src = try_limit_fill_on_bar_with_ticks(
candles[j], limit_px, lp_cfg["fill_slip_pct"],
ticks=bar_ticks, params=params,
)
if fp and fp > 0:
entry_price = fp
entry_time = candles[j]["candle_time"]
stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params)
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": entry_time,
"stop": stop_p,
"target": target_p,
"max_price": entry_price,
"session_low": entry_price,
"qty": calc_qty,
}
filled = True
i = j + 1
break
if not filled:
i += 1
continue
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
if use_ticks:
bar_ticks = collect_bar_ticks(
ticks_by_code, code, next_c["candle_time"], tick_tf,
)
entry_price, _align_src = align_entry_price_from_ticks(bar_ticks, entry_price)
stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params)
# 포지션 사이징 로직 (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,
"session_low": entry_price,
"qty": calc_qty,
}
i += 1 # 진입 봉 건너뜀
continue
return all_trades