Changes: - Updated import paths for `compute_atr_series` and `is_strategy_eod_bar` to reflect new module structure. - Removed the unused `compute_atr_series` function from `tail_engine.py`, streamlining the codebase. Impact: - These changes enhance code organization and maintainability by ensuring that only necessary components are imported and utilized, while also eliminating redundant code.
951 lines
38 KiB
Python
951 lines
38 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
kis_trader/engine/momentum_engine.py — 모멘텀(1분봉 추세추격) 전용 엔진
|
||
======================================================================
|
||
스캘핑 reversal(SCALP)과 완전 분리. 백테스트·파라서치·MomentumStrategy 공통.
|
||
|
||
[전략 컨셉 — SCAN vs TRIGGER]
|
||
- SCAN: HTS/KIS ``scalp`` 조건검색 (F/G/H/J) → target_candidates_history
|
||
- TRIGGER V2: 추세추격 패턴 OR (단순돌파 / 눌림재돌파) + EMA·거래량·RSI약세컷
|
||
※ 구 V1(고점추격방지·RSI상한·끝물컷)은 추세추격과 상충 → 기본 OFF
|
||
|
||
[청산 우선순위 — 추세추격 전용 (SCALP reversal·돌파와 다름, 실매 MomentumStrategy 동일)]
|
||
래칫(설정 시) → 어깨 → 트레일 → 손절 → 시간컷 → 금액손실컷 → 익절(tp_max 상한) → 장마감청산
|
||
※ 래칫티어가 있어도 미발동 시 어깨로 폴백. 익절%는 마지막 하드 캡.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||
|
||
from kis_trader.engine.ema_trend_filter import eval_ema_uptrend_reject
|
||
from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal
|
||
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.momentum_chase_patterns import (
|
||
chase_pattern_defaults,
|
||
eval_momentum_chase_pattern,
|
||
)
|
||
from kis_trader.engine.momentum_env_keys import (
|
||
momentum_env_bool,
|
||
momentum_env_float,
|
||
momentum_env_int,
|
||
_legacy_float,
|
||
)
|
||
from kis_trader.engine.strategy_eod import is_strategy_eod_bar
|
||
from kis_trader.utils.env import get_env_int
|
||
|
||
MOMENTUM_STRATEGY_ID = "MOMENTUM"
|
||
|
||
|
||
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 _t2dt(t: str) -> datetime:
|
||
return datetime.strptime(str(t)[:12], "%Y%m%d%H%M")
|
||
|
||
|
||
def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
|
||
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 compute_rsi_series(closes: list, period: int = 3) -> list:
|
||
"""RSI 시리즈 (Wilder)."""
|
||
rsi_list = [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 resolve_effective_tp_pct(tp_pct: float, tp_max_pct: float) -> float:
|
||
tp = abs(float(tp_pct))
|
||
cap = abs(float(tp_max_pct))
|
||
if cap > 0:
|
||
return min(tp, cap)
|
||
return tp
|
||
|
||
|
||
def effective_tp_pct_from_params(params: Dict[str, Any]) -> float:
|
||
return resolve_effective_tp_pct(
|
||
params.get("tp_pct", 0.015),
|
||
params.get("tp_max_pct", 0.02),
|
||
)
|
||
|
||
|
||
def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
|
||
"""env_config + config_momentum 병합 → 엔진 params dict."""
|
||
own_db = None
|
||
r: Dict[str, Any] = {}
|
||
try:
|
||
if db is None:
|
||
from database import TradeDB
|
||
own_db = TradeDB()
|
||
db = own_db
|
||
if hasattr(db, "get_merged_env_snapshot"):
|
||
r = db.get_merged_env_snapshot()
|
||
elif hasattr(db, "get_latest_env"):
|
||
latest = db.get_latest_env()
|
||
r = dict((latest or {}).get("snapshot") or {})
|
||
else:
|
||
row = db.conn.execute(
|
||
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
r = dict(row) if row else {}
|
||
|
||
rsi_period = momentum_env_int(r, "MOMENTUM_RSI_PERIOD", 3)
|
||
mom_rsi_min = _legacy_float(r, "MOMENTUM_RSI_MIN", ("SCALP_MOM_RSI_MIN",), 50.0)
|
||
mom_rsi_max = _legacy_float(r, "MOMENTUM_RSI_MAX", ("SCALP_MOM_RSI_MAX",), 80.0)
|
||
mom_vol_mult = _legacy_float(r, "MOMENTUM_VOL_MULT", ("SCALP_MOM_VOL_MULT",), 1.5)
|
||
mom_vol_win = momentum_env_int(r, "MOMENTUM_VOL_WIN", 5)
|
||
if r.get("SCALP_MOM_VOL_WIN") not in (None, "", "None") and "MOMENTUM_VOL_WIN" not in r:
|
||
try:
|
||
mom_vol_win = int(float(r["SCALP_MOM_VOL_WIN"]))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1430)
|
||
if r.get("SCALP_MOM_TIME_END_HM") not in (None, "", "None") and "MOMENTUM_TIME_END_HM" not in r:
|
||
try:
|
||
mom_time_end = int(float(r["SCALP_MOM_TIME_END_HM"]))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
sl_pct = abs(_legacy_float(
|
||
r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.015,
|
||
))
|
||
tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.025)
|
||
tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.02)
|
||
shoulder_high = _legacy_float(
|
||
r, "MOMENTUM_SHOULDER_MIN_HIGH_PCT",
|
||
("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), 0.005,
|
||
)
|
||
shoulder_cut = _legacy_float(
|
||
r, "MOMENTUM_SHOULDER_CUT_PCT",
|
||
("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"), 0.003,
|
||
)
|
||
ratchet_tiers = str(
|
||
r.get("MOMENTUM_RATCHET_TIERS")
|
||
or get_env_from_db("MOMENTUM_RATCHET_TIERS", "")
|
||
or ""
|
||
).strip()
|
||
trail_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_PCT", 0.0))
|
||
trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.0))
|
||
max_hold_bars = momentum_env_int(r, "MOMENTUM_MAX_HOLD_BARS", 0)
|
||
max_daily = momentum_env_int(r, "MOMENTUM_MAX_DAILY", 5)
|
||
min_price = momentum_env_float(r, "MOMENTUM_MIN_PRICE", 1000.0)
|
||
max_daily_chg = momentum_env_float(r, "MOMENTUM_MAX_DAILY_CHG", 20.0)
|
||
high_chase = momentum_env_float(r, "MOMENTUM_HIGH_CHASE_THR", 0.96)
|
||
mom_max_open = momentum_env_float(r, "MOMENTUM_MAX_FROM_OPEN_PCT", 999.0)
|
||
mom_min_open = momentum_env_float(r, "MOMENTUM_MIN_FROM_OPEN_PCT", -999.0)
|
||
max_loss_krw = momentum_env_int(r, "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", 200_000)
|
||
min_drop_loss = r.get("MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT") or r.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
|
||
min_drop_pct_for_loss_cut = 0.015
|
||
if min_drop_loss not in (None, "", "None"):
|
||
v = float(min_drop_loss)
|
||
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
|
||
cooldown_sec = momentum_env_int(r, "MOMENTUM_COOLDOWN_SEC", 600)
|
||
time_start = momentum_env_int(r, "MOMENTUM_TIME_START", 900)
|
||
time_end = momentum_env_int(r, "MOMENTUM_TIME_END", mom_time_end)
|
||
skip_hts = momentum_env_bool(r, "MOMENTUM_SKIP_HTS_SCAN_DUPES", True)
|
||
use_defense = momentum_env_bool(r, "MOMENTUM_USE_DEFENSE_FILTERS", True)
|
||
use_high_chase_f = momentum_env_bool(r, "MOMENTUM_USE_HIGH_CHASE_FILTER", False)
|
||
use_daily_range_f = momentum_env_bool(r, "MOMENTUM_USE_DAILY_RANGE_FILTER", False)
|
||
use_ema_filter = momentum_env_bool(r, "MOMENTUM_USE_EMA_FILTER", True)
|
||
use_rsi_max_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_MAX_FILTER", False)
|
||
pattern_breakout = momentum_env_bool(r, "MOMENTUM_PATTERN_BREAKOUT", True)
|
||
pattern_pullback = momentum_env_bool(r, "MOMENTUM_PATTERN_PULLBACK", True)
|
||
chase_lookback_min = momentum_env_int(r, "MOMENTUM_CHASE_LOOKBACK_MIN", 10)
|
||
pullback_lookback_min = momentum_env_int(r, "MOMENTUM_PULLBACK_LOOKBACK_MIN", 15)
|
||
pullback_min_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MIN_PCT", 0.3)
|
||
pullback_max_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MAX_PCT", 3.0)
|
||
setup_vol_max_mult = momentum_env_float(r, "MOMENTUM_SETUP_VOL_MAX_MULT", 0.8)
|
||
setup_bear_bars_min = momentum_env_int(r, "MOMENTUM_SETUP_BEAR_BARS_MIN", 1)
|
||
ema_fast_period = momentum_env_int(r, "MOMENTUM_EMA_FAST_PERIOD", 9)
|
||
ema_slow_period = momentum_env_int(r, "MOMENTUM_EMA_SLOW_PERIOD", 21)
|
||
slot_money = momentum_env_int(r, "MOMENTUM_SLOT_MONEY", 3_000_000)
|
||
max_stocks = momentum_env_int(r, "MOMENTUM_MAX_STOCKS", 3)
|
||
total_budget = momentum_env_int(r, "MOMENTUM_TOTAL_BUDGET_KRW", 0)
|
||
min_hold_sec = momentum_env_float(r, "MOMENTUM_MIN_HOLD_SEC", 30.0)
|
||
live_align = momentum_env_bool(r, "MOMENTUM_LIVE_BACKTEST_ALIGN", True)
|
||
lookback_bars = momentum_env_int(r, "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", 1)
|
||
force_eod = momentum_env_bool(r, "MOMENTUM_FORCE_EOD_EXIT", False)
|
||
eod_enabled = momentum_env_bool(r, "MOMENTUM_EOD_ENABLED", True)
|
||
if r.get("MOMENTUM_EOD_ENABLED") in (None, "", "None") and r.get("MOMENTUM_FORCE_EOD_EXIT") not in (None, "", "None"):
|
||
eod_enabled = force_eod
|
||
eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:25").strip() or "15:25"
|
||
portfolio_mode = True
|
||
except Exception:
|
||
rsi_period, mom_rsi_min, mom_rsi_max = 3, 50.0, 80.0
|
||
mom_vol_mult, mom_vol_win, mom_time_end = 1.5, 5, 1430
|
||
sl_pct, tp_pct, tp_max = 0.015, 0.025, 0.02
|
||
shoulder_high, shoulder_cut = 0.005, 0.003
|
||
ratchet_tiers, trail_pct, trail_arm_pct = "", 0.0, 0.0
|
||
max_hold_bars, max_daily = 0, 5
|
||
min_price, max_daily_chg, high_chase = 1000.0, 20.0, 0.96
|
||
mom_max_open, mom_min_open = 999.0, -999.0
|
||
max_loss_krw, min_drop_pct_for_loss_cut = 200_000, 0.015
|
||
cooldown_sec, time_start, time_end = 600, 900, 1430
|
||
skip_hts, use_defense = True, True
|
||
use_high_chase_f, use_daily_range_f = False, False
|
||
use_ema_filter = True
|
||
use_rsi_max_filter = False
|
||
pattern_breakout, pattern_pullback = True, True
|
||
chase_lookback_min, pullback_lookback_min = 10, 15
|
||
pullback_min_pct, pullback_max_pct = 0.3, 3.0
|
||
setup_vol_max_mult, setup_bear_bars_min = 0.8, 1
|
||
ema_fast_period, ema_slow_period = 9, 21
|
||
slot_money, max_stocks, total_budget = 3_000_000, 3, 0
|
||
min_hold_sec, live_align, lookback_bars = 30.0, True, 1
|
||
force_eod, portfolio_mode = False, True
|
||
eod_enabled, eod_hm = True, "15:25"
|
||
finally:
|
||
if own_db is not None:
|
||
try:
|
||
own_db.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"rsi_period": rsi_period,
|
||
"mom_rsi_min": mom_rsi_min,
|
||
"mom_rsi_max": mom_rsi_max,
|
||
"mom_vol_mult": mom_vol_mult,
|
||
"mom_vol_win": mom_vol_win,
|
||
"mom_time_end_hm": mom_time_end,
|
||
"mom_max_from_open_pct": mom_max_open,
|
||
"mom_min_from_open_pct": mom_min_open,
|
||
"sl_pct": sl_pct,
|
||
"tp_pct": tp_pct,
|
||
"tp_max_pct": tp_max,
|
||
"shoulder_min_high": shoulder_high,
|
||
"shoulder_cut_pct": shoulder_cut,
|
||
"ratchet_tiers": ratchet_tiers,
|
||
"trail_pct": trail_pct,
|
||
"trail_arm_pct": trail_arm_pct,
|
||
"max_hold_bars": max_hold_bars,
|
||
"max_daily": max_daily,
|
||
"min_price": min_price,
|
||
"max_daily_chg": max_daily_chg,
|
||
"high_chase_thr": high_chase,
|
||
"max_loss_krw": float(max_loss_krw),
|
||
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
|
||
"cooldown_min": cooldown_sec / 60.0,
|
||
"time_start_hm": time_start,
|
||
"time_end_hm": time_end,
|
||
"skip_hts_scan_dupes": skip_hts,
|
||
"use_defense_filters": use_defense,
|
||
"use_high_chase_filter": use_high_chase_f,
|
||
"use_daily_range_filter": use_daily_range_f,
|
||
"use_ema_filter": use_ema_filter,
|
||
"use_rsi_max_filter": use_rsi_max_filter,
|
||
"pattern_breakout": pattern_breakout,
|
||
"pattern_pullback": pattern_pullback,
|
||
"chase_lookback_min": chase_lookback_min,
|
||
"pullback_lookback_min": pullback_lookback_min,
|
||
"pullback_min_pct": pullback_min_pct,
|
||
"pullback_max_pct": pullback_max_pct,
|
||
"setup_vol_max_mult": setup_vol_max_mult,
|
||
"setup_bear_bars_min": setup_bear_bars_min,
|
||
"ema_fast_period": ema_fast_period,
|
||
"ema_slow_period": ema_slow_period,
|
||
"slot_money": float(slot_money),
|
||
"max_stocks": max_stocks,
|
||
"total_budget_krw": float(total_budget),
|
||
"min_hold_sec": min_hold_sec,
|
||
"live_backtest_align": live_align,
|
||
"live_signal_lookback_bars": lookback_bars,
|
||
"eod_enabled": eod_enabled,
|
||
"eod_hm": eod_hm,
|
||
"force_eod_exit": eod_enabled,
|
||
"portfolio_mode": portfolio_mode,
|
||
"scan_interval_min": 1,
|
||
}
|
||
|
||
|
||
def _parse_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]:
|
||
raw = params.get("ratchet_tiers")
|
||
if raw is None:
|
||
raw = get_env_from_db("MOMENTUM_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 _shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]:
|
||
smh = float(params.get("shoulder_min_high", 0.005))
|
||
sc = float(params.get("shoulder_cut_pct", 0.003))
|
||
return max(0.0, smh), max(0.0, sc)
|
||
|
||
|
||
def _minutes_held(position: Dict[str, Any], candle: Dict[str, Any]) -> Optional[int]:
|
||
try:
|
||
e = _t2dt(position.get("entry_time") or position.get("buy_time", ""))
|
||
n = _t2dt(candle.get("candle_time", ""))
|
||
return max(0, int((n - e).total_seconds() / 60))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _day_running_high_low(candles: List[Dict], i: int, day: str) -> Tuple[float, float, float]:
|
||
running_low = float(candles[i]["low"])
|
||
running_high = float(candles[i]["high"])
|
||
day_open = float(candles[i]["open"])
|
||
for j in range(i, -1, -1):
|
||
if candles[j]["candle_time"][:8] != day:
|
||
break
|
||
running_low = min(running_low, float(candles[j]["low"]))
|
||
running_high = max(running_high, float(candles[j]["high"]))
|
||
day_open = float(candles[j]["open"])
|
||
return running_high, running_low, day_open
|
||
|
||
|
||
def eval_momentum_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]]]:
|
||
"""TRIGGER V2: 추세추격 패턴 OR + 공통 가드 (시간·쿨다운·EMA·RSI약세·거래량)."""
|
||
if i < 1 or i >= len(candles):
|
||
return ("탈락-봉부족", f"인덱스 부적절 (i={i})", None)
|
||
|
||
rsi_period = int(params.get("rsi_period", 3))
|
||
rsi_min = float(params.get("mom_rsi_min", 50.0))
|
||
rsi_max = float(params.get("mom_rsi_max", 80.0))
|
||
time_start_hm = int(params.get("time_start_hm", 900))
|
||
time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
|
||
cooldown_min = float(params.get("cooldown_min", 10))
|
||
max_daily = int(params.get("max_daily", 5))
|
||
max_daily_chg = float(params.get("max_daily_chg", 20.0))
|
||
min_price = float(params.get("min_price", 1000.0))
|
||
use_defense = _to_bool(params.get("use_defense_filters"), True)
|
||
use_high_chase_f = _to_bool(params.get("use_high_chase_filter"), False)
|
||
use_daily_range_f = _to_bool(params.get("use_daily_range_filter"), False)
|
||
use_ema_filter = _to_bool(params.get("use_ema_filter"), True)
|
||
use_rsi_max_filter = _to_bool(params.get("use_rsi_max_filter"), False)
|
||
ema_fast_period = int(params.get("ema_fast_period", 9))
|
||
ema_slow_period = int(params.get("ema_slow_period", 21))
|
||
high_chase_thr = float(params.get("high_chase_thr", 0.96))
|
||
|
||
c = candles[i]
|
||
day = c["candle_time"][:8]
|
||
hm = int(c["candle_time"][8:12])
|
||
cl = float(c["close"])
|
||
|
||
if hm < time_start_hm or hm >= time_end_hm:
|
||
return (None, None, None)
|
||
|
||
if use_defense and cl < min_price:
|
||
return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), 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 state.get("daily_cnt", 0) >= max_daily:
|
||
return (None, None, None)
|
||
|
||
closes = [float(x["close"]) for x in candles]
|
||
ic = params.get("_indicator_cache")
|
||
if ic is not None and hasattr(ic, "rsi_at"):
|
||
rsi = ic.rsi_at(i, rsi_period)
|
||
else:
|
||
rsis = compute_rsi_series(closes, rsi_period)
|
||
rsi = rsis[i] if i < len(rsis) else None
|
||
if rsi is None:
|
||
return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None)
|
||
if rsi <= 0.0:
|
||
return ("탈락-RSI무효", "RSI=0.0 (봉 부족)", None)
|
||
if rsi < rsi_min:
|
||
return ("탈락-모멘텀약함", "RSI=%.1f < %.0f" % (rsi, rsi_min), None)
|
||
if use_rsi_max_filter and rsi > rsi_max:
|
||
return ("탈락-과열끝물", "RSI=%.1f > %.0f" % (rsi, rsi_max), None)
|
||
|
||
ema_fast_val = ic.ema_at(i, ema_fast_period) if ic is not None and hasattr(ic, "ema_at") else None
|
||
ema_slow_val = ic.ema_at(i, ema_slow_period) if ic is not None and hasattr(ic, "ema_at") else None
|
||
ema_rej, ema_msg = eval_ema_uptrend_reject(
|
||
closes, i, cl,
|
||
use_filter=use_ema_filter,
|
||
fast_period=ema_fast_period,
|
||
slow_period=ema_slow_period,
|
||
ema_fast_val=ema_fast_val,
|
||
ema_slow_val=ema_slow_val,
|
||
)
|
||
if ema_rej:
|
||
return (ema_rej, ema_msg, None)
|
||
|
||
running_high, running_low, day_open = _day_running_high_low(candles, i, day)
|
||
|
||
if use_daily_range_f and running_low > 0:
|
||
daily_chg_pct = (running_high - running_low) / running_low * 100
|
||
if daily_chg_pct > max_daily_chg:
|
||
return ("탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg), None)
|
||
|
||
if use_high_chase_f and running_high > 0 and cl >= running_high * high_chase_thr:
|
||
return (
|
||
"탈락-고점추격",
|
||
"현재가 %.0f ≥ 고가 %.0f × %.2f" % (cl, running_high, high_chase_thr),
|
||
None,
|
||
)
|
||
|
||
mom_max_from_open = float(params.get("mom_max_from_open_pct", 999.0))
|
||
mom_min_from_open = float(params.get("mom_min_from_open_pct", -999.0))
|
||
if day_open > 0 and mom_max_from_open < 900:
|
||
from_open_pct = (cl / day_open - 1) * 100
|
||
if from_open_pct > mom_max_from_open:
|
||
return ("탈락-끝물", "시가+%.1f%% > +%.0f%%" % (from_open_pct, mom_max_from_open), None)
|
||
if from_open_pct < mom_min_from_open:
|
||
return ("탈락-약세", "시가%+.1f%% < %+.0f%%" % (from_open_pct, mom_min_from_open), None)
|
||
|
||
pat_ok, pat_name, pat_metrics = eval_momentum_chase_pattern(candles, i, params)
|
||
if not pat_ok:
|
||
return (
|
||
"탈락-패턴미충족",
|
||
"추격패턴(%s) 미충족" % pat_name,
|
||
None,
|
||
)
|
||
|
||
sig: Dict[str, Any] = {
|
||
"signal": True,
|
||
"rsi": rsi,
|
||
"mode": "momentum",
|
||
"pattern": pat_name,
|
||
"signal_candle_time": c.get("candle_time"),
|
||
}
|
||
if isinstance(pat_metrics, dict):
|
||
sig.update(pat_metrics)
|
||
ws_rej, ws_msg = whipsaw_reject_for_signal(
|
||
params, "MOMENTUM",
|
||
signal_bar=c,
|
||
current_price=cl,
|
||
)
|
||
if ws_rej:
|
||
return (ws_rej, ws_msg, None)
|
||
ob_rej, ob_msg = orderbook_reject_for_entry(
|
||
params, "MOMENTUM", current_price=cl,
|
||
)
|
||
if ob_rej:
|
||
return (ob_rej, ob_msg, None)
|
||
prog_rej, prog_msg = program_reject_for_entry(
|
||
params, "MOMENTUM", current_price=cl,
|
||
)
|
||
if prog_rej:
|
||
return (prog_rej, prog_msg, None)
|
||
return (None, None, sig)
|
||
|
||
|
||
def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
|
||
out: List[Dict] = []
|
||
for c in candles:
|
||
if c.get("is_confirmed") in (0, False, "0", "false"):
|
||
continue
|
||
out.append(c)
|
||
if len(out) < len(candles) * 0.5:
|
||
return list(candles)
|
||
return out
|
||
|
||
|
||
def check_buy_signal_momentum_live(
|
||
candles: List[Dict],
|
||
params: Dict[str, Any],
|
||
state: Dict[str, Any],
|
||
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
||
"""실매·백테 공용 모멘텀 진입."""
|
||
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) < 6:
|
||
return ("탈락-봉부족", "확정봉 6개 미만", None)
|
||
|
||
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
|
||
None, None, None,
|
||
)
|
||
|
||
if live_align:
|
||
entry_i = len(confirmed) - 1
|
||
for k in range(lookback):
|
||
signal_i = entry_i - 1 - k
|
||
if signal_i < 1:
|
||
break
|
||
reject, msg, sig = eval_momentum_buy_at_index(
|
||
confirmed, signal_i, params, state,
|
||
)
|
||
if reject:
|
||
if k == 0:
|
||
last_reject = (reject, msg, None)
|
||
continue
|
||
if sig:
|
||
ent = confirmed[entry_i]
|
||
entry_open = float(ent.get("open", 0) or 0)
|
||
if entry_open <= 0:
|
||
entry_open = float(ent.get("close", 0) or 0)
|
||
sig["entry_price"] = entry_open
|
||
sig["entry_bar_key"] = ent.get("candle_time")
|
||
return (None, None, sig)
|
||
return last_reject
|
||
|
||
i = len(confirmed) - 1
|
||
return eval_momentum_buy_at_index(confirmed, i, params, state)
|
||
|
||
|
||
def check_sell_signal_momentum_live(
|
||
position: Dict[str, Any],
|
||
current_candle: Dict[str, Any],
|
||
params: Dict[str, Any],
|
||
is_eod: bool = False,
|
||
) -> Optional[Tuple[str, float]]:
|
||
"""추세추격 전용 청산 — 실매·백테·파서치 공용 (SCALP reversal 과 분리).
|
||
|
||
[청산 우선순위 — 스캘핑 V4 어깨 선행과 동일 계열]
|
||
1순위 래칫컷 / 어깨컷 — 고점 대비 되돌림 (상승 보유 → 하락 시 매도)
|
||
2순위 트레일컷
|
||
3순위 손절
|
||
4순위 시간컷
|
||
5순위 금액손실컷 (어깨·래칫 미발동 시)
|
||
6순위 익절 — tp_max 상한 (하드 캡)
|
||
7순위 장마감청산
|
||
"""
|
||
sl_pct = -abs(float(params.get("sl_pct", params.get("stop_loss_pct", 0.015))))
|
||
tp_pct = effective_tp_pct_from_params(params)
|
||
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))
|
||
shoulder_min_high, shoulder_cut_pct = _shoulder_ratios(params)
|
||
ratchet_tiers = _parse_ratchet_tiers(params)
|
||
max_hold_bars = int(params.get("max_hold_bars", 0) or 0)
|
||
max_loss_krw = float(params.get("max_loss_krw", 200_000.0))
|
||
min_hold_sec = float(params.get("min_hold_sec", 30.0))
|
||
min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))
|
||
|
||
try:
|
||
hi = float(current_candle.get("high", current_candle["close"]))
|
||
lo = float(current_candle.get("low", current_candle["close"]))
|
||
cl = float(current_candle["close"])
|
||
except Exception:
|
||
return None
|
||
|
||
candle_time = current_candle.get("candle_time", "")
|
||
if candle_time and position.get("entry_time"):
|
||
try:
|
||
if (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() < min_hold_sec:
|
||
return None
|
||
except Exception:
|
||
pass
|
||
|
||
max_price = max(float(position.get("max_price", position["entry_price"])), hi)
|
||
position["max_price"] = max_price
|
||
|
||
entry = float(position["entry_price"])
|
||
qty = int(position.get("qty", 1) or 1)
|
||
sl_line = entry * (1 + sl_pct)
|
||
tp_line = entry * (1 + tp_pct)
|
||
|
||
if ratchet_tiers and entry > 0:
|
||
peak_gain = (max_price - entry) / entry
|
||
cut_ratio = 0.0
|
||
for gain, cut in ratchet_tiers:
|
||
if peak_gain >= gain:
|
||
cut_ratio = cut
|
||
if cut_ratio > 0.0:
|
||
ratchet_line = max_price * (1.0 - cut_ratio)
|
||
if lo <= ratchet_line:
|
||
return ("래칫컷", ratchet_line)
|
||
|
||
trail_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high)
|
||
shoulder_line = max_price * (1.0 - shoulder_cut_pct) if trail_armed else 0.0
|
||
if trail_armed and lo <= shoulder_line:
|
||
return ("어깨컷", shoulder_line)
|
||
|
||
if lo <= sl_line:
|
||
return ("손절", sl_line)
|
||
|
||
if trail_pct > 0 and max_price > entry:
|
||
trail_arm_line = entry * (1.0 + trail_arm_pct)
|
||
if trail_arm_pct <= 0 or max_price >= trail_arm_line:
|
||
trail_line = max_price * (1.0 - trail_pct)
|
||
if lo <= trail_line:
|
||
return ("트레일컷", trail_line)
|
||
|
||
if max_hold_bars > 0:
|
||
held = _minutes_held(position, current_candle)
|
||
if held is not None and held >= max_hold_bars:
|
||
return ("시간컷", cl)
|
||
|
||
shoulder_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high)
|
||
profit_val = (lo - entry) * qty
|
||
drop_pct = (entry - lo) / entry if entry > 0 else 0.0
|
||
if (
|
||
not shoulder_armed
|
||
and not ratchet_tiers
|
||
and profit_val <= -max_loss_krw
|
||
and drop_pct >= min_drop_pct
|
||
):
|
||
exit_px = entry - (max_loss_krw / qty) if qty > 0 else lo
|
||
return ("금액손실컷", exit_px)
|
||
|
||
if hi >= tp_line:
|
||
return ("익절", tp_line)
|
||
|
||
if is_eod:
|
||
return ("장마감청산", cl)
|
||
|
||
return None
|
||
|
||
|
||
def _intrabar_exit_prices(
|
||
open_: float, high: float, low: float, close: float, n_checks: int,
|
||
) -> List[float]:
|
||
n = max(2, int(n_checks))
|
||
anchors: List[float] = []
|
||
for px in (float(open_), float(high), float(low), float(close)):
|
||
if not anchors or px != anchors[-1]:
|
||
anchors.append(px)
|
||
if len(anchors) == 1:
|
||
return [anchors[0]] * n
|
||
if n <= len(anchors):
|
||
return anchors[:n]
|
||
seg_count = len(anchors) - 1
|
||
extras = n - len(anchors)
|
||
per_seg = extras // seg_count
|
||
rem = extras % seg_count
|
||
out = [anchors[0]]
|
||
for si in range(seg_count):
|
||
a, b = anchors[si], anchors[si + 1]
|
||
extras_here = per_seg + (1 if si < rem else 0)
|
||
for j in range(1, extras_here + 1):
|
||
t = j / (extras_here + 1)
|
||
out.append(a + (b - a) * t)
|
||
if out[-1] != b:
|
||
out.append(b)
|
||
return out[:n] if len(out) >= n else out + [out[-1]] * (n - len(out))
|
||
|
||
|
||
def check_sell_signal_momentum_backtest_bar(
|
||
position: Dict[str, Any],
|
||
candle: Dict[str, Any],
|
||
params: Dict[str, Any],
|
||
is_eod: bool = False,
|
||
) -> Optional[Tuple[str, float]]:
|
||
"""백테: 1분 OHLC를 N회 가격 체크로 분할 → ``check_sell_signal_momentum_live`` 호출.
|
||
|
||
실매 MomentumStrategy 와 동일 청산 함수·우선순위. intrabar 순서만 분봉 OHLC로 근사.
|
||
"""
|
||
n_checks = get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6)
|
||
if n_checks <= 1:
|
||
return check_sell_signal_momentum_live(position, candle, params, is_eod)
|
||
|
||
o = float(candle.get("open", candle["close"]))
|
||
h = float(candle.get("high", candle["close"]))
|
||
l = float(candle.get("low", candle["close"]))
|
||
c = float(candle["close"])
|
||
ct = candle.get("candle_time", "")
|
||
prices = _intrabar_exit_prices(o, h, l, c, n_checks)
|
||
result: Optional[Tuple[str, float]] = None
|
||
for idx, px in enumerate(prices):
|
||
mp = float(position.get("max_price", 0) or 0)
|
||
if px > mp:
|
||
position["max_price"] = px
|
||
mp = px
|
||
sim = {"open": o, "high": mp, "low": px, "close": px, "candle_time": ct}
|
||
eod_here = bool(is_eod and idx == len(prices) - 1)
|
||
result = check_sell_signal_momentum_live(position, sim, params, is_eod=eod_here)
|
||
if result:
|
||
return result
|
||
return result
|
||
|
||
|
||
def build_universe_simulation_momentum(
|
||
codes_candles: Dict[str, List[Dict]],
|
||
top_n: int = 20,
|
||
min_score: float = 4.0,
|
||
scan_interval_min: int = 5,
|
||
) -> Dict[str, List[str]]:
|
||
"""모멘텀 SCAN 유니버스 시뮬레이션 (HTS scalp 조건검색 근사)."""
|
||
slot_codes_scores: Dict[str, List[Tuple[str, float, float]]] = {}
|
||
|
||
for code, rows in codes_candles.items():
|
||
if len(rows) < 2:
|
||
continue
|
||
candles = [dict(r) for r in rows]
|
||
by_day: Dict[str, List[Dict]] = {}
|
||
for c in candles:
|
||
day = c["candle_time"][:8]
|
||
by_day.setdefault(day, []).append(c)
|
||
|
||
for day, day_candles in by_day.items():
|
||
day_candles.sort(key=lambda x: x["candle_time"])
|
||
market_open_min = 9 * 60
|
||
seen_slots = set()
|
||
for c in day_candles:
|
||
ct = c["candle_time"]
|
||
hm = int(ct[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)
|
||
slot_key = day + str(slot_hm).zfill(4)
|
||
if slot_key in seen_slots:
|
||
continue
|
||
seen_slots.add(slot_key)
|
||
|
||
slot_min_val = (slot_hm // 100) * 60 + (slot_hm % 100)
|
||
as_of_min = max(market_open_min, slot_min_val - 1)
|
||
as_of_hm = (as_of_min // 60) * 100 + (as_of_min % 60)
|
||
as_of_str = day + str(as_of_hm).zfill(4)
|
||
up_to = [x for x in day_candles if x["candle_time"] <= as_of_str]
|
||
if len(up_to) < 2:
|
||
continue
|
||
|
||
o = float(up_to[0]["open"])
|
||
hi = max(float(x["high"]) for x in up_to)
|
||
cl = float(up_to[-1]["close"])
|
||
if o <= 0 or hi <= 0 or cl <= 0:
|
||
continue
|
||
|
||
from_open_pct = (cl / o - 1) * 100
|
||
if from_open_pct <= 0.3:
|
||
momentum_score = 0.0
|
||
elif from_open_pct > 20.0:
|
||
momentum_score = 0.0
|
||
else:
|
||
momentum_score = from_open_pct * 1.5
|
||
|
||
chase_ratio = cl / hi
|
||
chase_score = max(0.0, (chase_ratio - 0.85) * 50)
|
||
up_count = sum(1 for x in up_to if float(x["close"]) > float(x["open"]))
|
||
uptrend_score = (up_count / len(up_to)) * 5.0
|
||
|
||
closes = [float(x["close"]) for x in up_to]
|
||
rsis = compute_rsi_series(closes, 3)
|
||
last_rsi = rsis[-1] if rsis and rsis[-1] is not None else 0.0
|
||
if 50 <= last_rsi <= 70:
|
||
rsi_score = (last_rsi - 50) / 4.0
|
||
elif 70 < last_rsi <= 80:
|
||
rsi_score = 5.0 - (last_rsi - 70) / 2.0
|
||
else:
|
||
rsi_score = 0.0
|
||
|
||
total_score = momentum_score + chase_score + uptrend_score + rsi_score
|
||
vol_sum = sum(float(x.get("volume", 0)) for x in up_to)
|
||
slot_codes_scores.setdefault(slot_key, []).append((code, total_score, vol_sum))
|
||
|
||
universe_by_slot: Dict[str, List[str]] = {}
|
||
for slot_key, lst in slot_codes_scores.items():
|
||
lst = [(c, s, v) for c, s, v in lst if s >= min_score]
|
||
lst.sort(key=lambda x: (-x[1], -x[2]))
|
||
universe_by_slot[slot_key] = [x[0] for x in lst[:top_n]]
|
||
return universe_by_slot
|
||
|
||
|
||
def run_momentum_backtest(
|
||
codes_candles: 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]:
|
||
"""모멘텀 백테스트 — portfolio_mode 시 포트폴리오 모듈 위임."""
|
||
if _to_bool(params.get("portfolio_mode"), True):
|
||
from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio
|
||
return run_momentum_backtest_portfolio(
|
||
codes_candles, params, universe_by_slot=universe_by_slot,
|
||
ticks_by_code=ticks_by_code,
|
||
orderbook_by_code=orderbook_by_code,
|
||
program_by_code=program_by_code,
|
||
)
|
||
|
||
rsi_period = int(params.get("rsi_period", 3))
|
||
sl_pct = abs(float(params.get("sl_pct", 0.015)))
|
||
tp_pct = effective_tp_pct_from_params(params)
|
||
slot_money = float(params.get("slot_money", 300_000))
|
||
fee_rate = float(params.get("fee_rate", 0.00015))
|
||
sell_tax = float(params.get("sell_tax", 0.0018))
|
||
time_start_hm = int(params.get("time_start_hm", 900))
|
||
time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
|
||
max_loss_krw = float(params.get("max_loss_krw", 200_000.0))
|
||
|
||
all_trades: List[Dict] = []
|
||
|
||
for code, rows in codes_candles.items():
|
||
if len(rows) < max(rsi_period + 5, 6):
|
||
continue
|
||
candles = [dict(r) for r in rows]
|
||
|
||
position: Optional[Dict] = None
|
||
last_exit_dt: Dict[str, datetime] = {}
|
||
daily_cnt: Dict[str, int] = {}
|
||
cur_day = None
|
||
from kis_trader.engine.momentum_tick_replay import (
|
||
align_momentum_entry_from_ticks,
|
||
momentum_live_align_enabled,
|
||
resolve_momentum_sell_for_bar,
|
||
)
|
||
live_align = momentum_live_align_enabled(params)
|
||
|
||
for i in range(rsi_period + 1, len(candles)):
|
||
c = candles[i]
|
||
day = c["candle_time"][:8]
|
||
hm = int(c["candle_time"][8:12])
|
||
cl = float(c["close"])
|
||
|
||
if day != cur_day:
|
||
cur_day = day
|
||
|
||
is_eod = is_strategy_eod_bar(c["candle_time"], params, "MOMENTUM")
|
||
|
||
if position is not None:
|
||
if str(position["entry_time"])[:12] == str(c["candle_time"])[:12]:
|
||
continue
|
||
sell_res = resolve_momentum_sell_for_bar(
|
||
position, c, params,
|
||
is_eod=is_eod,
|
||
ticks_by_code=ticks_by_code,
|
||
code=code,
|
||
)
|
||
if sell_res:
|
||
reason, exit_price, sell_time_key, hold_min, _exit_src = sell_res
|
||
qty = position["qty"]
|
||
buy_amt = position["entry_price"] * qty
|
||
sell_amt = exit_price * qty
|
||
pnl = (
|
||
sell_amt - buy_amt
|
||
- buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax
|
||
)
|
||
all_trades.append({
|
||
"code": code,
|
||
"buy_time": position["entry_time"],
|
||
"sell_time": sell_time_key,
|
||
"buy_price": position["entry_price"],
|
||
"sell_price": round(exit_price, 2),
|
||
"qty": qty,
|
||
"pnl": round(pnl),
|
||
"profit_rate": round(
|
||
(exit_price - position["entry_price"]) / position["entry_price"] * 100, 2
|
||
),
|
||
"hold_min": hold_min,
|
||
"sell_reason": reason,
|
||
"rsi_entry": round(position.get("rsi", 0), 1),
|
||
"strategy": MOMENTUM_STRATEGY_ID,
|
||
})
|
||
last_exit_dt[day] = _t2dt(sell_time_key)
|
||
position = None
|
||
continue
|
||
|
||
if universe_by_slot is not None:
|
||
slot_key = _slot_key(c["candle_time"], int(params.get("scan_interval_min", 1)))
|
||
if code not in universe_by_slot.get(slot_key, []):
|
||
continue
|
||
if hm < time_start_hm or hm >= time_end_hm:
|
||
continue
|
||
if live_align:
|
||
if i < 6:
|
||
continue
|
||
signal_idx = i - 1
|
||
entry_bar_time = c["candle_time"]
|
||
entry_open = float(c["open"])
|
||
if entry_open <= 0:
|
||
continue
|
||
else:
|
||
if i < 5:
|
||
continue
|
||
signal_idx = i
|
||
if i + 1 >= len(candles):
|
||
continue
|
||
next_c = candles[i + 1]
|
||
if next_c["candle_time"][:8] != day:
|
||
continue
|
||
entry_bar_time = next_c["candle_time"]
|
||
entry_open = float(next_c["open"])
|
||
if entry_open <= 0:
|
||
continue
|
||
|
||
eval_params = dict(params)
|
||
if universe_by_slot is not None:
|
||
eval_params.setdefault("skip_hts_scan_dupes", True)
|
||
else:
|
||
eval_params.setdefault("skip_hts_scan_dupes", False)
|
||
state = {
|
||
"daily_cnt": daily_cnt.get(day, 0),
|
||
"last_exit_dt": last_exit_dt.get(day),
|
||
}
|
||
reject, _msg, sig = eval_momentum_buy_at_index(
|
||
candles, signal_idx, eval_params, state,
|
||
)
|
||
if reject or not sig:
|
||
continue
|
||
rsi = float(sig.get("rsi") or 0)
|
||
|
||
entry_price, entry_time_key, _src = align_momentum_entry_from_ticks(
|
||
ticks_by_code, code, entry_bar_time, entry_open, params,
|
||
)
|
||
|
||
invest_amount = slot_money
|
||
if max_loss_krw > 0 and sl_pct > 0:
|
||
invest_amount = min(max_loss_krw / sl_pct, slot_money)
|
||
qty = int(invest_amount / entry_price)
|
||
if qty < 1:
|
||
continue
|
||
|
||
position = {
|
||
"entry_price": entry_price,
|
||
"entry_time": entry_time_key,
|
||
"qty": qty,
|
||
"stop": entry_price * (1 - sl_pct),
|
||
"target": entry_price * (1 + tp_pct),
|
||
"max_price": entry_price,
|
||
"rsi": rsi,
|
||
}
|
||
daily_cnt[day] = daily_cnt.get(day, 0) + 1
|
||
|
||
return all_trades
|