변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
571 lines
23 KiB
Python
571 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
백테·파라서치 공통 — 시각순 포트폴리오(1회투자·동시보유·총한도·소액매수 스킵) 해석·요약.
|
||
꼬리·스캘핑·모멘텀·돌파·Updow 파라서치/웹이 동일 분모·지표를 쓰도록 단일 진입.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
# 전략별 env 키 (첫 매칭 우선)
|
||
STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = {
|
||
"TAIL": {
|
||
"max_stocks": ("TAIL_MAX_STOCKS",),
|
||
"total_budget": ("TAIL_TOTAL_BUDGET_KRW",),
|
||
"slot": ("TAIL_SLOT_MONEY",),
|
||
"per_stock_cap": ("TAIL_MAX_BUY_AMOUNT",),
|
||
"min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
"SCALP": {
|
||
"max_stocks": ("SCALP_MAX_STOCKS", "MAX_STOCKS"),
|
||
"total_budget": ("SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
|
||
"slot": ("SLOT_MONEY_DEFAULT",),
|
||
"per_stock_cap": ("SCALP_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
|
||
"min_invest_env": ("SCALP_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
"MOMENTUM": {
|
||
"max_stocks": ("MOMENTUM_MAX_STOCKS", "SCALP_MAX_STOCKS", "MAX_STOCKS"),
|
||
"total_budget": ("MOMENTUM_TOTAL_BUDGET_KRW", "SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
|
||
"slot": ("MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
|
||
"per_stock_cap": ("MOMENTUM_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
|
||
"min_invest_env": ("MOMENTUM_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
"BREAKOUT": {
|
||
"max_stocks": ("BREAKOUT_MAX_STOCKS", "MAX_STOCKS"),
|
||
"total_budget": ("BREAKOUT_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
|
||
"slot": ("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
|
||
"per_stock_cap": ("BREAKOUT_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
|
||
"min_invest_env": ("BREAKOUT_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
"RANGE_BREAK": {
|
||
"max_stocks": ("RANGE_BREAK_MAX_STOCKS", "MAX_STOCKS"),
|
||
"total_budget": ("RANGE_BREAK_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
|
||
"slot": ("RANGE_BREAK_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
|
||
"per_stock_cap": ("RANGE_BREAK_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
|
||
"min_invest_env": ("RANGE_BREAK_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
"UPDOW": {
|
||
"max_stocks": ("UPDOW_MAX_STOCKS", "MAX_STOCKS"),
|
||
"total_budget": ("UPDOW_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
|
||
"slot": ("UPDOW_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
|
||
"per_stock_cap": ("UPDOW_MAX_BUY_AMOUNT",),
|
||
"min_invest_env": ("UPDOW_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
"DBBAND": {
|
||
"max_stocks": ("DBBAND_MAX_STOCKS", "MAX_STOCKS"),
|
||
"total_budget": ("DBBAND_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
|
||
"slot": ("DBBAND_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
|
||
"per_stock_cap": ("DBBAND_MAX_BUY_AMOUNT",),
|
||
"min_invest_env": ("DBBAND_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
|
||
},
|
||
}
|
||
|
||
|
||
def _pick_env(r: Dict[str, Any], keys: Tuple[str, ...], default: Any = None) -> Any:
|
||
for k in keys:
|
||
v = r.get(k)
|
||
if v not in (None, "", "None"):
|
||
return v
|
||
return default
|
||
|
||
|
||
def fee_and_slot_from_env_row(
|
||
row: Optional[Dict[str, Any]],
|
||
*,
|
||
strategy: str = "SCALP",
|
||
) -> Tuple[float, float, float]:
|
||
"""env_config 1행 → (fee_rate, sell_tax, slot_money)."""
|
||
if not row:
|
||
return 0.015 / 100, 0.18 / 100, 3_000_000.0
|
||
r = dict(row)
|
||
fee_rate = float(r.get("FEE_RATE_PCT") or 0.015) / 100
|
||
sell_tax = float(r.get("SELL_TAX_RATE_PCT") or 0.18) / 100
|
||
keys = STRATEGY_PORTFOLIO_KEYS.get(strategy, STRATEGY_PORTFOLIO_KEYS["SCALP"])
|
||
slot_money = float(
|
||
_pick_env(r, keys["slot"], 3_000_000)
|
||
)
|
||
return fee_rate, sell_tax, slot_money
|
||
|
||
|
||
def resolve_portfolio_params(
|
||
env_row: Optional[Dict[str, Any]],
|
||
base_defaults: Optional[Dict[str, Any]] = None,
|
||
*,
|
||
strategy: str = "SCALP",
|
||
slot_money: Optional[float] = None,
|
||
max_stocks: Optional[int] = None,
|
||
total_budget_krw: Optional[float] = None,
|
||
) -> Dict[str, Any]:
|
||
"""웹·파라서치 공통 — 1회투자·동시보유·총한도. total_budget ≤0 → max_stocks×slot."""
|
||
r = dict(env_row) if env_row else {}
|
||
d = dict(base_defaults) if base_defaults else {}
|
||
keys = STRATEGY_PORTFOLIO_KEYS.get(strategy, STRATEGY_PORTFOLIO_KEYS["SCALP"])
|
||
|
||
slot = float(slot_money) if slot_money is not None else float(
|
||
_pick_env(r, keys["slot"], d.get("slot_money") or 3_000_000)
|
||
)
|
||
mxs = int(max_stocks) if max_stocks is not None else int(
|
||
_pick_env(r, keys["max_stocks"], d.get("max_stocks") or 3)
|
||
)
|
||
tb_raw = total_budget_krw
|
||
if tb_raw is None:
|
||
tb_raw = float(_pick_env(r, keys["total_budget"], d.get("total_budget_krw") or 0) or 0)
|
||
total_budget = float(tb_raw)
|
||
if total_budget <= 0:
|
||
total_budget = float(mxs * slot)
|
||
|
||
budget_warning = None
|
||
if total_budget < mxs * slot * 0.95:
|
||
budget_warning = (
|
||
f"총한도 {total_budget:,.0f}원 < 동시{mxs}×1회투자 "
|
||
f"{mxs * slot:,.0f}원 — 잔여금 소액매수·과다 회전 위험. "
|
||
"실매 정렬: 총한도↑ 또는 동시보유↓"
|
||
)
|
||
|
||
per_cap = int(float(_pick_env(r, keys["per_stock_cap"], d.get("short_max_buy_amount") or 0) or 0))
|
||
|
||
return {
|
||
"slot_money": slot,
|
||
"max_stocks": max(1, mxs),
|
||
"total_budget_krw": total_budget,
|
||
"short_max_buy_amount": per_cap,
|
||
"portfolio_mode": True,
|
||
"budget_warning": budget_warning,
|
||
"strategy": strategy,
|
||
}
|
||
|
||
|
||
def merge_portfolio_into_params(
|
||
params: Dict[str, Any],
|
||
portfolio: Dict[str, Any],
|
||
) -> Dict[str, Any]:
|
||
params["slot_money"] = float(portfolio["slot_money"])
|
||
params["max_stocks"] = int(portfolio["max_stocks"])
|
||
params["total_budget_krw"] = float(portfolio["total_budget_krw"])
|
||
params.setdefault("portfolio_mode", True)
|
||
sb = int(portfolio.get("short_max_buy_amount") or 0)
|
||
if sb > 0:
|
||
params["short_max_buy_amount"] = sb
|
||
return params
|
||
|
||
|
||
def merge_param_search_apply_source(
|
||
item: Dict[str, Any],
|
||
search_meta: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
파라서치 JSON rank 항목 + 파일 meta(포트폴리오·CLI) 병합.
|
||
apply_cfg → merged_params → params 순, params(그리드)가 최우선.
|
||
"""
|
||
src: Dict[str, Any] = {}
|
||
for key in ("apply_cfg", "merged_params"):
|
||
part = item.get(key)
|
||
if isinstance(part, dict):
|
||
src.update(part)
|
||
params = item.get("params")
|
||
if isinstance(params, dict):
|
||
src.update(params)
|
||
meta = dict(search_meta or {})
|
||
for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm", "time_end_hm"):
|
||
if meta.get(k) is not None:
|
||
src[k] = meta[k]
|
||
pf = meta.get("portfolio")
|
||
if isinstance(pf, dict):
|
||
for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm", "time_end_hm"):
|
||
if pf.get(k) is not None:
|
||
src[k] = pf[k]
|
||
return src
|
||
|
||
|
||
def session_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
|
||
"""매매시간 HHMM → env (전략별 TIME_*). 꼬리(SHORT/TAIL)는 TAIL_TIME_* 만."""
|
||
patch: Dict[str, str] = {}
|
||
ts = p.get("time_start_hm")
|
||
te = p.get("time_end_hm")
|
||
if ts in (None, ""):
|
||
ts = None
|
||
if te in (None, ""):
|
||
te = None
|
||
strat = (strategy or "").upper()
|
||
if ts is not None:
|
||
v = str(int(float(ts)))
|
||
if strat in ("TAIL", "SHORT"):
|
||
patch["TAIL_TIME_START"] = v
|
||
elif strat == "SCALP":
|
||
patch["SCALP_TIME_START"] = v
|
||
patch["TIME_START"] = v
|
||
elif strat == "MOMENTUM":
|
||
patch["MOMENTUM_TIME_START"] = v
|
||
elif strat == "BREAKOUT":
|
||
patch["BREAKOUT_TIME_START"] = v
|
||
elif strat == "RANGE_BREAK":
|
||
patch["RANGE_BREAK_TIME_START"] = v
|
||
else:
|
||
patch["TIME_START"] = v
|
||
if te is not None:
|
||
v = str(int(float(te)))
|
||
if strat in ("TAIL", "SHORT"):
|
||
patch["TAIL_TIME_END"] = v
|
||
elif strat == "SCALP":
|
||
patch["SCALP_TIME_END"] = v
|
||
patch["TIME_END"] = v
|
||
elif strat == "MOMENTUM":
|
||
patch["MOMENTUM_TIME_END"] = v
|
||
elif strat == "BREAKOUT":
|
||
patch["BREAKOUT_TIME_END"] = v
|
||
patch["BREAKOUT_GOLDEN_END_HM"] = v
|
||
elif strat == "RANGE_BREAK":
|
||
patch["RANGE_BREAK_TIME_END_HM"] = v
|
||
else:
|
||
patch["TIME_END"] = v
|
||
return patch
|
||
|
||
|
||
# 전략 전용 키 prefix — 쓰기 시 '남의 전략' 키 오염 방지용.
|
||
# (읽기 fallback 튜플엔 타 전략 키가 폴백으로 들어있어, 그대로 쓰면 SCALP·SHORT 등이 덮인다)
|
||
_KNOWN_STRATEGY_PREFIXES: Tuple[str, ...] = (
|
||
"TAIL_", "SHORT_", "SCALP_", "MOMENTUM_",
|
||
"BREAKOUT_", "RANGE_BREAK_", "UPDOW_", "DBBAND_",
|
||
)
|
||
|
||
|
||
def _is_foreign_strategy_key(key: str, own_prefix: str) -> bool:
|
||
"""key 가 '자기 전략 prefix 가 아닌' 다른 전략 전용 키인지. (무prefix 공용키는 False)."""
|
||
for pfx in _KNOWN_STRATEGY_PREFIXES:
|
||
if key.startswith(pfx):
|
||
return pfx != own_prefix
|
||
return False
|
||
|
||
|
||
def portfolio_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
|
||
"""1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save·파라서치 apply 공통).
|
||
|
||
※ 쓰기 시 '자기 전략 키 + 공용 무prefix 키' 만 기록한다. 읽기용 fallback 튜플에
|
||
섞인 다른 전략 전용 키(SCALP_*, SHORT_* 등)는 제외 → 남의 전략 설정 오염 금지.
|
||
"""
|
||
patch: Dict[str, str] = {}
|
||
strat = (strategy or "").upper()
|
||
keys = STRATEGY_PORTFOLIO_KEYS.get(strat)
|
||
if not keys:
|
||
return patch
|
||
own_prefix = strat + "_"
|
||
|
||
def _set_many(env_keys: Tuple[str, ...], value: str) -> None:
|
||
for k in env_keys:
|
||
if _is_foreign_strategy_key(k, own_prefix):
|
||
continue # 남의 전략 전용 키 → 건드리지 않음
|
||
patch[k] = value
|
||
|
||
sm = p.get("slot_money")
|
||
if sm not in (None, ""):
|
||
v = str(int(float(sm)))
|
||
_set_many(keys["slot"], v)
|
||
_set_many(keys.get("per_stock_cap", ()), v)
|
||
mxs = p.get("max_stocks")
|
||
if mxs not in (None, ""):
|
||
v = str(int(float(mxs)))
|
||
_set_many(keys["max_stocks"], v)
|
||
tb = p.get("total_budget_krw")
|
||
if tb not in (None, ""):
|
||
v = str(int(float(tb)))
|
||
_set_many(keys["total_budget"], v)
|
||
return patch
|
||
|
||
|
||
def min_invest_ratio_of_slot(params: Dict[str, Any], strategy: str = "SCALP") -> float:
|
||
v = params.get("min_invest_ratio_of_slot")
|
||
if v not in (None, ""):
|
||
return max(0.01, min(1.0, float(v)))
|
||
keys = STRATEGY_PORTFOLIO_KEYS.get(strategy, STRATEGY_PORTFOLIO_KEYS["SCALP"])
|
||
try:
|
||
from kis_trader.utils.env import get_env_float
|
||
for k in keys["min_invest_env"]:
|
||
raw = get_env_float(k, None)
|
||
if raw is not None:
|
||
return max(0.01, min(1.0, float(raw)))
|
||
return max(0.01, min(1.0, get_env_float("MIN_INVEST_RATIO_OF_SLOT", 0.90)))
|
||
except Exception:
|
||
return 0.90
|
||
|
||
|
||
def target_qty_and_cost(entry_price: float, slot_money: float) -> Tuple[int, float]:
|
||
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 portfolio_exposure_krw(portfolio: Dict[str, Dict[str, Any]]) -> float:
|
||
total = 0.0
|
||
for p in portfolio.values():
|
||
ep = float(p.get("entry_price") or p.get("buy_price") or 0)
|
||
total += ep * int(p.get("qty") or 1)
|
||
return total
|
||
|
||
|
||
def build_budget_warning(
|
||
portfolio: Dict[str, Any],
|
||
skip_stats: Optional[Dict[str, Any]] = None,
|
||
*,
|
||
min_invest_ratio: float = 0.9,
|
||
) -> Optional[str]:
|
||
msg = portfolio.get("budget_warning")
|
||
skip_stats = skip_stats or {}
|
||
skipped_micro = int(skip_stats.get("skipped_micro_buys") or 0)
|
||
if skipped_micro > 0:
|
||
micro_note = f"소액매수 스킵 {skipped_micro}건 (slot {min_invest_ratio * 100:.0f}% 미만)"
|
||
msg = f"{msg} | {micro_note}" if msg else micro_note
|
||
return msg
|
||
|
||
|
||
def _t2dt(candle_time: str) -> datetime:
|
||
s = str(candle_time).strip().replace("-", "").replace(":", "").replace(" ", "")
|
||
if len(s) >= 14:
|
||
return datetime.strptime(s[:14], "%Y%m%d%H%M%S")
|
||
return datetime.strptime(s[:12], "%Y%m%d%H%M")
|
||
|
||
|
||
def backtest_slip_pct(params: Optional[Dict[str, Any]]) -> float:
|
||
"""백테 체결 슬리피지(편도 %) — 엄격 체결 검증(STRICT_FILL_VERIFY)이 켜졌을 때만.
|
||
|
||
실매 정합: 실전은 항상 fill 확인 / 모의는 STRICT_FILL_VERIFY 로 확인. 백테도
|
||
같은 토글로 묶어 OFF=순수 이론 체결(슬립0), ON=실매 호가밀림 근사.
|
||
BACKTEST_SLIP_PCT(전 전략 공통, 편도 %) 단일 소스. params 우선 → env(DB) 폴백.
|
||
"""
|
||
p = params or {}
|
||
strict = p.get("strict_fill_verify")
|
||
if strict is None:
|
||
from kis_trader.utils.env import get_env_bool
|
||
strict = get_env_bool("STRICT_FILL_VERIFY", False)
|
||
if not bool(strict):
|
||
return 0.0
|
||
v = p.get("backtest_slip_pct")
|
||
if v is None:
|
||
from kis_trader.utils.env import get_env_float
|
||
v = get_env_float("BACKTEST_SLIP_PCT", 0.0)
|
||
return max(0.0, float(v or 0.0))
|
||
|
||
|
||
def vol_capped_qty(qty: int, candle: Optional[Dict[str, Any]], cap_pct: float) -> int:
|
||
"""진입봉 거래량×cap_pct% 로 체결 가능 주수 제한 (실매 IOC 유동성 제약 근사).
|
||
|
||
cap_pct<=0 → 제한 없음. 거래량 정보 없으면(0) 기존 동작 유지(보수적). 반환 0=미체결.
|
||
실매 시장가 IOC 가 호가에 있는 만큼만 체결되는 것을 봉단위로 근사 (소형주 미체결↑).
|
||
"""
|
||
if cap_pct <= 0:
|
||
return qty
|
||
try:
|
||
vol = int(float((candle or {}).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 _daily_halt_cfg(p: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""백테 daily_profit 시뮬용 cfg — params 우선 → env(DB) 폴백. (실매 load_* 와 동일 키)."""
|
||
from kis_trader.utils.env import get_env_from_db, get_env_float
|
||
|
||
def _pf(param_keys: Tuple[str, ...], key_env: str, default: float = 0.0) -> float:
|
||
# daily 전용 키(daily_trail_*) 우선 → 파람서치 호환 키 → env 폴백.
|
||
# 주의: 꼬리 개별포지션 'trail_arm_pct' 와 충돌 방지 위해 arm_pct 는
|
||
# 'daily_trail_arm_pct' 전용 키만 본다(generic 키 미참조).
|
||
for k in param_keys:
|
||
v = p.get(k)
|
||
if v is not None:
|
||
return float(v or 0.0)
|
||
return float(get_env_float(key_env, default) or 0.0)
|
||
|
||
mode = str(
|
||
p.get("daily_profit_mode") or get_env_from_db("DAILY_PROFIT_MODE", "fixed") or "fixed"
|
||
).lower()
|
||
# 다단계 tier(우선) — 전용키 daily_trail_tiers → env. 비면 단일 drop 폴백.
|
||
tiers = p.get("daily_trail_tiers")
|
||
if tiers is None:
|
||
tiers = get_env_from_db("DAILY_PROFIT_TRAIL_TIERS", "")
|
||
return {
|
||
"enabled": True,
|
||
"mode": mode,
|
||
"trail_tiers": str(tiers or "").strip(),
|
||
"krw": _pf(("daily_profit_target_krw",), "DAILY_PROFIT_TARGET_KRW"),
|
||
"pct": _pf(("daily_profit_target_pct",), "DAILY_PROFIT_TARGET_PCT"),
|
||
"trail_drop_pct": _pf(
|
||
("daily_trail_drop_pct", "trail_drop_pct"), "DAILY_PROFIT_TRAIL_DROP_PCT",
|
||
),
|
||
"trail_arm_krw": _pf(
|
||
("daily_trail_arm_krw", "trail_arm_krw"), "DAILY_PROFIT_TRAIL_ARM_KRW",
|
||
),
|
||
"trail_arm_pct": _pf(("daily_trail_arm_pct",), "DAILY_PROFIT_TRAIL_ARM_PCT"),
|
||
}
|
||
|
||
|
||
def apply_daily_profit_halt_sim(
|
||
trades: List[Dict], params: Optional[Dict[str, Any]], *, budget_krw: float,
|
||
) -> List[Dict]:
|
||
"""백테: 당일 실현손익 고정/트레일 익절 → 트리거 시각 이후 '신규 진입' 거래 제거.
|
||
|
||
실매 daily_profit_halt 의 _target_reached/_trail_reached 를 그대로 재사용(봉단위 근사).
|
||
당일 누적 실현손익을 청산 시각순으로 그려 고점(peak)을 추적, 고정목표 도달 또는
|
||
고점대비 trail_drop_pct% 되돌림이 처음 발생한 시각 이후의 신규 진입을 차단한다.
|
||
(보유분 청산은 그대로 — 실매 buy_allowed 와 동일: 신규매수만 중단.)
|
||
|
||
명시 게이트 BACKTEST_DAILY_PROFIT_TRAIL(또는 params['_backtest_daily_profit_trail'])
|
||
OFF 면 그대로 반환 → 기존 백테 동작 100% 불변.
|
||
"""
|
||
p = params or {}
|
||
gate = p.get("_backtest_daily_profit_trail")
|
||
if gate is None:
|
||
from kis_trader.utils.env import get_env_bool
|
||
gate = get_env_bool("BACKTEST_DAILY_PROFIT_TRAIL", False)
|
||
if not bool(gate):
|
||
return trades
|
||
|
||
from kis_trader.engine.daily_profit_halt import (
|
||
_guard_active, _target_reached, _trail_reached,
|
||
)
|
||
cfg = _daily_halt_cfg(p)
|
||
if not _guard_active(cfg):
|
||
return trades
|
||
mode = str(cfg.get("mode") or "fixed").lower()
|
||
|
||
by_day: Dict[str, List[Dict]] = {}
|
||
for t in trades:
|
||
et = str(t.get("entry_time") or t.get("buy_time") or "")
|
||
if len(et) < 8:
|
||
return trades # 진입 시각 없으면 시뮬 불가 → 불변
|
||
by_day.setdefault(et[:8], []).append(t)
|
||
|
||
kept: List[Dict] = []
|
||
for _day, ts in by_day.items():
|
||
closes = sorted(ts, key=lambda x: str(x.get("exit_time") or x.get("sell_time") or ""))
|
||
cum = 0.0
|
||
peak = 0.0
|
||
trigger_time: Optional[str] = None
|
||
for t in closes:
|
||
cum += float(t.get("pnl") or 0)
|
||
peak = max(peak, cum)
|
||
# 실매 _check_hit 와 동일: both 는 고정(하드캡) 먼저 → 트레일. 먼저 닿는 쪽.
|
||
fixed_hit = mode in ("fixed", "both") and _target_reached(cum, cfg, budget_krw)
|
||
trail_hit = mode in ("trailing", "both") and _trail_reached(cum, peak, cfg, budget_krw)
|
||
if fixed_hit or trail_hit:
|
||
trigger_time = str(t.get("exit_time") or t.get("sell_time") or "")
|
||
break
|
||
for t in ts:
|
||
et = str(t.get("entry_time") or t.get("buy_time") or "")
|
||
if trigger_time and et >= trigger_time:
|
||
continue # 트리거 후 신규매수 차단
|
||
kept.append(t)
|
||
return kept
|
||
|
||
|
||
def attach_scalp_trade_pnl(
|
||
trades: List[Dict],
|
||
*,
|
||
fee_rate: float,
|
||
sell_tax: float,
|
||
slip_pct: float = 0.0,
|
||
) -> None:
|
||
"""스캘핑·모멘텀·돌파 공통 손익 (buy_time/sell_time 필드).
|
||
|
||
slip_pct: 백테 체결 슬리피지(편도 %). 시장가 즉시 체결 가정이라 매수는 불리(+)·
|
||
매도는 불리(-) 로 진입·청산 양측 반영. 표시용 가격은 그대로 두고 pnl 에만 반영.
|
||
0=OFF(동작 불변).
|
||
"""
|
||
slip = max(0.0, float(slip_pct or 0.0)) / 100.0
|
||
for t in trades:
|
||
if t.get("pnl") is not None and t.get("pnl") != 0:
|
||
continue
|
||
ep = float(t.get("buy_price") or t.get("entry") or 0)
|
||
xp = float(t.get("sell_price") or t.get("exit") or 0)
|
||
qty = int(t.get("qty") or 1)
|
||
if slip > 0:
|
||
ep = ep * (1.0 + slip) # 매수 체결 불리 (실매 호가 밀림)
|
||
xp = xp * (1.0 - slip) # 매도 체결 불리
|
||
buy_amt = ep * qty
|
||
sell_amt = xp * qty
|
||
t["pnl"] = round(
|
||
sell_amt - buy_amt - buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax
|
||
)
|
||
bt = t.get("buy_time") or t.get("entry_time")
|
||
st = t.get("sell_time") or t.get("exit_time")
|
||
if bt and st:
|
||
t["hold_min"] = round((_t2dt(st) - _t2dt(bt)).total_seconds() / 60, 1)
|
||
|
||
|
||
def summarize_trades(
|
||
trades: List[Dict],
|
||
*,
|
||
total_budget_krw: float,
|
||
period_days: int = 1,
|
||
) -> Dict[str, Any]:
|
||
total = len(trades)
|
||
wins = [t for t in trades if t.get("pnl", 0) > 0]
|
||
losses = [t for t in trades if t.get("pnl", 0) <= 0]
|
||
total_pnl = sum(t.get("pnl", 0) for t in trades)
|
||
win_pnl = sum(t["pnl"] for t in wins)
|
||
loss_pnl = sum(t["pnl"] for t in losses)
|
||
win_rate = round(len(wins) / total * 100, 2) if total else 0.0
|
||
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
|
||
bot_pct = round(total_pnl / total_budget_krw * 100, 2) if total_budget_krw > 0 else 0.0
|
||
days = max(1, int(period_days))
|
||
daily_avg_pct = round(bot_pct / days, 3) if days > 0 else 0.0
|
||
hold_vals = [t.get("hold_min") for t in trades if t.get("hold_min") is not None]
|
||
avg_hold = round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else 0.0
|
||
return {
|
||
"total_trades": total,
|
||
"wins": len(wins),
|
||
"losses": len(losses),
|
||
"win_rate": win_rate,
|
||
"total_pnl": int(round(total_pnl)),
|
||
"pf": pf,
|
||
"bot_pct": bot_pct,
|
||
"daily_avg_pct": daily_avg_pct,
|
||
"avg_hold_min": avg_hold,
|
||
}
|
||
|
||
|
||
def resolve_trigger_snapshots_for_backtest(
|
||
candles_by_code: Dict[str, Any],
|
||
engine_params: Dict[str, Any],
|
||
*,
|
||
strategy: str,
|
||
meta_out: Optional[Dict[str, Any]] = None,
|
||
orderbook_by_code: Optional[Dict[str, Any]] = None,
|
||
program_by_code: Optional[Dict[str, Any]] = None,
|
||
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Dict[str, Any]]:
|
||
"""백테·파람서치 — ws_orderbook/ws_program 로드 (미제공 시 meta_out+DB)."""
|
||
from kis_trader.backtest.trigger_snapshot_loader import (
|
||
backtest_use_trigger_snapshot_db,
|
||
load_trigger_snapshots_by_code,
|
||
)
|
||
|
||
snap_meta: Dict[str, Any] = {}
|
||
if not backtest_use_trigger_snapshot_db(engine_params, strategy=strategy):
|
||
snap_meta["trigger_snapshot_skipped"] = True
|
||
snap_meta["skip_reason"] = "filters_off"
|
||
return orderbook_by_code, program_by_code, snap_meta
|
||
|
||
ob = dict(orderbook_by_code or {})
|
||
pg = dict(program_by_code or {})
|
||
if (not ob or not pg) and meta_out is not None:
|
||
start_key = str(meta_out.get("start_key") or "")
|
||
end_key = str(meta_out.get("end_key") or "")
|
||
db = meta_out.get("db")
|
||
if db and start_key and end_key:
|
||
ob_loaded, pg_loaded, snap_meta = load_trigger_snapshots_by_code(
|
||
db, start_key, end_key, set(candles_by_code.keys()),
|
||
engine_params=engine_params, strategy=strategy,
|
||
)
|
||
if not ob:
|
||
ob = ob_loaded
|
||
if not pg:
|
||
pg = pg_loaded
|
||
if snap_meta.get("log_verdict_by_code") and engine_params is not None:
|
||
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
|
||
return (ob or None), (pg or None), snap_meta
|