Files
kis_bot/kis_trader/engine/tail_engine.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

2672 lines
112 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% ~ -0.5% (당일 약세)
B) 체결강도 85% ~ 400%
C) 3봉전 대비 거래량 180% ~ 2000%
F) [일] 저가 대비 종가 +1% ~ +8% (꼬리 회복 구간)
→ ``KiwoomConditionSearchManager`` + ``CONDITION_SHORT_NAME=tail``
**레거시 KIS ``condition`` (REST 폴링)**
A) 1봉 등락률 -10% ~ -0.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`` % 상·하한 캡 (잡주 과대 목표가 방지).
백테 청산: ws_ticks 시간순 (``resolve_backtest_sell``, session_low) → OHLC 폴백 기본 OFF.
진입·지표는 N분봉 그대로.
■ 진입 모드 (``TAIL_ENTRY_MODE``)
- ``align``: 신호봉 확정 → **직후 봉**(signal+tf, 1M→3M 합성 포함) 시가·첫 틱 시장가
(구멍으로 먼 다음 3분 시가에 밀리지 않음)
- ``limit_atr``(기본): 신호봉 확정 → anchorATR×mult 지정가 → ``TAIL_LIMIT_VALID_BARS`` 봉 내
low 터치 시 체결, 미체결 시 다음 봉부터 취소(실매) / 백테 스킵
"""
from datetime import datetime
from typing import List, Dict, Any, Optional, Set, 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.tick_exit_common import (
backtest_sell_slip_pct,
backtest_tick_poll_ms,
collect_minute_ticks,
resolve_backtest_sell,
)
from kis_trader.engine.tail_tick_replay import (
align_entry_execute_time,
align_entry_price_from_ticks,
collect_bar_ticks,
resolve_align_entry_bar,
tail_backtest_tick_fallback_ohlc,
tail_backtest_use_tick_exit,
tail_backtest_wants_tick_replay,
tail_timeframe_min,
try_limit_fill_on_bar_with_ticks,
)
from kis_trader.engine.atr_series import compute_atr_series
from kis_trader.engine.strategy_eod import (
is_strategy_eod_bar,
parse_eod_hm,
resolve_strategy_eod_params,
)
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 resolve_tail_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool:
"""
HTS tail SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부.
- ``TAIL_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로
- 미설정 → ``SHORT_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True
"""
if r is None:
try:
from kis_trader.utils.env import get_strategy_env_dict
r = get_strategy_env_dict("TAIL") or {}
except Exception:
r = {}
raw = r.get("TAIL_SKIP_HTS_SCAN_DUPES")
if raw is not None and str(raw).strip() != "":
return tail_env_bool(r, "TAIL_SKIP_HTS_SCAN_DUPES", True)
# 엔진 defaults 에 이미 해석된 bool 이 있으면 universe fallback 금지
if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None:
return _to_bool(r.get("skip_hts_scan_dupes"), False)
src = str(r.get("SHORT_UNIVERSE_SOURCE") or "kiwoom_condition").strip().lower()
return src in ("kiwoom_condition", "condition")
def _tail_symbol_reentry_gate(
daily_cnt: int,
daily_pnl_krw: float,
slot_money: float,
params: Dict[str, Any],
) -> Tuple[bool, str]:
"""종목당 당일 재진입 게이트 — 무지성 횟수 제한 대신 손실예산·최소엣지로 판정.
``TAIL_MAX_DAILY`` (기본 3, 안전판)는 그대로 두되, 실질적인 과매매 통제는
이 게이트가 담당한다:
- 오늘 그 종목 **첫 진입**(daily_cnt==0)은 항상 통과.
- **재진입**(daily_cnt>=1)부터는:
1) 종목 당일 실현손익이 손실한도(KRW·PCT 중 먼저 닿는 쪽=더 작은 금액) 이하면 차단.
"3번까지만" 대신 "-3만원까지만" 처럼 손실 크기로 제어.
2) ``TAIL_REENTRY_REQUIRE_NONNEG``/``TAIL_REENTRY_MIN_EDGE_KRW`` 미충족이면 차단.
→ 수수료·세금(왕복 약 0.21%)도 못 건진 재진입을 걸러 회전매매 방지.
반환: (allowed, reject_detail) — allowed=False 면 detail 에 사유 메시지.
"""
if daily_cnt <= 0:
return True, ""
loss_limit_krw = abs(float(params.get("symbol_daily_loss_limit_krw", 0) or 0))
loss_limit_pct = abs(float(params.get("symbol_daily_loss_limit_pct", 0) or 0))
candidate_limits: List[float] = []
if loss_limit_krw > 0:
candidate_limits.append(loss_limit_krw)
if loss_limit_pct > 0 and slot_money > 0:
candidate_limits.append(slot_money * loss_limit_pct / 100.0)
if candidate_limits:
active_limit = min(candidate_limits)
if daily_pnl_krw <= -active_limit:
return False, f"당일 실현 {daily_pnl_krw:+,.0f}원 ≤ -{active_limit:,.0f}"
require_nonneg = _to_bool(params.get("reentry_require_nonneg"), False)
if require_nonneg and daily_pnl_krw < 0:
return False, f"당일 실현 {daily_pnl_krw:+,.0f}원 < 0 (재진입 최소수익 미달)"
min_edge = float(params.get("reentry_min_edge_krw", 0) or 0)
if min_edge > 0 and daily_pnl_krw < min_edge:
return False, f"당일 실현 {daily_pnl_krw:+,.0f}원 < {min_edge:,.0f}원 (재진입 최소엣지 미달)"
return True, ""
def resolve_tail_cand_limit(params: Optional[Dict[str, Any]] = None) -> int:
"""
``SHORT_CAND_LIMIT`` — 실매 ``BaseStrategy._post_filter_candidates`` 의 후보 하드캡과
동일한 값. 실매는 유니버스 편입 순서 상위 N개만 매수 체크 대상으로 본다(0=무제한).
백테도 그 시각 유니버스에서 동일하게 상위 N개만 남겨야 실매와 정합된다.
``params["cand_limit"]`` 명시 시 그대로(파라서치 그리드용) → 없으면 DB
``SHORT_CAND_LIMIT`` 값을 그대로 읽는다.
"""
if params is not None and params.get("cand_limit") is not None:
try:
return max(0, int(float(params.get("cand_limit"))))
except (TypeError, ValueError):
pass
from kis_trader.utils.env import get_env_int
return max(0, get_env_int("SHORT_CAND_LIMIT", 0))
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, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
env_config 최신 행에서 꼬리잡기 관련 값과 고급 방어 로직 값을 전부 로드.
백테스트·파라미터서치·실매매가 동일 DB 값을 쓰도록 단일 소스.
env_row 가 있으면 DB 조회 생략 (웹 백테 env 타임라인용).
"""
own_db = None
r: Dict[str, Any] = {}
try:
if env_row is not None:
r = dict(env_row)
else:
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", 20)
# 종목당 일일 재진입 게이트 — 무지성 횟수 대신 손실예산·최소엣지 (양수=절댓값)
symbol_daily_loss_limit_krw = tail_env_float(r, "TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW", 30000.0)
symbol_daily_loss_limit_pct = tail_env_float(r, "TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT", 1.5)
reentry_require_nonneg = tail_env_bool(r, "TAIL_REENTRY_REQUIRE_NONNEG", False)
reentry_min_edge_krw = tail_env_float(r, "TAIL_REENTRY_MIN_EDGE_KRW", 0.0)
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)
# 실매 BaseStrategy._post_filter_candidates 후보 하드캡과 동일 (0=무제한)
cand_limit = tail_env_int(r, "SHORT_CAND_LIMIT", 0)
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)
eod_enabled = tail_env_bool(r, "TAIL_EOD_ENABLED", True)
eod_hm = str(r.get("TAIL_EOD_HM") or "15:20").strip() or "15:20"
# ws_ticks 진입가 재생 기본 ON — tail_tick_replay·모멘텀·돌파와 동일 (실매 체결 정합, env=0 일 때만 OFF)
backtest_use_tick_db = tail_env_bool(r, "TAIL_BACKTEST_USE_TICK_DB", True)
backtest_use_tick_exit = tail_env_bool(r, "TAIL_BACKTEST_USE_TICK_EXIT", True)
backtest_tick_fallback_ohlc = tail_env_bool(r, "TAIL_BACKTEST_TICK_FALLBACK_OHLC", False)
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, 20
symbol_daily_loss_limit_krw, symbol_daily_loss_limit_pct = 30000.0, 1.5
reentry_require_nonneg, reentry_min_edge_krw = False, 0.0
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
cand_limit = 0
ratchet_tiers, max_hold_bars = "", 0
eod_enabled, eod_hm = True, "15:20"
backtest_use_tick_db, backtest_use_tick_exit = True, True
backtest_tick_fallback_ohlc = False
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, 20
symbol_daily_loss_limit_krw, symbol_daily_loss_limit_pct = 30000.0, 1.5
reentry_require_nonneg, reentry_min_edge_krw = False, 0.0
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
cand_limit = 0
ratchet_tiers, max_hold_bars = "", 0
eod_enabled, eod_hm = True, "15:20"
backtest_use_tick_db, backtest_use_tick_exit = True, True
backtest_tick_fallback_ohlc = False
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,
# 종목당 일일 재진입 게이트 (기본 0/false=OFF, 동작 불변)
"symbol_daily_loss_limit_krw": symbol_daily_loss_limit_krw,
"symbol_daily_loss_limit_pct": symbol_daily_loss_limit_pct,
"reentry_require_nonneg": reentry_require_nonneg,
"reentry_min_edge_krw": reentry_min_edge_krw,
# 고급 방어 파라미터 반환
"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,
"cand_limit": cand_limit,
"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_use_tick_exit": backtest_use_tick_exit,
"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,
"eod_enabled": eod_enabled,
"eod_hm": eod_hm,
"force_eod_exit": eod_enabled,
**_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 _t2dt(t: str) -> datetime:
"""candle_time / 실매 buy_time → datetime (공통 파서)."""
from kis_trader.utils.trade_time import parse_trade_datetime
return parse_trade_datetime(t)
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 중복 필터 생략
if params.get("skip_hts_scan_dupes") is None:
skip_hts = resolve_tail_skip_hts_scan_dupes()
else:
skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), resolve_tail_skip_hts_scan_dupes())
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 (
"탈락-일일한도",
f"daily={state.get('daily_cnt', 0)}/{max_daily}",
None,
)
_reentry_ok, _reentry_detail = _tail_symbol_reentry_gate(
int(state.get("daily_cnt", 0) or 0),
float(state.get("daily_pnl_krw", 0.0) or 0.0),
float(params.get("slot_money", 0) or 0),
params,
)
if not _reentry_ok:
return ("탈락-종목일일손익게이트", _reentry_detail, 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`` (기본): 백테와 동일 — **직전 확정봉=신호봉**,
**현재 확정봉=진입봉** (신호 직후 봉 시가·실매 시장가 정합).
- ``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
return _eval_live_align_lookback(
confirmed, entry_i, params, state, lookback=lookback,
)
i = len(confirmed) - 1
return _eval_tail_buy_at_index(confirmed, i, params, state)
def _eval_live_align_lookback(
candles: List[Dict],
entry_i: int,
params: Dict[str, Any],
state: Dict[str, Any],
*,
lookback: int = 1,
) -> tuple:
"""실매 align: entry_i=진입봉, entry_i-1-k=신호봉 (lookback개까지)."""
lb = max(1, int(lookback))
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
None, None, None,
)
for k in range(lb):
signal_i = entry_i - 1 - k
if signal_i < 19:
break
reject, msg, sig = _eval_tail_buy_at_index(
candles, signal_i, params, state,
)
if reject:
if k == 0:
last_reject = (reject, msg, None)
continue
if sig:
ent = candles[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")
sig["signal_bar_key"] = candles[signal_i].get("candle_time")
sig["signal_i"] = signal_i
sig["entry_i"] = entry_i
return (None, None, sig)
return last_reject
def _last_closed_bar_index(
candles: List[Dict],
as_of_hhmm: str,
tf_min: int,
) -> int:
"""as_of(YYYYMMDDHHMM) 시점까지 **이미 마감**된 마지막 봉 인덱스. 없으면 -1.
봉 시작 + tf_min ≤ as_of 이면 확정(실매: 진행 중 봉은 신호/진입봉으로 안 씀).
"""
from kis_trader.engine.candle_rollup import add_candle_minutes
as_of = str(as_of_hhmm or "")[:12]
if len(as_of) < 12 or not candles:
return -1
best = -1
for i, c in enumerate(candles):
ct = str(c.get("candle_time") or "")[:12]
if len(ct) < 12:
continue
end_t = add_candle_minutes(ct, int(tf_min))
if end_t and end_t <= as_of:
best = i
elif ct > as_of:
break
return best
def _first_universe_minute_by_code(
universe_by_slot: Optional[Dict[str, List[str]]],
universe_timeline: Optional[Any],
codes: Optional[set] = None,
) -> Dict[str, str]:
"""종목별 최초 편입 분키(YYYYMMDDHHMM)."""
out: Dict[str, str] = {}
want = codes
if universe_timeline is not None and hasattr(universe_timeline, "_keys"):
try:
keys = list(getattr(universe_timeline, "_keys") or [])
code_lists = list(getattr(universe_timeline, "_codes") or [])
for sk, snap_codes in zip(keys, code_lists):
minute = str(sk)[:12]
for c in snap_codes or []:
cs = str(c).strip()
if not cs or (want is not None and cs not in want):
continue
if cs not in out:
out[cs] = minute
if out:
return out
except Exception:
pass
if not universe_by_slot:
return out
for slot in sorted(universe_by_slot.keys()):
minute = str(slot)[:12]
for c in universe_by_slot.get(slot) or []:
cs = str(c).strip()
if not cs or (want is not None and cs not in want):
continue
if cs not in out:
out[cs] = minute
return out
def _universe_enter_minutes(
universe_by_slot: Optional[Dict[str, List[str]]],
universe_timeline: Optional[Any],
codes: Optional[set] = None,
) -> Set[str]:
"""유니버스 ENTER 분키 집합 (재편입 포함) — 실매 REAL I(편입) 시각 근사.
최초 편입만 쓰는 ``_first_universe_minute_by_code`` 와 달리,
EXIT 후 다시 들어온 분도 시계에 넣어 Phase 0-incl 이 재검사한다.
"""
out: Set[str] = set()
want = codes
prev: Set[str] = set()
def _iter_snaps():
if universe_timeline is not None and hasattr(universe_timeline, "_keys"):
try:
keys = list(getattr(universe_timeline, "_keys") or [])
code_lists = list(getattr(universe_timeline, "_codes") or [])
for sk, snap_codes in zip(keys, code_lists):
yield str(sk)[:12], {
str(c).strip() for c in (snap_codes or []) if str(c).strip()
}
return
except Exception:
pass
if not universe_by_slot:
return
for slot in sorted(universe_by_slot.keys()):
yield str(slot)[:12], {
str(c).strip() for c in (universe_by_slot.get(slot) or []) if str(c).strip()
}
for minute, cur in _iter_snaps():
if want is not None:
cur = {c for c in cur if c in want}
entered = cur - prev
if entered and minute:
out.add(minute)
prev = cur
return out
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:
# 실매 buy_time 은 "YYYY-MM-DD HH:MM:SS" — [:12] 자르면 파싱 실패
e_dt = _t2dt(str(et))
c_dt = _t2dt(str(ct))
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 _last_close_at_or_before(
candles: List[Dict],
t: str,
default: float,
) -> float:
"""시계 ``t`` 이하 마지막 확정 종가 — EOD 틱 없을 때 실매 장마감가 근사."""
key = str(t or "")[:12]
last = float(default or 0)
if not key:
return last
for c in candles:
ct = str(c.get("candle_time") or "")[:12]
if not ct:
continue
if ct > key:
break
px = float(c.get("close") or 0)
if px > 0:
last = px
return last
def _inject_tail_eod_timeline_minutes(
all_times_set: Set[str],
params: Dict[str, Any],
period_ymd: str,
) -> None:
"""
실매 DB ``TAIL_EOD_ENABLED`` / ``TAIL_EOD_HM`` (params eod_*) 토글을 백테 시계에 반영.
3분봉 격자에 EOD 분이 없어도, 실매 벽시계처럼 해당 HHMM에 청산 판정 가능.
"""
enabled, eod_hm = resolve_strategy_eod_params(params, "TAIL")
if not enabled:
return
hh, mm = parse_eod_hm(eod_hm)
days: Set[str] = set()
py = str(period_ymd or "")[:8]
if len(py) == 8 and py.isdigit():
days.add(py)
for t in all_times_set:
d = str(t or "")[:8]
if len(d) == 8 and d.isdigit():
if py and d < py:
continue
days.add(d)
for d in days:
all_times_set.add(f"{d}{hh:02d}{mm:02d}")
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 _fill_portfolio_align_entry(
*,
portfolio: Dict[str, Dict[str, Any]],
ctx: Dict[str, Any],
code: str,
pe: Dict[str, Any],
t: str,
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
tick_tf: int,
use_ticks: bool,
max_stocks: int,
slot_money: float,
total_budget: float,
min_invest_ratio: float,
vol_fill_cap_pct: float,
tick_fill_stats: Dict[str, int],
) -> Tuple[bool, int, int]:
"""align 예약 진입 체결 — 실매: 진입봉 확정 직후 시장가."""
skipped_micro = 0
skipped_vol = 0
if code in portfolio or not pe:
return False, skipped_micro, skipped_vol
if len(portfolio) >= max_stocks:
return False, skipped_micro, skipped_vol
entry_price = float(pe.get("entry_price") or 0)
if use_ticks:
bar_key = str(pe.get("entry_bar_key") or t)[:12]
bar_ticks = collect_bar_ticks(ticks_by_code, code, bar_key, tick_tf)
if bar_ticks:
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:
return False, skipped_micro, skipped_vol
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:
return False, 1, 0
invest = min(float(slot_money), remaining, target_cost)
qty = int(invest / entry_price)
if qty < 1:
return False, 1, 0
if vol_fill_cap_pct > 0:
_eidx = ctx["time_index"].get(str(pe.get("entry_bar_key") or t)[:12])
if _eidx is None:
_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:
return False, 0, 1
qty = capped
cost = qty * entry_price
if cost < min_required:
return False, 1, 0
if exposure + cost > total_budget + 1e-6:
return False, 1, 0
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,
}
return True, 0, 0
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
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)
# 종목 일일 손익 게이트용 — 실매 realized_pnl 과 동일 net 기준으로 온라인 누적 (수수료/세금 반영)
gate_fee_rate = float(params.get("fee_rate", 0.00015) or 0.00015)
gate_sell_tax = float(params.get("sell_tax", 0.0018) or 0.0018)
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)
use_tick_exit = bool(ticks_by_code) and tail_backtest_use_tick_exit(params)
tick_fallback_ohlc = tail_backtest_tick_fallback_ohlc(params)
tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="TAIL_BACKTEST_POLL_MS")
tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="TAIL_BACKTEST_SELL_SLIP_PCT")
tick_fill_stats = {"ws_ticks": 0, "ohlc_low": 0, "ohlc_open": 0}
tick_exit_count = 0
ohlc_exit_count = 0
attach_indicator_caches_to_params(params, candles_by_code)
# 종목별 컨텍스트
ctx_by_code: Dict[str, Dict[str, Any]] = {}
all_times_set = set()
period_ymd = str(params.get("_bt_period_start_ymd") or "")[:8]
universe_timeline = params.get("_universe_timeline")
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": {},
"daily_pnl": {},
"pending_entry": None,
"pending_limit": None,
}
for c in candles:
ct = c["candle_time"]
# 웜업(전일) 봉은 지표용 — 매매 시계에는 기간일만
if period_ymd and str(ct)[:8] < period_ymd:
continue
all_times_set.add(ct)
# align: 진입 체결은 봉 **마감** 시각(실매 확정봉 직후) — 시계에 마감분 추가
_bar_close = align_entry_execute_time(ct, tick_tf)
if _bar_close:
if not period_ymd or str(_bar_close)[:8] >= period_ymd:
all_times_set.add(_bar_close)
# 편입·재편입 시각을 시계에 넣어, 봉 경계가 아니어도 실매처럼 즉시 매수 판정
enter_mins = _universe_enter_minutes(
universe_by_slot, universe_timeline, set(ctx_by_code.keys()),
)
for _m in enter_mins:
if _m and (not period_ymd or str(_m)[:8] >= period_ymd):
all_times_set.add(_m)
# 실매 EOD 토글·시각(DB) → 백테 공유 시계에 EOD 분 주입 (종목별 3분봉 키 유무와 무관)
_inject_tail_eod_timeline_minutes(all_times_set, params, period_ymd)
all_times = sorted(all_times_set)
portfolio: Dict[str, Dict[str, Any]] = {}
all_trades: List[Dict] = []
# 초단위 유니버스 타임라인 (실매 get_universe_at 정합, 돌파·모멘텀 공통). 없으면 1분 슬롯 폴백.
# 실매 후보 하드캡(SHORT_CAND_LIMIT) — 백테도 그 시각 유니버스 상위 N개만 검사해야
# "실매는 20개만 보는데 백테는 그 시각 풀 전체를 본다" 는 정합 오차가 사라진다.
cand_limit = resolve_tail_cand_limit(params)
inclusion_lookback_on = _to_bool(
params.get("backtest_inclusion_lookback", True), True,
)
if "backtest_inclusion_lookback" not in params:
from kis_trader.utils.env import get_env_bool
inclusion_lookback_on = get_env_bool("TAIL_BT_INCLUSION_LOOKBACK", True)
live_sig_lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
inclusion_entries = 0
prev_uni_set: Optional[Set[str]] = None
from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at
for t in all_times:
if apply_env_timeline_at(params, t, "SHORT"):
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)
cand_limit = resolve_tail_cand_limit(params)
slot_key = _slot_key(t, params.get("scan_interval_min", 1))
uni_codes = _universe_codes_at(t, slot_key, universe_timeline, universe_by_slot)
if uni_codes is not None and cand_limit > 0 and len(uni_codes) > cand_limit:
uni_codes = uni_codes[:cand_limit]
uni_set = set(uni_codes) if uni_codes is not None else None
# ── Phase 0-incl: 유니버스 신규·재편입 직후 lookback (실매 check_buy_signal_live) ──
if (
inclusion_lookback_on
and uni_set is not None
and not is_limit_atr_entry(short_entry_mode(params))
):
# 실매: EXIT 후 RE-ENTER 때도 즉시 매수 검사. 최초 편입만 보면 샘표류 누락.
if prev_uni_set is None:
newly = set(uni_set) # 첫 스냅샷 = 전원 신규 편입으로 취급
else:
newly = uni_set - prev_uni_set
if newly and len(portfolio) < max_stocks:
incl_cands: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
for code in newly:
if code in portfolio or code not in ctx_by_code:
continue
ctx = ctx_by_code[code]
if ctx.get("pending_entry") or ctx.get("pending_limit"):
continue
entry_i = _last_closed_bar_index(ctx["candles"], t, tick_tf)
if entry_i < 19:
continue
day = str(t)[:8]
if ctx["daily_cnt"].get(day, 0) >= int(params.get("max_daily", 3)):
continue
eval_params = dict(params)
ic = get_indicator_cache_from_params(params, code)
if ic is not None:
eval_params["_indicator_cache"] = ic
state = {
"daily_cnt": ctx["daily_cnt"].get(day, 0),
"last_exit_dt": ctx["last_exit_dt"].get(day),
"daily_pnl_krw": ctx["daily_pnl"].get(day, 0.0),
}
reject, _msg, sig = _eval_live_align_lookback(
ctx["candles"], entry_i, eval_params, state,
lookback=live_sig_lookback,
)
if reject or not sig:
continue
ent = ctx["candles"][entry_i]
entry_price = float(sig.get("entry_price") or ent.get("open") or 0)
if entry_price <= 0:
continue
atr = (
ctx["atrs"][entry_i]
if entry_i < len(ctx["atrs"]) and ctx["atrs"][entry_i] is not None
else entry_price * 0.01
)
stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params)
exec_t = align_entry_execute_time(ent.get("candle_time"), tick_tf) or t
pri = _buy_priority_key(code, uni_codes)
incl_cands.append((pri, code, {
"entry_time": exec_t,
"entry_price": entry_price,
"stop": stop_p,
"target": target_p,
"entry_bar_key": ent.get("candle_time"),
"from_inclusion": True,
}))
if incl_cands:
incl_cands.sort(key=lambda x: x[0])
_pri, pick_code, pe = incl_cands[0]
# 편입 직후 = 실매 즉시 시장가 (진입봉 이미 확정된 경우 같은 시각 체결)
_ctx_pick = ctx_by_code[pick_code]
if str(pe.get("entry_time") or "")[:12] == str(t)[:12]:
_filled, _sm, _sv = _fill_portfolio_align_entry(
portfolio=portfolio,
ctx=_ctx_pick,
code=pick_code,
pe=pe,
t=t,
ticks_by_code=ticks_by_code,
tick_tf=tick_tf,
use_ticks=use_ticks,
max_stocks=max_stocks,
slot_money=float(slot_money),
total_budget=float(total_budget),
min_invest_ratio=min_invest_ratio,
vol_fill_cap_pct=vol_fill_cap_pct,
tick_fill_stats=tick_fill_stats,
)
skipped_micro_buys += _sm
skipped_vol_unfilled += _sv
if _filled:
inclusion_entries += 1
else:
_ctx_pick["pending_entry"] = pe
else:
_ctx_pick["pending_entry"] = pe
inclusion_entries += 1
if uni_set is not None:
prev_uni_set = set(uni_set)
# ── 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 — 진입봉 확정 직후 첫 틱 / 편입 lookback) ──
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
_filled, _sm, _sv = _fill_portfolio_align_entry(
portfolio=portfolio,
ctx=ctx,
code=code,
pe=pe,
t=t,
ticks_by_code=ticks_by_code,
tick_tf=tick_tf,
use_ticks=use_ticks,
max_stocks=max_stocks,
slot_money=float(slot_money),
total_budget=float(total_budget),
min_invest_ratio=min_invest_ratio,
vol_fill_cap_pct=vol_fill_cap_pct,
tick_fill_stats=tick_fill_stats,
)
skipped_micro_buys += _sm
skipped_vol_unfilled += _sv
if _filled:
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)
candles = ctx["candles"]
day = t[:8]
pos = portfolio[code]
# 편입 lookback 등으로 타임라인에 3분봉 키가 아닌 분(예: 13:23)이
# 들어오면 time_index miss. 실매는 WS 틱마다 청산하므로, 해당 분
# 틱만으로 청산 검사(봉 OHLC 폴백 없음 — 유령 청산 방지).
# 단 EOD는 실매 벽시계와 동일하게 ``t`` 기준(DB eod_enabled/eod_hm)으로 추적.
is_eod = is_strategy_eod_bar(t, params, "TAIL")
if idx is None:
if not use_tick_exit and not is_eod:
continue
bar_ticks = (
collect_minute_ticks(ticks_by_code, code, t)
if use_tick_exit else None
)
if not bar_ticks and not is_eod:
continue
ep0 = float(pos.get("entry_price") or 0)
# EOD·분키 miss: 직전 종가로 장마감 체결 근사 (틱 있으면 틱 경로 우선)
last_px = _last_close_at_or_before(candles, t, ep0)
if last_px <= 0:
last_px = ep0
max_p = max(float(pos.get("max_price") or ep0), last_px)
sess_lo = float(pos.get("session_low") or ep0)
if last_px > 0:
sess_lo = min(sess_lo, last_px) if sess_lo > 0 else last_px
pos["max_price"] = max_p
if sess_lo > 0:
pos["session_low"] = sess_lo
cur_c_info = {
"open": last_px,
"high": max_p,
"low": sess_lo if sess_lo > 0 else last_px,
"close": last_px,
"candle_time": t,
}
else:
c = candles[idx]
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
op = float(c["open"])
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,
}
bar_ticks = (
collect_bar_ticks(ticks_by_code, code, t, tick_tf)
if use_tick_exit else None
)
# 분키 miss: 일반 청산은 틱만(유령 OHLC 방지). EOD만 시계·직전종가 폴백 허용.
_tick_fb = bool(is_eod) if idx is None else bool(tick_fallback_ohlc or is_eod)
res = resolve_backtest_sell(
pos,
cur_c_info,
params,
is_eod=is_eod,
sell_fn=check_sell_signal_live,
low_mode="session_low",
ticks=bar_ticks,
use_tick_exit=use_tick_exit,
tick_fallback_ohlc=_tick_fb,
poll_ms=tick_poll_ms,
slip_pct=tick_sell_slip,
)
if not res:
continue
reason, exit_price, sell_time, _hold_min, exit_src = res
if exit_src == "ws_ticks":
tick_exit_count += 1
else:
ohlc_exit_count += 1
all_trades.append({
"code": code,
"entry_time": pos["entry_time"],
"exit_time": sell_time or 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(sell_time or t)
ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
# 종목 일일 손익 게이트용 net pnl 누적 — attach_tail_trade_pnl 과 동일 공식(수수료/세금)
_qty = int(pos.get("qty", 1) or 1)
_ep, _xp = float(pos["entry_price"]), float(exit_price)
_net_pnl = (
(_xp - _ep) * _qty
- (_ep + _xp) * _qty * gate_fee_rate
- _xp * _qty * gate_sell_tax
)
ctx["daily_pnl"][day] = ctx["daily_pnl"].get(day, 0.0) + _net_pnl
del portfolio[code]
# ── Phase 2: 신규 매수 신호 (align=진입봉 확정 직후 / limit=신호봉 이후 지정가) ──
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
candles = ctx["candles"]
day = t[:8]
if uni_set is not None:
if code not in uni_set:
continue
if 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
if "skip_hts_scan_dupes" not in eval_params:
eval_params["skip_hts_scan_dupes"] = resolve_tail_skip_hts_scan_dupes()
state = {
"daily_cnt": ctx["daily_cnt"].get(day, 0),
"last_exit_dt": ctx["last_exit_dt"].get(day),
"daily_pnl_krw": ctx["daily_pnl"].get(day, 0.0),
}
if is_limit_atr_entry(short_entry_mode(params)):
idx = ctx["time_index"].get(t)
if idx is None:
continue
c = candles[idx]
cl = float(c["close"])
if cl <= 0:
continue
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,
)
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)
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
# align — 진입봉 **마감** 시각에만 검사 (실매: 확정봉 직후 시장가)
entry_i = _last_closed_bar_index(candles, t, tick_tf)
if entry_i < 19:
continue
ent = candles[entry_i]
close_t = align_entry_execute_time(ent.get("candle_time"), tick_tf)
if not close_t or str(t)[:12] != str(close_t)[:12]:
continue
ent_ct = str(ent.get("candle_time") or "")
inject_whipsaw_ticks_into_params(
eval_params,
ticks_by_code=ticks_by_code,
code=code,
bar_candle_time=ent_ct,
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=ent_ct,
)
reject, _msg, sig = _eval_live_align_lookback(
candles, entry_i, eval_params, state,
lookback=live_sig_lookback,
)
if reject or not sig:
continue
cl = float(ent.get("close") or 0)
atr = (
ctx["atrs"][entry_i]
if entry_i < len(ctx["atrs"]) and ctx["atrs"][entry_i] is not None
else cl * 0.01
)
pri = _buy_priority_key(code, uni_codes)
entry_price = float(sig.get("entry_price") or ent.get("open") or cl)
if entry_price <= 0:
continue
stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params)
candidates.append((pri, code, {
"entry_time": close_t,
"entry_price": entry_price,
"stop": stop_p,
"target": target_p,
"entry_bar_key": ent.get("candle_time"),
}))
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_pick = ctx_by_code[pick_code]
if str(pe.get("entry_time") or "")[:12] == str(t)[:12]:
_filled, _sm, _sv = _fill_portfolio_align_entry(
portfolio=portfolio,
ctx=_ctx_pick,
code=pick_code,
pe=pe,
t=t,
ticks_by_code=ticks_by_code,
tick_tf=tick_tf,
use_ticks=use_ticks,
max_stocks=max_stocks,
slot_money=float(slot_money),
total_budget=float(total_budget),
min_invest_ratio=min_invest_ratio,
vol_fill_cap_pct=vol_fill_cap_pct,
tick_fill_stats=tick_fill_stats,
)
skipped_micro_buys += _sm
skipped_vol_unfilled += _sv
if not _filled:
_ctx_pick["pending_entry"] = pe
else:
_ctx_pick["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 tick_exit_count or ohlc_exit_count:
skip_stats["tick_exit_count"] = tick_exit_count
skip_stats["ohlc_exit_count"] = ohlc_exit_count
if inclusion_entries:
skip_stats["inclusion_lookback_entries"] = inclusion_entries
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)
use_tick_exit = bool(ticks_by_code) and tail_backtest_use_tick_exit(params)
tick_fallback_ohlc = tail_backtest_tick_fallback_ohlc(params)
tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="TAIL_BACKTEST_POLL_MS")
tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="TAIL_BACKTEST_SELL_SLIP_PCT")
# 파라미터 준비
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))
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)))
gate_fee_rate = float(params.get("fee_rate", 0.00015) or 0.00015)
gate_sell_tax = float(params.get("sell_tax", 0.0018) or 0.0018)
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] = {}
daily_pnl: Dict[str, float] = {}
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 = is_strategy_eod_bar(c["candle_time"], params, "TAIL")
# ── 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"],
}
bar_ticks = (
collect_bar_ticks(
ticks_by_code, code, c["candle_time"], tick_tf,
) if use_tick_exit else None
)
res = resolve_backtest_sell(
position,
cur_c_info,
params,
is_eod=is_eod,
sell_fn=check_sell_signal_live,
low_mode="session_low",
ticks=bar_ticks,
use_tick_exit=use_tick_exit,
tick_fallback_ohlc=tick_fallback_ohlc,
poll_ms=tick_poll_ms,
slip_pct=tick_sell_slip,
)
if res:
reason, exit_price, sell_time, _hold_min, _exit_src = res
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": sell_time or 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(sell_time or c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
# 종목 일일 손익 게이트용 net pnl 누적 (포트폴리오 루프와 동일 공식)
_qty = int(position.get("qty", 1) or 1)
_ep, _xp = float(position["entry_price"]), float(exit_price)
_net_pnl = (
(_xp - _ep) * _qty
- (_ep + _xp) * _qty * gate_fee_rate
- _xp * _qty * gate_sell_tax
)
daily_pnl[day] = daily_pnl.get(day, 0.0) + _net_pnl
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 "skip_hts_scan_dupes" not in eval_params:
eval_params["skip_hts_scan_dupes"] = resolve_tail_skip_hts_scan_dupes()
state = {
"daily_cnt": daily_cnt.get(day, 0),
"last_exit_dt": last_exit_dt.get(day),
"daily_pnl_krw": daily_pnl.get(day, 0.0),
}
live_sig_lb = max(1, int(params.get("live_signal_lookback_bars", 1)))
# ── 3. 매수 — limit_atr: 신호봉 i / align: 진입봉 확정(다음 봉 시각) ──
if is_limit_atr_entry(short_entry_mode(params)):
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
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
# align — 진입봉 i-1 마감 시각 = 현재 봉 i 시작 (연속 봉 가정)
if i < 20:
i += 1
continue
entry_i = i - 1
ent = candles[entry_i]
if (
align_entry_execute_time(ent.get("candle_time"), tick_tf)[:12]
!= str(c.get("candle_time") or "")[:12]
):
i += 1
continue
reject, _msg, sig = _eval_live_align_lookback(
candles, entry_i, eval_params, state, lookback=live_sig_lb,
)
if reject or not sig:
i += 1
continue
atr = (
atrs[entry_i]
if atrs[entry_i] is not None
else float(ent.get("close") or cl) * 0.01
)
entry_price = float(sig.get("entry_price") or ent.get("open") or cl)
if entry_price <= 0:
i += 1
continue
if use_ticks:
bar_ticks = collect_bar_ticks(
ticks_by_code, code, ent["candle_time"], tick_tf,
)
entry_price, _align_src = align_entry_price_from_ticks(bar_ticks, entry_price)
entry_time = str(c["candle_time"])[:12]
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": entry_time,
"stop": stop_p,
"target": target_p,
"max_price": entry_price,
"session_low": entry_price,
"qty": calc_qty,
}
i += 1
continue
return all_trades