feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
@@ -16,16 +16,26 @@ from kis_trader.backtest.backtest_portfolio_common import (
|
||||
)
|
||||
from kis_trader.engine.scalping_engine import (
|
||||
_apply_buy_state_filters,
|
||||
_eval_momentum_buy_at_index,
|
||||
_eval_scalp_buy_at_index,
|
||||
_macd_lines_from_params,
|
||||
_slot_key,
|
||||
_t2dt,
|
||||
_to_bool,
|
||||
check_sell_signal_backtest_bar,
|
||||
check_sell_signal_live,
|
||||
compute_rsi_series,
|
||||
effective_tp_pct_from_params,
|
||||
)
|
||||
from kis_trader.engine.tick_exit_common import (
|
||||
backtest_sell_slip_pct,
|
||||
backtest_tick_poll_ms,
|
||||
collect_minute_ticks,
|
||||
resolve_backtest_sell,
|
||||
strategy_tick_fallback_ohlc,
|
||||
strategy_use_tick_exit,
|
||||
)
|
||||
from kis_trader.engine.tail_tick_replay import align_entry_price_from_ticks
|
||||
from kis_trader.utils.env import get_env_bool
|
||||
|
||||
|
||||
def _buy_priority_key(
|
||||
@@ -84,28 +94,37 @@ def _resolve_invest_cap_krw(params: Dict[str, Any], slot_money: float) -> float:
|
||||
return invest_amount
|
||||
|
||||
|
||||
def _scalp_use_tick_entry(params: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""백테 진입: 예약 체결 시 해당 분 첫 틱 가격 (기본 ON — 실매 체결 정합)."""
|
||||
if params is not None and params.get("backtest_use_tick_entry") is not None:
|
||||
return _to_bool(params.get("backtest_use_tick_entry"), True)
|
||||
return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True)
|
||||
|
||||
|
||||
def run_scalping_backtest_portfolio(
|
||||
codes_candles: Dict[str, List[Dict]],
|
||||
params: Dict[str, Any],
|
||||
universe_by_slot: Optional[Dict[str, List[str]]] = None,
|
||||
mode: str = "reversal",
|
||||
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사.
|
||||
|
||||
- ``mode='reversal'``: ``_eval_scalp_buy_at_index``
|
||||
- ``mode='momentum'``: ``_eval_momentum_buy_at_index``
|
||||
- reversal 전용 (모멘텀은 ``momentum_portfolio_backtest``).
|
||||
- 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_live``
|
||||
"""
|
||||
mode = str(mode or "reversal").strip().lower()
|
||||
if mode == "momentum":
|
||||
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,
|
||||
)
|
||||
strategy = "SCALP"
|
||||
|
||||
rsi_period = int(params.get("rsi_period", 3))
|
||||
min_bars = max(rsi_period + 5, 6) if mode == "momentum" else rsi_period + 5
|
||||
min_bars = rsi_period + 5
|
||||
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
|
||||
sl_pct = abs(float(params.get("sl_pct", 0.015)))
|
||||
tp_pct = effective_tp_pct_from_params(params)
|
||||
@@ -119,6 +138,18 @@ def run_scalping_backtest_portfolio(
|
||||
use_macd_cross = _to_bool(params.get("use_macd_cross", False), False)
|
||||
|
||||
skipped_micro_buys = 0
|
||||
use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit(
|
||||
params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True,
|
||||
)
|
||||
tick_fallback_ohlc = strategy_tick_fallback_ohlc(
|
||||
params, "SCALP_BACKTEST_TICK_FALLBACK_OHLC", default=False,
|
||||
)
|
||||
tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="SCALP_BACKTEST_POLL_MS")
|
||||
tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="SCALP_BACKTEST_SELL_SLIP_PCT")
|
||||
use_tick_entry = bool(ticks_by_code) and _scalp_use_tick_entry(params)
|
||||
tick_exit_count = 0
|
||||
ohlc_exit_count = 0
|
||||
tick_entry_count = 0
|
||||
|
||||
ctx_by_code: Dict[str, Dict[str, Any]] = {}
|
||||
all_times_set = set()
|
||||
@@ -145,10 +176,21 @@ def run_scalping_backtest_portfolio(
|
||||
portfolio: Dict[str, Dict[str, Any]] = {}
|
||||
all_trades: List[Dict] = []
|
||||
|
||||
from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at
|
||||
|
||||
for t in all_times:
|
||||
if apply_env_timeline_at(params, t, "SCALP"):
|
||||
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, params.get("scan_interval_min", 1))
|
||||
|
||||
# ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가) ──
|
||||
# ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가 / 첫 틱) ──
|
||||
pending_codes = [
|
||||
code for code, ctx in ctx_by_code.items()
|
||||
if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t
|
||||
@@ -162,6 +204,17 @@ def run_scalping_backtest_portfolio(
|
||||
if len(portfolio) >= max_stocks:
|
||||
break
|
||||
entry_price = float(pe["entry_price"])
|
||||
# [틱 진입] 예약 체결 시각의 첫 체결가 — 실매 시가 근사보다 정합
|
||||
if use_tick_entry and entry_price > 0:
|
||||
minute_ticks = collect_minute_ticks(ticks_by_code, code, t)
|
||||
aligned, align_src = align_entry_price_from_ticks(minute_ticks, entry_price)
|
||||
if aligned > 0:
|
||||
entry_price = float(aligned)
|
||||
if align_src == "ws_ticks":
|
||||
tick_entry_count += 1
|
||||
# 진입가 변경 시 손절·익절 재계산 (비율 동일)
|
||||
pe["stop"] = entry_price * (1 - sl_pct)
|
||||
pe["target"] = entry_price * (1 + tp_pct)
|
||||
if entry_price <= 0:
|
||||
continue
|
||||
exposure = portfolio_exposure_krw(portfolio)
|
||||
@@ -217,9 +270,6 @@ def run_scalping_backtest_portfolio(
|
||||
if t == pos["entry_time"]:
|
||||
continue
|
||||
|
||||
max_p = max(float(pos.get("max_price", 0) or 0), hi)
|
||||
pos["max_price"] = max_p
|
||||
|
||||
cur_c_info = {
|
||||
"open": op,
|
||||
"high": hi,
|
||||
@@ -227,14 +277,41 @@ def run_scalping_backtest_portfolio(
|
||||
"close": cl,
|
||||
"candle_time": t,
|
||||
}
|
||||
res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod)
|
||||
if not res:
|
||||
continue
|
||||
reason, exit_price = res
|
||||
if use_tick_exit:
|
||||
minute_ticks = collect_minute_ticks(ticks_by_code, code, t)
|
||||
res5 = resolve_backtest_sell(
|
||||
pos,
|
||||
cur_c_info,
|
||||
params,
|
||||
is_eod=is_eod,
|
||||
sell_fn=check_sell_signal_live,
|
||||
low_mode="current",
|
||||
ticks=minute_ticks,
|
||||
use_tick_exit=use_tick_exit,
|
||||
tick_fallback_ohlc=tick_fallback_ohlc,
|
||||
poll_ms=tick_poll_ms,
|
||||
slip_pct=tick_sell_slip,
|
||||
)
|
||||
if not res5:
|
||||
continue
|
||||
reason, exit_price, sell_time, _hold_min, exit_src = res5
|
||||
if exit_src == "ws_ticks":
|
||||
tick_exit_count += 1
|
||||
else:
|
||||
ohlc_exit_count += 1
|
||||
else:
|
||||
max_p = max(float(pos.get("max_price", 0) or 0), hi)
|
||||
pos["max_price"] = max_p
|
||||
res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod)
|
||||
if not res:
|
||||
continue
|
||||
reason, exit_price = res
|
||||
sell_time = t
|
||||
ohlc_exit_count += 1
|
||||
trade: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"buy_time": pos["entry_time"],
|
||||
"sell_time": t,
|
||||
"sell_time": sell_time or t,
|
||||
"buy_price": pos["entry_price"],
|
||||
"sell_price": round(exit_price, 2),
|
||||
"qty": pos.get("qty", 1),
|
||||
@@ -245,7 +322,7 @@ def run_scalping_backtest_portfolio(
|
||||
if pos.get("rsi") is not None:
|
||||
trade["rsi_entry"] = round(float(pos["rsi"]), 1)
|
||||
all_trades.append(trade)
|
||||
ctx["last_exit_dt"][day] = _t2dt(t)
|
||||
ctx["last_exit_dt"][day] = _t2dt(sell_time or t)
|
||||
ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
|
||||
del portfolio[code]
|
||||
|
||||
@@ -275,33 +352,35 @@ def run_scalping_backtest_portfolio(
|
||||
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)
|
||||
if "skip_hts_scan_dupes" not in eval_params:
|
||||
from kis_trader.engine.scalping_engine import resolve_scalp_skip_hts_scan_dupes
|
||||
eval_params["skip_hts_scan_dupes"] = resolve_scalp_skip_hts_scan_dupes()
|
||||
state = {
|
||||
"daily_cnt": ctx["daily_cnt"].get(day, 0),
|
||||
"last_exit_dt": ctx["last_exit_dt"].get(day),
|
||||
}
|
||||
|
||||
if mode == "momentum":
|
||||
if idx < 5:
|
||||
continue
|
||||
reject, _msg, sig = _eval_momentum_buy_at_index(
|
||||
candles, idx, eval_params, state,
|
||||
)
|
||||
else:
|
||||
st = _apply_buy_state_filters(candles, idx, eval_params, state)
|
||||
if st[2] is None:
|
||||
continue
|
||||
reject, _msg, sig = _eval_scalp_buy_at_index(
|
||||
candles, idx, eval_params, macd_combined=ctx.get("macd_combined"),
|
||||
)
|
||||
st = _apply_buy_state_filters(candles, idx, eval_params, state)
|
||||
if st[2] is None:
|
||||
continue
|
||||
from kis_trader.backtest.trigger_snapshot_loader import (
|
||||
inject_trigger_snapshots_into_params,
|
||||
)
|
||||
inject_trigger_snapshots_into_params(
|
||||
eval_params,
|
||||
orderbook_by_code=params.get("_bt_orderbook_by_code"),
|
||||
program_by_code=params.get("_bt_program_by_code"),
|
||||
code=code,
|
||||
bar_candle_time=str(c.get("candle_time") or t),
|
||||
)
|
||||
reject, _msg, sig = _eval_scalp_buy_at_index(
|
||||
candles, idx, eval_params, macd_combined=ctx.get("macd_combined"),
|
||||
)
|
||||
|
||||
if reject or not sig:
|
||||
continue
|
||||
rsi = sig.get("rsi")
|
||||
if rsi is None and mode == "reversal":
|
||||
if rsi is None:
|
||||
continue
|
||||
|
||||
if idx + 1 >= len(candles):
|
||||
@@ -332,7 +411,15 @@ def run_scalping_backtest_portfolio(
|
||||
_pri, pick_code, pe = candidates[0]
|
||||
ctx_by_code[pick_code]["pending_entry"] = pe
|
||||
|
||||
skip_stats: Dict[str, Any] = {}
|
||||
if skipped_micro_buys:
|
||||
params["_portfolio_skip_stats"] = {"skipped_micro_buys": 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 tick_entry_count:
|
||||
skip_stats["tick_entry_count"] = tick_entry_count
|
||||
if skip_stats:
|
||||
params["_portfolio_skip_stats"] = skip_stats
|
||||
all_trades.sort(key=lambda x: x["sell_time"])
|
||||
return all_trades
|
||||
|
||||
Reference in New Issue
Block a user