mirror of
https://github.com/xavierchuan/FX-ML-Trading-Engine.git
synced 2026-07-27 18:17:44 +00:00
491 lines
19 KiB
Python
491 lines
19 KiB
Python
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}")
|