Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
774 lines
28 KiB
Python
774 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
모멘텀 시각순 포트폴리오 백테스트 — tail/breakout 과 동일 구조.
|
|
|
|
청산: ws_ticks 틱 리플레이. 진입: live_align(T-1신호→T시가) + ws_ticks 첫 체결.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from kis_trader.backtest.backtest_portfolio_common import (
|
|
attach_scalp_trade_pnl,
|
|
backtest_slip_pct,
|
|
min_invest_ratio_of_slot,
|
|
portfolio_exposure_krw,
|
|
target_qty_and_cost,
|
|
)
|
|
from kis_trader.engine.momentum_engine import (
|
|
MOMENTUM_STRATEGY_ID,
|
|
_slot_key,
|
|
_t2dt,
|
|
_to_bool,
|
|
effective_tp_pct_from_params,
|
|
eval_momentum_buy_at_index,
|
|
)
|
|
from kis_trader.strategies.base import is_strategy_eod_bar
|
|
from kis_trader.engine.indicator_cache import (
|
|
attach_indicator_caches_to_params,
|
|
get_indicator_cache_from_params,
|
|
)
|
|
from kis_trader.engine.whipsaw_filter import inject_whipsaw_ticks_into_params
|
|
from kis_trader.backtest.trigger_snapshot_loader import inject_trigger_snapshots_into_params
|
|
from kis_trader.engine.momentum_tick_replay import (
|
|
align_momentum_entry_from_ticks,
|
|
collect_minute_ticks,
|
|
momentum_backtest_live_scan_queue_enabled,
|
|
momentum_backtest_scan_sec,
|
|
momentum_backtest_use_tick_exit,
|
|
momentum_live_align_enabled,
|
|
resolve_momentum_sell_for_bar,
|
|
try_momentum_sell_on_ticks,
|
|
)
|
|
from kis_trader.backtest.momentum_universe_timeline import (
|
|
MomentumUniverseTimeline,
|
|
momentum_backtest_universe_scan_at_enabled,
|
|
)
|
|
|
|
|
|
def _buy_priority_key(
|
|
code: str,
|
|
slot_key: str,
|
|
universe_by_slot: Optional[Dict[str, List[str]]],
|
|
universe_codes: Optional[List[str]] = None,
|
|
) -> Tuple[int, str]:
|
|
if universe_codes is not None:
|
|
try:
|
|
return (universe_codes.index(code), code)
|
|
except ValueError:
|
|
return (999999, code)
|
|
if universe_by_slot is None:
|
|
return (0, code)
|
|
lst = universe_by_slot.get(slot_key) or []
|
|
try:
|
|
return (lst.index(code), code)
|
|
except ValueError:
|
|
return (999999, code)
|
|
|
|
|
|
def _max_stocks_from_params(params: Dict[str, Any]) -> int:
|
|
for key in ("max_stocks", "momentum_max_stocks"):
|
|
v = params.get(key)
|
|
if v not in (None, "", 0):
|
|
return max(1, int(v))
|
|
try:
|
|
from kis_trader.utils.env import get_env_int
|
|
n = get_env_int("MOMENTUM_MAX_STOCKS", 0) or get_env_int("MAX_STOCKS", 3)
|
|
return max(1, int(n))
|
|
except Exception:
|
|
return 3
|
|
|
|
|
|
def _total_budget_from_params(params: Dict[str, Any]) -> float:
|
|
for key in ("total_budget_krw", "momentum_total_budget_krw"):
|
|
v = params.get(key)
|
|
if v not in (None, ""):
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
try:
|
|
from kis_trader.utils.env import get_env_int
|
|
cap = get_env_int("MOMENTUM_TOTAL_BUDGET_KRW", 0)
|
|
if cap > 0:
|
|
return float(cap)
|
|
except Exception:
|
|
pass
|
|
return 0.0
|
|
|
|
|
|
def _resolve_invest_cap_krw(params: Dict[str, Any], slot_money: float) -> float:
|
|
sl_pct = abs(float(params.get("sl_pct", 0.015)))
|
|
max_loss_krw = float(params.get("max_loss_krw", 200_000.0))
|
|
invest_amount = float(slot_money)
|
|
if max_loss_krw > 0 and sl_pct > 0:
|
|
invest_amount = min(max_loss_krw / sl_pct, float(slot_money))
|
|
return invest_amount
|
|
|
|
|
|
def _try_open_momentum_position(
|
|
portfolio: Dict[str, Dict[str, Any]],
|
|
code: str,
|
|
pe: Dict[str, Any],
|
|
*,
|
|
invest_cap: float,
|
|
total_budget: float,
|
|
min_invest_ratio: float,
|
|
max_stocks: int,
|
|
entry_stats: Dict[str, int],
|
|
) -> bool:
|
|
if code in portfolio or len(portfolio) >= max_stocks:
|
|
return False
|
|
entry_price = float(pe.get("entry_price") or 0)
|
|
if entry_price <= 0:
|
|
return False
|
|
exposure = portfolio_exposure_krw(portfolio)
|
|
remaining = max(0.0, total_budget - exposure)
|
|
target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap)
|
|
min_required = target_cost * min_invest_ratio
|
|
if target_qty < 1 or remaining < min_required:
|
|
return False
|
|
invest = min(invest_cap, remaining, target_cost)
|
|
qty = int(invest / entry_price)
|
|
if qty < 1:
|
|
return False
|
|
cost = qty * entry_price
|
|
if cost < min_required or exposure + cost > total_budget + 1e-6:
|
|
return False
|
|
entry_time = str(pe.get("entry_time") or "")
|
|
portfolio[code] = {
|
|
"entry_price": entry_price,
|
|
"entry_time": entry_time,
|
|
"qty": qty,
|
|
"stop": pe["stop"],
|
|
"target": pe["target"],
|
|
"max_price": entry_price,
|
|
"rsi": pe.get("rsi"),
|
|
}
|
|
src = str(pe.get("entry_source") or "ohlc_open")
|
|
if src == "ws_ticks":
|
|
entry_stats["tick_entry_count"] = entry_stats.get("tick_entry_count", 0) + 1
|
|
else:
|
|
entry_stats["ohlc_entry_count"] = entry_stats.get("ohlc_entry_count", 0) + 1
|
|
return True
|
|
|
|
|
|
def _time_bounds_hm(params: Dict[str, Any]) -> Tuple[int, int]:
|
|
ts = int(params.get("time_start_hm", 900))
|
|
te = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
|
|
return ts, te
|
|
|
|
|
|
def _hm_to_minutes(hm: int) -> int:
|
|
return (hm // 100) * 60 + (hm % 100)
|
|
|
|
|
|
def _build_scan_time_keys(
|
|
minute_set: Set[str],
|
|
scan_sec: int,
|
|
time_start_hm: int,
|
|
time_end_hm: int,
|
|
) -> List[str]:
|
|
"""장중 분봉이 있는 구간만 N초 간격 스캔 시각(YYYYMMDDHHMMSS) 생성."""
|
|
if not minute_set or scan_sec < 1:
|
|
return []
|
|
start_min = _hm_to_minutes(time_start_hm)
|
|
end_min = _hm_to_minutes(time_end_hm)
|
|
days = sorted({m[:8] for m in minute_set})
|
|
out: List[str] = []
|
|
for day in days:
|
|
day_minutes = sorted(m for m in minute_set if m.startswith(day))
|
|
for minute_key in day_minutes:
|
|
hm = int(minute_key[8:12])
|
|
bar_min = _hm_to_minutes(hm)
|
|
if bar_min < start_min or bar_min >= end_min:
|
|
continue
|
|
base = datetime.strptime(minute_key, "%Y%m%d%H%M")
|
|
sec = 0
|
|
while sec < 60:
|
|
out.append(base.replace(second=sec).strftime("%Y%m%d%H%M%S"))
|
|
sec += scan_sec
|
|
return out
|
|
|
|
|
|
def _is_minute_tail_scan(scan_key: str, scan_sec: int) -> bool:
|
|
sec = int(str(scan_key)[-2:])
|
|
return sec + scan_sec >= 60
|
|
|
|
|
|
def _record_momentum_sell(
|
|
*,
|
|
portfolio: Dict[str, Dict[str, Any]],
|
|
code: str,
|
|
ctx: Dict[str, Any],
|
|
pos: Dict[str, Any],
|
|
reason: str,
|
|
exit_price: float,
|
|
sell_time_key: str,
|
|
hold_min: float,
|
|
exit_source: str,
|
|
all_trades: List[Dict],
|
|
tick_exit_count: int,
|
|
ohlc_exit_count: int,
|
|
) -> Tuple[int, int]:
|
|
trade: Dict[str, Any] = {
|
|
"code": code,
|
|
"buy_time": pos["entry_time"],
|
|
"sell_time": sell_time_key,
|
|
"buy_price": pos["entry_price"],
|
|
"sell_price": round(exit_price, 2),
|
|
"qty": pos.get("qty", 1),
|
|
"pnl": 0,
|
|
"sell_reason": reason,
|
|
"hold_min": hold_min,
|
|
"exit_source": exit_source,
|
|
"strategy": MOMENTUM_STRATEGY_ID,
|
|
}
|
|
if pos.get("rsi") is not None:
|
|
trade["rsi_entry"] = round(float(pos["rsi"]), 1)
|
|
all_trades.append(trade)
|
|
day = sell_time_key[:8]
|
|
ctx["last_exit_dt"][day] = _t2dt(sell_time_key)
|
|
del portfolio[code]
|
|
if exit_source == "ws_ticks":
|
|
tick_exit_count += 1
|
|
else:
|
|
ohlc_exit_count += 1
|
|
return tick_exit_count, ohlc_exit_count
|
|
|
|
|
|
def _process_sells_for_scan(
|
|
portfolio: Dict[str, Dict[str, Any]],
|
|
ctx_by_code: Dict[str, Dict[str, Any]],
|
|
scan_key: str,
|
|
*,
|
|
params: Dict[str, Any],
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
|
|
all_trades: List[Dict],
|
|
tick_exit_count: int,
|
|
ohlc_exit_count: int,
|
|
scan_sec: int,
|
|
) -> Tuple[int, int]:
|
|
"""스캔 시각까지 틱·OHLC 청산 (실매 루프: 매도 먼저)."""
|
|
bar_t = scan_key[:12]
|
|
is_eod = is_strategy_eod_bar(bar_t, params, "MOMENTUM")
|
|
for code in list(portfolio.keys()):
|
|
ctx = ctx_by_code.get(code)
|
|
if ctx is None:
|
|
continue
|
|
idx = ctx["time_index"].get(bar_t)
|
|
if idx is None:
|
|
continue
|
|
candles = ctx["candles"]
|
|
c = candles[idx]
|
|
pos = portfolio[code]
|
|
if str(pos.get("entry_time") or "")[:12] == bar_t:
|
|
continue
|
|
entry_time = str(pos.get("entry_time") or "")
|
|
|
|
sold = False
|
|
if momentum_backtest_use_tick_exit(params) and ticks_by_code:
|
|
minute_ticks = collect_minute_ticks(ticks_by_code, code, bar_t)
|
|
# 공유메모리 컬럼 뷰면 dict 재구성 없이 뷰 캡핑(동일 문자열 비교). 아니면 기존 리스트 캡핑.
|
|
try:
|
|
from kis_trader.backtest.shared_ticks import TickColumnView
|
|
_is_view = isinstance(minute_ticks, TickColumnView)
|
|
except Exception:
|
|
_is_view = False
|
|
if _is_view:
|
|
capped = minute_ticks.cap_by_tick_time_le(scan_key[:14])
|
|
else:
|
|
capped = [
|
|
tk for tk in minute_ticks
|
|
if str(tk.get("tick_time") or "")[:14] <= scan_key[:14]
|
|
]
|
|
if capped:
|
|
tick_res = try_momentum_sell_on_ticks(
|
|
pos, capped, params, is_eod=is_eod, entry_time=entry_time,
|
|
)
|
|
if tick_res:
|
|
reason, fill_px, sell_time, hold_min = tick_res
|
|
tick_exit_count, ohlc_exit_count = _record_momentum_sell(
|
|
portfolio=portfolio, code=code, ctx=ctx, pos=pos,
|
|
reason=reason, exit_price=fill_px, sell_time_key=sell_time,
|
|
hold_min=hold_min, exit_source="ws_ticks",
|
|
all_trades=all_trades,
|
|
tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count,
|
|
)
|
|
sold = True
|
|
|
|
if sold:
|
|
continue
|
|
|
|
if not _is_minute_tail_scan(scan_key, scan_sec):
|
|
continue
|
|
|
|
cur_c_info = {
|
|
"open": float(c["open"]),
|
|
"high": float(c["high"]),
|
|
"low": float(c["low"]),
|
|
"close": float(c["close"]),
|
|
"candle_time": bar_t,
|
|
}
|
|
sell_res = resolve_momentum_sell_for_bar(
|
|
pos, cur_c_info, params,
|
|
is_eod=is_eod,
|
|
ticks_by_code=ticks_by_code,
|
|
code=code,
|
|
)
|
|
if not sell_res:
|
|
continue
|
|
reason, exit_price, sell_time_key, hold_min, exit_source = sell_res
|
|
tick_exit_count, ohlc_exit_count = _record_momentum_sell(
|
|
portfolio=portfolio, code=code, ctx=ctx, pos=pos,
|
|
reason=reason, exit_price=exit_price, sell_time_key=sell_time_key,
|
|
hold_min=hold_min, exit_source=exit_source,
|
|
all_trades=all_trades,
|
|
tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count,
|
|
)
|
|
return tick_exit_count, ohlc_exit_count
|
|
|
|
|
|
def _universe_codes_for_scan(
|
|
*,
|
|
scan_key: str,
|
|
slot_key: str,
|
|
universe_by_slot: Optional[Dict[str, List[str]]],
|
|
universe_timeline: Optional[MomentumUniverseTimeline],
|
|
use_scan_at: bool,
|
|
) -> Optional[List[str]]:
|
|
if use_scan_at and universe_timeline is not None:
|
|
return universe_timeline.codes_at(scan_key)
|
|
if universe_by_slot is None:
|
|
return None
|
|
return universe_by_slot.get(slot_key, [])
|
|
|
|
|
|
def _collect_buy_candidates(
|
|
*,
|
|
bar_t: str,
|
|
slot_key: str,
|
|
ctx_by_code: Dict[str, Dict[str, Any]],
|
|
portfolio: Dict[str, Dict[str, Any]],
|
|
params: Dict[str, Any],
|
|
universe_by_slot: Optional[Dict[str, List[str]]],
|
|
universe_codes: Optional[List[str]] = None,
|
|
live_align: bool,
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
|
|
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]],
|
|
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]],
|
|
sl_pct: float,
|
|
tp_pct: float,
|
|
min_tick_time: str = "",
|
|
eval_memo: Optional[Dict[Tuple, Any]] = None,
|
|
) -> List[Tuple[Tuple[int, str], str, Dict[str, Any]]]:
|
|
candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
|
|
# 순회 대상 종목: 유니버스가 있으면 그 종목만 순회 (전종목 261개 → 유니버스 ~28개).
|
|
# 기존엔 전종목을 돌며 universe_codes 에 없는 종목을 버려 ~9배 낭비했음.
|
|
# 후보는 아래에서 우선순위 키로 재정렬하므로 순회 순서는 결과에 무관 → 동작 불변.
|
|
if universe_codes is not None:
|
|
iter_codes = universe_codes
|
|
elif universe_by_slot is not None:
|
|
iter_codes = universe_by_slot.get(slot_key, [])
|
|
else:
|
|
iter_codes = list(ctx_by_code.keys())
|
|
seen_codes: Set[str] = set()
|
|
for code in iter_codes:
|
|
if code in seen_codes: # 유니버스 중복 종목 1회만 평가 (전종목 순회와 동일 결과)
|
|
continue
|
|
seen_codes.add(code)
|
|
ctx = ctx_by_code.get(code)
|
|
if ctx is None:
|
|
continue
|
|
if code in portfolio or ctx.get("pending_entry"):
|
|
continue
|
|
idx = ctx["time_index"].get(bar_t)
|
|
if idx is None:
|
|
continue
|
|
candles = ctx["candles"]
|
|
c = candles[idx]
|
|
day = bar_t[:8]
|
|
cl = float(c["close"])
|
|
if cl <= 0:
|
|
continue
|
|
if live_align:
|
|
if idx < 6:
|
|
continue
|
|
signal_idx = idx - 1
|
|
signal_bar_time = candles[signal_idx]["candle_time"]
|
|
entry_bar_time = bar_t
|
|
entry_open = float(c["open"])
|
|
if entry_open <= 0:
|
|
continue
|
|
else:
|
|
if idx < 5:
|
|
continue
|
|
signal_idx = idx
|
|
signal_bar_time = bar_t
|
|
if idx + 1 >= len(candles):
|
|
continue
|
|
next_c = candles[idx + 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)
|
|
ic = get_indicator_cache_from_params(params, code)
|
|
if ic is not None:
|
|
eval_params["_indicator_cache"] = ic
|
|
inject_whipsaw_ticks_into_params(
|
|
eval_params,
|
|
ticks_by_code=ticks_by_code,
|
|
code=code,
|
|
bar_candle_time=signal_bar_time,
|
|
strategy="MOMENTUM",
|
|
tf_min=1,
|
|
)
|
|
inject_trigger_snapshots_into_params(
|
|
eval_params,
|
|
orderbook_by_code=orderbook_by_code,
|
|
program_by_code=program_by_code,
|
|
code=code,
|
|
bar_candle_time=entry_bar_time if live_align else signal_bar_time,
|
|
prefer_time=min_tick_time or (entry_bar_time if live_align else signal_bar_time),
|
|
)
|
|
eval_params.setdefault(
|
|
"skip_hts_scan_dupes",
|
|
universe_codes is not None or universe_by_slot is not None,
|
|
)
|
|
state = {
|
|
"daily_cnt": ctx["daily_cnt"].get(day, 0),
|
|
"last_exit_dt": ctx["last_exit_dt"].get(day),
|
|
}
|
|
# eval 메모이즈: 10초 스캔큐가 같은 분·종목을 6번 평가하던 중복 제거.
|
|
# eval_memo 가 None 이 아닐 때만(=틱·호가·프로그램·verdict 데이터 전무로
|
|
# 스캔초에 결과가 무관할 때만) 동작 → 데이터 있으면 기존 경로 100% 불변.
|
|
# 키: (종목, 신호봉idx, 당일매수수, 마지막청산시각) — 매수신호 결과를 좌우하는 상태 전부.
|
|
if eval_memo is not None:
|
|
_led = state["last_exit_dt"]
|
|
_memo_key = (
|
|
code, signal_idx, int(state["daily_cnt"] or 0),
|
|
_led.isoformat() if _led is not None else "",
|
|
)
|
|
_cached = eval_memo.get(_memo_key)
|
|
if _cached is not None:
|
|
reject, _msg, sig = _cached
|
|
else:
|
|
reject, _msg, sig = eval_momentum_buy_at_index(
|
|
candles, signal_idx, eval_params, state,
|
|
)
|
|
eval_memo[_memo_key] = (reject, _msg, sig)
|
|
else:
|
|
reject, _msg, sig = eval_momentum_buy_at_index(
|
|
candles, signal_idx, eval_params, state,
|
|
)
|
|
if reject or not sig:
|
|
continue
|
|
entry_price, entry_time_key, entry_src = align_momentum_entry_from_ticks(
|
|
ticks_by_code, code, entry_bar_time, entry_open, params,
|
|
min_tick_time=min_tick_time,
|
|
)
|
|
pe_data: Dict[str, Any] = {
|
|
"entry_time": entry_time_key,
|
|
"entry_price": entry_price,
|
|
"entry_source": entry_src,
|
|
"stop": entry_price * (1 - sl_pct),
|
|
"target": entry_price * (1 + tp_pct),
|
|
"rsi": sig.get("rsi"),
|
|
}
|
|
candidates.append((
|
|
_buy_priority_key(code, slot_key, universe_by_slot, universe_codes),
|
|
code, pe_data,
|
|
))
|
|
return candidates
|
|
|
|
|
|
def run_momentum_backtest_portfolio(
|
|
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]:
|
|
"""시각순 포트폴리오 백테스트 — MOMENTUM 전용."""
|
|
rsi_period = int(params.get("rsi_period", 3))
|
|
min_bars = max(rsi_period + 5, 6)
|
|
sl_pct = abs(float(params.get("sl_pct", 0.015)))
|
|
tp_pct = effective_tp_pct_from_params(params)
|
|
max_stocks = _max_stocks_from_params(params)
|
|
slot_money = float(params.get("slot_money", 300_000))
|
|
total_budget = _total_budget_from_params(params)
|
|
if total_budget <= 0:
|
|
total_budget = float(max_stocks * slot_money)
|
|
min_invest_ratio = min_invest_ratio_of_slot(params, strategy=MOMENTUM_STRATEGY_ID)
|
|
invest_cap = _resolve_invest_cap_krw(params, slot_money)
|
|
fee_rate = float(params.get("fee_rate", 0.00015))
|
|
sell_tax = float(params.get("sell_tax", 0.0018))
|
|
|
|
skipped_micro_buys = 0
|
|
tick_exit_count = 0
|
|
ohlc_exit_count = 0
|
|
entry_stats: Dict[str, int] = {}
|
|
live_align = momentum_live_align_enabled(params)
|
|
live_scan_queue = momentum_backtest_live_scan_queue_enabled(params)
|
|
scan_sec = momentum_backtest_scan_sec(params)
|
|
universe_timeline = params.get("_momentum_universe_timeline")
|
|
use_scan_at = (
|
|
momentum_backtest_universe_scan_at_enabled(params)
|
|
and universe_timeline is not None
|
|
)
|
|
attach_indicator_caches_to_params(params, codes_candles)
|
|
# eval 메모이즈 게이트 — 틱·호가·프로그램·log verdict 가 전무하면 스캔초마다
|
|
# 매수신호 결과가 동일하므로 (code,신호봉idx,당일매수수,마지막청산시각) 으로 캐시 가능.
|
|
# 하나라도 있으면 None → 메모 비활성(기존 경로 그대로, 결과 불변).
|
|
_eval_memo_safe = (
|
|
not ticks_by_code
|
|
and not orderbook_by_code
|
|
and not program_by_code
|
|
and not params.get("_backtest_log_verdict_by_code")
|
|
)
|
|
eval_memo: Optional[Dict[Tuple, Any]] = {} if _eval_memo_safe else None
|
|
ctx_by_code: Dict[str, Dict[str, Any]] = {}
|
|
all_times_set = set()
|
|
period_start = str(params.get("_backtest_period_start_key") or "")[:12]
|
|
for code, raw_rows in codes_candles.items():
|
|
if len(raw_rows) < min_bars:
|
|
continue
|
|
candles = [dict(r) for r in raw_rows]
|
|
ctx_by_code[code] = {
|
|
"code": code,
|
|
"candles": candles,
|
|
"time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)},
|
|
"last_exit_dt": {},
|
|
"daily_cnt": {},
|
|
"pending_entry": None,
|
|
}
|
|
for c in candles:
|
|
ct = str(c.get("candle_time") or "")
|
|
if period_start and ct < period_start:
|
|
continue
|
|
all_times_set.add(ct)
|
|
|
|
all_times = sorted(all_times_set)
|
|
portfolio: Dict[str, Dict[str, Any]] = {}
|
|
all_trades: List[Dict] = []
|
|
scan_events = 0
|
|
scan_buys = 0
|
|
|
|
if live_scan_queue and live_align:
|
|
time_start_hm, time_end_hm = _time_bounds_hm(params)
|
|
scan_keys = _build_scan_time_keys(all_times_set, scan_sec, time_start_hm, time_end_hm)
|
|
for scan_key in scan_keys:
|
|
scan_events += 1
|
|
bar_t = scan_key[:12]
|
|
slot_key = _slot_key(bar_t, int(params.get("scan_interval_min", 1)))
|
|
|
|
tick_exit_count, ohlc_exit_count = _process_sells_for_scan(
|
|
portfolio, ctx_by_code, scan_key,
|
|
params=params,
|
|
ticks_by_code=ticks_by_code,
|
|
all_trades=all_trades,
|
|
tick_exit_count=tick_exit_count,
|
|
ohlc_exit_count=ohlc_exit_count,
|
|
scan_sec=scan_sec,
|
|
)
|
|
|
|
if len(portfolio) >= max_stocks:
|
|
continue
|
|
if portfolio_exposure_krw(portfolio) >= total_budget - 1e-6:
|
|
continue
|
|
|
|
scan_univ = _universe_codes_for_scan(
|
|
scan_key=scan_key,
|
|
slot_key=slot_key,
|
|
universe_by_slot=universe_by_slot,
|
|
universe_timeline=universe_timeline,
|
|
use_scan_at=use_scan_at,
|
|
)
|
|
if scan_univ is not None and not scan_univ:
|
|
continue
|
|
|
|
candidates = _collect_buy_candidates(
|
|
bar_t=bar_t,
|
|
slot_key=slot_key,
|
|
ctx_by_code=ctx_by_code,
|
|
portfolio=portfolio,
|
|
params=params,
|
|
universe_by_slot=universe_by_slot,
|
|
universe_codes=scan_univ,
|
|
live_align=True,
|
|
ticks_by_code=ticks_by_code,
|
|
orderbook_by_code=orderbook_by_code,
|
|
program_by_code=program_by_code,
|
|
sl_pct=sl_pct,
|
|
tp_pct=tp_pct,
|
|
min_tick_time=scan_key,
|
|
eval_memo=eval_memo,
|
|
)
|
|
if not candidates:
|
|
continue
|
|
candidates.sort(key=lambda x: x[0])
|
|
_pri, pick_code, pe = candidates[0]
|
|
pick_ctx = ctx_by_code[pick_code]
|
|
if _try_open_momentum_position(
|
|
portfolio, pick_code, pe,
|
|
invest_cap=invest_cap,
|
|
total_budget=total_budget,
|
|
min_invest_ratio=min_invest_ratio,
|
|
max_stocks=max_stocks,
|
|
entry_stats=entry_stats,
|
|
):
|
|
pick_ctx["daily_cnt"][bar_t[:8]] = pick_ctx["daily_cnt"].get(bar_t[:8], 0) + 1
|
|
scan_buys += 1
|
|
else:
|
|
skipped_micro_buys += 1
|
|
else:
|
|
for t in all_times:
|
|
slot_key = _slot_key(t, int(params.get("scan_interval_min", 1)))
|
|
|
|
pending_codes = [
|
|
code for code, ctx in ctx_by_code.items()
|
|
if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t
|
|
]
|
|
pending_codes.sort(key=lambda c: _buy_priority_key(c, slot_key, universe_by_slot))
|
|
for code in pending_codes:
|
|
ctx = ctx_by_code[code]
|
|
pe = ctx.pop("pending_entry", None)
|
|
if not pe or code in portfolio:
|
|
continue
|
|
if len(portfolio) >= max_stocks:
|
|
break
|
|
entry_open = float(pe.get("entry_price") or 0)
|
|
entry_price, entry_time_key, entry_src = align_momentum_entry_from_ticks(
|
|
ticks_by_code, code, t, entry_open, params,
|
|
)
|
|
pe = dict(pe)
|
|
pe["entry_price"] = entry_price
|
|
pe["entry_time"] = entry_time_key
|
|
pe["entry_source"] = entry_src
|
|
if not _try_open_momentum_position(
|
|
portfolio, code, pe,
|
|
invest_cap=invest_cap,
|
|
total_budget=total_budget,
|
|
min_invest_ratio=min_invest_ratio,
|
|
max_stocks=max_stocks,
|
|
entry_stats=entry_stats,
|
|
):
|
|
skipped_micro_buys += 1
|
|
continue
|
|
ctx["daily_cnt"][t[:8]] = ctx["daily_cnt"].get(t[:8], 0) + 1
|
|
break
|
|
|
|
for code in list(portfolio.keys()):
|
|
ctx = ctx_by_code.get(code)
|
|
if ctx is None:
|
|
continue
|
|
idx = ctx["time_index"].get(t)
|
|
if idx is None:
|
|
continue
|
|
candles = ctx["candles"]
|
|
c = candles[idx]
|
|
day = t[:8]
|
|
if str(portfolio[code]["entry_time"])[:12] == str(t)[:12]:
|
|
continue
|
|
is_eod = is_strategy_eod_bar(t, params, "MOMENTUM")
|
|
cur_c_info = {
|
|
"open": float(c["open"]),
|
|
"high": float(c["high"]),
|
|
"low": float(c["low"]),
|
|
"close": float(c["close"]),
|
|
"candle_time": t,
|
|
}
|
|
pos = portfolio[code]
|
|
sell_res = resolve_momentum_sell_for_bar(
|
|
pos, cur_c_info, params,
|
|
is_eod=is_eod,
|
|
ticks_by_code=ticks_by_code,
|
|
code=code,
|
|
)
|
|
if not sell_res:
|
|
continue
|
|
reason, exit_price, sell_time_key, hold_min, exit_source = sell_res
|
|
tick_exit_count, ohlc_exit_count = _record_momentum_sell(
|
|
portfolio=portfolio, code=code, ctx=ctx, pos=pos,
|
|
reason=reason, exit_price=exit_price, sell_time_key=sell_time_key,
|
|
hold_min=hold_min, exit_source=exit_source,
|
|
all_trades=all_trades,
|
|
tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count,
|
|
)
|
|
|
|
if len(portfolio) >= max_stocks:
|
|
continue
|
|
if portfolio_exposure_krw(portfolio) >= total_budget - 1e-6:
|
|
continue
|
|
|
|
candidates = _collect_buy_candidates(
|
|
bar_t=t,
|
|
slot_key=slot_key,
|
|
ctx_by_code=ctx_by_code,
|
|
portfolio=portfolio,
|
|
params=params,
|
|
universe_by_slot=universe_by_slot,
|
|
live_align=live_align,
|
|
ticks_by_code=ticks_by_code,
|
|
orderbook_by_code=orderbook_by_code,
|
|
program_by_code=program_by_code,
|
|
sl_pct=sl_pct,
|
|
tp_pct=tp_pct,
|
|
)
|
|
if not candidates:
|
|
continue
|
|
candidates.sort(key=lambda x: x[0])
|
|
_pri, pick_code, pe = candidates[0]
|
|
pick_ctx = ctx_by_code[pick_code]
|
|
if live_align:
|
|
if _try_open_momentum_position(
|
|
portfolio, pick_code, pe,
|
|
invest_cap=invest_cap,
|
|
total_budget=total_budget,
|
|
min_invest_ratio=min_invest_ratio,
|
|
max_stocks=max_stocks,
|
|
entry_stats=entry_stats,
|
|
):
|
|
pick_ctx["daily_cnt"][t[:8]] = pick_ctx["daily_cnt"].get(t[:8], 0) + 1
|
|
else:
|
|
skipped_micro_buys += 1
|
|
else:
|
|
pick_ctx["pending_entry"] = pe
|
|
|
|
skip_stats: Dict[str, Any] = {}
|
|
if skipped_micro_buys:
|
|
skip_stats["skipped_micro_buys"] = skipped_micro_buys
|
|
if tick_exit_count or ohlc_exit_count:
|
|
skip_stats["tick_exit_count"] = tick_exit_count
|
|
skip_stats["ohlc_exit_count"] = ohlc_exit_count
|
|
if entry_stats:
|
|
skip_stats.update(entry_stats)
|
|
if live_scan_queue and live_align:
|
|
skip_stats["buy_queue_mode"] = "live_scan"
|
|
skip_stats["scan_sec"] = scan_sec
|
|
skip_stats["scan_events"] = scan_events
|
|
skip_stats["scan_buys"] = scan_buys
|
|
if use_scan_at:
|
|
skip_stats["universe_mode"] = "scan_at"
|
|
utm = params.get("_universe_timeline_meta") or {}
|
|
skip_stats["universe_debounce_sec"] = utm.get("debounce_sec")
|
|
else:
|
|
skip_stats["universe_mode"] = "minute_slot"
|
|
else:
|
|
skip_stats["buy_queue_mode"] = "minute_legacy"
|
|
if skip_stats:
|
|
params["_portfolio_skip_stats"] = skip_stats
|
|
|
|
attach_scalp_trade_pnl(
|
|
all_trades, fee_rate=fee_rate, sell_tax=sell_tax,
|
|
slip_pct=backtest_slip_pct(params),
|
|
)
|
|
all_trades.sort(key=lambda x: x["sell_time"])
|
|
return all_trades
|