Files
FX-ML-Trading-Engine/Q Research/scripts/grid_search_rsi_trailing.py
T
2025-11-14 23:16:51 +00:00

491 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import itertools
import os
import sys
from copy import deepcopy
from pathlib import Path
from queue import Empty, Queue
from typing import Dict, Iterable, List, Optional
import pandas as pd
import yaml
from loguru import logger
# 允许直接 import 项目内模块
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.backtest.strategy_engine import StrategyEngine, parse_strategy_specs, _coerce_fx_rates # noqa: E402
from data.csv_feed import CSVFeed # noqa: E402
from metrics.perf import trade_stats # noqa: E402
BASE_CONFIG_PATH = Path("config/optimized_eurusd_v2_with_rsi.yaml")
DEFAULT_SYMBOL = "EURUSD"
DEFAULT_CSV = Path("data/raw/EURUSD_H1.csv")
# 重点围绕 ATR / RSI / cooldown 进行调参
PARAM_GRID = {
"fast": [20],
"slow": [120],
"atr_sl": [1.0, 1.3, 1.6],
"atr_tp": [None, 3.0, 4.5],
"rsi_long_thresh": [60],
"rsi_short_thresh": [40],
"cooldown": [12, 24, 36],
"trailing_enable_atr_mult": [0.5],
"trailing_atr_mult": [0.5],
"htf_factor": [4],
"size_tier_mode": ["base"],
"boll_window": [32, 48, 64],
"boll_enter_z": [1.0, 1.3, 1.6],
"boll_exit_z": [0.2, 0.4],
"boll_allow_short": [False],
}
SIZE_TIER_PRESETS = {
"base": {
"base_size_mult": 1.0,
"size_tiers": [{"size_mult": 1.0}],
},
"balanced": {
"base_size_mult": 1.0,
"size_tiers": [
{"name": "strong", "min_atr_pct": 0.55, "min_trend_bars": 6, "size_mult": 1.3},
{"name": "base", "size_mult": 1.0},
],
},
"aggressive": {
"base_size_mult": 1.0,
"size_tiers": [
{"name": "strong", "min_atr_pct": 0.6, "min_trend_bars": 8, "size_mult": 1.6},
{"name": "mid", "min_atr_pct": 0.45, "min_trend_strength": 0.00008, "size_mult": 1.2},
],
},
}
def _apply_size_tier_mode(cfg: Dict[str, object], mode: Optional[str]) -> None:
if not mode:
return
preset = SIZE_TIER_PRESETS.get(mode)
if not preset:
logger.warning(f"[GRID] 未知 size_tier 模式: {mode}")
return
strategies = cfg.get("strategies")
if not isinstance(strategies, list):
return
for strat in strategies:
if isinstance(strat, dict) and strat.get("name") == "regime_sma":
params = strat.setdefault("params", {})
tiers = preset.get("size_tiers")
if tiers is not None:
params["size_tiers"] = deepcopy(tiers)
if "base_size_mult" in preset:
params["base_size_mult"] = preset["base_size_mult"]
def _apply_bollinger_params(cfg: Dict[str, object], overrides: Dict[str, object]) -> None:
strategies = cfg.get("strategies")
if not isinstance(strategies, list):
return
for strat in strategies:
if isinstance(strat, dict) and strat.get("name") == "bollinger_mean_revert":
params = strat.setdefault("params", {})
if overrides.get("window") is not None:
params["window"] = int(overrides["window"])
if overrides.get("enter_z") is not None:
params["enter_z"] = float(overrides["enter_z"])
if overrides.get("exit_z") is not None:
params["exit_z"] = float(overrides["exit_z"])
if overrides.get("allow_short") is not None:
params["allow_short"] = bool(overrides["allow_short"])
def _to_float(value: Optional[object]) -> Optional[float]:
if value is None:
return None
if isinstance(value, str) and value.lower() in {"none", "null", ""}:
return None
return float(value)
def load_base_config(path: Path = BASE_CONFIG_PATH) -> Dict[str, object]:
with path.open("r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def evaluate_config(base_cfg: Dict[str, object], overrides: Dict[str, object], symbol: str) -> Optional[Dict[str, object]]:
cfg = deepcopy(base_cfg)
local_overrides = dict(overrides)
size_mode = local_overrides.pop("size_tier_mode", None)
boll_window = local_overrides.pop("boll_window", None)
boll_enter = local_overrides.pop("boll_enter_z", None)
boll_exit = local_overrides.pop("boll_exit_z", None)
boll_allow_short = local_overrides.pop("boll_allow_short", None)
cfg.update(local_overrides)
if size_mode:
_apply_size_tier_mode(cfg, size_mode)
if any(v is not None for v in [boll_window, boll_enter, boll_exit, boll_allow_short]):
_apply_bollinger_params(
cfg,
{
"window": boll_window,
"enter_z": boll_enter,
"exit_z": boll_exit,
"allow_short": boll_allow_short,
},
)
symbol = cfg.get("symbol", DEFAULT_SYMBOL)
csv_path = Path(cfg.get("csv", DEFAULT_CSV))
if not csv_path.exists():
logger.error(f"CSV 路径不存在: {csv_path}")
return None
initial_cash = float(cfg.get("cash", 100_000))
qty = float(cfg.get("qty", 10_000))
account_ccy = cfg.get("account_ccy", "USD")
fast = int(cfg.get("fast", 20))
slow = int(cfg.get("slow", 150))
spread = float(cfg.get("spread", 1.0))
slip = float(cfg.get("slip", 0.2))
comm = float(cfg.get("comm", 2.0))
stop_loss_pips = _to_float(cfg.get("sl"))
take_profit_pips = _to_float(cfg.get("tp"))
atr_sl = _to_float(cfg.get("atr_sl"))
atr_tp = _to_float(cfg.get("atr_tp"))
atr_window = int(cfg.get("atr_window", 14))
rsi_period = int(cfg.get("rsi_period", 14))
rsi_long = _to_float(cfg.get("rsi_long_thresh"))
rsi_short = _to_float(cfg.get("rsi_short_thresh"))
enable_trailing = bool(cfg.get("enable_trailing", False))
trailing_enable = float(cfg.get("trailing_enable_atr_mult", 1.0))
trailing_mult = float(cfg.get("trailing_atr_mult", 0.5))
slope_lookback = int(cfg.get("slope_lookback", 0))
cooldown = int(cfg.get("cooldown", 0))
allow_short = bool(cfg.get("allow_short", True))
long_only_above_slow = bool(cfg.get("long_only_above_slow", False))
short_only_below_slow = bool(cfg.get("short_only_below_slow", False))
risk_per_trade_pct = _to_float(cfg.get("risk_per_trade_pct"))
max_drawdown_pct = _to_float(cfg.get("max_drawdown_pct"))
max_position_units = _to_float(cfg.get("max_position_units"))
cfg_fx_rates = _coerce_fx_rates(cfg.get("fx_rates"))
fx_rates = cfg_fx_rates if cfg_fx_rates else None
strategy_specs = parse_strategy_specs(cfg.get("strategies"))
engine = StrategyEngine(
symbol=symbol,
fast_win=fast,
slow_win=slow,
spread_pips=spread,
commission_per_million=comm,
slippage_pips=slip,
stop_loss_pips=stop_loss_pips,
take_profit_pips=take_profit_pips,
atr_sl=atr_sl,
atr_tp=atr_tp,
atr_window=atr_window,
rsi_period=rsi_period,
rsi_long_thresh=rsi_long,
rsi_short_thresh=rsi_short,
enable_trailing=enable_trailing,
trailing_enable_atr_mult=trailing_enable,
trailing_atr_mult=trailing_mult,
long_only_above_slow=long_only_above_slow,
slope_lookback=slope_lookback,
cooldown=cooldown,
qty=qty,
account_ccy=account_ccy,
fx_rates=fx_rates,
strategy_specs=strategy_specs,
allow_short=allow_short,
short_only_below_slow=short_only_below_slow,
risk_per_trade_pct=risk_per_trade_pct,
max_drawdown_pct=max_drawdown_pct,
max_position_units=max_position_units,
)
engine.set_initial_cash(initial_cash)
q: Queue = Queue()
feed = CSVFeed(q, path=str(csv_path), symbol=symbol)
feed.start()
try:
while True:
try:
event = q.get(timeout=0.05)
except Empty:
if hasattr(feed, "pump"):
feed.pump(n=100)
if getattr(feed, "finished", False):
break
continue
if event.get("type") != "bar":
continue
engine.handle_bar(event)
finally:
engine.finalize()
summary = engine.summary(fast, slow)
stats = trade_stats(engine.trade_log) if engine.trade_log else {}
final_equity = summary.get("final_equity", engine.cash)
ret_pct = (final_equity / initial_cash - 1.0) if initial_cash else None
return {
"params": overrides,
"summary": summary,
"stats": stats,
"final_equity": final_equity,
"return_pct": ret_pct,
"symbol": symbol,
}
def param_product(grid: Dict[str, Iterable[object]]) -> Iterable[Dict[str, object]]:
keys = list(grid.keys())
for combo in itertools.product(*(grid[k] for k in keys)):
params = dict(zip(keys, combo))
fast_v = params.get("fast")
slow_v = params.get("slow")
if fast_v is not None and slow_v is not None:
if float(fast_v) >= float(slow_v):
continue
long_v = params.get("rsi_long_thresh")
short_v = params.get("rsi_short_thresh")
if long_v is not None and short_v is not None and long_v <= short_v:
continue
yield params
def run_grid(base_cfg: Dict[str, object], save_suffix: Optional[str] = None, param_grid: Optional[Dict[str, List[object]]] = None) -> Path:
# 降低日志噪音
logger.remove()
logger.add(sys.stderr, level="WARNING")
results: List[Dict[str, object]] = []
grid_def = deepcopy(param_grid or PARAM_GRID)
combos = list(param_product(grid_def))
total = len(combos)
print(f"将测试 {total} 种参数组合")
suffix = f"_{save_suffix}" if save_suffix else ""
out_dir = Path("data/grid")
out_dir.mkdir(parents=True, exist_ok=True)
out_filename = f"grid_rsi_trailing_diagnostics{suffix}.csv"
def fmt_pct(value: Optional[float]) -> str:
return f"{value:.2%}" if value is not None else "NA"
def fmt_float(value: Optional[float], digits: int = 3) -> str:
return f"{value:.{digits}f}" if value is not None else "NA"
for idx, params in enumerate(combos, 1):
print(f"\n[{idx}/{total}] 评估参数: {params}")
res = evaluate_config(base_cfg, params, base_cfg.get("symbol", DEFAULT_SYMBOL))
if not res:
print(" -> 运行失败")
continue
summary = res["summary"]
stats = res["stats"]
sharpe = summary.get("sharpe")
win_rate = stats.get("win_rate")
exp = stats.get("expectancy")
trades = summary.get("trades")
ret_pct = res["return_pct"]
dd = summary.get("max_drawdown")
print(
" -> Sharpe={} 回撤={} 胜率={} 期望={} 交易数={}".format(
fmt_float(sharpe),
fmt_pct(dd),
fmt_pct(win_rate),
fmt_float(exp, digits=2),
trades if trades is not None else "NA",
)
)
res_record = {
"sharpe": sharpe,
"ann_return": summary.get("ann_return"),
"ann_vol": summary.get("ann_vol"),
"max_drawdown": summary.get("max_drawdown"),
"trades": trades,
"return_pct": ret_pct,
"win_rate": win_rate,
"rr": stats.get("rr"),
"expectancy": exp,
"median_hold": stats.get("median_hold"),
"symbol": res.get("symbol", base_cfg.get("symbol", DEFAULT_SYMBOL)),
**params,
"source_file": out_filename,
}
results.append(res_record)
if not results:
print("\n未得到任何有效结果。")
return
df = pd.DataFrame(results)
df["sharpe"] = pd.to_numeric(df["sharpe"], errors="coerce")
df["expectancy"] = pd.to_numeric(df["expectancy"], errors="coerce")
df = df.sort_values(by="sharpe", ascending=False)
out_path = out_dir / out_filename
df.to_csv(out_path, index=False)
print(f"\n已保存全部结果 -> {out_path}")
top_n = df.head(5)
print("\nTop 5 组合:")
for _, row in top_n.iterrows():
win_rate_val = row.get("win_rate")
win_rate_val = None if pd.isna(win_rate_val) else win_rate_val
cooldown_val = row.get("cooldown")
cooldown_disp = int(cooldown_val) if cooldown_val is not None and not pd.isna(cooldown_val) else "NA"
atr_sl_disp = fmt_float(row.get("atr_sl"), digits=2)
atr_tp_val = row.get("atr_tp")
atr_tp_disp = "None" if atr_tp_val is None or (isinstance(atr_tp_val, float) and pd.isna(atr_tp_val)) else fmt_float(atr_tp_val, digits=2)
rsi_short = row.get("rsi_short_thresh")
rsi_long = row.get("rsi_long_thresh")
fast_val = row.get("fast")
slow_val = row.get("slow")
print(
" Sharpe={} 回撤={} 胜率={} fast/slow={}/{} cooldown={} atr_sl={} atr_tp={} RSI=({}/{})".format(
fmt_float(row.get("sharpe")),
fmt_pct(row.get("max_drawdown")),
fmt_pct(win_rate_val),
fmt_float(fast_val, digits=0) if fast_val is not None and not pd.isna(fast_val) else "NA",
fmt_float(slow_val, digits=0) if slow_val is not None and not pd.isna(slow_val) else "NA",
cooldown_disp,
atr_sl_disp,
atr_tp_disp,
fmt_float(rsi_short, digits=1) if rsi_short is not None and not pd.isna(rsi_short) else "NA",
fmt_float(rsi_long, digits=1) if rsi_long is not None and not pd.isna(rsi_long) else "NA",
)
)
usdjpy_mask = (
df["symbol"].astype(str).str.upper().eq("USDJPY")
& df["sharpe"].gt(1.5)
& df["expectancy"].gt(2.0)
)
top_usdjpy = df.loc[usdjpy_mask].head(3)
if not top_usdjpy.empty:
print("\nUSDJPY Sharpe>1.5 & Expectancy>$2 (Top 3):")
for _, row in top_usdjpy.iterrows():
print(
" Sharpe={:.3f} Expectancy=${:.2f} Trades={} atr_sl={} atr_tp={} cooldown={}".format(
row["sharpe"],
row["expectancy"],
row.get("trades", "NA"),
fmt_float(row.get("atr_sl"), digits=2),
"None" if pd.isna(row.get("atr_tp")) or row.get("atr_tp") is None else fmt_float(row.get("atr_tp"), digits=2),
int(row.get("cooldown")) if row.get("cooldown") is not None and not pd.isna(row.get("cooldown")) else "NA",
)
)
best_out = out_dir / "grid_usdjpy_top3.csv"
top_usdjpy.to_csv(best_out, index=False)
print(f"\n已保存 USDJPY 筛选结果 -> {best_out}")
else:
print("\nUSDJPY 暂无满足 Sharpe>1.5 & Expectancy>$2 的组合。")
return out_path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Grid search for ATR/RSI/trailing parameters.")
parser.add_argument(
"--config",
type=str,
default=str(BASE_CONFIG_PATH),
help="YAML 配置路径(默认使用 optimized_eurusd_v2_with_rsi.yaml",
)
parser.add_argument("--symbol", type=str, default=None, help="覆盖配置中的 symbol(可选)")
parser.add_argument("--csv", type=str, default=None, help="覆盖配置中的 csv 路径(可选)")
parser.add_argument("--suffix", type=str, default=None, help="输出文件名后缀(默认取 symbol")
parser.add_argument("--atr-sl", type=str, default=None, help="自定义 atr_sl 列表,例如 '1.0,1.3,1.6'")
parser.add_argument("--atr-tp", type=str, default=None, help="自定义 atr_tp 列表,例如 'None,3.0,4.0'")
parser.add_argument("--cooldown-list", type=str, default=None, help="自定义 cooldown 列表,例如 '12,24,36'")
parser.add_argument("--htf-factor-list", type=str, default=None, help="自定义 htf_factor 列表,例如 '2,4,6'")
parser.add_argument("--size-tier-mode-list", type=str, default=None, help="size_tier 模式列表,例如 'base,aggressive'")
parser.add_argument("--boll-window-list", type=str, default=None, help="Bollinger 窗口列表,例如 '32,48,64'")
parser.add_argument("--boll-enter-list", type=str, default=None, help="Bollinger 入场 Z 值列表,例如 '1.0,1.3'")
parser.add_argument("--boll-exit-list", type=str, default=None, help="Bollinger 退出 Z 值列表,例如 '0.2,0.4'")
parser.add_argument("--boll-allow-short", type=str, default=None, help="Bollinger 是否允许做空,示例 'true,false'")
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
cfg_path = Path(args.config)
base_cfg = load_base_config(cfg_path)
if args.symbol:
base_cfg["symbol"] = args.symbol
if args.csv:
base_cfg["csv"] = args.csv
suffix = args.suffix or base_cfg.get("symbol")
grid_override = deepcopy(PARAM_GRID)
def _parse_float_list(raw: Optional[str]) -> Optional[List[Optional[float]]]:
if raw is None:
return None
values: List[Optional[float]] = []
for token in raw.split(","):
token = token.strip()
if not token:
continue
if token.lower() in {"none", "null"}:
values.append(None)
else:
values.append(float(token))
return values or None
atr_sl_list = _parse_float_list(args.atr_sl)
atr_tp_list = _parse_float_list(args.atr_tp)
cooldown_list = None
if args.cooldown_list:
cooldown_list = [int(item.strip()) for item in args.cooldown_list.split(",") if item.strip()]
htf_factor_list = None
if args.htf_factor_list:
htf_factor_list = [int(item.strip()) for item in args.htf_factor_list.split(",") if item.strip()]
size_mode_list = None
if args.size_tier_mode_list:
size_mode_list = [item.strip() for item in args.size_tier_mode_list.split(",") if item.strip()]
boll_window_list = None
if args.boll_window_list:
boll_window_list = [int(item.strip()) for item in args.boll_window_list.split(",") if item.strip()]
boll_enter_list = _parse_float_list(args.boll_enter_list)
boll_exit_list = _parse_float_list(args.boll_exit_list)
boll_allow_short_list = None
if args.boll_allow_short:
mapping = {"true": True, "false": False, "1": True, "0": False}
boll_allow_short_list = [
mapping.get(item.strip().lower(), item.strip().lower() in {"true", "1"}) for item in args.boll_allow_short.split(",") if item.strip()
]
if atr_sl_list:
grid_override["atr_sl"] = atr_sl_list
if atr_tp_list:
grid_override["atr_tp"] = atr_tp_list
if cooldown_list:
grid_override["cooldown"] = cooldown_list
if htf_factor_list:
grid_override["htf_factor"] = htf_factor_list
if size_mode_list:
grid_override["size_tier_mode"] = size_mode_list
if boll_window_list:
grid_override["boll_window"] = boll_window_list
if boll_enter_list:
grid_override["boll_enter_z"] = boll_enter_list
if boll_exit_list:
grid_override["boll_exit_z"] = boll_exit_list
if boll_allow_short_list:
grid_override["boll_allow_short"] = boll_allow_short_list
out_csv = run_grid(base_cfg, suffix, grid_override)
print(f"结果已写入: {out_csv}")