Files
kis_bot/kis_trader/engine/tail_engine.py
Your Name e1ac8d119b chore: 작업 중 발생한 부수적 변경 사항 및 누락된 파일 전체 커밋
- 프론트엔드 UI 업데이트 (backtest.html, backtest.js) 엔진 라디오 버튼 통합 관련 반영
- Rust 플러그인(kis_rust_core) 및 컴파일 소스코드 추가
- CLI 백테스트 스크립트 수정 및 최신화
- 기타 스크래치 테스트 스크립트, 로그 요약 마크다운(.md) 등 누락 파일 일괄 반영
- 추가적으로 아직 발견되지 않은 엣지 케이스나 렌더링 오류가 포함되어 있을 가능성이 있음
2026-09-06 17:04:50 +09:00

2973 lines
124 KiB
Python
Raw Permalink 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 SCAN 통과 후보에 타점·리스크 재검사]
반전 패턴 OR(망치·핀바·장악·관통·하라미·도지·샛별) + 당일 회복률·3분봉 회복 위치,
(선택) 신호봉 거래량 폭증(``TAIL_VOL_MULT``×N봉평균, 0=OFF) + RSI·MA20,
고점추격·피뢰침, 시간대/쿨다운. 패턴별 ``TAIL_PATTERN_*`` env 로 ON/OFF.
``TAIL_SKIP_HTS_SCAN_DUPES=false`` (운영 기본) 이면 TRIGGER 에서
**3분봉 직전대비 등락**(``TAIL_BAR_CHG_MIN/MAX_PCT``) 을 추가 검사.
(HTS 일봉 A·1분 G 와 **축이 다름** — 라벨을 HTS A 로 부르지 말 것.
true 이면 이 3분 등락 재검사만 생략. 시가→저점 낙폭은 ``TAIL_USE_INTRADAY_DROP``.)
■ 엔진 공통 로직 (백테·실매 동일)
매도 우선순위 (V4):
1) 트레일(어깨컷) — max_price 갱신 후 되돌림(저가로 터치 판정, 체결=매도선), 발동 수익% 충족 시 **손절·금액손실보다 우선**
2) ATR 캡 적용 익절 / 3) ATR 캡 적용 손절 / 4) 금액손실컷(트레일 미발동 구간만) / 5) 장마감
ATR 목표·손절: 배수 × ATR 후 ``TAIL_ATR_*_MIN/MAX_PCT`` % 상·하한 캡 (잡주 과대 목표가 방지).
백테 청산: ws_ticks 시간순 (``resolve_backtest_sell``, session_low).
**실매와 동일** — 고점(max_price)·청산 판정은 틱만. 봉 OHLC high/low 로 max 선반영·폴백 금지
(``TAIL_BACKTEST_TICK_FALLBACK_OHLC`` 기본 False, EOD 도 OHLC 폴백 강제 금지 → 틱 미청산 시 장마감 플랫만).
진입·지표는 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:
"""
TRIGGER 의 **3분봉 직전대비 등락**(BAR_CHG) 재검사 생략 여부.
이름에 HTS 가 들어가지만, 끄는 대상은 HTS 일봉 A/1분 G 가 아니라
``TAIL_BAR_CHG_*`` (신호 3분봉 vs 직전 3분봉) 이다.
- ``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:
# SCALP 와 동일 — RAM TTL(get_strategy_env_dict). 매 호출 full snapshot 금지.
try:
from kis_trader.utils.env import get_strategy_env_dict
r = get_strategy_env_dict("SHORT") or {}
except Exception:
r = {}
if not r:
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)
whipsaw_filter = tail_env_bool(r, "TAIL_WHIPSAW_FILTER_ENABLED", False)
whipsaw_subbar_sec = tail_env_int(r, "TAIL_WHIPSAW_SUBBAR_SEC", 10)
whipsaw_lookback_sec = tail_env_int(r, "TAIL_WHIPSAW_LOOKBACK_SEC", 30)
whipsaw_dip_pct = tail_env_float(r, "TAIL_WHIPSAW_DIP_PCT", 0.5)
whipsaw_tol = tail_env_float(r, "TAIL_WHIPSAW_RECOVERY_TOL_PCT", 0.1)
min_bid_ask_ratio = tail_env_float(r, "TAIL_ORDERBOOK_MIN_BID_ASK_RATIO", 0.0)
ob_ask_max_mult = tail_env_float(r, "TAIL_ORDERBOOK_ENTRY_ASK_MAX_MULT", 0.0)
else:
min_drop, min_rec = 0.03, 0.5
tail_ratio, tail_pct = 1.5, 0.003
sl_pct, tp_pct = 0.03, 0.05
shoulder_high, shoulder_cut = 0.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({})
whipsaw_filter = False
whipsaw_subbar_sec, whipsaw_lookback_sec = 10, 30
whipsaw_dip_pct, whipsaw_tol = 0.5, 0.1
min_bid_ask_ratio, ob_ask_max_mult = 0.0, 0.0
except Exception:
min_drop, min_rec = 0.03, 0.5
tail_ratio, tail_pct = 1.5, 0.003
sl_pct, tp_pct = 0.03, 0.05
shoulder_high, shoulder_cut = 0.015, 0.03
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
max_rec_3m, high_chase = 0.8, 0.96
time_start, time_end, max_daily = 930, 1500, 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({})
whipsaw_filter = False
whipsaw_subbar_sec, whipsaw_lookback_sec = 10, 30
whipsaw_dip_pct, whipsaw_tol = 0.5, 0.1
min_bid_ask_ratio, ob_ask_max_mult = 0.0, 0.0
finally:
if own_db is not None:
try:
own_db.close()
except Exception:
pass
return {
"min_drop_rate": min_drop,
"min_recovery_ratio": min_rec,
"max_rec_3m": max_rec_3m,
"tail_ratio_min": tail_ratio,
"tail_pct_min": tail_pct,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"shoulder_min_high": shoulder_high,
"shoulder_cut_pct": shoulder_cut,
"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,
"whipsaw_filter": whipsaw_filter,
"whipsaw_subbar_sec": whipsaw_subbar_sec,
"whipsaw_lookback_sec": whipsaw_lookback_sec,
"whipsaw_dip_pct": whipsaw_dip_pct,
"whipsaw_tol": whipsaw_tol,
"min_bid_ask_ratio": min_bid_ask_ratio,
"ob_ask_max_mult": ob_ask_max_mult,
**_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 — skip_hts=true 이면 아래 3분 봉등락(BAR_CHG) 재검사만 생략
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)
# 3분봉 직전대비 등락 (BAR_CHG) — HTS 일봉A/1분G 와 축이 다름. skip_hts=true 면 생략
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}% (3분 직전대비: {bar_chg_min_pct:.1f}~{bar_chg_max_pct:.1f}%)",
None,
)
# 당일 시가→저점 낙폭 — BAR_CHG 와 다른 축; ``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)
ob_snap = params.get("_backtest_orderbook_snapshot")
if ob_snap is not None:
sig["backtest_ob_source"] = str(getattr(ob_snap, "source", "") or "").strip()
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)
# 최소 보유 시간 검사 (너무 짧으면 청산 무시). EOD는 무조건 청산.
if (not is_eod) and 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)
entry_src = "ohlc_open"
# 중분 편입 시가 애매 — pe 생성 시 enroll_et 기록, 여기서 최종 가드
from kis_trader.engine.mid_enroll_entry_gate import is_entry_open_ambiguous
_ebk = str(pe.get("entry_bar_key") or t)[:12]
_en = pe.get("enroll_et")
if _en and is_entry_open_ambiguous(
_ebk, _en, tf_min=max(1, int(tick_tf or 1)),
):
return False, skipped_micro, skipped_vol
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)
entry_src = str(align_src or "ws_ticks")
if align_src in tick_fill_stats:
tick_fill_stats[align_src] += 1
else:
tick_fill_stats["ws_ticks"] = tick_fill_stats.get("ws_ticks", 0) + 1
else:
tick_fill_stats["ohlc_open"] = tick_fill_stats.get("ohlc_open", 0) + 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,
"entry_source": entry_src,
"ob_source": str(pe.get("backtest_ob_source") or ""),
}
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
# 백테 CLI/웹 잡 진행률 (매매 로직 불변 — 파일에 pct만 기록)
_prog_path = str(params.get("_bt_progress_file") or "").strip()
_prog_base = float(params.get("_bt_progress_base_pct") or 45)
_prog_span = float(params.get("_bt_progress_span_pct") or 50)
_prog_n = max(1, len(all_times))
_prog_last_i = -1
def _emit_bt_progress(i: int) -> None:
nonlocal _prog_last_i
if not _prog_path:
return
# 과도한 디스크 쓰기 방지
if i != 0 and i != _prog_n - 1 and (i - _prog_last_i) < max(25, _prog_n // 40):
return
_prog_last_i = i
try:
pct = _prog_base + _prog_span * (float(i + 1) / float(_prog_n))
payload = {
"pct": int(max(0, min(99, round(pct)))),
"phase": "engine",
"message": f"시계 {i + 1}/{_prog_n}",
"step": int(i + 1),
"total": int(_prog_n),
"ts": __import__("time").time(),
}
from pathlib import Path as _P
_p = _P(_prog_path)
_tmp = _p.with_suffix(".tmp")
_tmp.write_text(
__import__("json").dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
_tmp.replace(_p)
except Exception:
pass
for _ti, t in enumerate(all_times):
_emit_bt_progress(_ti)
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
from kis_trader.engine.mid_enroll_entry_gate import (
bt_resolve_enroll,
is_entry_open_ambiguous,
)
_ebk = str(ent.get("candle_time") or "")[:12]
_enroll = bt_resolve_enroll(
code, t,
universe_timeline=universe_timeline,
universe_by_slot=universe_by_slot,
params=params,
)
if _enroll and is_entry_open_ambiguous(
_ebk, _enroll, tf_min=tick_tf, params=params,
):
continue
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"),
"enroll_et": _enroll,
"from_inclusion": True,
"backtest_ob_source": sig.get("backtest_ob_source"),
}))
if incl_cands:
incl_cands.sort(key=lambda x: x[0])
_pri, pick_code, pe = incl_cands[0]
# 편입·재편입 직후 체결:
# - entry_time == t : 진입봉 마감 시각과 동일 → 즉시 체결
# - entry_time < t : 재편입 lookback 이 과거 신호봉을 찾은 경우
# (실매: EXIT 후 RE-ENTER 시 과거 align 진입가로 즉시 시장가).
# 과거 entry_time 을 pending 으로 남기면 Phase0b 가 영구 미체결 → 금지.
# - entry_time > t : 아직 진입봉 미도래 → pending
_ctx_pick = ctx_by_code[pick_code]
_pe_t = str(pe.get("entry_time") or "")[:12]
_now_t = str(t)[:12]
_fill_now = (not _pe_t) or (_pe_t <= _now_t)
if _fill_now:
_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:
# 잔여한도 등으로 즉시 실패 시에만 pending (시각은 현재 t 로 맞춤)
pe = dict(pe)
pe["entry_time"] = t
_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,
"entry_source": str(fill_src or "ohlc_low"),
"ob_source": str(pl.get("backtest_ob_source") or ""),
}
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")
ep0 = float(pos.get("entry_price") or 0)
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
# 직전 종가 = EOD 플랫 체결가용. max_price 에는 넣지 않음(실매=틱/WS만).
last_px = _last_close_at_or_before(candles, t, ep0)
if last_px <= 0:
last_px = ep0
cur_c_info = {
"open": last_px,
"high": float(pos.get("max_price") or last_px),
"low": float(pos.get("session_low") or 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"])
last_px = cl if cl > 0 else ep0
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
)
# 틱 청산 ON: 봉 high/holding_peak 로 max_price 선반영 금지
# (3분봉 high 에 미래 분 고점 포함 → 래칫 look-ahead, 실매와 분기).
# 틱 OFF(레거시 OHLC 청산)일 때만 봉 high 로 고점 갱신.
if not use_tick_exit:
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
# OHLC 폴백: env/params 명시 ON 만. 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=bool(tick_fallback_ohlc),
poll_ms=tick_poll_ms,
slip_pct=tick_sell_slip,
)
if not res:
# 틱으로 미청산 + EOD: 실매처럼 현재가(직전종가)로 장마감만 — 봉 고저로 래칫/손절 재계산 금지
if is_eod and last_px > 0:
reason, exit_price, sell_time, exit_src = (
"장마감", last_px, t, "eod_flat",
)
else:
continue
else:
reason, exit_price, sell_time, _hold_min, exit_src = res
if exit_src == "ws_ticks":
tick_exit_count += 1
elif exit_src == "eod_flat":
pass
else:
ohlc_exit_count += 1
peak_px = float(pos.get("max_price") or exit_price or 0)
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(peak_px),
"qty": pos.get("qty", 1),
"entry_source": str(pos.get("entry_source") or ""),
"exit_source": str(exit_src or ""),
"ob_source": str(pos.get("ob_source") or ""),
})
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,
"backtest_ob_source": sig.get("backtest_ob_source"),
}))
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)
from kis_trader.engine.mid_enroll_entry_gate import (
bt_resolve_enroll,
is_entry_open_ambiguous,
)
_ebk = str(ent.get("candle_time") or "")[:12]
_enroll = bt_resolve_enroll(
code, t,
universe_timeline=universe_timeline,
universe_by_slot=universe_by_slot,
params=params,
)
if _enroll and is_entry_open_ambiguous(
_ebk, _enroll, tf_min=tick_tf, params=params,
):
continue
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"),
"enroll_et": _enroll,
"backtest_ob_source": sig.get("backtest_ob_source"),
}))
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_rust_experimental(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any]
) -> List[Dict]:
"""꼬리잡기 매매 Rust 엔진 실험용 브릿지"""
try:
import kis_rust_core
except ImportError:
raise RuntimeError("kis_rust_core module is not available")
def get_float(k, default=0.0):
try: return float(params.get(k, default))
except: return float(default)
def get_int(k, default=0):
try: return int(params.get(k, default))
except: return int(default)
def get_bool(k, default=False):
v = params.get(k, default)
if isinstance(v, bool): return v
s = str(v).lower()
if s in ("1", "true", "yes", "t", "y"): return True
return False
rust_params = kis_rust_core.TailParams(
get_int("time_start_hm", 930),
get_int("time_end_hm", 1500),
get_float("cooldown_min", 15.0),
get_int("max_daily", 3),
get_float("min_price", 1000.0),
get_float("rsi_threshold", 78.0),
get_float("min_drop_rate", 0.03),
get_float("min_recovery_ratio", 0.5),
get_float("tail_pct_min", 0.003),
get_bool("skip_hts_scan_dupes", True),
abs(get_float("sl_pct", 0.03)),
get_float("tp_pct", 0.05),
get_float("trail_pct", 0.0),
get_float("trail_arm_pct", 0.0),
get_float("shoulder_min_high", 0.005),
get_float("shoulder_cut_pct", 0.003),
get_int("max_hold_bars", 0),
get_float("max_loss_krw", 200000.0),
get_float("min_drop_pct_for_loss_cut", 0.015),
)
import uuid
import json
session_id = f"exp_tail_{uuid.uuid4().hex[:8]}"
try:
candles_json = json.dumps(codes_candles)
kis_rust_core.init_backtest_session_json(session_id, candles_json)
except Exception as e:
print(f"[ERROR] Failed to serialize or init rust session: {e}")
return []
all_trades = []
try:
r_trades = kis_rust_core.run_engine_trial_tail(session_id, rust_params)
all_trades = [
{
"code": t.code,
"buy_time": t.buy_time,
"sell_time": t.sell_time,
"buy_price": t.buy_price,
"sell_price": t.sell_price,
"entry_time": t.buy_time,
"exit_time": t.sell_time,
"entry": t.buy_price,
"exit": t.sell_price,
"exit_reason": t.reason,
"profit_rate": t.pnl_pct,
"qty": 1,
"pnl": 0,
"sell_reason": t.reason,
"max_price": t.max_price,
"rsi_entry": t.rsi_entry,
"strategy": "SHORT"
}
for t in r_trades
]
except Exception as e:
print(f"[ERROR] run_tail_backtest_rust_experimental failed: {e}")
finally:
try:
kis_rust_core.clear_backtest_session(session_id)
except:
pass
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 params.get("use_rust", False):
return run_tail_backtest_rust_experimental(candles_by_code, params)
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:
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
)
# 틱 청산 ON: 봉 high 로 max_price 선반영 금지 (실매=틱/WS만)
if not use_tick_exit:
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
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=bool(tick_fallback_ohlc),
poll_ms=tick_poll_ms,
slip_pct=tick_sell_slip,
)
if not res and is_eod and cl > 0:
res = ("장마감", cl, c["candle_time"], 0.0, "eod_flat")
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(float(position.get("max_price") or exit_price)),
"qty": position.get("qty", 1),
"entry_source": str(position.get("entry_source") or ""),
"exit_source": str(_exit_src or ""),
"ob_source": str(position.get("ob_source") or ""),
})
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
def init_rust_session(
session_id: str,
candles_by_code: Dict[str, List[Dict]],
ticks_by_code: Optional[Dict[str, List[Dict]]] = None,
):
"""
Rust 인메모리 엔진에 파이썬 딕셔너리 데이터를 한 번에 밀어넣는 래퍼 함수.
"""
try:
import kis_rust_core
except ImportError:
print("[WARNING] kis_rust_core 모듈을 찾을 수 없습니다. Rust 백테스트가 불가능합니다.")
return
import json
# 딕셔너리를 JSON 문자열로 직렬화하여 Rust로 한 번에 전달 (Zero-copy 파싱)
try:
candles_json = json.dumps(candles_by_code)
except Exception as e:
print(f"[ERROR] Failed to serialize candles to JSON: {e}")
return
rust_ticks = {}
# Phase 2 틱 데이터 지원 확장 대비 (현재는 빈 딕셔너리 전달)
if ticks_by_code:
pass
try:
kis_rust_core.init_backtest_session_json(session_id, candles_json)
except Exception as e:
print(f"[ERROR] Failed to init_rust_session: {e}")
def clear_rust_session(session_id: str):
"""
Rust 인메모리 엔진의 메모리를 해제하는 래퍼 함수.
"""
try:
import kis_rust_core
kis_rust_core.clear_backtest_session(session_id)
except Exception as e:
pass