#!/usr/bin/env python3 import subprocess import sqlite3 import argparse import os import sys import csv from datetime import datetime import pandas as pd # ---------- defaults ---------- # Скрипт для бектестів із підтримкою --open-interval-hours як float та гнучкого кешу BACKTEST_SCRIPT = "short_top_gainers_backtest_v1.py" UNIVERSE_FILE = "universe.txt" CACHE_DB = "combined_cache_1440_2h.db" # Fixed supporting params (from prior strong config) FIXED_ARGS = { "min_rsi": 60, "require_at_least_n_high": 2, "max_atr_ratio": 0.05, "base_tp_multiplier": 0.0, "max_extra_tp": 0.0, } # Grid defaults DEFAULT_RISK_PCTS = [0.01, 0.02, 0.03, 0.05] DEFAULT_HOLD_HOURS = [24, 36, 48] DEFAULT_COOLDOWN_DAYS = [3, 5] DEFAULT_MIN_OVERBOUGHT_INDEX = [70, 80, 90] DEFAULT_OPEN_INTERVAL = [24] # дефолтні інтервали OPEN_HOUR = 1 TOP_N = 4 # ---------- helpers ---------- def parse_range_list(arg): """Парсить рядок із одиночних значень та діапазонів у список чисел (int або float). Підтримує крок 1 для цілих і 0.1 для дробових. Приклади: 1-5 -> [1,2,3,4,5] 0.1-0.5 -> [0.1,0.2,0.3,0.4,0.5] 1,3,5-7 -> [1,3,5,6,7] """ items = [] for part in arg.split(','): part = part.strip() if '-' in part: start_str, end_str = part.split('-', 1) try: start = float(start_str) end = float(end_str) except ValueError: raise argparse.ArgumentTypeError(f"Invalid range '{part}'") # визначаємо крок: 1 для цілих, 0.1 для дробових step = 1 if ('.' not in start_str and '.' not in end_str) else 0.1 # точність: не потрібна для цілих prec = 0 if step == 1 else max( len(start_str.split('.')[-1]), len(end_str.split('.')[-1]), ) cur = start while cur <= end + 1e-9: if step == 1: items.append(int(cur)) else: items.append(round(cur, prec)) cur += step else: try: if '.' in part: items.append(float(part)) else: items.append(int(part)) except ValueError: raise argparse.ArgumentTypeError(f"Invalid number '{part}'") return sorted(set(items)) def compute_metrics_from_db(db_path): if not os.path.exists(db_path): return None conn = sqlite3.connect(db_path) try: df = pd.read_sql_query("SELECT * FROM trades", conn, parse_dates=["open_time_utc"]) finally: conn.close() if df.empty: return None df = df.sort_values(["open_time_utc", "symbol"]) df["short_return_decimal"] = df["short_return_pct"] / 100.0 wins = df[df["short_return_decimal"] > 0] losses = df[df["short_return_decimal"] <= 0] win_rate = len(wins) / len(df) if len(df) else 0 equity = (1 + df["short_return_decimal"]).cumprod() peak = equity.cummax() drawdown = (equity - peak) / peak max_dd = drawdown.min() total_return = equity.iloc[-1] - 1 if not equity.empty else 0 gross_win = wins["short_return_decimal"].sum() gross_loss = abs(losses["short_return_decimal"].sum()) profit_factor = gross_win / gross_loss if gross_loss != 0 else float("inf") return { "total_return_pct": total_return * 100, "win_rate": win_rate, "max_drawdown_pct": max_dd * 100, "profit_factor": profit_factor, "trades": len(df), } def is_pareto_efficient(results): pareto = [] for i, a in enumerate(results): dominated = False for j, b in enumerate(results): if i == j: continue if (b["total_return_pct"] >= a["total_return_pct"] and b["max_drawdown_pct"] >= a["max_drawdown_pct"] and (b["total_return_pct"] > a["total_return_pct"] or b["max_drawdown_pct"] > a["max_drawdown_pct"])): dominated = True break if not dominated: pareto.append(a) return pareto # ---------- main ---------- def main(): parser = argparse.ArgumentParser(description="Multi-dimensional grid search with flexible cache and entry intervals") parser.add_argument("--backtest-script", default=BACKTEST_SCRIPT, help="скрипт для запуску бектесту") parser.add_argument("--cache-db", default=CACHE_DB, help="шлях до SQLite кеш-бази з індикаторами") parser.add_argument("--hour", "-o", type=int, default=OPEN_HOUR, help="вхідна година Kyiv") parser.add_argument("--top-n", "-n", type=int, default=TOP_N) parser.add_argument("--risk-pcts", nargs="+", type=float, default=DEFAULT_RISK_PCTS) parser.add_argument("--hold-hours", nargs="+", type=int, default=DEFAULT_HOLD_HOURS) parser.add_argument("--cooldown-days", nargs="+", type=int, default=DEFAULT_COOLDOWN_DAYS) parser.add_argument("--min-overbought-indexes", nargs="+", type=float, default=DEFAULT_MIN_OVERBOUGHT_INDEX) parser.add_argument( "--open-interval-hours", type=parse_range_list, default=DEFAULT_OPEN_INTERVAL, help="інтервали у годинах між входами; підтримує діапазони (e.g. 1-12 або 0.1-1)" ) parser.add_argument("--quiet-backtest", action="store_true", help="приглушити вивід бектесту") parser.add_argument("--no-ascii", action="store_true", help="не просити ASCII equity") parser.add_argument("--output-csv", "-oout", default="multi_param_grid.csv", help="файл для збереження результатів") args = parser.parse_args() all_results = [] total_combinations = ( len(args.risk_pcts) * len(args.hold_hours) * len(args.cooldown_days) * len(args.min_overbought_indexes) * len(args.open_interval_hours) ) print(f"[{datetime.now().isoformat()}] Running grid: {total_combinations} combinations") for risk in args.risk_pcts: for hold in args.hold_hours: for cooldown in args.cooldown_days: for mbi in args.min_overbought_indexes: for interval in args.open_interval_hours: out_db = f"temp_r{risk}_h{hold}_c{cooldown}_ob{int(mbi)}_i{interval}.db" cmd = [ "python3", args.backtest_script, "-c", args.cache_db, "-b", out_db, "-o", str(args.hour), "-n", str(args.top_n), "--open-interval-hours", str(interval), "--hold-hours", str(hold), "--cooldown-days", str(cooldown), "--min-overbought-index", str(mbi), "--min-rsi", str(FIXED_ARGS["min_rsi"]), "--require-at-least-n-high", str(FIXED_ARGS["require_at_least_n_high"]), "--max-atr-ratio", str(FIXED_ARGS["max_atr_ratio"]), "--risk-pct", str(risk), "--base-tp-multiplier", str(FIXED_ARGS["base_tp_multiplier"]), "--max-extra-tp", str(FIXED_ARGS["max_extra_tp"]), "-u", UNIVERSE_FILE, ] if not args.no_ascii: cmd.append("--ascii-equity") if args.quiet_backtest: cmd.append("--quiet") if os.path.exists(out_db): os.remove(out_db) print(f"[{datetime.now().isoformat()}] risk={risk} hold={hold} cooldown={cooldown} ob={mbi} interval={interval} ...") try: subprocess.run(cmd, check=True, stdout=(subprocess.DEVNULL if args.quiet_backtest else None)) except subprocess.CalledProcessError as e: print(f"[WARN] failed combo r={risk} h={hold} c={cooldown} ob={mbi} i={interval}: {e}", file=sys.stderr) continue metrics = compute_metrics_from_db(out_db) if metrics: record = { "risk_pct": risk, "hold_hours": hold, "cooldown_days": cooldown, "min_overbought_index": mbi, "open_interval_hours": interval, "total_return_pct": metrics["total_return_pct"], "win_rate": metrics["win_rate"], "max_drawdown_pct": metrics["max_drawdown_pct"], "profit_factor": metrics["profit_factor"], "trades": metrics["trades"], } all_results.append(record) print(f" -> return {record['total_return_pct']:.2f}% | dd {record['max_drawdown_pct']:.2f}% | pf {record['profit_factor']:.3f}") if os.path.exists(out_db): os.remove(out_db) if not all_results: print("No successful runs.") return # save full grid keys = [ "risk_pct", "hold_hours", "cooldown_days", "min_overbought_index", "open_interval_hours", "total_return_pct", "win_rate", "max_drawdown_pct", "profit_factor", "trades" ] with open(args.output_csv, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=keys) writer.writeheader() for r in all_results: writer.writerow(r) print(f"Saved full grid to {args.output_csv}") # Pareto front pareto = is_pareto_efficient(all_results) pareto_sorted = sorted(pareto, key=lambda x: (-x["total_return_pct"], -x["max_drawdown_pct"])) print("\n=== Pareto-efficient front ===") for r in pareto_sorted: print( f"risk={r['risk_pct']} hold={r['hold_hours']} cooldown={r['cooldown_days']} ob={r['min_overbought_index']} " f"interval={r['open_interval_hours']} return={r['total_return_pct']:.2f}% " f"dd={r['max_drawdown_pct']:.2f}% pf={r['profit_factor']:.3f} win_rate={r['win_rate']:.2%}" ) pareto_csv = args.output_csv.replace(".csv", "_pareto.csv") with open(pareto_csv, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=keys) writer.writeheader() for r in pareto_sorted: writer.writerow(r) print(f"Saved Pareto front to {pareto_csv}") if __name__ == "__main__": main()