변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
566 lines
21 KiB
Python
566 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
모멘텀 vs 무작위 진입 벤치마크 — param_search 와 동일 데이터·포트폴리오·청산.
|
||
|
||
청산: ``check_sell_signal_momentum_backtest_bar`` (실매 ``check_sell_signal_momentum_live`` 동일).
|
||
무작위: 유니버스(MOMENTUM history) + 매매시간 + 쿨다운·일일한도만 맞추고,
|
||
TRIGGER(RSI·vol·EMA) 없이 슬롯마다 후보 1종목 무작위 선택.
|
||
|
||
실행 (기본: 백그라운드 — nohup 불필요):
|
||
cd /home/hoon/kis_bot
|
||
python3 -m kis_trader.backtest.momentum_random_benchmark \\
|
||
--start 2026-06-01 --end 2026-06-14 --seeds 100
|
||
|
||
# 포그라운드(터미널 붙잡기)가 필요할 때만:
|
||
python3 -m kis_trader.backtest.momentum_random_benchmark --foreground --seeds 10
|
||
|
||
로그: /tmp/mom_random_bench.log (기본) · PID: /tmp/mom_random_bench.pid
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import random
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
_BG_WORKER_ENV = "MOM_RANDOM_BENCH_WORKER"
|
||
DEFAULT_LOG_PATH = "/tmp/mom_random_bench.log"
|
||
DEFAULT_PID_PATH = "/tmp/mom_random_bench.pid"
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||
if ROOT not in sys.path:
|
||
sys.path.insert(0, ROOT)
|
||
|
||
from database import TradeDB # noqa: E402
|
||
from kis_trader.backtest import momentum_backtest_common as mbc # noqa: E402
|
||
from kis_trader.backtest import scalping_backtest_common as sbc # noqa: E402
|
||
from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402
|
||
attach_scalp_trade_pnl,
|
||
backtest_slip_pct,
|
||
load_portfolio_env_row,
|
||
min_invest_ratio_of_slot,
|
||
portfolio_exposure_krw,
|
||
target_qty_and_cost,
|
||
)
|
||
from kis_trader.backtest.momentum_portfolio_backtest import ( # noqa: E402
|
||
_buy_priority_key,
|
||
_max_stocks_from_params,
|
||
_resolve_invest_cap_krw,
|
||
_total_budget_from_params,
|
||
)
|
||
from kis_trader.backtest.param_search_momentum import ( # noqa: E402
|
||
_load_candles_for_search,
|
||
_mom_fixed_defaults,
|
||
_ui_to_engine_params,
|
||
)
|
||
from kis_trader.engine.momentum_engine import ( # noqa: E402
|
||
MOMENTUM_STRATEGY_ID,
|
||
_slot_key,
|
||
_t2dt,
|
||
_to_bool,
|
||
check_sell_signal_momentum_backtest_bar,
|
||
effective_tp_pct_from_params,
|
||
eval_momentum_buy_at_index,
|
||
)
|
||
|
||
|
||
def _hm_from_candle_time(t: str) -> int:
|
||
s = str(t)[8:12]
|
||
return int(s) if len(s) >= 4 else 0
|
||
|
||
|
||
def _session_ok(t: str, params: Dict[str, Any]) -> bool:
|
||
hm = _hm_from_candle_time(t)
|
||
ts = int(params.get("time_start_hm", 900))
|
||
te = int(params.get("time_end_hm", 1530))
|
||
return ts <= hm < te
|
||
|
||
|
||
def _cooldown_ok(t: str, day: str, last_exit_dt, cooldown_min: float) -> bool:
|
||
if not last_exit_dt:
|
||
return True
|
||
try:
|
||
from datetime import datetime as _dt
|
||
cur = _dt.strptime(t, "%Y%m%d%H%M%S")
|
||
last = last_exit_dt if hasattr(last_exit_dt, "year") else _t2dt(str(last_exit_dt))
|
||
elapsed = (cur - last).total_seconds() / 60.0
|
||
return elapsed >= float(cooldown_min)
|
||
except Exception:
|
||
return True
|
||
|
||
|
||
def run_portfolio(
|
||
codes_candles: Dict[str, List[Dict]],
|
||
params: Dict[str, Any],
|
||
universe_by_slot: Optional[Dict[str, List[str]]],
|
||
*,
|
||
random_seed: Optional[int] = None,
|
||
) -> List[Dict]:
|
||
"""시각순 포트폴리오 — random_seed 있으면 무작위 진입."""
|
||
rng = random.Random(random_seed) if random_seed is not None else None
|
||
rsi_period = int(params.get("rsi_period", 3))
|
||
min_bars = max(rsi_period + 5, 6)
|
||
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)
|
||
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)
|
||
cooldown_min = float(params.get("cooldown_min", 10))
|
||
max_daily = int(params.get("max_daily", 5))
|
||
|
||
ctx_by_code: Dict[str, Dict[str, Any]] = {}
|
||
all_times_set = set()
|
||
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:
|
||
all_times_set.add(c["candle_time"])
|
||
|
||
all_times = sorted(all_times_set)
|
||
portfolio: Dict[str, Dict[str, Any]] = {}
|
||
all_trades: List[Dict] = []
|
||
|
||
for t in all_times:
|
||
if not _session_ok(t, params):
|
||
continue
|
||
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_price = float(pe["entry_price"])
|
||
if entry_price <= 0:
|
||
continue
|
||
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:
|
||
continue
|
||
invest = min(invest_cap, remaining, target_cost)
|
||
qty = int(invest / entry_price)
|
||
if qty < 1:
|
||
continue
|
||
cost = qty * entry_price
|
||
if cost < min_required or exposure + cost > total_budget + 1e-6:
|
||
continue
|
||
portfolio[code] = {
|
||
"entry_price": entry_price,
|
||
"entry_time": t,
|
||
"qty": qty,
|
||
"stop": pe["stop"],
|
||
"target": pe["target"],
|
||
"max_price": entry_price,
|
||
"rsi": pe.get("rsi"),
|
||
}
|
||
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 t == portfolio[code]["entry_time"]:
|
||
continue
|
||
is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day)
|
||
is_eod = is_eod_raw and force_eod_exit
|
||
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]
|
||
res = check_sell_signal_momentum_backtest_bar(pos, cur_c_info, params, is_eod=is_eod)
|
||
if not res:
|
||
continue
|
||
reason, exit_price = res
|
||
trade: Dict[str, Any] = {
|
||
"code": code,
|
||
"buy_time": pos["entry_time"],
|
||
"sell_time": t,
|
||
"buy_price": pos["entry_price"],
|
||
"sell_price": round(exit_price, 2),
|
||
"qty": pos.get("qty", 1),
|
||
"pnl": 0,
|
||
"sell_reason": reason,
|
||
"hold_min": 0,
|
||
"strategy": MOMENTUM_STRATEGY_ID,
|
||
}
|
||
all_trades.append(trade)
|
||
ctx["last_exit_dt"][day] = _t2dt(t)
|
||
del portfolio[code]
|
||
|
||
if len(portfolio) >= max_stocks:
|
||
continue
|
||
if portfolio_exposure_krw(portfolio) >= total_budget - 1e-6:
|
||
continue
|
||
|
||
candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
|
||
for code, ctx in ctx_by_code.items():
|
||
if code in portfolio or ctx.get("pending_entry"):
|
||
continue
|
||
idx = ctx["time_index"].get(t)
|
||
if idx is None:
|
||
continue
|
||
candles = ctx["candles"]
|
||
c = candles[idx]
|
||
day = t[:8]
|
||
cl = float(c["close"])
|
||
if universe_by_slot is not None and code not in universe_by_slot.get(slot_key, []):
|
||
continue
|
||
if cl <= 0 or idx < 5:
|
||
continue
|
||
if ctx["daily_cnt"].get(day, 0) >= max_daily:
|
||
continue
|
||
if not _cooldown_ok(t, day, ctx["last_exit_dt"].get(day), cooldown_min):
|
||
continue
|
||
if idx + 1 >= len(candles):
|
||
continue
|
||
next_c = candles[idx + 1]
|
||
if next_c["candle_time"][:8] != day:
|
||
continue
|
||
entry_price = float(next_c["open"])
|
||
if entry_price <= 0:
|
||
continue
|
||
|
||
if rng is None:
|
||
eval_params = dict(params)
|
||
if "skip_hts_scan_dupes" not in eval_params:
|
||
from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes
|
||
eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes()
|
||
state = {
|
||
"daily_cnt": ctx["daily_cnt"].get(day, 0),
|
||
"last_exit_dt": ctx["last_exit_dt"].get(day),
|
||
}
|
||
reject, _msg, sig = eval_momentum_buy_at_index(candles, idx, eval_params, state)
|
||
if reject or not sig:
|
||
continue
|
||
pe_data: Dict[str, Any] = {
|
||
"entry_time": next_c["candle_time"],
|
||
"entry_price": entry_price,
|
||
"stop": entry_price * (1 - sl_pct),
|
||
"target": entry_price * (1 + tp_pct),
|
||
"rsi": sig.get("rsi"),
|
||
}
|
||
else:
|
||
pe_data = {
|
||
"entry_time": next_c["candle_time"],
|
||
"entry_price": entry_price,
|
||
"stop": entry_price * (1 - sl_pct),
|
||
"target": entry_price * (1 + tp_pct),
|
||
"rsi": None,
|
||
}
|
||
candidates.append((_buy_priority_key(code, slot_key, universe_by_slot), code, pe_data))
|
||
|
||
if not candidates:
|
||
continue
|
||
if rng is not None:
|
||
_pri, pick_code, pe = rng.choice(candidates)
|
||
else:
|
||
candidates.sort(key=lambda x: x[0])
|
||
_pri, pick_code, pe = candidates[0]
|
||
ctx_by_code[pick_code]["pending_entry"] = pe
|
||
ctx_by_code[pick_code]["daily_cnt"][t[:8]] = (
|
||
ctx_by_code[pick_code]["daily_cnt"].get(t[:8], 0) + 1
|
||
)
|
||
|
||
fee_rate = float(params.get("fee_rate", 0.00015))
|
||
sell_tax = float(params.get("sell_tax", 0.0018))
|
||
attach_scalp_trade_pnl(
|
||
all_trades, fee_rate=fee_rate, sell_tax=sell_tax,
|
||
slip_pct=backtest_slip_pct(params),
|
||
)
|
||
return all_trades
|
||
|
||
|
||
def _build_engine_params(ui: Dict[str, Any], fixed: Dict[str, Any]) -> Dict[str, Any]:
|
||
merged = dict(fixed)
|
||
merged.update(ui)
|
||
return _ui_to_engine_params(merged)
|
||
|
||
|
||
def _load_universe(start: str, end: str) -> Optional[Dict[str, List[str]]]:
|
||
start_ymd = start.replace("-", "")
|
||
end_ymd = end.replace("-", "")
|
||
try:
|
||
universe, _, _, _, _ = mbc.resolve_momentum_universe(
|
||
start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
|
||
)
|
||
return universe
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _stats(trades: List[Dict], total_budget: float, period_days: int) -> Dict[str, Any]:
|
||
return mbc.summarize_momentum_trades(
|
||
trades, total_budget_krw=total_budget, period_days=period_days,
|
||
)
|
||
|
||
|
||
def _print_row(label: str, st: Dict[str, Any]) -> None:
|
||
print(
|
||
f" {label:<22} | 손익 {st['total_pnl']:>10,.0f}원 | "
|
||
f"거래 {st['total_trades']:>3} | 승률 {st['win_rate']:>5.1f}% | "
|
||
f"PF {st['pf']:>5.2f} | MDD {st.get('mdd_krw', st.get('mdd', 0)):,.0f}",
|
||
flush=True,
|
||
)
|
||
|
||
|
||
def _read_running_pid(pid_path: str) -> Optional[int]:
|
||
try:
|
||
with open(pid_path, "r", encoding="utf-8") as f:
|
||
pid = int(f.read().strip())
|
||
os.kill(pid, 0)
|
||
return pid
|
||
except (OSError, ValueError, ProcessLookupError):
|
||
return None
|
||
|
||
|
||
def _write_pid(pid_path: str) -> None:
|
||
with open(pid_path, "w", encoding="utf-8") as f:
|
||
f.write(str(os.getpid()))
|
||
|
||
|
||
def _clear_pid(pid_path: str) -> None:
|
||
try:
|
||
if _read_running_pid(pid_path) == os.getpid():
|
||
os.remove(pid_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _spawn_background(log_path: str, pid_path: str) -> int:
|
||
"""부모는 즉시 반환 — 워커는 detached 세션에서 로그 파일로 출력."""
|
||
running = _read_running_pid(pid_path)
|
||
if running:
|
||
print(f"⛔ 이미 실행 중 (pid={running})", flush=True)
|
||
print(f" tail -f {log_path}", flush=True)
|
||
return 2
|
||
|
||
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
|
||
log_f = open(log_path, "a", encoding="utf-8")
|
||
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
log_f.write(f"\n[{stamp}] 백그라운드 워커 시작\n")
|
||
log_f.flush()
|
||
|
||
child_argv = [sys.executable, "-u"] + sys.argv[1:]
|
||
if "--foreground" not in child_argv:
|
||
child_argv.append("--foreground")
|
||
|
||
env = os.environ.copy()
|
||
env[_BG_WORKER_ENV] = "1"
|
||
|
||
proc = subprocess.Popen(
|
||
child_argv,
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=log_f,
|
||
stderr=subprocess.STDOUT,
|
||
cwd=ROOT,
|
||
env=env,
|
||
start_new_session=True,
|
||
)
|
||
log_f.close()
|
||
try:
|
||
with open(pid_path, "w", encoding="utf-8") as pf:
|
||
pf.write(str(proc.pid))
|
||
except OSError:
|
||
pass
|
||
print(f"✅ 백그라운드 시작 pid={proc.pid}", flush=True)
|
||
print(f" 로그: {log_path}", flush=True)
|
||
print(f" 확인: tail -f {log_path}", flush=True)
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="모멘텀 vs 무작위 진입 벤치마크")
|
||
parser.add_argument("--start", default="2026-06-01")
|
||
parser.add_argument("--end", default="2026-06-14")
|
||
parser.add_argument("--seeds", type=int, default=100, help="무작위 시드 반복 횟수")
|
||
parser.add_argument("--json-rank1", default="", help="search_momentum JSON (1위 params)")
|
||
parser.add_argument(
|
||
"--foreground", action="store_true",
|
||
help="포그라운드 실행 (기본: 백그라운드)",
|
||
)
|
||
parser.add_argument("--log", default=DEFAULT_LOG_PATH, help="백그라운드 로그 경로")
|
||
parser.add_argument("--pid-file", default=DEFAULT_PID_PATH, help="실행 중 PID 파일")
|
||
args = parser.parse_args()
|
||
|
||
is_worker = os.environ.get(_BG_WORKER_ENV) == "1" or args.foreground
|
||
if not is_worker:
|
||
return _spawn_background(args.log, args.pid_file)
|
||
|
||
_write_pid(args.pid_file)
|
||
try:
|
||
return _run_benchmark(args)
|
||
finally:
|
||
_clear_pid(args.pid_file)
|
||
|
||
|
||
def _run_benchmark(args: argparse.Namespace) -> int:
|
||
t0 = time.time()
|
||
fixed = _mom_fixed_defaults()
|
||
rsi_period = int(fixed.get("rsi_period", 3))
|
||
|
||
env_row = load_portfolio_env_row()
|
||
|
||
fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
|
||
portfolio = sbc.resolve_scalp_portfolio_params(
|
||
env_row, None, strategy="MOMENTUM", slot_money=slot_from_env,
|
||
)
|
||
slot_money = float(portfolio["slot_money"])
|
||
max_stocks = int(portfolio["max_stocks"])
|
||
total_budget = float(portfolio["total_budget_krw"])
|
||
period_days = max(
|
||
1,
|
||
(datetime.strptime(args.end, "%Y-%m-%d") - datetime.strptime(args.start, "%Y-%m-%d")).days + 1,
|
||
)
|
||
|
||
json_path = args.json_rank1
|
||
if not json_path:
|
||
json_path = os.path.join(
|
||
HERE, "results", "search_momentum_fast_20260615_012529.json",
|
||
)
|
||
with open(json_path, "r", encoding="utf-8") as f:
|
||
search_data = json.load(f)
|
||
rank1_ui = dict((search_data.get("top") or [{}])[0].get("params") or {})
|
||
|
||
rank1_engine = _build_engine_params(rank1_ui, fixed)
|
||
rank1_engine["slot_money"] = slot_money
|
||
rank1_engine["max_stocks"] = max_stocks
|
||
rank1_engine["total_budget_krw"] = total_budget
|
||
rank1_engine["fee_rate"] = fee_rate
|
||
rank1_engine["sell_tax"] = sell_tax
|
||
|
||
db_engine = _build_engine_params({}, fixed)
|
||
db_engine["slot_money"] = slot_money
|
||
db_engine["max_stocks"] = max_stocks
|
||
db_engine["total_budget_krw"] = total_budget
|
||
db_engine["fee_rate"] = fee_rate
|
||
db_engine["sell_tax"] = sell_tax
|
||
|
||
print("=" * 72, flush=True)
|
||
print(f"모멘텀 벤치마크 {args.start} ~ {args.end} ({period_days}일)", flush=True)
|
||
print(
|
||
f"포트폴리오: 슬롯 {slot_money:,.0f} | 동시 {max_stocks} | 한도 {total_budget:,.0f} | "
|
||
f"수수료 {fee_rate*100:.4f}% + 세 {sell_tax*100:.2f}%",
|
||
flush=True,
|
||
)
|
||
print("=" * 72, flush=True)
|
||
|
||
print("⏳ 캔들 로드...", flush=True)
|
||
candles = _load_candles_for_search(args.start, args.end, rsi_period)
|
||
print(f"✅ {len(candles):,}종목", flush=True)
|
||
|
||
universe = _load_universe(args.start, args.end)
|
||
if universe:
|
||
avg = sum(len(v) for v in universe.values()) / max(1, len(universe))
|
||
print(f"✅ 유니버스: MOMENTUM history | {len(universe):,}슬롯 · 평균 {avg:.1f}종", flush=True)
|
||
else:
|
||
print("⚠️ 유니버스 이력 없음 — 전종목", flush=True)
|
||
|
||
print("\n[1] 탐색 1위 로직 (fast grid rank1)", flush=True)
|
||
print(f" params: vol×{rank1_ui.get('mom_vol_mult')} RSI {rank1_ui.get('mom_rsi_min')}~{rank1_ui.get('mom_rsi_max')} "
|
||
f"EMA {'ON' if rank1_ui.get('use_ema_filter') else 'OFF'} "
|
||
f"{rank1_ui.get('ema_fast_period')}/{rank1_ui.get('ema_slow_period')}", flush=True)
|
||
t1 = run_portfolio(candles, rank1_engine, universe, random_seed=None)
|
||
st1 = _stats(t1, total_budget, period_days)
|
||
_print_row("탐색1위 로직", st1)
|
||
|
||
print("\n[2] 현재 DB 실매 설정", flush=True)
|
||
t2 = run_portfolio(candles, db_engine, universe, random_seed=None)
|
||
st2 = _stats(t2, total_budget, period_days)
|
||
_print_row("DB 실매", st2)
|
||
|
||
print(f"\n[3] 무작위 진입 × {args.seeds}회 (동일 유니버스·청산·포트폴리오)", flush=True)
|
||
pnls: List[float] = []
|
||
trades_n: List[int] = []
|
||
win_rates: List[float] = []
|
||
pfs: List[float] = []
|
||
for seed in range(1, args.seeds + 1):
|
||
tr = run_portfolio(candles, rank1_engine, universe, random_seed=seed)
|
||
st = _stats(tr, total_budget, period_days)
|
||
pnls.append(float(st["total_pnl"]))
|
||
trades_n.append(int(st["total_trades"]))
|
||
win_rates.append(float(st["win_rate"]))
|
||
pfs.append(float(st["pf"]))
|
||
if seed % 25 == 0:
|
||
print(f" ... seed {seed}/{args.seeds}", flush=True)
|
||
|
||
import statistics
|
||
avg_pnl = statistics.mean(pnls)
|
||
med_pnl = statistics.median(pnls)
|
||
avg_pf = statistics.mean(pfs)
|
||
avg_wr = statistics.mean(win_rates)
|
||
avg_tr = statistics.mean(trades_n)
|
||
beat = sum(1 for p in pnls if p > st1["total_pnl"])
|
||
beat_db = sum(1 for p in pnls if p > st2["total_pnl"])
|
||
|
||
print(f"\n{'=' * 72}", flush=True)
|
||
print("📊 요약", flush=True)
|
||
_print_row("탐색1위 로직", st1)
|
||
_print_row("DB 실매", st2)
|
||
print(
|
||
f" {'무작위(평균)':<22} | 손익 {avg_pnl:>10,.0f}원 | "
|
||
f"거래 {avg_tr:>5.0f} | 승률 {avg_wr:>5.1f}% | PF {avg_pf:>5.2f}",
|
||
flush=True,
|
||
)
|
||
print(
|
||
f" {'무작위(중앙값)':<22} | 손익 {med_pnl:>10,.0f}원 | "
|
||
f"min {min(pnls):,.0f} max {max(pnls):,.0f}",
|
||
flush=True,
|
||
)
|
||
print(
|
||
f"\n 무작위 {args.seeds}회 중 탐색1위보다 나은 비율: {beat}/{args.seeds} ({100*beat/args.seeds:.0f}%)",
|
||
flush=True,
|
||
)
|
||
print(
|
||
f" 무작위 {args.seeds}회 중 DB실매보다 나은 비율: {beat_db}/{args.seeds} ({100*beat_db/args.seeds:.0f}%)",
|
||
flush=True,
|
||
)
|
||
if st1["total_pnl"] <= avg_pnl:
|
||
print("\n ⚠️ 탐색1위 로직 ≤ 무작위 평균 → TRIGGER 엣지 없음 (운/노이즈 수준)", flush=True)
|
||
else:
|
||
print(f"\n ✅ 탐색1위가 무작위 평균 대비 {st1['total_pnl']-avg_pnl:+,.0f}원", flush=True)
|
||
print(f"\n⏱ 총 {time.time()-t0:.0f}초", flush=True)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|