변경 사항 ---- - _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>
945 lines
38 KiB
Python
945 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.utils.env import get_env_from_db, 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)
|
||
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
|
||
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,
|
||
"force_eod_exit": force_eod,
|
||
"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))
|
||
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
|
||
|
||
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_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
|
||
is_eod = is_eod_raw and force_eod_exit
|
||
|
||
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
|