feat: Implement backtest source management and enhance candle data handling Changes: - Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests. - Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment. - Refactored existing code to utilize the new source management functions, improving code readability and maintainability. - Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources. Impact: - These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
2795 lines
118 KiB
Python
2795 lines
118 KiB
Python
#!/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``(기본): 신호봉 확정 → anchor−ATR×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)
|
||
else:
|
||
min_drop, min_rec = 0.03, 0.5
|
||
tail_ratio, tail_pct = 1.5, 0.003
|
||
sl_pct, tp_pct = 0.03, 0.05
|
||
shoulder_high, shoulder_cut = 0.005, 0.003
|
||
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
|
||
max_rec_3m, high_chase = 0.8, 0.96
|
||
time_start, time_end, max_daily = 930, 1500, 20
|
||
symbol_daily_loss_limit_krw, symbol_daily_loss_limit_pct = 30000.0, 1.5
|
||
reentry_require_nonneg, reentry_min_edge_krw = False, 0.0
|
||
min_price, max_daily_change, ma20_max_above = 1000.0, 20.0, 3.0
|
||
stop_atr_mult, target_atr_mult = 2.0, 2.5
|
||
atr_sl_min_pct, atr_sl_max_pct = 0.8, 6.0
|
||
atr_tp_min_pct, atr_tp_max_pct = 0.5, 3.0
|
||
max_loss_krw, risk_pct, kelly_mult = 200000, 0.01, 0.25
|
||
min_drop_pct_for_loss_cut = 0.015
|
||
min_hold_sec, capital = 30.0, 100000000.0
|
||
skip_hts_scan_dupes, use_intraday_drop = True, False
|
||
use_ma20_filter, use_rsi_filter = False, True
|
||
use_daily_range_f, use_high_chase_f = True, True
|
||
bar_chg_min_pct, bar_chg_max_pct = -10.0, -1.5
|
||
tail_vol_mult, tail_vol_win = 0.0, 5
|
||
max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0
|
||
cand_limit = 0
|
||
ratchet_tiers, max_hold_bars = "", 0
|
||
eod_enabled, eod_hm = True, "15:20"
|
||
backtest_use_tick_db, backtest_use_tick_exit = True, True
|
||
backtest_tick_fallback_ohlc = False
|
||
trail_pct, trail_arm_pct = 0.0, 0.0
|
||
_pat = _load_tail_pattern_params_from_row({})
|
||
except Exception:
|
||
min_drop, min_rec = 0.03, 0.5
|
||
tail_ratio, tail_pct = 1.5, 0.003
|
||
sl_pct, tp_pct = 0.03, 0.05
|
||
shoulder_high, shoulder_cut = 0.015, 0.03
|
||
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
|
||
max_rec_3m, high_chase = 0.8, 0.96
|
||
time_start, time_end, max_daily = 930, 1500, 20
|
||
symbol_daily_loss_limit_krw, symbol_daily_loss_limit_pct = 30000.0, 1.5
|
||
reentry_require_nonneg, reentry_min_edge_krw = False, 0.0
|
||
min_price, max_daily_change, ma20_max_above = 1000.0, 20.0, 3.0
|
||
stop_atr_mult, target_atr_mult = 2.5, 5.0
|
||
atr_sl_min_pct, atr_sl_max_pct = 0.8, 6.0
|
||
atr_tp_min_pct, atr_tp_max_pct = 0.5, 5.0
|
||
max_loss_krw, risk_pct, kelly_mult = 200000, 0.01, 0.25
|
||
min_drop_pct_for_loss_cut = 0.015
|
||
min_hold_sec, capital = 30.0, 100000000.0
|
||
skip_hts_scan_dupes, use_intraday_drop = True, False
|
||
use_ma20_filter, use_rsi_filter = False, True
|
||
use_daily_range_f, use_high_chase_f = True, True
|
||
bar_chg_min_pct, bar_chg_max_pct = -10.0, -1.5
|
||
tail_vol_mult, tail_vol_win = 0.0, 5
|
||
max_stocks, total_budget_krw, slot_money, short_max_buy = 3, 0, 3_000_000, 0
|
||
cand_limit = 0
|
||
ratchet_tiers, max_hold_bars = "", 0
|
||
eod_enabled, eod_hm = True, "15:20"
|
||
backtest_use_tick_db, backtest_use_tick_exit = True, True
|
||
backtest_tick_fallback_ohlc = False
|
||
trail_pct, trail_arm_pct = 0.0, 0.0
|
||
_pat = _load_tail_pattern_params_from_row({})
|
||
finally:
|
||
if own_db is not None:
|
||
try:
|
||
own_db.close()
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"min_drop_rate": min_drop,
|
||
"min_recovery_ratio": min_rec,
|
||
"max_rec_3m": max_rec_3m,
|
||
"tail_ratio_min": tail_ratio,
|
||
"tail_pct_min": tail_pct,
|
||
"sl_pct": sl_pct,
|
||
"tp_pct": tp_pct,
|
||
"shoulder_min_high": shoulder_high,
|
||
"shoulder_cut_pct": shoulder_cut,
|
||
"ratchet_tiers": ratchet_tiers,
|
||
"max_hold_bars": max_hold_bars,
|
||
"rsi_period": rsi_period,
|
||
"rsi_threshold": rsi_threshold,
|
||
"high_chase_thr": high_chase,
|
||
"cooldown_min": cooldown_sec // 60,
|
||
"time_start_hm": time_start,
|
||
"time_end_hm": time_end,
|
||
"max_daily": max_daily,
|
||
# 종목당 일일 재진입 게이트 (기본 0/false=OFF, 동작 불변)
|
||
"symbol_daily_loss_limit_krw": symbol_daily_loss_limit_krw,
|
||
"symbol_daily_loss_limit_pct": symbol_daily_loss_limit_pct,
|
||
"reentry_require_nonneg": reentry_require_nonneg,
|
||
"reentry_min_edge_krw": reentry_min_edge_krw,
|
||
# 고급 방어 파라미터 반환
|
||
"min_price": min_price,
|
||
"max_daily_change": max_daily_change,
|
||
"ma20_max_above": ma20_max_above,
|
||
"stop_atr_mult": stop_atr_mult,
|
||
"target_atr_mult": target_atr_mult,
|
||
"atr_sl_min_pct": atr_sl_min_pct,
|
||
"atr_sl_max_pct": atr_sl_max_pct,
|
||
"atr_tp_min_pct": atr_tp_min_pct,
|
||
"atr_tp_max_pct": atr_tp_max_pct,
|
||
"max_loss_krw": max_loss_krw,
|
||
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
|
||
"risk_pct": risk_pct,
|
||
"kelly_mult": kelly_mult,
|
||
"min_hold_sec": min_hold_sec,
|
||
"capital": capital,
|
||
# SCAN/TRIGGER 분리 플래그
|
||
"skip_hts_scan_dupes": skip_hts_scan_dupes,
|
||
"use_intraday_drop": use_intraday_drop,
|
||
"use_ma20_filter": use_ma20_filter,
|
||
"use_rsi_filter": use_rsi_filter,
|
||
"use_daily_range_filter": use_daily_range_f,
|
||
"use_high_chase_filter": use_high_chase_f,
|
||
"bar_chg_min_pct": bar_chg_min_pct,
|
||
"bar_chg_max_pct": bar_chg_max_pct,
|
||
"tail_vol_mult": tail_vol_mult,
|
||
"tail_vol_win": tail_vol_win,
|
||
"max_stocks": max_stocks,
|
||
"total_budget_krw": total_budget_krw,
|
||
"slot_money": slot_money,
|
||
"short_max_buy_amount": short_max_buy,
|
||
"cand_limit": cand_limit,
|
||
"portfolio_mode": True,
|
||
"entry_mode": short_entry_mode(
|
||
{"entry_mode": r.get("TAIL_ENTRY_MODE")} if r else None
|
||
),
|
||
"limit_atr_mult": tail_limit_params(
|
||
{
|
||
"limit_atr_mult": tail_env_float(r, "TAIL_LIMIT_ATR_MULT", 1.5) if r else None,
|
||
"limit_anchor": (r.get("TAIL_LIMIT_ANCHOR") if r else None),
|
||
"limit_valid_bars": tail_env_int(r, "TAIL_LIMIT_VALID_BARS", 1) if r else None,
|
||
"limit_fill_slip_pct": tail_env_float(r, "TAIL_LIMIT_FILL_SLIP_PCT", 0.0) if r else None,
|
||
}
|
||
).get("mult", 1.5),
|
||
"limit_anchor": tail_limit_params(
|
||
{
|
||
"limit_atr_mult": tail_env_float(r, "TAIL_LIMIT_ATR_MULT", 1.5) if r else None,
|
||
"limit_anchor": (r.get("TAIL_LIMIT_ANCHOR") if r else None),
|
||
}
|
||
).get("anchor", "signal_low"),
|
||
"limit_valid_bars": int(tail_limit_params(
|
||
{"limit_valid_bars": tail_env_int(r, "TAIL_LIMIT_VALID_BARS", 1) if r else None}
|
||
).get("valid_bars", 1)),
|
||
"limit_fill_slip_pct": float(tail_limit_params(
|
||
{"limit_fill_slip_pct": tail_env_float(r, "TAIL_LIMIT_FILL_SLIP_PCT", 0.0) if r else None}
|
||
).get("fill_slip_pct", 0.0)),
|
||
"backtest_use_tick_db": backtest_use_tick_db,
|
||
"backtest_use_tick_exit": backtest_use_tick_exit,
|
||
"backtest_tick_fallback_ohlc": backtest_tick_fallback_ohlc,
|
||
# 체결량 상한(진입봉 거래량×N%) — 0=OFF. 실매 IOC 미체결 근사 (DB·웹·CLI 공통).
|
||
"backtest_vol_fill_cap_pct": tail_env_float(r, "TAIL_BACKTEST_VOL_FILL_CAP_PCT", 0.0) if r else 0.0,
|
||
"trail_pct": trail_pct,
|
||
"trail_arm_pct": trail_arm_pct,
|
||
"eod_enabled": eod_enabled,
|
||
"eod_hm": eod_hm,
|
||
"force_eod_exit": eod_enabled,
|
||
**_pat,
|
||
}
|
||
|
||
|
||
def compute_rsi_series(closes: List[float], period: int = 14) -> List[Optional[float]]:
|
||
"""RSI 시리즈 (Wilder 스무딩)."""
|
||
rsi_list: List[Optional[float]] = [None] * len(closes)
|
||
if len(closes) < period + 1:
|
||
return rsi_list
|
||
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||
gains = [max(d, 0) for d in deltas]
|
||
losses = [max(-d, 0) for d in deltas]
|
||
avg_gain = sum(gains[:period]) / period
|
||
avg_loss = sum(losses[:period]) / period
|
||
for i in range(period, len(closes)):
|
||
idx = i - 1
|
||
if i > period:
|
||
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
|
||
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
|
||
rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
|
||
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
|
||
rsi_list[i] = rsi_val
|
||
return rsi_list
|
||
|
||
|
||
def compute_sma_series(closes: List[float], period: int = 20) -> List[Optional[float]]:
|
||
"""단순 이동평균(SMA) 계산기 (엔진 내부용)."""
|
||
sma_list: List[Optional[float]] = [None] * len(closes)
|
||
if len(closes) < period:
|
||
return sma_list
|
||
running_sum = sum(closes[:period])
|
||
sma_list[period - 1] = running_sum / period
|
||
for i in range(period, len(closes)):
|
||
running_sum += closes[i] - closes[i - period]
|
||
sma_list[i] = running_sum / period
|
||
return sma_list
|
||
|
||
|
||
def _t2dt(t: str) -> datetime:
|
||
"""candle_time / 실매 buy_time → datetime (공통 파서)."""
|
||
from kis_trader.utils.trade_time import parse_trade_datetime
|
||
return parse_trade_datetime(t)
|
||
|
||
|
||
def _pct_to_frac(v: Any, default_pct: float) -> float:
|
||
"""퍼센트 숫자(1.5=1.5%%) 또는 소수(0.015) → 비율 소수."""
|
||
if v is None or v == "":
|
||
v = default_pct
|
||
x = float(v)
|
||
return x / 100.0 if x >= 0.2 else x
|
||
|
||
|
||
def _read_tail_pct_from_row(r: Dict[str, Any], db_key: str, env_key: str, default_pct: float) -> float:
|
||
"""env_config 행 → 비율 소수. 없으면 get_env_float 폴백."""
|
||
raw = r.get(db_key) if r else None
|
||
if raw not in (None, ""):
|
||
return _pct_to_frac(raw, default_pct)
|
||
try:
|
||
from kis_trader.utils.env import get_env_float
|
||
return _pct_to_frac(get_env_float(env_key, default_pct), default_pct)
|
||
except Exception:
|
||
return _pct_to_frac(default_pct, default_pct)
|
||
|
||
|
||
def compute_tail_atr_prices(
|
||
entry_price: float,
|
||
atr_value: Optional[float],
|
||
params: Dict[str, Any],
|
||
) -> Tuple[float, float]:
|
||
"""
|
||
꼬리잡기 ATR 손절/익절가 — 배수 적용 후 % 상·하한 캡 (UPDOW ``resolve_atr_exit_pcts`` 와 동일 철학).
|
||
|
||
:return: (stop_price, target_price)
|
||
"""
|
||
if entry_price <= 0:
|
||
return entry_price * 0.97, entry_price * 1.03
|
||
atr = float(atr_value) if atr_value and float(atr_value) > 0 else entry_price * 0.01
|
||
stop_mult = float(params.get("stop_atr_mult", 2.5))
|
||
target_mult = float(params.get("target_atr_mult", 5.0))
|
||
|
||
sl_pct = (atr * stop_mult) / entry_price
|
||
tp_pct = (atr * target_mult) / entry_price
|
||
|
||
sl_min = _pct_to_frac(params.get("atr_sl_min_pct", 0.8), 0.8)
|
||
sl_max = _pct_to_frac(params.get("atr_sl_max_pct", 6.0), 6.0)
|
||
tp_min = _pct_to_frac(params.get("atr_tp_min_pct", 0.5), 0.5)
|
||
tp_max = _pct_to_frac(params.get("atr_tp_max_pct", 5.0), 5.0)
|
||
|
||
sl_pct = min(max(sl_pct, sl_min), sl_max)
|
||
tp_pct = min(max(tp_pct, tp_min), tp_max)
|
||
|
||
stop_p = entry_price * (1.0 - sl_pct)
|
||
target_p = entry_price * (1.0 + tp_pct)
|
||
return stop_p, target_p
|
||
|
||
|
||
def _load_tail_pattern_params_from_row(r: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""반전 패턴 ON/OFF 및 형태 임계값 — env ``TAIL_PATTERN_*`` 단일 소스."""
|
||
row = r or {}
|
||
return {
|
||
"pattern_hammer": tail_env_bool(row, "TAIL_PATTERN_HAMMER", True),
|
||
"pattern_pin": tail_env_bool(row, "TAIL_PATTERN_PIN", False),
|
||
"pattern_engulfing": tail_env_bool(row, "TAIL_PATTERN_ENGULFING", False),
|
||
"pattern_piercing": tail_env_bool(row, "TAIL_PATTERN_PIERCING", False),
|
||
"pattern_harami": tail_env_bool(row, "TAIL_PATTERN_HARAMI", False),
|
||
"pattern_doji": tail_env_bool(row, "TAIL_PATTERN_DOJI", False),
|
||
"pattern_morning_star": tail_env_bool(row, "TAIL_PATTERN_MORNING_STAR", False),
|
||
"pin_close_upper_ratio": tail_env_float(row, "TAIL_PIN_CLOSE_UPPER_RATIO", 0.66),
|
||
"pin_max_upper_tail_ratio": tail_env_float(row, "TAIL_PIN_MAX_UPPER_TAIL_RATIO", 0.35),
|
||
"engulf_min_body_ratio": tail_env_float(row, "TAIL_ENGULF_MIN_BODY_RATIO", 1.0),
|
||
"piercing_penetrate_ratio": tail_env_float(row, "TAIL_PIERCING_PENETRATE_RATIO", 0.5),
|
||
"harami_max_body_ratio": tail_env_float(row, "TAIL_HARAMI_MAX_BODY_RATIO", 0.5),
|
||
"doji_body_max_ratio": tail_env_float(row, "TAIL_DOJI_BODY_MAX_RATIO", 0.15),
|
||
"morning_star_body_max_ratio": tail_env_float(row, "TAIL_MORNING_STAR_BODY_MAX_RATIO", 0.35),
|
||
"candle_lookback": tail_env_int(row, "TAIL_CANDLE_LOOKBACK", 3),
|
||
}
|
||
|
||
|
||
def _bar_ohlc(c: Dict) -> Tuple[float, float, float, float]:
|
||
return float(c["open"]), float(c["high"]), float(c["low"]), float(c["close"])
|
||
|
||
|
||
def _body_parts(op: float, hi: float, lo: float, cl: float) -> Dict[str, float]:
|
||
"""봉 몸통·꼬리 분해 (도지: 몸통 0 이면 range 기준 최소값)."""
|
||
body_top = max(op, cl)
|
||
body_bot = min(op, cl)
|
||
body_len = body_top - body_bot
|
||
rng = hi - lo if hi > lo else 0.0
|
||
if body_len <= 0:
|
||
body_len = max(rng * 0.001, 1e-6) if rng > 0 else 1.0
|
||
lower_tail = max(0.0, body_bot - lo) if lo > 0 else 0.0
|
||
upper_tail = max(0.0, hi - body_top) if hi > 0 else 0.0
|
||
return {
|
||
"body_top": body_top,
|
||
"body_bot": body_bot,
|
||
"body_len": body_len,
|
||
"range": rng,
|
||
"lower_tail": lower_tail,
|
||
"upper_tail": upper_tail,
|
||
"is_bullish": cl >= op,
|
||
"is_bearish": cl < op,
|
||
}
|
||
|
||
|
||
def _hammer_tail_metrics(
|
||
op: float, hi: float, lo: float, cl: float, lo_ref: float,
|
||
) -> Tuple[float, float, float]:
|
||
parts = _body_parts(op, hi, lo, cl)
|
||
tail_len = parts["lower_tail"]
|
||
body_len = parts["body_len"]
|
||
lo_use = lo if lo > 0 else lo_ref
|
||
tail_ratio = tail_len / body_len if body_len > 0 else 0.0
|
||
tail_pct = tail_len / lo_use if lo_use > 0 and tail_len > 0 else 0.0
|
||
return tail_ratio, tail_pct, lo_use
|
||
|
||
|
||
def _detect_hammer_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""망치(하단 꼬리) — 신호봉 또는 lookback 봉에서 탐색."""
|
||
rsi_period = int(params.get("rsi_period", 14))
|
||
lookback = int(params.get("candle_lookback", 3))
|
||
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
|
||
tail_pct_min = float(params.get("tail_pct_min", 0.003))
|
||
lo_ref = float(candles[i]["low"]) if float(candles[i]["low"]) > 0 else float(candles[i]["high"])
|
||
|
||
for j in range(i, max(i - lookback, rsi_period) - 1, -1):
|
||
op, hi, lo, cl = _bar_ohlc(candles[j])
|
||
body_top = max(op, cl)
|
||
body_bot = min(op, cl)
|
||
body_len = body_top - body_bot if body_top > body_bot else 1.0
|
||
tail_len = body_bot - lo if lo > 0 else 0.0
|
||
lo_use = lo if lo > 0 else lo_ref
|
||
if tail_len <= 0:
|
||
continue
|
||
tail_ratio = tail_len / body_len if body_len > 0 else 0.0
|
||
tail_pct = tail_len / lo_use if lo_use > 0 else 0.0
|
||
if tail_ratio >= tail_ratio_min and tail_pct >= tail_pct_min:
|
||
return True, {
|
||
"pattern": "hammer",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": j,
|
||
}
|
||
return False, {}
|
||
|
||
|
||
def _detect_pin_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""스트릭트 핀바 — 하단 꼬리 + 종가 상단 1/3 + 윗꼬리 짧음."""
|
||
rsi_period = int(params.get("rsi_period", 14))
|
||
lookback = int(params.get("candle_lookback", 3))
|
||
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
|
||
tail_pct_min = float(params.get("tail_pct_min", 0.003))
|
||
pin_close_upper = float(params.get("pin_close_upper_ratio", 0.66))
|
||
pin_upper_max = float(params.get("pin_max_upper_tail_ratio", 0.35))
|
||
lo_ref = float(candles[i]["low"]) if float(candles[i]["low"]) > 0 else float(candles[i]["high"])
|
||
|
||
for j in range(i, max(i - lookback, rsi_period) - 1, -1):
|
||
op, hi, lo, cl = _bar_ohlc(candles[j])
|
||
parts = _body_parts(op, hi, lo, cl)
|
||
if parts["range"] <= 0:
|
||
continue
|
||
tail_ratio, tail_pct, _ = _hammer_tail_metrics(op, hi, lo, cl, lo_ref)
|
||
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
|
||
continue
|
||
close_pos = (cl - lo) / parts["range"]
|
||
if close_pos < pin_close_upper:
|
||
continue
|
||
if parts["body_len"] > 0 and parts["upper_tail"] / parts["body_len"] > pin_upper_max:
|
||
continue
|
||
return True, {
|
||
"pattern": "pin",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": j,
|
||
"close_pos": close_pos,
|
||
}
|
||
return False, {}
|
||
|
||
|
||
def _detect_engulfing_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""불리시 장악형 — 전봉 음봉 몸통을 현재 양봉이 완전 삼킴."""
|
||
if i < 1:
|
||
return False, {}
|
||
engulf_min = float(params.get("engulf_min_body_ratio", 1.0))
|
||
po, ph, pl, pc = _bar_ohlc(candles[i - 1])
|
||
co, ch, cl, cc = _bar_ohlc(candles[i])
|
||
prev = _body_parts(po, ph, pl, pc)
|
||
curr = _body_parts(co, ch, cl, cc)
|
||
if not prev["is_bearish"] or not curr["is_bullish"]:
|
||
return False, {}
|
||
if co > pc or cc < po:
|
||
return False, {}
|
||
if curr["body_len"] < prev["body_len"] * engulf_min:
|
||
return False, {}
|
||
tail_ratio, tail_pct, _ = _hammer_tail_metrics(co, ch, cl, cc, pl if pl > 0 else ch)
|
||
return True, {
|
||
"pattern": "engulfing",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": i,
|
||
}
|
||
|
||
|
||
def _detect_piercing_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""관통형 — 갭다운 후 전봉 몸통 중간 이상 회복(완전 장악 전)."""
|
||
if i < 1:
|
||
return False, {}
|
||
penetrate = float(params.get("piercing_penetrate_ratio", 0.5))
|
||
po, ph, pl, pc = _bar_ohlc(candles[i - 1])
|
||
co, ch, cl, cc = _bar_ohlc(candles[i])
|
||
prev = _body_parts(po, ph, pl, pc)
|
||
curr = _body_parts(co, ch, cl, cc)
|
||
if not prev["is_bearish"] or not curr["is_bullish"]:
|
||
return False, {}
|
||
if co >= pc:
|
||
return False, {}
|
||
midpoint = prev["body_bot"] + prev["body_len"] * penetrate
|
||
if cc <= midpoint:
|
||
return False, {}
|
||
if cc >= po:
|
||
return False, {}
|
||
tail_ratio, tail_pct, _ = _hammer_tail_metrics(co, ch, cl, cc, pl if pl > 0 else ch)
|
||
return True, {
|
||
"pattern": "piercing",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": i,
|
||
}
|
||
|
||
|
||
def _detect_harami_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""불리시 하라미 — 큰 음봉 안에 작은 양봉(몸통 포함)."""
|
||
if i < 1:
|
||
return False, {}
|
||
harami_max = float(params.get("harami_max_body_ratio", 0.5))
|
||
po, ph, pl, pc = _bar_ohlc(candles[i - 1])
|
||
co, ch, cl, cc = _bar_ohlc(candles[i])
|
||
prev = _body_parts(po, ph, pl, pc)
|
||
curr = _body_parts(co, ch, cl, cc)
|
||
if not prev["is_bearish"]:
|
||
return False, {}
|
||
if curr["body_top"] > prev["body_top"] or curr["body_bot"] < prev["body_bot"]:
|
||
return False, {}
|
||
if curr["body_len"] > prev["body_len"] * harami_max:
|
||
return False, {}
|
||
if not curr["is_bullish"]:
|
||
return False, {}
|
||
tail_ratio, tail_pct, _ = _hammer_tail_metrics(co, ch, cl, cc, pl if pl > 0 else ch)
|
||
return True, {
|
||
"pattern": "harami",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": i,
|
||
}
|
||
|
||
|
||
def _detect_doji_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""저점 도지 — 몸통 극소 + 하단 꼬리(망치형) + 종가 중상단."""
|
||
doji_max = float(params.get("doji_body_max_ratio", 0.15))
|
||
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
|
||
tail_pct_min = float(params.get("tail_pct_min", 0.003))
|
||
pin_close_upper = float(params.get("pin_close_upper_ratio", 0.66))
|
||
op, hi, lo, cl = _bar_ohlc(candles[i])
|
||
parts = _body_parts(op, hi, lo, cl)
|
||
if parts["range"] <= 0:
|
||
return False, {}
|
||
real_body = abs(cl - op)
|
||
if real_body / parts["range"] > doji_max:
|
||
return False, {}
|
||
tail_ratio, tail_pct, _ = _hammer_tail_metrics(op, hi, lo, cl, lo if lo > 0 else hi)
|
||
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
|
||
return False, {}
|
||
close_pos = (cl - lo) / parts["range"]
|
||
if close_pos < pin_close_upper:
|
||
return False, {}
|
||
return True, {
|
||
"pattern": "doji",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": i,
|
||
"close_pos": close_pos,
|
||
}
|
||
|
||
|
||
def _detect_morning_star_pattern(
|
||
candles: List[Dict], i: int, params: Dict[str, Any],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""샛별형(3봉) — 장음봉 + 작은 별 + 양봉이 1봉 몸통 중간 돌파."""
|
||
if i < 2:
|
||
return False, {}
|
||
star_max = float(params.get("morning_star_body_max_ratio", 0.35))
|
||
o0, h0, l0, c0 = _bar_ohlc(candles[i - 2])
|
||
o1, h1, l1, c1 = _bar_ohlc(candles[i - 1])
|
||
o2, h2, l2, c2 = _bar_ohlc(candles[i])
|
||
bear = _body_parts(o0, h0, l0, c0)
|
||
star = _body_parts(o1, h1, l1, c1)
|
||
bull = _body_parts(o2, h2, l2, c2)
|
||
if not bear["is_bearish"] or bear["body_len"] <= 0:
|
||
return False, {}
|
||
if star["body_len"] > bear["body_len"] * star_max:
|
||
return False, {}
|
||
midpoint = (bear["body_top"] + bear["body_bot"]) / 2.0
|
||
if not bull["is_bullish"] or c2 <= midpoint:
|
||
return False, {}
|
||
tail_ratio, tail_pct, _ = _hammer_tail_metrics(o2, h2, l2, c2, l2 if l2 > 0 else h2)
|
||
return True, {
|
||
"pattern": "morning_star",
|
||
"tail_ratio": tail_ratio,
|
||
"tail_pct": tail_pct,
|
||
"pattern_bar_idx": i,
|
||
}
|
||
|
||
|
||
def eval_tail_reversal_pattern(
|
||
candles: List[Dict],
|
||
i: int,
|
||
params: Dict[str, Any],
|
||
) -> Tuple[bool, str, Dict[str, Any]]:
|
||
"""
|
||
TRIGGER 반전 패턴 OR 평가.
|
||
|
||
``TAIL_PATTERN_*`` 가 모두 OFF 이면 망치만(기존 동작) 검사.
|
||
"""
|
||
checks = []
|
||
if _to_bool(params.get("pattern_hammer"), True):
|
||
checks.append(("hammer", _detect_hammer_pattern))
|
||
if _to_bool(params.get("pattern_pin"), False):
|
||
checks.append(("pin", _detect_pin_pattern))
|
||
if _to_bool(params.get("pattern_engulfing"), False):
|
||
checks.append(("engulfing", _detect_engulfing_pattern))
|
||
if _to_bool(params.get("pattern_piercing"), False):
|
||
checks.append(("piercing", _detect_piercing_pattern))
|
||
if _to_bool(params.get("pattern_harami"), False):
|
||
checks.append(("harami", _detect_harami_pattern))
|
||
if _to_bool(params.get("pattern_doji"), False):
|
||
checks.append(("doji", _detect_doji_pattern))
|
||
if _to_bool(params.get("pattern_morning_star"), False):
|
||
checks.append(("morning_star", _detect_morning_star_pattern))
|
||
if not checks:
|
||
checks.append(("hammer", _detect_hammer_pattern))
|
||
|
||
for name, fn in checks:
|
||
ok, metrics = fn(candles, i, params)
|
||
if ok:
|
||
return True, name, metrics
|
||
|
||
active = ",".join(n for n, _ in checks)
|
||
return False, active, {}
|
||
|
||
|
||
def _tail_volume_spike_ok(
|
||
candles: List[Dict],
|
||
i: int,
|
||
params: Dict[str, Any],
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
TRIGGER — 신호봉 거래량이 직전 N봉 평균 대비 배수 이상인지 (모멘텀 mom_vol_mult 와 동일 패턴).
|
||
``tail_vol_mult`` ≤ 0 이면 OFF (기존 백테·실매 동작 유지).
|
||
"""
|
||
vol_mult = float(params.get("tail_vol_mult", 0) or 0)
|
||
vol_win = int(params.get("tail_vol_win", 5) or 5)
|
||
if vol_mult <= 0:
|
||
return True, ""
|
||
vol = float(candles[i].get("volume", 0) or 0)
|
||
win = max(1, min(vol_win, i))
|
||
vols = [float(candles[k].get("volume", 0) or 0) for k in range(i - win, i)]
|
||
if not vols or sum(vols) <= 0:
|
||
return False, "거래량창없음"
|
||
avg = sum(vols) / len(vols)
|
||
if avg <= 0 or vol < avg * vol_mult:
|
||
ratio = vol / avg if avg > 0 else 0.0
|
||
return False, "%.2fx < %.1fx" % (ratio, vol_mult)
|
||
return True, ""
|
||
|
||
|
||
def _eval_tail_buy_at_index(
|
||
candles: List[Dict],
|
||
i: int,
|
||
params: Dict[str, Any],
|
||
state: Dict[str, Any],
|
||
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
||
"""신호봉 인덱스 ``i`` 에서 꼬리잡기 매수 조건 평가 (백테스트 루프와 동일 시점)."""
|
||
if i < 19 or i >= len(candles):
|
||
return ("탈락-데이터", f"인덱스 부적절 (i={i})", None)
|
||
|
||
c = candles[i]
|
||
day = c["candle_time"][:8]
|
||
hm = int(c["candle_time"][8:12])
|
||
op = float(c["open"])
|
||
hi = float(c["high"])
|
||
lo = float(c["low"])
|
||
cl = float(c["close"])
|
||
|
||
# 엔진 파라미터 로드
|
||
min_drop_rate = float(params.get("min_drop_rate", 0.03))
|
||
min_recovery_ratio = float(params.get("min_recovery_ratio", 0.5))
|
||
max_rec_3m = float(params.get("max_rec_3m", 0.8))
|
||
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
|
||
tail_pct_min = float(params.get("tail_pct_min", 0.003))
|
||
rsi_period = int(params.get("rsi_period", 14))
|
||
rsi_threshold = float(params.get("rsi_threshold", 78))
|
||
high_chase_thr = float(params.get("high_chase_thr", 0.96))
|
||
time_start_hm = int(params.get("time_start_hm", 930))
|
||
time_end_hm = int(params.get("time_end_hm", 1500))
|
||
cooldown_min = float(params.get("cooldown_min", 15))
|
||
max_daily = int(params.get("max_daily", 3))
|
||
|
||
# 방어 로직 파라미터
|
||
min_price = float(params.get("min_price", 1000.0))
|
||
max_daily_change = float(params.get("max_daily_change", 20.0))
|
||
ma20_max_above = float(params.get("ma20_max_above", 3.0))
|
||
|
||
# SCAN/TRIGGER — 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)
|
||
return (None, None, sig)
|
||
|
||
|
||
def check_buy_signal_live(
|
||
candles: List[Dict],
|
||
params: Dict[str, Any],
|
||
state: Dict[str, Any],
|
||
) -> tuple:
|
||
"""
|
||
실시간 꼬리잡기 매수 신호.
|
||
|
||
- ``live_backtest_align=True`` (기본): 백테와 동일 — **직전 확정봉=신호봉**,
|
||
**현재 확정봉=진입봉** (신호 직후 봉 시가·실매 시장가 정합).
|
||
- ``live_backtest_align=False``: 구버전 — 마지막 확정봉 1개만 검사.
|
||
|
||
candles: 3분봉 (candle_time, open, high, low, close, volume)
|
||
state: { "last_exit_dt": datetime|None, "daily_cnt": int }
|
||
"""
|
||
live_align = _to_bool(params.get("live_backtest_align", True), True)
|
||
lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
|
||
confirmed = _confirmed_candles_only(candles)
|
||
|
||
if len(confirmed) < 20:
|
||
return ("탈락-데이터", f"확정봉 부족 (len={len(confirmed)} < 20)", None)
|
||
|
||
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
|
||
None, None, None,
|
||
)
|
||
|
||
if live_align:
|
||
entry_i = len(confirmed) - 1
|
||
return _eval_live_align_lookback(
|
||
confirmed, entry_i, params, state, lookback=lookback,
|
||
)
|
||
|
||
i = len(confirmed) - 1
|
||
return _eval_tail_buy_at_index(confirmed, i, params, state)
|
||
|
||
|
||
def _eval_live_align_lookback(
|
||
candles: List[Dict],
|
||
entry_i: int,
|
||
params: Dict[str, Any],
|
||
state: Dict[str, Any],
|
||
*,
|
||
lookback: int = 1,
|
||
) -> tuple:
|
||
"""실매 align: entry_i=진입봉, entry_i-1-k=신호봉 (lookback개까지)."""
|
||
lb = max(1, int(lookback))
|
||
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
|
||
None, None, None,
|
||
)
|
||
for k in range(lb):
|
||
signal_i = entry_i - 1 - k
|
||
if signal_i < 19:
|
||
break
|
||
reject, msg, sig = _eval_tail_buy_at_index(
|
||
candles, signal_i, params, state,
|
||
)
|
||
if reject:
|
||
if k == 0:
|
||
last_reject = (reject, msg, None)
|
||
continue
|
||
if sig:
|
||
ent = candles[entry_i]
|
||
entry_open = float(ent.get("open", 0) or 0)
|
||
if entry_open <= 0:
|
||
entry_open = float(ent.get("close", 0) or 0)
|
||
sig["entry_price"] = entry_open
|
||
sig["entry_bar_key"] = ent.get("candle_time")
|
||
sig["signal_bar_key"] = candles[signal_i].get("candle_time")
|
||
sig["signal_i"] = signal_i
|
||
sig["entry_i"] = entry_i
|
||
return (None, None, sig)
|
||
return last_reject
|
||
|
||
|
||
def _last_closed_bar_index(
|
||
candles: List[Dict],
|
||
as_of_hhmm: str,
|
||
tf_min: int,
|
||
) -> int:
|
||
"""as_of(YYYYMMDDHHMM) 시점까지 **이미 마감**된 마지막 봉 인덱스. 없으면 -1.
|
||
|
||
봉 시작 + tf_min ≤ as_of 이면 확정(실매: 진행 중 봉은 신호/진입봉으로 안 씀).
|
||
"""
|
||
from kis_trader.engine.candle_rollup import add_candle_minutes
|
||
|
||
as_of = str(as_of_hhmm or "")[:12]
|
||
if len(as_of) < 12 or not candles:
|
||
return -1
|
||
best = -1
|
||
for i, c in enumerate(candles):
|
||
ct = str(c.get("candle_time") or "")[:12]
|
||
if len(ct) < 12:
|
||
continue
|
||
end_t = add_candle_minutes(ct, int(tf_min))
|
||
if end_t and end_t <= as_of:
|
||
best = i
|
||
elif ct > as_of:
|
||
break
|
||
return best
|
||
|
||
|
||
def _first_universe_minute_by_code(
|
||
universe_by_slot: Optional[Dict[str, List[str]]],
|
||
universe_timeline: Optional[Any],
|
||
codes: Optional[set] = None,
|
||
) -> Dict[str, str]:
|
||
"""종목별 최초 편입 분키(YYYYMMDDHHMM)."""
|
||
out: Dict[str, str] = {}
|
||
want = codes
|
||
if universe_timeline is not None and hasattr(universe_timeline, "_keys"):
|
||
try:
|
||
keys = list(getattr(universe_timeline, "_keys") or [])
|
||
code_lists = list(getattr(universe_timeline, "_codes") or [])
|
||
for sk, snap_codes in zip(keys, code_lists):
|
||
minute = str(sk)[:12]
|
||
for c in snap_codes or []:
|
||
cs = str(c).strip()
|
||
if not cs or (want is not None and cs not in want):
|
||
continue
|
||
if cs not in out:
|
||
out[cs] = minute
|
||
if out:
|
||
return out
|
||
except Exception:
|
||
pass
|
||
if not universe_by_slot:
|
||
return out
|
||
for slot in sorted(universe_by_slot.keys()):
|
||
minute = str(slot)[:12]
|
||
for c in universe_by_slot.get(slot) or []:
|
||
cs = str(c).strip()
|
||
if not cs or (want is not None and cs not in want):
|
||
continue
|
||
if cs not in out:
|
||
out[cs] = minute
|
||
return out
|
||
|
||
|
||
def _universe_enter_minutes(
|
||
universe_by_slot: Optional[Dict[str, List[str]]],
|
||
universe_timeline: Optional[Any],
|
||
codes: Optional[set] = None,
|
||
) -> Set[str]:
|
||
"""유니버스 ENTER 분키 집합 (재편입 포함) — 실매 REAL I(편입) 시각 근사.
|
||
|
||
최초 편입만 쓰는 ``_first_universe_minute_by_code`` 와 달리,
|
||
EXIT 후 다시 들어온 분도 시계에 넣어 Phase 0-incl 이 재검사한다.
|
||
"""
|
||
out: Set[str] = set()
|
||
want = codes
|
||
prev: Set[str] = set()
|
||
|
||
def _iter_snaps():
|
||
if universe_timeline is not None and hasattr(universe_timeline, "_keys"):
|
||
try:
|
||
keys = list(getattr(universe_timeline, "_keys") or [])
|
||
code_lists = list(getattr(universe_timeline, "_codes") or [])
|
||
for sk, snap_codes in zip(keys, code_lists):
|
||
yield str(sk)[:12], {
|
||
str(c).strip() for c in (snap_codes or []) if str(c).strip()
|
||
}
|
||
return
|
||
except Exception:
|
||
pass
|
||
if not universe_by_slot:
|
||
return
|
||
for slot in sorted(universe_by_slot.keys()):
|
||
yield str(slot)[:12], {
|
||
str(c).strip() for c in (universe_by_slot.get(slot) or []) if str(c).strip()
|
||
}
|
||
|
||
for minute, cur in _iter_snaps():
|
||
if want is not None:
|
||
cur = {c for c in cur if c in want}
|
||
entered = cur - prev
|
||
if entered and minute:
|
||
out.add(minute)
|
||
prev = cur
|
||
return out
|
||
|
||
|
||
def _tail_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]:
|
||
"""래칫 단계 트레일 — 돌파 ``_breakout_ratchet_tiers`` 와 동일 형식.
|
||
|
||
env/params ``"2:1.5,5:1.0"`` → +2% 수익부터 고점 대비 1.5% 되돌림컷.
|
||
비어 있으면 [] (= OFF, 단일 어깨컷 사용).
|
||
"""
|
||
raw = params.get("ratchet_tiers")
|
||
if raw is None:
|
||
from kis_trader.utils.env import get_env_from_db
|
||
raw = get_env_from_db("TAIL_RATCHET_TIERS", "")
|
||
if isinstance(raw, (list, tuple)):
|
||
pairs = list(raw)
|
||
else:
|
||
s = str(raw or "").strip()
|
||
if not s:
|
||
return []
|
||
pairs = []
|
||
for chunk in s.split(","):
|
||
chunk = chunk.strip()
|
||
if not chunk or ":" not in chunk:
|
||
continue
|
||
g, c = chunk.split(":", 1)
|
||
pairs.append((g, c))
|
||
tiers: List[Tuple[float, float]] = []
|
||
for g, c in pairs:
|
||
try:
|
||
gain = abs(float(g)) / 100.0
|
||
cut = abs(float(c)) / 100.0
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if gain <= 0 or cut <= 0:
|
||
continue
|
||
tiers.append((gain, cut))
|
||
tiers.sort(key=lambda x: x[0])
|
||
return tiers
|
||
|
||
|
||
def _tail_max_hold_minutes(params: Dict[str, Any]) -> int:
|
||
"""시간컷: 최대 보유 **분** (돌파 BREAKOUT_MAX_HOLD_BARS 와 동일 — 이름은 bars, 단위는 분). 0=OFF."""
|
||
v = params.get("max_hold_bars")
|
||
if v is None:
|
||
from kis_trader.utils.env import get_env_int
|
||
v = get_env_int("TAIL_MAX_HOLD_BARS", 0)
|
||
try:
|
||
return max(0, int(float(v)))
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def _tail_minutes_held(position: Dict[str, Any], current_candle: Dict[str, Any]) -> Optional[int]:
|
||
"""진입 이후 경과 분 — 시간컷 판정."""
|
||
et = position.get("entry_time") or position.get("buy_time")
|
||
ct = current_candle.get("candle_time")
|
||
if not et or not ct:
|
||
return None
|
||
try:
|
||
# 실매 buy_time 은 "YYYY-MM-DD HH:MM:SS" — [:12] 자르면 파싱 실패
|
||
e_dt = _t2dt(str(et))
|
||
c_dt = _t2dt(str(ct))
|
||
return max(0, int((c_dt - e_dt).total_seconds() // 60))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def check_sell_signal_live(
|
||
position: Dict[str, Any],
|
||
current_candle: Dict[str, Any],
|
||
params: Dict[str, Any],
|
||
is_eod: bool = False,
|
||
) -> Optional[tuple]:
|
||
"""
|
||
실시간 및 백테스트 공통 청산 조건.
|
||
[V4]: 트레일(어깨) 저가 기준 · 수익 구간 우선 · ATR 캡 익절/손절 · 금액손실컷(트레일 미발동만).
|
||
|
||
position: entry_price, entry_time(YYYYMMDDHHMM), stop, target, max_price, qty
|
||
current_candle: high, low, close, candle_time
|
||
반환: (reason_str, exit_price) 또는 None
|
||
"""
|
||
shoulder_min_high = float(params.get("shoulder_min_high", 0.005))
|
||
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.003))
|
||
max_loss_krw = int(params.get("max_loss_krw", 200000))
|
||
min_hold_sec = float(params.get("min_hold_sec", 30.0))
|
||
|
||
hi = float(current_candle.get("high", current_candle["close"]))
|
||
lo = float(current_candle.get("low", current_candle["close"]))
|
||
cl = float(current_candle["close"])
|
||
candle_time = current_candle.get("candle_time", "")
|
||
|
||
max_p = max(float(position.get("max_price", 0) or 0), hi)
|
||
ep = float(position["entry_price"])
|
||
stop = float(position["stop"])
|
||
target = float(position["target"])
|
||
qty = int(position.get("qty", 1) or 1)
|
||
|
||
# 최소 보유 시간 검사 (너무 짧으면 청산 무시)
|
||
if candle_time and position.get("entry_time"):
|
||
try:
|
||
entry_dt = _t2dt(position["entry_time"])
|
||
curr_dt = _t2dt(candle_time)
|
||
if (curr_dt - entry_dt).total_seconds() < min_hold_sec:
|
||
return None
|
||
except Exception:
|
||
pass
|
||
|
||
reason = None
|
||
exit_price = cl
|
||
profit_val = (lo - ep) * qty
|
||
drop_pct = (ep - lo) / ep if ep > 0 else 0.0
|
||
min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))
|
||
|
||
# 1순위: 트레일 — 돌파와 동일: 래칫 문자열 있으면 **단일 어깨 대체**, 없으면 shoulder_min/cut
|
||
ratchet_tiers = _tail_ratchet_tiers(params)
|
||
trail_armed = False
|
||
if ratchet_tiers and ep > 0:
|
||
peak_gain = (max_p - ep) / ep
|
||
cut_ratio = 0.0
|
||
for gain_thr, cut in ratchet_tiers:
|
||
if peak_gain >= gain_thr:
|
||
cut_ratio = cut
|
||
if cut_ratio > 0.0:
|
||
trail_armed = True
|
||
ratchet_line = max_p * (1.0 - cut_ratio)
|
||
if lo > 0 and lo <= ratchet_line:
|
||
reason = "래칫컷"
|
||
exit_price = ratchet_line
|
||
else:
|
||
trail_armed = ep > 0 and max_p >= ep * (1.0 + shoulder_min_high)
|
||
trail_stop_px = max_p * (1.0 - shoulder_cut_pct) if trail_armed else 0.0
|
||
if trail_armed and lo > 0 and lo <= trail_stop_px:
|
||
reason = "어깨컷"
|
||
exit_price = trail_stop_px
|
||
|
||
# 2순위: ATR 캡 익절
|
||
if not reason and hi >= target:
|
||
reason = "익절"
|
||
exit_price = target
|
||
# 3순위: ATR 캡 손절
|
||
if not reason and lo > 0 and lo <= stop:
|
||
reason = "손절"
|
||
exit_price = stop
|
||
# 4순위: 보조 트레일링 — 래칫/어깨(1순위)와 별도. TAIL_TRAIL_PCT>0 일 때만 (돌파 BREAKOUT_TRAIL_* 와 동일)
|
||
# trail_arm_pct: 고점이 진입×(1+arm) 도달 후에만 무장 (0=즉시 무장). 어깨 미발동 구간의 팝 후 되돌림 보호.
|
||
if not reason:
|
||
trail_pct = abs(float(params.get("trail_pct", 0.0) or 0.0))
|
||
trail_arm_pct = abs(float(params.get("trail_arm_pct", 0.0) or 0.0))
|
||
if trail_pct > 0 and ep > 0:
|
||
trail_arm_line = ep * (1.0 + trail_arm_pct)
|
||
if max_p > ep and max_p >= trail_arm_line:
|
||
trail_line = max_p * (1.0 - trail_pct)
|
||
if lo > 0 and lo <= trail_line:
|
||
reason = "트레일컷"
|
||
exit_price = trail_line
|
||
# 5순위: 금액 손실컷 — 1순위 어깨/래칫 미발동(수익% 문턱 못 넘음)일 때만
|
||
if (
|
||
not reason
|
||
and not trail_armed
|
||
and profit_val <= -max_loss_krw
|
||
and drop_pct >= min_drop_pct
|
||
):
|
||
reason = "금액손실컷"
|
||
exit_price = ep - (max_loss_krw / qty) if qty > 0 else lo
|
||
# 6순위: 시간컷 — 돌파와 동일(분 단위), 기본 OFF. 어깨·손절·익절 뒤, 장마감 앞
|
||
if not reason:
|
||
max_hold_min = _tail_max_hold_minutes(params)
|
||
if max_hold_min > 0:
|
||
held = _tail_minutes_held(position, current_candle)
|
||
if held is not None and held >= max_hold_min:
|
||
reason = "시간컷"
|
||
exit_price = cl
|
||
# 7순위: 장 마감 강제 청산
|
||
if not reason and is_eod:
|
||
reason = "장마감"
|
||
exit_price = cl
|
||
|
||
if reason:
|
||
return (reason, exit_price)
|
||
return None
|
||
|
||
|
||
def resolve_tail_invest_amount_krw(params: Dict[str, Any]) -> int:
|
||
"""
|
||
실매 ``resolve_invest_amount_krw`` + ``TailCatchStrategy`` 종목당 상한과 동일.
|
||
1회 매수 금액 = slot_money (MAX_LOSS/sl 로 투자금을 줄이지 않음).
|
||
"""
|
||
slot = int(float(params.get("slot_money") or params.get("invest_amount") or 0))
|
||
if slot <= 0:
|
||
try:
|
||
from kis_trader.utils.env import get_env_int
|
||
slot = get_env_int("TAIL_SLOT_MONEY", 3_000_000)
|
||
except Exception:
|
||
slot = 3_000_000
|
||
hard_cap = int(float(params.get("short_max_buy_amount") or params.get("per_stock_cap") or 0))
|
||
if hard_cap <= 0:
|
||
try:
|
||
from kis_trader.utils.env import get_env_int
|
||
hard_cap = get_env_int("TAIL_MAX_BUY_AMOUNT", 0)
|
||
except Exception:
|
||
hard_cap = 0
|
||
if hard_cap > 0 and slot > hard_cap:
|
||
return hard_cap
|
||
return slot
|
||
|
||
|
||
def _portfolio_exposure_krw(portfolio: Dict[str, Dict[str, Any]]) -> float:
|
||
"""동시 보유 매입금 합 (실매 UPDOW 노출·SHORT 총한도와 동일 개념)."""
|
||
total = 0.0
|
||
for p in portfolio.values():
|
||
total += float(p.get("entry_price") or 0) * int(p.get("qty") or 1)
|
||
return total
|
||
|
||
|
||
def _last_close_at_or_before(
|
||
candles: List[Dict],
|
||
t: str,
|
||
default: float,
|
||
) -> float:
|
||
"""시계 ``t`` 이하 마지막 확정 종가 — EOD 틱 없을 때 실매 장마감가 근사."""
|
||
key = str(t or "")[:12]
|
||
last = float(default or 0)
|
||
if not key:
|
||
return last
|
||
for c in candles:
|
||
ct = str(c.get("candle_time") or "")[:12]
|
||
if not ct:
|
||
continue
|
||
if ct > key:
|
||
break
|
||
px = float(c.get("close") or 0)
|
||
if px > 0:
|
||
last = px
|
||
return last
|
||
|
||
|
||
def _inject_tail_eod_timeline_minutes(
|
||
all_times_set: Set[str],
|
||
params: Dict[str, Any],
|
||
period_ymd: str,
|
||
) -> None:
|
||
"""
|
||
실매 DB ``TAIL_EOD_ENABLED`` / ``TAIL_EOD_HM`` (params eod_*) 토글을 백테 시계에 반영.
|
||
3분봉 격자에 EOD 분이 없어도, 실매 벽시계처럼 해당 HHMM에 청산 판정 가능.
|
||
"""
|
||
enabled, eod_hm = resolve_strategy_eod_params(params, "TAIL")
|
||
if not enabled:
|
||
return
|
||
hh, mm = parse_eod_hm(eod_hm)
|
||
days: Set[str] = set()
|
||
py = str(period_ymd or "")[:8]
|
||
if len(py) == 8 and py.isdigit():
|
||
days.add(py)
|
||
for t in all_times_set:
|
||
d = str(t or "")[:8]
|
||
if len(d) == 8 and d.isdigit():
|
||
if py and d < py:
|
||
continue
|
||
days.add(d)
|
||
for d in days:
|
||
all_times_set.add(f"{d}{hh:02d}{mm:02d}")
|
||
|
||
|
||
|
||
def _tail_max_stocks_from_params(params: Dict[str, Any]) -> int:
|
||
for key in ("max_stocks", "short_max_stocks"):
|
||
v = params.get(key)
|
||
if v not in (None, "", 0):
|
||
return max(1, int(v))
|
||
try:
|
||
from kis_trader.utils.env import get_env_int
|
||
n = get_env_int("TAIL_MAX_STOCKS", 3)
|
||
return max(1, int(n))
|
||
except Exception:
|
||
return 3
|
||
|
||
|
||
def _tail_total_budget_from_params(params: Dict[str, Any]) -> float:
|
||
"""0 이하면 호출측에서 max_stocks×slot_money 로 유도."""
|
||
for key in ("total_budget_krw", "short_total_budget_krw"):
|
||
v = params.get(key)
|
||
if v not in (None, ""):
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
try:
|
||
from kis_trader.utils.env import get_env_int
|
||
cap = get_env_int("TAIL_TOTAL_BUDGET_KRW", 0)
|
||
if cap > 0:
|
||
return float(cap)
|
||
except Exception:
|
||
pass
|
||
return 0.0
|
||
|
||
|
||
def _tail_min_invest_ratio_of_slot(params: Dict[str, Any]) -> float:
|
||
"""1회 slot 대비 최소 투입 비율 — 미만이면 매수 스킵 (실매: 풀 slot 주문 후 예수금 부족 시만 축소)."""
|
||
v = params.get("min_invest_ratio_of_slot")
|
||
if v not in (None, ""):
|
||
return max(0.01, min(1.0, float(v)))
|
||
try:
|
||
from kis_trader.utils.env import get_env_float
|
||
return max(0.01, min(1.0, get_env_float("TAIL_MIN_INVEST_RATIO_OF_SLOT", 0.90)))
|
||
except Exception:
|
||
return 0.90
|
||
|
||
|
||
def _tail_target_qty_and_cost(entry_price: float, slot_money: float) -> Tuple[int, float]:
|
||
"""실매와 동일: 1회 투자금(slot) 기준 목표 수량·매입금."""
|
||
from kis_trader.utils.position_sizing import invest_qty_for_price
|
||
|
||
qty = invest_qty_for_price(entry_price, slot_money)
|
||
if qty < 1:
|
||
return 0, 0.0
|
||
return qty, qty * entry_price
|
||
|
||
|
||
def _vol_capped_qty(qty: int, candle: Dict[str, Any], cap_pct: float) -> int:
|
||
"""진입봉 거래량×cap_pct% 로 체결 가능 주수 제한 (실매 유동성 제약 근사).
|
||
|
||
cap_pct<=0 → 제한 없음. 거래량 정보 없으면(0) 기존 동작 유지(보수적). 반환 0=미체결.
|
||
"""
|
||
if cap_pct <= 0:
|
||
return qty
|
||
try:
|
||
vol = int(float(candle.get("volume") or 0))
|
||
except (TypeError, ValueError):
|
||
vol = 0
|
||
if vol <= 0:
|
||
return qty
|
||
fillable = int(vol * cap_pct / 100.0)
|
||
return min(qty, fillable)
|
||
|
||
|
||
def _buy_priority_key(
|
||
code: str,
|
||
uni_codes: Optional[List[str]],
|
||
) -> Tuple[int, str]:
|
||
"""실매 후보 순회 순서 근사 — 유니버스 편입 순서(index), 없으면 code 정렬."""
|
||
if uni_codes is None:
|
||
return (0, code)
|
||
try:
|
||
return (uni_codes.index(code), code)
|
||
except ValueError:
|
||
return (999999, code)
|
||
|
||
|
||
def _universe_codes_at(
|
||
t: str,
|
||
slot_key: str,
|
||
universe_timeline: Optional[Any],
|
||
universe_by_slot: Optional[Dict[str, List[str]]],
|
||
) -> Optional[List[str]]:
|
||
"""그 시각(봉 마감초) 유효 유니버스 코드 리스트 (돌파·모멘텀 공통 로직).
|
||
|
||
- ``universe_timeline`` (초단위, 실매 get_universe_at 정합) 우선 — 봉 마감(HH:MM:59)
|
||
직전 최신 조건검색 스냅샷. strict lag(1분 지연) 없이 실매와 동일 시점 조회.
|
||
- 없으면 1분 슬롯(``universe_by_slot``) 폴백. 둘 다 없으면 None(전종목·필터없음).
|
||
"""
|
||
if universe_timeline is not None:
|
||
return universe_timeline.codes_at(str(t)[:12] + "59")
|
||
if universe_by_slot is not None:
|
||
return universe_by_slot.get(slot_key, [])
|
||
return None
|
||
|
||
|
||
def _fill_portfolio_align_entry(
|
||
*,
|
||
portfolio: Dict[str, Dict[str, Any]],
|
||
ctx: Dict[str, Any],
|
||
code: str,
|
||
pe: Dict[str, Any],
|
||
t: str,
|
||
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
|
||
tick_tf: int,
|
||
use_ticks: bool,
|
||
max_stocks: int,
|
||
slot_money: float,
|
||
total_budget: float,
|
||
min_invest_ratio: float,
|
||
vol_fill_cap_pct: float,
|
||
tick_fill_stats: Dict[str, int],
|
||
) -> Tuple[bool, int, int]:
|
||
"""align 예약 진입 체결 — 실매: 진입봉 확정 직후 시장가."""
|
||
skipped_micro = 0
|
||
skipped_vol = 0
|
||
if code in portfolio or not pe:
|
||
return False, skipped_micro, skipped_vol
|
||
if len(portfolio) >= max_stocks:
|
||
return False, skipped_micro, skipped_vol
|
||
|
||
entry_price = float(pe.get("entry_price") or 0)
|
||
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,
|
||
}
|
||
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,
|
||
}))
|
||
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"),
|
||
}
|
||
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 ""),
|
||
})
|
||
ctx["last_exit_dt"][day] = _t2dt(sell_time or t)
|
||
ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
|
||
# 종목 일일 손익 게이트용 net pnl 누적 — attach_tail_trade_pnl 과 동일 공식(수수료/세금)
|
||
_qty = int(pos.get("qty", 1) or 1)
|
||
_ep, _xp = float(pos["entry_price"]), float(exit_price)
|
||
_net_pnl = (
|
||
(_xp - _ep) * _qty
|
||
- (_ep + _xp) * _qty * gate_fee_rate
|
||
- _xp * _qty * gate_sell_tax
|
||
)
|
||
ctx["daily_pnl"][day] = ctx["daily_pnl"].get(day, 0.0) + _net_pnl
|
||
del portfolio[code]
|
||
|
||
# ── Phase 2: 신규 매수 신호 (align=진입봉 확정 직후 / limit=신호봉 이후 지정가) ──
|
||
if len(portfolio) >= max_stocks:
|
||
continue
|
||
exposure = _portfolio_exposure_krw(portfolio)
|
||
if exposure >= total_budget - 1e-6:
|
||
continue
|
||
|
||
time_start_hm = int(params.get("time_start_hm", 930))
|
||
time_end_hm = int(params.get("time_end_hm", 1500))
|
||
hm = int(t[8:12])
|
||
|
||
candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
|
||
for code, ctx in ctx_by_code.items():
|
||
if code in portfolio or ctx.get("pending_entry") or ctx.get("pending_limit"):
|
||
continue
|
||
candles = ctx["candles"]
|
||
day = t[:8]
|
||
|
||
if uni_set is not None:
|
||
if code not in uni_set:
|
||
continue
|
||
if hm < time_start_hm or hm > time_end_hm:
|
||
continue
|
||
if ctx["daily_cnt"].get(day, 0) >= int(params.get("max_daily", 3)):
|
||
continue
|
||
if day in ctx["last_exit_dt"]:
|
||
elapsed = (_t2dt(t) - ctx["last_exit_dt"][day]).total_seconds() / 60
|
||
if elapsed < float(params.get("cooldown_min", 15)):
|
||
continue
|
||
|
||
eval_params = dict(params)
|
||
ic = get_indicator_cache_from_params(params, code)
|
||
if ic is not None:
|
||
eval_params["_indicator_cache"] = ic
|
||
if "skip_hts_scan_dupes" not in eval_params:
|
||
eval_params["skip_hts_scan_dupes"] = resolve_tail_skip_hts_scan_dupes()
|
||
state = {
|
||
"daily_cnt": ctx["daily_cnt"].get(day, 0),
|
||
"last_exit_dt": ctx["last_exit_dt"].get(day),
|
||
"daily_pnl_krw": ctx["daily_pnl"].get(day, 0.0),
|
||
}
|
||
|
||
if is_limit_atr_entry(short_entry_mode(params)):
|
||
idx = ctx["time_index"].get(t)
|
||
if idx is None:
|
||
continue
|
||
c = candles[idx]
|
||
cl = float(c["close"])
|
||
if cl <= 0:
|
||
continue
|
||
inject_whipsaw_ticks_into_params(
|
||
eval_params,
|
||
ticks_by_code=ticks_by_code,
|
||
code=code,
|
||
bar_candle_time=t,
|
||
strategy="TAIL",
|
||
tf_min=tick_tf,
|
||
)
|
||
inject_trigger_snapshots_into_params(
|
||
eval_params,
|
||
orderbook_by_code=orderbook_by_code,
|
||
program_by_code=program_by_code,
|
||
code=code,
|
||
bar_candle_time=t,
|
||
)
|
||
reject, _msg, sig = _eval_tail_buy_at_index(candles, idx, eval_params, state)
|
||
if reject or not sig:
|
||
continue
|
||
atr = ctx["atrs"][idx] if ctx["atrs"][idx] is not None else cl * 0.01
|
||
pri = _buy_priority_key(code, uni_codes)
|
||
lp_cfg = tail_limit_params(params)
|
||
sig_bar = candles[idx]
|
||
anchor_px = resolve_limit_anchor_price(
|
||
lp_cfg["anchor"], sig_bar, candles, idx,
|
||
)
|
||
min_px = float(params.get("min_price", 1000.0))
|
||
limit_px = compute_atr_limit_price(
|
||
anchor_px, atr, lp_cfg["mult"], min_price=min_px,
|
||
)
|
||
if limit_px <= 0:
|
||
continue
|
||
stop_p, target_p = compute_tail_atr_prices(limit_px, atr, params)
|
||
vu = limit_valid_until_bar_key(candles, idx, lp_cfg["valid_bars"])
|
||
candidates.append((pri, code, {
|
||
"pending_limit": True,
|
||
"signal_i": idx,
|
||
"limit_price": limit_px,
|
||
"valid_until": vu,
|
||
"fill_slip": lp_cfg["fill_slip_pct"],
|
||
"stop": stop_p,
|
||
"target": target_p,
|
||
}))
|
||
continue
|
||
|
||
# align — 진입봉 **마감** 시각에만 검사 (실매: 확정봉 직후 시장가)
|
||
entry_i = _last_closed_bar_index(candles, t, tick_tf)
|
||
if entry_i < 19:
|
||
continue
|
||
ent = candles[entry_i]
|
||
close_t = align_entry_execute_time(ent.get("candle_time"), tick_tf)
|
||
if not close_t or str(t)[:12] != str(close_t)[:12]:
|
||
continue
|
||
ent_ct = str(ent.get("candle_time") or "")
|
||
inject_whipsaw_ticks_into_params(
|
||
eval_params,
|
||
ticks_by_code=ticks_by_code,
|
||
code=code,
|
||
bar_candle_time=ent_ct,
|
||
strategy="TAIL",
|
||
tf_min=tick_tf,
|
||
)
|
||
inject_trigger_snapshots_into_params(
|
||
eval_params,
|
||
orderbook_by_code=orderbook_by_code,
|
||
program_by_code=program_by_code,
|
||
code=code,
|
||
bar_candle_time=ent_ct,
|
||
)
|
||
reject, _msg, sig = _eval_live_align_lookback(
|
||
candles, entry_i, eval_params, state,
|
||
lookback=live_sig_lookback,
|
||
)
|
||
if reject or not sig:
|
||
continue
|
||
cl = float(ent.get("close") or 0)
|
||
atr = (
|
||
ctx["atrs"][entry_i]
|
||
if entry_i < len(ctx["atrs"]) and ctx["atrs"][entry_i] is not None
|
||
else cl * 0.01
|
||
)
|
||
pri = _buy_priority_key(code, uni_codes)
|
||
entry_price = float(sig.get("entry_price") or ent.get("open") or cl)
|
||
if entry_price <= 0:
|
||
continue
|
||
stop_p, target_p = compute_tail_atr_prices(entry_price, atr, params)
|
||
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,
|
||
}))
|
||
|
||
if not candidates:
|
||
continue
|
||
candidates.sort(key=lambda x: x[0])
|
||
_pri, pick_code, pe = candidates[0]
|
||
if pe.get("pending_limit"):
|
||
ctx_by_code[pick_code]["pending_limit"] = pe
|
||
else:
|
||
_ctx_pick = ctx_by_code[pick_code]
|
||
if str(pe.get("entry_time") or "")[:12] == str(t)[:12]:
|
||
_filled, _sm, _sv = _fill_portfolio_align_entry(
|
||
portfolio=portfolio,
|
||
ctx=_ctx_pick,
|
||
code=pick_code,
|
||
pe=pe,
|
||
t=t,
|
||
ticks_by_code=ticks_by_code,
|
||
tick_tf=tick_tf,
|
||
use_ticks=use_ticks,
|
||
max_stocks=max_stocks,
|
||
slot_money=float(slot_money),
|
||
total_budget=float(total_budget),
|
||
min_invest_ratio=min_invest_ratio,
|
||
vol_fill_cap_pct=vol_fill_cap_pct,
|
||
tick_fill_stats=tick_fill_stats,
|
||
)
|
||
skipped_micro_buys += _sm
|
||
skipped_vol_unfilled += _sv
|
||
if not _filled:
|
||
_ctx_pick["pending_entry"] = pe
|
||
else:
|
||
_ctx_pick["pending_entry"] = pe
|
||
|
||
skip_stats: Dict[str, Any] = {}
|
||
if skipped_micro_buys:
|
||
skip_stats["skipped_micro_buys"] = skipped_micro_buys
|
||
if skipped_vol_unfilled:
|
||
skip_stats["skipped_vol_unfilled"] = skipped_vol_unfilled
|
||
if use_ticks:
|
||
skip_stats["tick_entry_sources"] = dict(tick_fill_stats)
|
||
if tick_exit_count or ohlc_exit_count:
|
||
skip_stats["tick_exit_count"] = tick_exit_count
|
||
skip_stats["ohlc_exit_count"] = ohlc_exit_count
|
||
if inclusion_entries:
|
||
skip_stats["inclusion_lookback_entries"] = inclusion_entries
|
||
if skip_stats:
|
||
params["_portfolio_skip_stats"] = skip_stats
|
||
return all_trades
|
||
|
||
|
||
def run_tail_backtest(
|
||
candles_by_code: Dict[str, List[Dict]],
|
||
params: Dict[str, Any],
|
||
universe_by_slot: Optional[Dict[str, List[str]]] = None,
|
||
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
|
||
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
|
||
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
|
||
) -> List[Dict]:
|
||
"""
|
||
백테스트 1회 실행. (backtest_web 및 tail_param_search 호출용)
|
||
|
||
기본: ``portfolio_mode=true`` → 시각순 포트폴리오 (실매 MAX_STOCKS·총한도·slot_money).
|
||
``portfolio_mode=false`` → 레거시 종목별 독립 루프.
|
||
"""
|
||
if _to_bool(params.get("portfolio_mode"), True):
|
||
return run_tail_backtest_portfolio(
|
||
candles_by_code, params, universe_by_slot,
|
||
ticks_by_code=ticks_by_code,
|
||
orderbook_by_code=orderbook_by_code,
|
||
program_by_code=program_by_code,
|
||
)
|
||
|
||
# ── 레거시: 종목별 독립 시뮬 ──
|
||
tick_tf = tail_timeframe_min(params)
|
||
use_ticks = bool(ticks_by_code) and tail_backtest_wants_tick_replay(params)
|
||
use_tick_exit = bool(ticks_by_code) and tail_backtest_use_tick_exit(params)
|
||
tick_fallback_ohlc = tail_backtest_tick_fallback_ohlc(params)
|
||
tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="TAIL_BACKTEST_POLL_MS")
|
||
tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="TAIL_BACKTEST_SELL_SLIP_PCT")
|
||
|
||
# 파라미터 준비
|
||
min_drop_rate = float(params.get("min_drop_rate", 0.03))
|
||
min_recovery_ratio = float(params.get("min_recovery_ratio", 0.5))
|
||
max_rec_3m = float(params.get("max_rec_3m", 0.8))
|
||
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
|
||
tail_pct_min = float(params.get("tail_pct_min", 0.003))
|
||
shoulder_min_high = float(params.get("shoulder_min_high", 0.005))
|
||
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.003))
|
||
rsi_period = int(params.get("rsi_period", 14))
|
||
rsi_threshold = float(params.get("rsi_threshold", 78))
|
||
high_chase_thr = float(params.get("high_chase_thr", 0.96))
|
||
time_start_hm = int(params.get("time_start_hm", 930))
|
||
time_end_hm = int(params.get("time_end_hm", 1500))
|
||
cooldown_min = float(params.get("cooldown_min", 15))
|
||
max_daily = int(params.get("max_daily", 3))
|
||
|
||
# 방어 로직 (동적 계산용)
|
||
stop_atr_mult = float(params.get("stop_atr_mult", 2.5))
|
||
target_atr_mult = float(params.get("target_atr_mult", 8.0))
|
||
max_loss_krw = int(params.get("max_loss_krw", 200000))
|
||
risk_pct = float(params.get("risk_pct", 0.01))
|
||
kelly_mult = float(params.get("kelly_mult", 0.25))
|
||
capital = float(params.get("capital", 100000000.0))
|
||
static_sl_pct = abs(float(params.get("sl_pct", 0.03)))
|
||
gate_fee_rate = float(params.get("fee_rate", 0.00015) or 0.00015)
|
||
gate_sell_tax = float(params.get("sell_tax", 0.0018) or 0.0018)
|
||
|
||
all_trades: List[Dict] = []
|
||
|
||
for code, candles in candles_by_code.items():
|
||
if len(candles) < rsi_period + 5:
|
||
continue
|
||
|
||
# 벡터 연산으로 지표 선행 계산 (백테스트 속도 최적화)
|
||
closes = [float(c["close"]) for c in candles]
|
||
rsis = compute_rsi_series(closes, rsi_period)
|
||
ma20s = compute_sma_series(closes, 20)
|
||
atrs = compute_atr_series(candles, 14)
|
||
|
||
position = None
|
||
last_exit_dt: Dict[str, datetime] = {}
|
||
daily_cnt: Dict[str, int] = {}
|
||
daily_pnl: Dict[str, float] = {}
|
||
cur_day = None
|
||
running_open, running_high, running_low = 0.0, 0.0, 0.0
|
||
|
||
i = rsi_period + 1
|
||
while i < len(candles):
|
||
c = candles[i]
|
||
day = c["candle_time"][:8]
|
||
hm = int(c["candle_time"][8:12])
|
||
op = float(c["open"])
|
||
hi = float(c["high"])
|
||
lo = float(c["low"])
|
||
cl = float(c["close"])
|
||
|
||
# 일일 변수 초기화 및 갱신
|
||
if day != cur_day:
|
||
cur_day = day
|
||
running_open = op
|
||
running_high = hi
|
||
running_low = lo if lo > 0 else hi
|
||
else:
|
||
running_high = max(running_high, hi)
|
||
if lo > 0:
|
||
running_low = min(running_low, lo)
|
||
|
||
is_eod = is_strategy_eod_bar(c["candle_time"], params, "TAIL")
|
||
|
||
# ── 1. 청산 검사 (포지션 보유 중일 때) ──
|
||
if position is not None:
|
||
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 ""),
|
||
})
|
||
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 |