Files
kis_bot/kis_trader/backtest/momentum_portfolio_backtest.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

812 lines
30 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,
flatten_remaining_portfolio_trades,
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_skip_pre_subscribe,
momentum_backtest_use_tick_exit,
momentum_live_align_enabled,
resolve_momentum_sell_for_bar,
try_momentum_sell_on_ticks,
)
from kis_trader.backtest.momentum_tick_loader import entry_before_first_tick
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,
skip_pre_sub: bool = False,
) -> 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
# 정합용: 구독(첫 틱) 전 진입봉 제외 — 파람 기본 OFF
if skip_pre_sub and entry_before_first_tick(
ticks_by_code, code, entry_bar_time,
):
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)
skip_pre_sub = momentum_backtest_skip_pre_subscribe(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
from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at
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]
if apply_env_timeline_at(params, bar_t, "MOMENTUM"):
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)
invest_cap = _resolve_invest_cap_krw(params, slot_money)
sl_pct = abs(float(params.get("sl_pct", 0.015)))
tp_pct = effective_tp_pct_from_params(params)
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,
skip_pre_sub=skip_pre_sub,
)
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:
if apply_env_timeline_at(params, t, "MOMENTUM"):
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)
invest_cap = _resolve_invest_cap_krw(params, slot_money)
sl_pct = abs(float(params.get("sl_pct", 0.015)))
tp_pct = effective_tp_pct_from_params(params)
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,
skip_pre_sub=skip_pre_sub,
)
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"
flat_n = flatten_remaining_portfolio_trades(
portfolio, ctx_by_code, all_trades,
params=params, strategy=MOMENTUM_STRATEGY_ID,
)
if flat_n:
skip_stats["bt_flatten_count"] = flat_n
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