From 523483f26dd5548a954b4f04ad3841f62d8bb431 Mon Sep 17 00:00:00 2001 From: wiki-agent Date: Sun, 23 Aug 2026 04:11:24 +0000 Subject: [PATCH] Add freqtrade quant strategies and deploy docs --- .gitignore | 1 + quant/README.md | 207 +++++++ quant/freqtrade-deploy-guide.md | 325 ++++++++++ .../config.live.json | 197 ++++++ .../config.local.json | 141 +++++ .../multi_asset_channel_breakout_v5.live.py | 187 ++++++ .../multi_asset_channel_breakout_v5.py | 187 ++++++ .../backtest_runtime_defaults.json | 17 + .../full_year_backtest.json | 58 ++ .../phase6_offset18_nolock/params.json | 24 + .../phase6_offset18_nolock/strategy.py | 568 ++++++++++++++++++ .../full_year_backtest.json | 58 ++ .../phase7_seed251_c915/params.json | 24 + .../best_params/phase7a_top3_full_year.json | 176 ++++++ .../best_params/phase7b_top3_full_year.json | 230 +++++++ .../best_params/phase8a_full_year.json | 157 +++++ .../full_year_backtest.json | 57 ++ .../seed185_candidate_243/params.json | 24 + .../seed185_candidate_243/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 57 ++ .../seed190_candidate_011/params.json | 24 + .../seed190_candidate_011/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 57 ++ .../seed191_candidate_106/params.json | 24 + .../seed191_candidate_106/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 57 ++ .../seed191_candidate_148/params.json | 24 + .../seed191_candidate_148/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 56 ++ .../seed235_candidate_395/params.json | 24 + .../seed235_candidate_395/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 56 ++ .../seed236_candidate_270/params.json | 24 + .../seed236_candidate_270/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 56 ++ .../seed238_candidate_202/params.json | 24 + .../seed238_candidate_202/strategy.py | 541 +++++++++++++++++ .../full_year_backtest.json | 57 ++ .../seed250_candidate_213/params.json | 24 + .../seed250_candidate_213/strategy.py | 568 ++++++++++++++++++ .../full_year_backtest.json | 57 ++ .../seed251_candidate_915/params.json | 24 + .../seed251_candidate_915/strategy.py | 568 ++++++++++++++++++ .../full_year_backtest.json | 34 ++ .../seed260_candidate_066/params.json | 24 + .../full_year_backtest.json | 34 ++ .../seed260_candidate_077/params.json | 24 + .../full_year_backtest.json | 34 ++ .../seed260_candidate_169/params.json | 24 + .../full_year_backtest.json | 34 ++ .../seed270_candidate_002/params.json | 24 + .../full_year_backtest.json | 34 ++ .../seed270_candidate_006/params.json | 24 + .../full_year_backtest.json | 34 ++ .../seed270_candidate_008/params.json | 24 + .../full_year_backtest.json | 34 ++ .../seed280_candidate_099/params.json | 24 + .../full_year_backtest.json | 38 ++ .../seed290_candidate_385/params.json | 24 + .../full_year_backtest.json | 38 ++ .../seed290_candidate_405/params.json | 24 + .../full_year_backtest.json | 38 ++ .../seed292_candidate_291/params.json | 24 + .../full_year_backtest.json | 39 ++ .../seed292_candidate_421/params.json | 24 + .../channel_breakout.strategy_defaults.json | 11 + quant/quantdinger/channel_breakout.yaml | 12 + quant/quantdinger/config.json | 141 +++++ quant/quantdinger/live_params.json | 24 + .../quantdinger/quantdinger_strategy.live.py | 521 ++++++++++++++++ quant/quantdinger/quantdinger_strategy.py | 521 ++++++++++++++++ quant/stoploss_leverage_adjustment.md | 193 ++++++ ...指标平台建设 · 会议要点-2026年08月20日.md | 62 ++ 73 files changed, 10346 insertions(+) create mode 100644 quant/README.md create mode 100644 quant/freqtrade-deploy-guide.md create mode 100644 quant/multi_asset_channel_breakout_v5/config.live.json create mode 100644 quant/multi_asset_channel_breakout_v5/config.local.json create mode 100644 quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.live.py create mode 100644 quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.py create mode 100644 quant/quantdinger/backtest_runtime_defaults.json create mode 100644 quant/quantdinger/best_params/phase6_offset18_nolock/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/phase6_offset18_nolock/params.json create mode 100644 quant/quantdinger/best_params/phase6_offset18_nolock/strategy.py create mode 100644 quant/quantdinger/best_params/phase7_seed251_c915/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/phase7_seed251_c915/params.json create mode 100644 quant/quantdinger/best_params/phase7a_top3_full_year.json create mode 100644 quant/quantdinger/best_params/phase7b_top3_full_year.json create mode 100644 quant/quantdinger/best_params/phase8a_full_year.json create mode 100644 quant/quantdinger/best_params/seed185_candidate_243/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed185_candidate_243/params.json create mode 100644 quant/quantdinger/best_params/seed185_candidate_243/strategy.py create mode 100644 quant/quantdinger/best_params/seed190_candidate_011/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed190_candidate_011/params.json create mode 100644 quant/quantdinger/best_params/seed190_candidate_011/strategy.py create mode 100644 quant/quantdinger/best_params/seed191_candidate_106/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed191_candidate_106/params.json create mode 100644 quant/quantdinger/best_params/seed191_candidate_106/strategy.py create mode 100644 quant/quantdinger/best_params/seed191_candidate_148/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed191_candidate_148/params.json create mode 100644 quant/quantdinger/best_params/seed191_candidate_148/strategy.py create mode 100644 quant/quantdinger/best_params/seed235_candidate_395/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed235_candidate_395/params.json create mode 100644 quant/quantdinger/best_params/seed235_candidate_395/strategy.py create mode 100644 quant/quantdinger/best_params/seed236_candidate_270/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed236_candidate_270/params.json create mode 100644 quant/quantdinger/best_params/seed236_candidate_270/strategy.py create mode 100644 quant/quantdinger/best_params/seed238_candidate_202/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed238_candidate_202/params.json create mode 100644 quant/quantdinger/best_params/seed238_candidate_202/strategy.py create mode 100644 quant/quantdinger/best_params/seed250_candidate_213/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed250_candidate_213/params.json create mode 100644 quant/quantdinger/best_params/seed250_candidate_213/strategy.py create mode 100644 quant/quantdinger/best_params/seed251_candidate_915/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed251_candidate_915/params.json create mode 100644 quant/quantdinger/best_params/seed251_candidate_915/strategy.py create mode 100644 quant/quantdinger/best_params/seed260_candidate_066/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed260_candidate_066/params.json create mode 100644 quant/quantdinger/best_params/seed260_candidate_077/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed260_candidate_077/params.json create mode 100644 quant/quantdinger/best_params/seed260_candidate_169/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed260_candidate_169/params.json create mode 100644 quant/quantdinger/best_params/seed270_candidate_002/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed270_candidate_002/params.json create mode 100644 quant/quantdinger/best_params/seed270_candidate_006/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed270_candidate_006/params.json create mode 100644 quant/quantdinger/best_params/seed270_candidate_008/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed270_candidate_008/params.json create mode 100644 quant/quantdinger/best_params/seed280_candidate_099/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed280_candidate_099/params.json create mode 100644 quant/quantdinger/best_params/seed290_candidate_385/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed290_candidate_385/params.json create mode 100644 quant/quantdinger/best_params/seed290_candidate_405/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed290_candidate_405/params.json create mode 100644 quant/quantdinger/best_params/seed292_candidate_291/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed292_candidate_291/params.json create mode 100644 quant/quantdinger/best_params/seed292_candidate_421/full_year_backtest.json create mode 100644 quant/quantdinger/best_params/seed292_candidate_421/params.json create mode 100644 quant/quantdinger/channel_breakout.strategy_defaults.json create mode 100644 quant/quantdinger/channel_breakout.yaml create mode 100644 quant/quantdinger/config.json create mode 100644 quant/quantdinger/live_params.json create mode 100644 quant/quantdinger/quantdinger_strategy.live.py create mode 100644 quant/quantdinger/quantdinger_strategy.py create mode 100644 quant/stoploss_leverage_adjustment.md create mode 100644 工作记录/标准数仓治理与指标平台建设 · 会议要点-2026年08月20日.md diff --git a/.gitignore b/.gitignore index e43b0f9..eda77f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +._* diff --git a/quant/README.md b/quant/README.md new file mode 100644 index 0000000..a6e4268 --- /dev/null +++ b/quant/README.md @@ -0,0 +1,207 @@ +# Freqtrade 策略说明与对比 + +## 文件清单 + +``` +quant/ +├── README.md ← 本文档(策略说明与对比) +├── freqtrade-deploy-guide.md ← 部署操作指南 +├── stoploss_leverage_adjustment.md ← 止损杠杆调整文档 +│ +├── multi_asset_channel_breakout_v5/ ← 当前线上策略 +│ ├── multi_asset_channel_breakout_v5.py (本地副本) +│ ├── multi_asset_channel_breakout_v5.live.py (线上备份) +│ ├── config.live.json (线上 config.json) +│ └── config.local.json (本地 config.json) +│ +└── quantdinger/ ← 旧策略 + ├── quantdinger_strategy.py (本地副本) + ├── quantdinger_strategy.live.py (线上备份) + ├── config.json (QuantDinger 专用配置) + ├── live_params.json (实盘运行参数) + ├── channel_breakout.strategy_defaults.json (策略默认参数) + ├── channel_breakout.yaml (策略配置 YAML) + ├── backtest_runtime_defaults.json (回测运行默认参数) + └── best_params/ ← 最优参数组合(搜索结果) + ├── phase6_offset18_nolock/ + ├── phase7_seed251_c915/ + ├── phase7a_top3_full_year.json + ├── phase7b_top3_full_year.json + ├── phase8a_full_year.json + ├── seed185_candidate_243/ + ├── seed190_candidate_011/ + ├── ...(共 20 组最优参数,每组含 params.json + full_year_backtest.json) + └── seed292_candidate_421/ +``` + +--- + +## 一、当前线上策略:MultiAssetChannelBreakoutV5 + +### 基本信息 + +- **策略文件**:`multi_asset_channel_breakout_v5.py` +- **时间周期**:15m +- **杠杆**:固定 10x(OKX 隔离保证金) +- **交易对**:BTC/USDT、ETH/USDT +- **方向**:仅做多 +- **代码量**:186 行,7 个可优化参数 + +### 核心逻辑 + +**入场**:收盘价突破 ATR 通道上轨 + 在长均线上方 + ADX > 28(趋势+动量共振)。 + +**出场**:收盘价跌破 141 周期最低点 + ATR×0.19(通道底动态退出线)。 + +**止损**(分段动态,通过 `trade.leverage` 联动): + +| 盈利阶段 | 止损行为 | 价格含义 | +|---|---|---| +| < 5% | 硬止损兜底 | 价格 -8% | +| 5%~15% | 保本+5% | 入场价 × 1.05 | +| 15%~30% | 允许 10% 回撤 | 当前价 -10% | +| > 30% | 允许 10% 回撤 | 当前价 -10% | + +**入场二次确认**(`confirm_trade_entry`):防假突破(下单价不能低于信号 K 线均线/上轨)+ 防追高(滑点 > 1% 放弃)。 + +**ROI 止盈**:关闭(`minimal_roi = {"0": 100}`),完全依赖信号出场 + 动态止损。 + +### 参数配置 + +参数在策略代码内定义,无外部 JSON。关键参数: + +| 参数 | 默认值 | 说明 | +|---|---|---| +| `up_line_span` | 340 | 上轨窗口(K 线数) | +| `buy_stop_profit_span` | 141 | 下轨窗口 | +| `ma_span_long_days` | 2 | 长均线天数(×96 = 15m 周期数) | +| `adx_threshold` | 28 | 动量阈值 | +| `up_line_offset` | -0.18 | 上轨 ATR 偏移 | +| `buy_stop_profit_offset` | 0.19 | 下轨 ATR 偏移 | +| `stoploss` | -0.80 | 硬止损(保证金维度) | + +### config.json 关键配置 + +```json +{ + "max_open_trades": 2, + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "trading_mode": "futures", + "margin_mode": "isolated", + "stoploss": -0.80, + "strategy": "MultiAssetChannelBreakoutV5", + "pair_whitelist": ["BTC/USDT:USDT", "ETH/USDT:USDT"] +} +``` + +--- + +## 二、旧策略:QuantDinger + +### 基本信息 + +- **策略文件**:`quantdinger_strategy.py` +- **时间周期**:5m +- **杠杆**:固定 10x(OKX 隔离保证金) +- **方向**:仅做多 +- **代码量**:521 行,20+ 个可优化参数 + +### 核心逻辑 + +**Regime 检测引擎**:把市场分为四种状态,带滞回 hysteresis 防频繁切换: +- 压缩(compression):波动率低于阈值 +- 扩张(expansion):波动率高于阈值 +- 趋势(trend):均线斜率 + 价格位移超阈值 +- 震荡(range):以上都不满足 + +**N 值**:自定义 ATR,用 6 个 chunk(每 10 根 K 线)的 high-low spread 的 EMA 平滑。 + +**入场**(三种模式,regime 驱动): +- 趋势:突破上轨 + N 值偏移 +- 压缩/扩张:波动率扩张 + 突破短周期上轨 +- 震荡:回踩短均线后反弹 + +**退出**(五层): +1. 保护止损(入场价 - N 值 × 杠杆系数) +2. 跟踪止损(通道底 + N 值 × offset) +3. 保本止损(盈利超 buffer 后启动) +4. 利润锁定(从峰值盈利回撤 33% 退出) +5. 时间止损(持仓 1440 根 5m K 线 ≈ 5 天) + +**仓位管理**:`custom_stake_amount` 根据 N 值动态计算仓位,波动大缩小、波动小放大。 + +**杠杆处理**:使用 `stoploss_from_absolute()` 辅助函数,接受 leverage 参数,天然杠杆感知。 + +### 参数配置 + +参数在策略代码内定义,20+ 个 `RealParameter`/`IntParameter`。关键参数: + +| 参数 | 默认值 | 说明 | +|---|---|---| +| `strategy_lever_rate` | 1.0 | 杠杆系数(影响止损+仓位) | +| `profit_line` | 0.08 | 利润锁定触发线 | +| `lock_profit_rate` | 0.33 | 利润回撤锁定比例 | +| `up_line_span` | 1008 | 上轨窗口 | +| `exit_max_loss_pct` | 0.03 | 保护止损比例 | +| `exit_max_hold_bars` | 1440 | 最大持仓 K 线数 | +| `stoploss` | -0.10 | 硬止损(极宽松兜底) | + +--- + +## 三、两套策略对比 + +### 架构对比 + +| 维度 | QuantDinger(旧) | V5(当前) | +|---|---|---| +| 时间周期 | 5m | 15m | +| 代码量 | 521 行 | 186 行 | +| 可优化参数 | 20+ | 7 | +| 市场状态识别 | 四态 regime + hysteresis | 无 | +| 入场逻辑 | 三种 regime 驱动模式 | 单一突破 | +| 退出体系 | 五层(保护+跟踪+保本+锁利+时间) | 两层(信号+分段止损) | +| 仓位管理 | 动态(N 值驱动) | 固定 | +| 杠杆处理 | `stoploss_from_absolute`(杠杆感知) | `trade.leverage` 动态联动 | +| 预热 K 线 | 1400(+预取 12 天) | 600 | +| 持仓时长上限 | 5 天(1440 根 5m) | 无上限 | + +### 设计哲学对比 + +| | QuantDinger | V5 | +|---|---|---| +| 核心理念 | 自适应系统:感知状态→选策略→动态管理 | 简单突破:突破就进,跌破就出 | +| 复杂度 | 高(regime 引擎 + 五层退出 + 动态仓位) | 低(一个入场 + 一个出场 + 分段止损) | +| 优点 | 适应不同市场状态,精细化管理 | 简单鲁棒,不易出错,给足趋势空间 | +| 缺点 | 参数多易过拟合,5m 噪音大,逻辑复杂难维护 | 不区分市场状态,震荡市可能频繁假突破 | +| 适合行情 | 趋势 + 震荡都能处理 | 单边趋势行情 | + +### 止损策略对比 + +| | QuantDinger | V5 | +|---|---|---| +| 止损维度 | 价格维度(`stoploss_from_absolute`) | 保证金维度(`trade.leverage` 联动) | +| 硬止损 | -0.10(宽松兜底,实际靠 custom_stoploss) | -0.80(价格 -8%,接近强平线) | +| 跟踪止损 | 通道底 + N 值 × offset | 信号出场(跌破通道底) | +| 保本 | 盈利 > 0.1% 后,止损 = 入场价 - N×0.3 | 盈利 > 5% 后,止损 = 入场价 × 1.05 | +| 利润锁定 | 峰值回撤 33% 退出 | 无(让利润跑) | +| 时间止损 | 5 天 | 无 | + +### 切换原因 + +从 QuantDinger 切换到 V5 的主要原因: +1. **简化**:QuantDinger 5m + 20+ 参数过于复杂,维护成本高 +2. **止损 bug**:V5 原来的止损参数被杠杆压缩 10 倍,已修复并改为 `trade.leverage` 联动 +3. **趋势空间**:V5 的 15m + 宽止损更适合通道突破策略,给趋势更多运行空间 +4. **鲁棒性**:策略越简单越不容易在意外行情中出问题 + +--- + +## 四、部署信息 + +- **服务器**:Vultr(通过 ecs 跳板访问,SSH config: `vultr`) +- **路径**:`/opt/freqtrade/user_data/strategies/` +- **配置**:`/opt/freqtrade/user_data/config.json` +- **服务**:`systemctl restart freqtrade` +- **部署详情**:见 `freqtrade-deploy-guide.md` +- **止损调整记录**:见 `stoploss_leverage_adjustment.md` diff --git a/quant/freqtrade-deploy-guide.md b/quant/freqtrade-deploy-guide.md new file mode 100644 index 0000000..4902bfc --- /dev/null +++ b/quant/freqtrade-deploy-guide.md @@ -0,0 +1,325 @@ +# Freqtrade 实盘部署全流程 + +从零部署 Freqtrade 交易机器人到 Vultr 服务器,运行 OKX USDT-M 永续合约实盘交易。 + +--- + +## 1. 服务器信息 + +| 项目 | 详情 | +|------|------| +| 服务商 | Vultr | +| IP | `104.156.239.5` | +| 系统 | Ubuntu 22.04 | +| 配置 | 2 vCPU / 2GB RAM / 61GB SSD | +| 部署路径 | `/opt/freqtrade/` | +| SSH 用户 | `root` | +| SSH 密钥 | 本机 `~/.ssh/id_rsa` | +| 跳板 | 通过阿里云 ecs 跳板(ProxyJump) | + +### SSH 配置 + +在本机 `~/.ssh/config` 中添加: + +``` +Host vultr + HostName 104.156.239.5 + User root + IdentityFile ~/.ssh/id_rsa + IdentitiesOnly yes + ProxyJump ecs +``` + +连接命令:`ssh vultr` + +### 端口 + +| 端口 | 用途 | +|------|------| +| 22 | SSH | +| 8080 | Freqtrade API Server / freqUI | + +--- + +## 2. 前置依赖安装 + +```bash +# 更新系统 +ssh vultr "apt update && apt install -y software-properties-common" + +# 安装 Python 3.12(Ubuntu 22.04 默认 3.10 不满足 freqtrade >=3.11) +ssh vultr "add-apt-repository -y ppa:deadsnakes/ppa && apt install -y python3.12 python3.12-venv python3.12-dev" + +# 安装构建依赖 +ssh vultr "apt install -y build-essential git curl" + +# 配置防火墙 +ssh vultr "ufw allow 22/tcp && ufw allow 8080/tcp" +``` + +--- + +## 3. 部署 Freqtrade + +### 3.1 克隆仓库 + +```bash +# 方式一:从 Gitea 克隆(推荐) +ssh vultr "git clone https://git.looq.fun/liam/freqtrade-deploy.git /opt/freqtrade" + +# 方式二:从本机 scp 推送 +scp -o ProxyJump=ecs -r /root/agents/freqtrade vultr:/opt/freqtrade +``` + +### 3.2 创建虚拟环境并安装 + +```bash +ssh vultr "cd /opt/freqtrade && python3.12 -m venv .venv && .venv/bin/pip install -e ." +``` + +### 3.3 安装 freqUI + +```bash +ssh vultr "cd /opt/freqtrade && .venv/bin/freqtrade install-ui" +``` + +--- + +## 4. 配置 + +### 4.1 部署策略文件 + +将策略文件推送到服务器: + +```bash +scp -o ProxyJump=ecs user_data/strategies/multi_asset_channel_breakout_v5.py \ + vultr:/opt/freqtrade/user_data/strategies/ +``` + +### 4.2 配置 config.json + +在 `/opt/freqtrade/user_data/config.json` 中配置以下关键项: + +```json +{ + "max_open_trades": 2, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "dry_run": false, + "timeframe": "15m", + "stoploss": -0.80, + "trading_mode": "futures", + "margin_mode": "isolated", + "exchange": { + "name": "okx", + "key": "e756108d-f14c-4777-8351-cd989f389dd2", + "secret": "BC4579AEE9816ED4A38218910B6EC278", + "password": "30l9L666.", + "ccxt_config": {"hostname": "www.okx.cab"}, + "ccxt_async_config": {"hostname": "www.okx.cab"}, + "pair_whitelist": ["BTC/USDT:USDT", "ETH/USDT:USDT"] + }, + "pairlists": [{"method": "StaticPairList"}], + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080, + "username": "liam", + "password": "30l9L666" + }, + "webhook": { + "enabled": true, + "url": "https://open.feishu.cn/open-apis/bot/v2/hook/2b1bc9a6-b470-447f-b783-1b253dcf522c" + }, + "strategy": "MultiAssetChannelBreakoutV5", + "strategy_path": "user_data/strategies/", + "initial_state": "running" +} +``` + +**OKX API Key 说明:** +- key: `e756108d-f14c-4777-8351-cd989f389dd2` +- secret: `BC4579AEE9816ED4A38218910B6EC278` +- password: `30l9L666.` +- hostname: `www.okx.cab`(OKX 国内加速域名,非 `www.okx.com`) +- 该 key 无 IP 限制(`ip: "0"`),任何服务器都能用 + +**freqUI 登录:** +- 地址:`http://104.156.239.5:8080/` +- 用户名:`liam` +- 密码:`30l9L666` + +--- + +## 5. Systemd 服务 + +### 5.1 创建服务文件 + +```bash +ssh vultr "cat > /etc/systemd/system/freqtrade.service << 'EOF' +[Unit] +Description=Freqtrade Trading Bot +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/freqtrade +ExecStart=/opt/freqtrade/.venv/bin/freqtrade trade -c user_data/config.json +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF" +``` + +### 5.2 启用并启动 + +```bash +ssh vultr "systemctl daemon-reload && systemctl enable freqtrade && systemctl start freqtrade" +``` + +--- + +## 6. 验证部署 + +```bash +# 1. 检查服务状态 +ssh vultr "systemctl status freqtrade --no-pager" + +# 2. 检查 API 响应 +curl -s http://104.156.239.5:8080/api/v1/ping +# 应返回: {"status":"pong"} + +# 3. 检查策略加载 +ssh vultr "journalctl -u freqtrade --no-pager -n 30 | grep -E 'strategy|timeframe|RUNNING'" + +# 4. 检查交易对白名单 +ssh vultr "journalctl -u freqtrade --no-pager | grep 'Whitelist with'" + +# 5. 访问 Web UI +open http://104.156.239.5:8080/ +``` + +--- + +## 7. 运维命令 + +```bash +# 查看服务状态 +ssh vultr "systemctl status freqtrade --no-pager" + +# 查看实时日志 +ssh vultr "journalctl -u freqtrade.service -f" + +# 查看最近 50 行日志 +ssh vultr "journalctl -u freqtrade.service --no-pager -n 50" + +# 重启服务(部署新策略或修改配置后) +ssh vultr "systemctl restart freqtrade" + +# 停止服务 +ssh vultr "systemctl stop freqtrade" + +# 查看交易记录(SQLite) +ssh vultr "cd /opt/freqtrade && python3 -c \" +import sqlite3 +conn = sqlite3.connect('tradesv3.sqlite') +conn.row_factory = sqlite3.Row +rows = conn.execute('SELECT id,pair,open_rate,close_rate,close_profit_abs,exit_reason FROM trades ORDER BY id').fetchall() +for r in rows: + print(dict(r)) +conn.close() +\"" +``` + +--- + +## 8. 当前线上配置摘要 + +| 配置项 | 值 | +|--------|------| +| 策略 | MultiAssetChannelBreakoutV5 | +| 交易所 | OKX | +| 交易模式 | USDT-M 永续合约 / 逐仓 | +| 币对 | BTC/USDT, ETH/USDT | +| 时间周期 | 15m | +| 杠杆 | 10x(策略内固定) | +| 最大持仓 | 2 | +| 硬止损 | -80%(config 与策略 `stoploss=-0.80` 一致;10x 杠杆下=价格 -8%) | +| 资金量 | unlimited(全仓比例 99%) | +| API Server | 0.0.0.0:8080 | +| 飞书 Webhook | 已启用 | +| UI | FreqUI | + +--- + +## 9. 策略说明(MultiAssetChannelBreakoutV5) + +### 9.1 核心逻辑 + +| 模块 | 说明 | +|------|------| +| 指标 | ATR(10) 波动率 + SMA 长均线 + 通道上下轨 + ADX(14) 动量 | +| 入场 | 收盘价突破动态上轨 **且** 在长均线上方 **且** ADX > 28 | +| 入场确认 | 实时下单防追高(偏离信号 K 线收盘价 >1% 取消)+ 防假突破(价格不低于信号 K 线均线和上轨) | +| 出场 | 收盘价跌破动态退出线(下轨 + ATR × 0.19) | +| 止损 | 分段:盈利 >5% 保本、>15% 允许 10% 回撤、>30% 允许 10% 回撤,其余靠 -8% 硬止损 | +| 杠杆 | 固定 10x | + +### 9.2 关键参数 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `up_line_span` | 340 | 上轨窗口(15m K 线数,约 3.5 天) | +| `up_line_offset` | -0.18 | 上轨 ATR 偏移(负值=上轨低于最高点,降低入场门槛) | +| `buy_stop_profit_span` | 141 | 下轨窗口 | +| `buy_stop_profit_offset` | 0.19 | 下轨 ATR 偏移 | +| `ma_span_long_days` | 2 | 长均线天数(2×96=192 根 15m K 线) | +| `adx_threshold` | 28 | 动量过滤阈值 | +| `startup_candle_count` | 600 | 预热 K 线数 | + +### 9.3 策略文件位置 + +| 位置 | 文件 | +|------|------| +| 本地 | `user_data/strategies/multi_asset_channel_breakout_v5.py` | +| 服务器 | `/opt/freqtrade/user_data/strategies/multi_asset_channel_breakout_v5.py` | +| 备份 | `skills/core/` | + +--- + +## 10. 更换策略流程 + +```bash +# 1. 修改策略文件 +# 本地编辑 user_data/strategies/xxx.py + +# 2. 推送到服务器 +scp -o ProxyJump=ecs user_data/strategies/xxx.py vultr:/opt/freqtrade/user_data/strategies/ + +# 3. 修改 config.json 中的 strategy 名称和 timeframe +ssh vultr "sed -i 's/\"strategy\": \"旧策略\"/\"strategy\": \"新策略\"/' /opt/freqtrade/user_data/config.json" + +# 4. 重启服务 +ssh vultr "systemctl restart freqtrade" + +# 5. 验证 +ssh vultr "journalctl -u freqtrade --no-pager -n 20 | grep -E 'strategy|RUNNING|Error'" +``` + +--- + +## 11. 注意事项 + +- **SSH 跳板**:必须通过 `ProxyJump ecs` 走阿里云跳板,直连 Vultr 会超时 +- **Python 版本**:Ubuntu 22.04 默认 3.10,必须装 3.12 +- **OKX hostname**:用 `www.okx.cab` 而非 `www.okx.com`,国内访问 okx.com 受限 +- **时区**:服务器保持 UTC,所有交易所时间戳和 freqtrade 内部均用 UTC +- **Warmup**:策略启动时通过 REST 拉取历史 K 线(startup_candle_count=600),约 3 次 API 调用,完成后图表立即可用 +- **仅做多**:`can_short = False` +- **ROI 禁用**:`minimal_roi = {"0": 100}`,全部由信号出场和 custom_stoploss 处理 +- **服务自恢复**:`Restart=on-failure` + `RestartSec=10`,进程崩溃 10 秒后自动重启 diff --git a/quant/multi_asset_channel_breakout_v5/config.live.json b/quant/multi_asset_channel_breakout_v5/config.live.json new file mode 100644 index 0000000..b703686 --- /dev/null +++ b/quant/multi_asset_channel_breakout_v5/config.live.json @@ -0,0 +1,197 @@ +{ + "max_open_trades": 2, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": false, + "timeframe": "15m", + "stoploss": -0.80, + "cancel_open_orders_on_exit": false, + "trading_mode": "futures", + "margin_mode": "isolated", + "collateral": "USDT", + "unfilledtimeout": { + "entry": 10, + "exit": 10, + "exit_timeout_count": 0, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "ask", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0 + }, + "exchange": { + "name": "okx", + "key": "e756108d-f14c-4777-8351-cd989f389dd2", + "secret": "BC4579AEE9816ED4A38218910B6EC278", + "password": "30l9L666.", + "ccxt_config": { + "hostname": "www.okx.cab" + }, + "ccxt_async_config": { + "hostname": "www.okx.cab" + }, + "pair_whitelist": [ + "BTC/USDT:USDT", + "ETH/USDT:USDT" + ], + "pair_blacklist": [] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080, + "verbosity": "error", + "enable_openapi": true, + "jwt_secret_key": "somethingRandomSomethingRandom123", + "CORS_origins": [ + "*" + ], + "username": "liam", + "password": "30l9L666" + }, + "bot_name": "freqtrade", + "force_entry_enable": true, + "webhook": { + "enabled": true, + "url": "https://open.feishu.cn/open-apis/bot/v2/hook/2b1bc9a6-b470-447f-b783-1b253dcf522c", + "format": "json", + "timeout": 10, + "entry": { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "content": "🟢 交易信号: {pair}", + "tag": "plain_text" + }, + "template": "blue" + }, + "elements": [ + { + "tag": "div", + "text": { + "content": "方向: {direction}\n入场价: {open_rate}\n金额: {stake_amount:.2f} {stake_currency}\n杠杆: {leverage}x\n信号: {enter_tag}", + "tag": "lark_md" + } + }, + { + "tag": "hr" + }, + { + "tag": "div", + "text": { + "content": "时间: {open_date}", + "tag": "lark_md" + } + } + ] + } + }, + "entry_fill": { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "content": "✅ 入场成交: {pair}", + "tag": "plain_text" + }, + "template": "green" + }, + "elements": [ + { + "tag": "div", + "text": { + "content": "成交价: {open_rate}\n数量: {amount}\n金额: {stake_amount:.2f} {stake_currency}\n杠杆: {leverage}x", + "tag": "lark_md" + } + } + ] + } + }, + "exit": { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "content": "🔴 退出信号: {pair}", + "tag": "plain_text" + }, + "template": "red" + }, + "elements": [ + { + "tag": "div", + "text": { + "content": "当前价: {current_rate}\n盈亏率: {profit_ratio:.2%}\n原因: {exit_reason}", + "tag": "lark_md" + } + } + ] + } + }, + "exit_fill": { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "content": "✅ 退出成交: {pair}", + "tag": "plain_text" + }, + "template": "green" + }, + "elements": [ + { + "tag": "div", + "text": { + "content": "退出价: {close_rate}\n盈亏率: {profit_ratio:.2%}\n盈亏额: {profit_amount:.4f}\n原因: {exit_reason}", + "tag": "lark_md" + } + } + ] + } + }, + "entry_cancel": { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "content": "⚠ 入场取消: {pair}", + "tag": "plain_text" + }, + "template": "yellow" + }, + "elements": [ + { + "tag": "div", + "text": { + "content": "原因: {reason}\n数量: {amount}\n价格: {open_rate}", + "tag": "lark_md" + } + } + ] + } + } + }, + "initial_state": "running", + "strategy": "MultiAssetChannelBreakoutV5", + "strategy_path": "user_data/strategies/" +} \ No newline at end of file diff --git a/quant/multi_asset_channel_breakout_v5/config.local.json b/quant/multi_asset_channel_breakout_v5/config.local.json new file mode 100644 index 0000000..aff4f6e --- /dev/null +++ b/quant/multi_asset_channel_breakout_v5/config.local.json @@ -0,0 +1,141 @@ +{ + "max_open_trades": 5, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": false, + "timeframe": "5m", + "stoploss": -0.10, + "cancel_open_orders_on_exit": false, + "trading_mode": "futures", + "margin_mode": "isolated", + "collateral": "USDT", + "unfilledtimeout": { + "entry": 10, + "exit": 10, + "exit_timeout_count": 0, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "ask", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0 + }, + "exchange": { + "name": "okx", + "key": "e756108d-f14c-4777-8351-cd989f389dd2", + "secret": "BC4579AEE9816ED4A38218910B6EC278", + "password": "30l9L666.", + "ccxt_config": { + "hostname": "www.okx.cab" + }, + "ccxt_async_config": { + "hostname": "www.okx.cab" + }, + "pair_whitelist": [ + "BTC/USDT:USDT", + "ETH/USDT:USDT", + "TRX/USDT:USDT" + ], + "pair_blacklist": [] + }, + "pairlists": [ + {"method": "StaticPairList"} + ], + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080, + "verbosity": "error", + "enable_openapi": true, + "jwt_secret_key": "somethingRandomSomethingRandom123", + "CORS_origins": ["*"], + "username": "liam", + "password": "30l9L666" + }, + "bot_name": "freqtrade", + "force_entry_enable": true, + "webhook": { + "enabled": true, + "url": "https://open.feishu.cn/open-apis/bot/v2/hook/2b1bc9a6-b470-447f-b783-1b253dcf522c", + "format": "json", + "timeout": 10, + "entry": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "🟢 交易信号: {pair}", "tag": "plain_text"}, + "template": "blue" + }, + "elements": [ + {"tag": "div", "text": {"content": "方向: {direction}\n入场价: {open_rate}\n金额: {stake_amount:.2f} {stake_currency}\n杠杆: {leverage}x\n信号: {enter_tag}", "tag": "lark_md"}}, + {"tag": "hr"}, + {"tag": "div", "text": {"content": "时间: {open_date}", "tag": "lark_md"}} + ] + } + }, + "entry_fill": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "✅ 入场成交: {pair}", "tag": "plain_text"}, + "template": "green" + }, + "elements": [ + {"tag": "div", "text": {"content": "成交价: {open_rate}\n数量: {amount}\n金额: {stake_amount:.2f} {stake_currency}\n杠杆: {leverage}x", "tag": "lark_md"}} + ] + } + }, + "exit": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "🔴 退出信号: {pair}", "tag": "plain_text"}, + "template": "red" + }, + "elements": [ + {"tag": "div", "text": {"content": "当前价: {current_rate}\n盈亏率: {profit_ratio:.2%}\n原因: {exit_reason}", "tag": "lark_md"}} + ] + } + }, + "exit_fill": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "✅ 退出成交: {pair}", "tag": "plain_text"}, + "template": "green" + }, + "elements": [ + {"tag": "div", "text": {"content": "退出价: {close_rate}\n盈亏率: {profit_ratio:.2%}\n盈亏额: {profit_amount:.4f}\n原因: {exit_reason}", "tag": "lark_md"}} + ] + } + }, + "entry_cancel": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "⚠ 入场取消: {pair}", "tag": "plain_text"}, + "template": "yellow" + }, + "elements": [ + {"tag": "div", "text": {"content": "原因: {reason}\n数量: {amount}\n价格: {open_rate}", "tag": "lark_md"}} + ] + } + } + }, + "initial_state": "running", + "strategy": "QuantDingerStrategy", + "strategy_path": "user_data/strategies/" +} diff --git a/quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.live.py b/quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.live.py new file mode 100644 index 0000000..0d0efe8 --- /dev/null +++ b/quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.live.py @@ -0,0 +1,187 @@ +import pandas as pd +import talib.abstract as ta +from functools import reduce +from datetime import datetime +from freqtrade.strategy import (IStrategy, IntParameter, DecimalParameter) +from freqtrade.persistence import Trade + + +class MultiAssetChannelBreakoutV5(IStrategy): + """ + 多币种通道突破策略 V5。 + + 策略核心思想: + 1) 用 ATR 构建波动率自适应通道,避免固定阈值在不同波动阶段失效; + 2) 用长周期均线 + ADX 过滤震荡,仅在趋势和动量共振时入场; + 3) 用动态下轨出场 + 自定义分段止损,兼顾跟踪利润与回撤控制; + 4) 用入场二次确认减少信号触发后追高与滑点风险。 + + 说明: + - 当前默认参数来自 `MultiAssetChannelBreakoutV3.json` 的优化结果; + - 策略为单向做多(can_short=False),期货模式下通过 leverage() 固定 10x。 + """ + + INTERFACE_VERSION = 3 + + # --- 1) 基础交易设置 --- + # 关闭 ROI 快速止盈,完全依赖信号出场 + custom_stoploss 风控。 + minimal_roi = {"0": 100} + # 硬止损作为最后防线(保证金维度 -80% = 价格 -8%,10x 杠杆下接近爆仓线但能扛住正常波动)。 + stoploss = -0.80 + trailing_stop = False + use_custom_stoploss = True + timeframe = "15m" + can_short = False + # 预热 K 线数量:覆盖长均线与通道计算窗口,避免初期信号失真。 + startup_candle_count = 600 + + # --- 2) 可优化参数(默认值已填入优化结果) --- + # 上轨窗口:越大越平滑,越小越敏感。 + up_line_span = IntParameter(100, 400, default=340, space="buy", optimize=True) + # 下轨窗口:用于退出通道底边计算。 + buy_stop_profit_span = IntParameter(50, 200, default=141, space="buy", optimize=True) + # 长均线天数(会换算成 15m 周期)。 + ma_span_long_days = IntParameter(1, 5, default=2, space="buy", optimize=True) + # 动量阈值:过滤弱趋势。 + adx_threshold = IntParameter(15, 40, default=28, space="buy", optimize=True) + # 上轨波动率偏移:ATR * offset。 + up_line_offset = DecimalParameter(-0.2, 0.4, default=-0.18, decimals=2, space="buy", optimize=True) + + # 下轨波动率偏移:ATR * offset,用于构建动态退出线。 + buy_stop_profit_offset = DecimalParameter(-0.2, 0.2, default=0.19, decimals=2, space="sell", optimize=True) + + def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + 计算策略使用的全部指标。 + + 指标列表: + - n_value: ATR(10),用于描述近期真实波动率; + - ma_long: 长周期均线(天数 * 96 根 15m K); + - up_line: 历史高点通道上轨 + ATR 偏移; + - bottom_line: 历史低点通道下轨; + - adx: ADX(14) 动量强度指标。 + """ + dataframe["n_value"] = ta.ATR(dataframe, timeperiod=10) + + ma_length = self.ma_span_long_days.value * 96 + dataframe["ma_long"] = ta.SMA(dataframe, timeperiod=ma_length) + + base_up_line = dataframe["high"].rolling(window=self.up_line_span.value).max().shift(1) + dataframe["up_line"] = base_up_line + (dataframe["n_value"] * self.up_line_offset.value) + + dataframe["bottom_line"] = dataframe["low"].rolling(window=self.buy_stop_profit_span.value).min().shift(1) + dataframe["adx"] = ta.ADX(dataframe, timeperiod=14) + return dataframe + + def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + 入场逻辑(做多): + - 指标有效(ATR、上轨非空); + - 收盘价在长均线上方(趋势过滤); + - ADX 高于阈值(动量过滤); + - 收盘价突破动态上轨(突破确认)。 + """ + conditions = [] + conditions.append(dataframe["n_value"].notnull()) + conditions.append(dataframe["up_line"].notnull()) + conditions.append(dataframe["close"] > dataframe["ma_long"]) + conditions.append(dataframe["adx"] > self.adx_threshold.value) + conditions.append(dataframe["close"] > dataframe["up_line"]) + + if conditions: + is_entry = reduce(lambda x, y: x & y, conditions) + dataframe.loc[is_entry, "enter_long"] = 1 + dataframe.loc[is_entry, "enter_tag"] = "trend_breakout" + + return dataframe + + def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + 出场逻辑: + - 构建动态退出线:bottom_line + ATR * buy_stop_profit_offset; + - 收盘价跌破退出线时触发平仓信号。 + """ + offset = self.buy_stop_profit_offset.value + exit_line = dataframe["bottom_line"] + (dataframe["n_value"] * offset) + + exit_condition = dataframe["close"] < exit_line + dataframe.loc[exit_condition, "exit_long"] = 1 + dataframe.loc[exit_condition, "exit_tag"] = "channel_exit" + return dataframe + + def leverage(self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: str, side: str, + **kwargs) -> float: + """ + 统一固定 10x 杠杆。 + """ + return 10.0 + + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + """ + 分段动态止损(所有阈值通过 trade.leverage 动态联动杠杆): + - 盈利 > 价格+30%: 允许 10% 价格回撤; + - 盈利 > 价格+15%: 允许 10% 价格回撤; + - 盈利 > 价格+5%: 将止损上移到保本上方约 5%(覆盖手续费与滑点); + - 其余阶段: 交由全局硬止损处理。 + """ + L = trade.leverage + + if current_profit > 0.30 * L: + return 0.10 * L + + if current_profit > 0.15 * L: + return 0.10 * L + + if current_profit > 0.05 * L: + breakeven_target = trade.open_rate * 1.05 + return (current_rate - breakeven_target) / current_rate * L + + return L + + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, current_time: datetime, entry_tag: str, + side: str, **kwargs) -> bool: + """ + 入场二次确认(防追高与防假突破): + - 取触发信号的已收盘 K 线 (iloc[-2]) 作为基准; + - 实时下单价不能低穿信号 K 线的均线和上轨(防假突破被瞬间砸回); + - 下单价较信号 K 线收盘价高出 0.5% 以上,放弃交易(防滑点追高)。 + """ + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + + # 确保数据量足够回溯 + if len(dataframe) < 3: + return False + + # --- 1. 动态寻找真正的"信号 K 线" --- + # 解决 Freqtrade 实盘中 DataFrame 最后一根 K 线可能是正在运行的新 K 线, + # 也可能是刚刚收盘的信号 K 线的"索引漂移"问题。 + signal_candle = None + + # 倒序检查最后 3 根 K 线,定位打上 enter_long=1 标签的那一根 + for i in range(-1, -4, -1): + if dataframe['enter_long'].iloc[i] == 1: + signal_candle = dataframe.iloc[i] + break + + # 如果最近几根都没信号(比如极端网络延迟造成的陈旧发单),果断放弃交易 + if signal_candle is None: + return False + + # --- 2. 核心校验:必须与信号当期的指标进行对比 --- + # 解决"突破极值瞬间跳升"导致订单被错杀的问题 + + # 防假突破被瞬间砸回:实时下单价 (rate) 不能低穿【信号发生时】的均线和上轨 + if rate <= signal_candle["ma_long"]: + return False + if rate <= signal_candle["up_line"]: + return False + + # 防滑点追高:实时下单价 (rate) 较【信号发生时】的收盘价不能偏离过高 + # 注意:此处允许 1% 的追高滑点,如果在 15m 级别觉得 1% 的滑点成本太大,可以改为 1.005 (0.5%) + if rate > signal_candle["close"] * 1.01: + return False + + return True diff --git a/quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.py b/quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.py new file mode 100644 index 0000000..0d0efe8 --- /dev/null +++ b/quant/multi_asset_channel_breakout_v5/multi_asset_channel_breakout_v5.py @@ -0,0 +1,187 @@ +import pandas as pd +import talib.abstract as ta +from functools import reduce +from datetime import datetime +from freqtrade.strategy import (IStrategy, IntParameter, DecimalParameter) +from freqtrade.persistence import Trade + + +class MultiAssetChannelBreakoutV5(IStrategy): + """ + 多币种通道突破策略 V5。 + + 策略核心思想: + 1) 用 ATR 构建波动率自适应通道,避免固定阈值在不同波动阶段失效; + 2) 用长周期均线 + ADX 过滤震荡,仅在趋势和动量共振时入场; + 3) 用动态下轨出场 + 自定义分段止损,兼顾跟踪利润与回撤控制; + 4) 用入场二次确认减少信号触发后追高与滑点风险。 + + 说明: + - 当前默认参数来自 `MultiAssetChannelBreakoutV3.json` 的优化结果; + - 策略为单向做多(can_short=False),期货模式下通过 leverage() 固定 10x。 + """ + + INTERFACE_VERSION = 3 + + # --- 1) 基础交易设置 --- + # 关闭 ROI 快速止盈,完全依赖信号出场 + custom_stoploss 风控。 + minimal_roi = {"0": 100} + # 硬止损作为最后防线(保证金维度 -80% = 价格 -8%,10x 杠杆下接近爆仓线但能扛住正常波动)。 + stoploss = -0.80 + trailing_stop = False + use_custom_stoploss = True + timeframe = "15m" + can_short = False + # 预热 K 线数量:覆盖长均线与通道计算窗口,避免初期信号失真。 + startup_candle_count = 600 + + # --- 2) 可优化参数(默认值已填入优化结果) --- + # 上轨窗口:越大越平滑,越小越敏感。 + up_line_span = IntParameter(100, 400, default=340, space="buy", optimize=True) + # 下轨窗口:用于退出通道底边计算。 + buy_stop_profit_span = IntParameter(50, 200, default=141, space="buy", optimize=True) + # 长均线天数(会换算成 15m 周期)。 + ma_span_long_days = IntParameter(1, 5, default=2, space="buy", optimize=True) + # 动量阈值:过滤弱趋势。 + adx_threshold = IntParameter(15, 40, default=28, space="buy", optimize=True) + # 上轨波动率偏移:ATR * offset。 + up_line_offset = DecimalParameter(-0.2, 0.4, default=-0.18, decimals=2, space="buy", optimize=True) + + # 下轨波动率偏移:ATR * offset,用于构建动态退出线。 + buy_stop_profit_offset = DecimalParameter(-0.2, 0.2, default=0.19, decimals=2, space="sell", optimize=True) + + def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + 计算策略使用的全部指标。 + + 指标列表: + - n_value: ATR(10),用于描述近期真实波动率; + - ma_long: 长周期均线(天数 * 96 根 15m K); + - up_line: 历史高点通道上轨 + ATR 偏移; + - bottom_line: 历史低点通道下轨; + - adx: ADX(14) 动量强度指标。 + """ + dataframe["n_value"] = ta.ATR(dataframe, timeperiod=10) + + ma_length = self.ma_span_long_days.value * 96 + dataframe["ma_long"] = ta.SMA(dataframe, timeperiod=ma_length) + + base_up_line = dataframe["high"].rolling(window=self.up_line_span.value).max().shift(1) + dataframe["up_line"] = base_up_line + (dataframe["n_value"] * self.up_line_offset.value) + + dataframe["bottom_line"] = dataframe["low"].rolling(window=self.buy_stop_profit_span.value).min().shift(1) + dataframe["adx"] = ta.ADX(dataframe, timeperiod=14) + return dataframe + + def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + 入场逻辑(做多): + - 指标有效(ATR、上轨非空); + - 收盘价在长均线上方(趋势过滤); + - ADX 高于阈值(动量过滤); + - 收盘价突破动态上轨(突破确认)。 + """ + conditions = [] + conditions.append(dataframe["n_value"].notnull()) + conditions.append(dataframe["up_line"].notnull()) + conditions.append(dataframe["close"] > dataframe["ma_long"]) + conditions.append(dataframe["adx"] > self.adx_threshold.value) + conditions.append(dataframe["close"] > dataframe["up_line"]) + + if conditions: + is_entry = reduce(lambda x, y: x & y, conditions) + dataframe.loc[is_entry, "enter_long"] = 1 + dataframe.loc[is_entry, "enter_tag"] = "trend_breakout" + + return dataframe + + def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + 出场逻辑: + - 构建动态退出线:bottom_line + ATR * buy_stop_profit_offset; + - 收盘价跌破退出线时触发平仓信号。 + """ + offset = self.buy_stop_profit_offset.value + exit_line = dataframe["bottom_line"] + (dataframe["n_value"] * offset) + + exit_condition = dataframe["close"] < exit_line + dataframe.loc[exit_condition, "exit_long"] = 1 + dataframe.loc[exit_condition, "exit_tag"] = "channel_exit" + return dataframe + + def leverage(self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: str, side: str, + **kwargs) -> float: + """ + 统一固定 10x 杠杆。 + """ + return 10.0 + + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + """ + 分段动态止损(所有阈值通过 trade.leverage 动态联动杠杆): + - 盈利 > 价格+30%: 允许 10% 价格回撤; + - 盈利 > 价格+15%: 允许 10% 价格回撤; + - 盈利 > 价格+5%: 将止损上移到保本上方约 5%(覆盖手续费与滑点); + - 其余阶段: 交由全局硬止损处理。 + """ + L = trade.leverage + + if current_profit > 0.30 * L: + return 0.10 * L + + if current_profit > 0.15 * L: + return 0.10 * L + + if current_profit > 0.05 * L: + breakeven_target = trade.open_rate * 1.05 + return (current_rate - breakeven_target) / current_rate * L + + return L + + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, current_time: datetime, entry_tag: str, + side: str, **kwargs) -> bool: + """ + 入场二次确认(防追高与防假突破): + - 取触发信号的已收盘 K 线 (iloc[-2]) 作为基准; + - 实时下单价不能低穿信号 K 线的均线和上轨(防假突破被瞬间砸回); + - 下单价较信号 K 线收盘价高出 0.5% 以上,放弃交易(防滑点追高)。 + """ + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + + # 确保数据量足够回溯 + if len(dataframe) < 3: + return False + + # --- 1. 动态寻找真正的"信号 K 线" --- + # 解决 Freqtrade 实盘中 DataFrame 最后一根 K 线可能是正在运行的新 K 线, + # 也可能是刚刚收盘的信号 K 线的"索引漂移"问题。 + signal_candle = None + + # 倒序检查最后 3 根 K 线,定位打上 enter_long=1 标签的那一根 + for i in range(-1, -4, -1): + if dataframe['enter_long'].iloc[i] == 1: + signal_candle = dataframe.iloc[i] + break + + # 如果最近几根都没信号(比如极端网络延迟造成的陈旧发单),果断放弃交易 + if signal_candle is None: + return False + + # --- 2. 核心校验:必须与信号当期的指标进行对比 --- + # 解决"突破极值瞬间跳升"导致订单被错杀的问题 + + # 防假突破被瞬间砸回:实时下单价 (rate) 不能低穿【信号发生时】的均线和上轨 + if rate <= signal_candle["ma_long"]: + return False + if rate <= signal_candle["up_line"]: + return False + + # 防滑点追高:实时下单价 (rate) 较【信号发生时】的收盘价不能偏离过高 + # 注意:此处允许 1% 的追高滑点,如果在 15m 级别觉得 1% 的滑点成本太大,可以改为 1.005 (0.5%) + if rate > signal_candle["close"] * 1.01: + return False + + return True diff --git a/quant/quantdinger/backtest_runtime_defaults.json b/quant/quantdinger/backtest_runtime_defaults.json new file mode 100644 index 0000000..7509eab --- /dev/null +++ b/quant/quantdinger/backtest_runtime_defaults.json @@ -0,0 +1,17 @@ +{ + "strategy": null, + "data": null, + "symbol": null, + "market": "Crypto", + "timeframe": "1D", + "start": null, + "end": null, + "capital": 10000, + "commission": 0.0012, + "slippage": 0.0, + "leverage": 1, + "direction": "long", + "params": {}, + "config": null, + "script": false +} diff --git a/quant/quantdinger/best_params/phase6_offset18_nolock/full_year_backtest.json b/quant/quantdinger/best_params/phase6_offset18_nolock/full_year_backtest.json new file mode 100644 index 0000000..b6d5156 --- /dev/null +++ b/quant/quantdinger/best_params/phase6_offset18_nolock/full_year_backtest.json @@ -0,0 +1,58 @@ +{ + "candidate": "phase6_offset18_nolock", + "note": "Manual test: offset=1.8 with profit_line=0 (lock_profit disabled). Discovered through Phase 6 analysis.", + "train_score": null, + "full_year_backtest": { + "totalReturn": 41.66, + "maxDrawdown": -13.45, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.32, + "sharpeRatio": 1.59, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 910.0, + "2025-02": -361.0, + "2025-03": -142.0, + "2025-04": 1269.0, + "2025-05": 1222.0, + "2025-06": 380.0, + "2025-07": 1088.0, + "2025-08": 326.0, + "2025-09": 408.0, + "2025-10": 1806.0, + "2025-11": -124.0, + "2025-12": -1374.0 + }, + "exit_reasons": { + "time_stop_pct": 8.64, + "breakeven_stop_pct": 64.20, + "trailing_stop_pct": 27.16, + "lock_profit_stop_pct": 0.0 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.0, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/phase6_offset18_nolock/params.json b/quant/quantdinger/best_params/phase6_offset18_nolock/params.json new file mode 100644 index 0000000..1a98e74 --- /dev/null +++ b/quant/quantdinger/best_params/phase6_offset18_nolock/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.0, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/phase6_offset18_nolock/strategy.py b/quant/quantdinger/best_params/phase6_offset18_nolock/strategy.py new file mode 100644 index 0000000..0705b8f --- /dev/null +++ b/quant/quantdinger/best_params/phase6_offset18_nolock/strategy.py @@ -0,0 +1,568 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_lock_profit(ctx, bar, params): + profit_line = float(params['profit_line']) + if profit_line <= 0: + return False + max_pp = ctx.get_state('max_profit_pct', 0.0) + if max_pp < profit_line: + return False + + lock_rate = float(params['lock_profit_rate']) + current_profit = ctx.unrealized_profit_pct(bar.close) + # Lock level = peak profit retained after allowing lock_rate drawdown + lock_level = max_pp * (1.0 - lock_rate) + # Only trigger while still in profit; let breakeven/protective handle losses + if current_profit > 0 and current_profit < lock_level: + ctx.close_position() + ctx.set_state('exit_reason', 'lock_profit_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → lock_profit → breakeven → time + # Lock profit sits between trailing and breakeven: once profit exceeds + # profit_line, it protects a fraction of peak gains before breakeven kicks in. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_lock_profit(ctx, bar, params): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + ctx.set_state('max_profit_pct', 0.0) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability and lock_profit exit + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + max_pp = ctx.get_state('max_profit_pct', 0.0) + if profit > max_pp: + ctx.set_state('max_profit_pct', profit) + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('max_profit_pct', 0.0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/phase7_seed251_c915/full_year_backtest.json b/quant/quantdinger/best_params/phase7_seed251_c915/full_year_backtest.json new file mode 100644 index 0000000..27a684f --- /dev/null +++ b/quant/quantdinger/best_params/phase7_seed251_c915/full_year_backtest.json @@ -0,0 +1,58 @@ +{ + "candidate": "seed251_candidate_915", + "note": "Phase 7 deep search rescued best. Walk-forward score 0.485463, full-year backtest +42.78%.", + "train_score": 0.485463, + "full_year_backtest": { + "totalReturn": 42.78, + "maxDrawdown": -13.45, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.34, + "sharpeRatio": 1.63, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.79, + "2025-02": -360.86, + "2025-03": -142.35, + "2025-04": 1269.0, + "2025-05": 1320.12, + "2025-06": 383.08, + "2025-07": 1096.37, + "2025-08": 328.32, + "2025-09": 410.73, + "2025-10": 1820.57, + "2025-11": -124.82, + "2025-12": -1384.31 + }, + "exit_reasons": { + "time_stop": 8.64, + "breakeven_stop": 64.20, + "trailing_stop": 25.93, + "lock_profit_stop": 1.23 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } +} diff --git a/quant/quantdinger/best_params/phase7_seed251_c915/params.json b/quant/quantdinger/best_params/phase7_seed251_c915/params.json new file mode 100644 index 0000000..5d51558 --- /dev/null +++ b/quant/quantdinger/best_params/phase7_seed251_c915/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/phase7a_top3_full_year.json b/quant/quantdinger/best_params/phase7a_top3_full_year.json new file mode 100644 index 0000000..b011d72 --- /dev/null +++ b/quant/quantdinger/best_params/phase7a_top3_full_year.json @@ -0,0 +1,176 @@ +[ + { + "name": "phase7a_top1", + "score": 0.505735, + "totalReturn": 35.39, + "annualReturn": 35.39, + "maxDrawdown": -13.21, + "sharpeRatio": 1.5, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.233021224122976, + "avgWin": 534.21125, + "avgLoss": -58.888000000000005, + "totalProfit": 4719.66, + "exitReasons": { + "lock_profit_stop": 8, + "breakeven_stop": 52, + "trailing_stop": 18, + "time_stop": 3 + }, + "monthlyPnL": { + "2025-01": 329.34000000000003, + "2025-02": -341.65, + "2025-03": 123.53000000000003, + "2025-04": 971.94, + "2025-05": 1062.13, + "2025-06": 405.49, + "2025-07": 1030.36, + "2025-08": 409.42999999999995, + "2025-09": 388.15999999999997, + "2025-10": 1729.12, + "2025-11": -75.50999999999999, + "2025-12": -1312.6799999999998 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + }, + { + "name": "phase7a_top2", + "score": 0.499407, + "totalReturn": 34.75, + "annualReturn": 34.75, + "maxDrawdown": -13.32, + "sharpeRatio": 1.48, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.200093898409144, + "avgWin": 533.045625, + "avgLoss": -59.63892307692308, + "totalProfit": 4652.2, + "exitReasons": { + "lock_profit_stop": 8, + "breakeven_stop": 52, + "trailing_stop": 18, + "time_stop": 3 + }, + "monthlyPnL": { + "2025-01": 329.34000000000003, + "2025-02": -341.65, + "2025-03": 123.53000000000003, + "2025-04": 970.19, + "2025-05": 1022.8100000000002, + "2025-06": 404.09, + "2025-07": 1026.7600000000002, + "2025-08": 408.0, + "2025-09": 386.8, + "2025-10": 1723.0900000000001, + "2025-11": -94.31999999999998, + "2025-12": -1306.4400000000003 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.0, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + }, + { + "name": "phase7a_top3", + "score": 0.498957, + "totalReturn": 36.37, + "annualReturn": 36.37, + "maxDrawdown": -13.21, + "sharpeRatio": 1.5, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.254899461208859, + "avgWin": 541.71, + "avgLoss": -59.135230769230766, + "totalProfit": 4823.57, + "exitReasons": { + "lock_profit_stop": 5, + "breakeven_stop": 52, + "trailing_stop": 19, + "time_stop": 5 + }, + "monthlyPnL": { + "2025-01": 198.66000000000003, + "2025-02": -337.33, + "2025-03": 108.28000000000003, + "2025-04": 1300.3, + "2025-05": 1080.8700000000001, + "2025-06": 364.09000000000003, + "2025-07": 1044.3899999999999, + "2025-08": 337.42999999999995, + "2025-09": 391.17999999999995, + "2025-10": 1733.9099999999999, + "2025-11": -76.07, + "2025-12": -1322.1399999999999 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + } +] \ No newline at end of file diff --git a/quant/quantdinger/best_params/phase7b_top3_full_year.json b/quant/quantdinger/best_params/phase7b_top3_full_year.json new file mode 100644 index 0000000..c8c366a --- /dev/null +++ b/quant/quantdinger/best_params/phase7b_top3_full_year.json @@ -0,0 +1,230 @@ +[ + { + "name": "seed270_c008_pull6", + "note": "pullback=6, WF=0.423832", + "totalReturn": 38.74, + "maxDrawdown": -14.76, + "sharpeRatio": 1.5, + "n_trades": 84, + "winRate": 17.857142857142858, + "profitFactor": 2.1890406849283455, + "avgWin": 632.3846666666667, + "avgLoss": -62.80144927536232, + "totalProfit": 5152.47, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 54, + "trailing_stop": 22, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -389.07000000000005, + "2025-03": -141.80999999999997, + "2025-04": 1264.03, + "2025-05": 1276.92, + "2025-06": 379.97, + "2025-07": 1031.7100000000005, + "2025-08": 323.94, + "2025-09": 405.23999999999995, + "2025-10": 1796.3300000000002, + "2025-11": -359.36, + "2025-12": -1345.2199999999998 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 720, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.63, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 48, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 6, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + }, + { + "name": "seed270_c006_pull10", + "note": "pullback=10, WF=0.429253", + "totalReturn": 38.3, + "maxDrawdown": -13.9, + "sharpeRatio": 1.51, + "n_trades": 77, + "winRate": 18.181818181818183, + "profitFactor": 2.242589395501348, + "avgWin": 644.8485714285715, + "avgLoss": -63.89920634920635, + "totalProfit": 5002.2300000000005, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 51, + "trailing_stop": 18, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -359.34999999999997, + "2025-03": -142.38, + "2025-04": 1269.1799999999998, + "2025-05": 1340.2, + "2025-06": 384.16, + "2025-07": 1134.0500000000002, + "2025-08": 330.46, + "2025-09": -76.26, + "2025-10": 1772.54, + "2025-11": -219.31, + "2025-12": -1340.8499999999997 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 576, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.63, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 10, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + }, + { + "name": "seed270_c002_pull4", + "note": "pullback=4, WF=0.385782", + "totalReturn": 36.08, + "maxDrawdown": -14.76, + "sharpeRatio": 1.4, + "n_trades": 89, + "winRate": 16.853932584269664, + "profitFactor": 2.111636990442031, + "avgWin": 626.4086666666666, + "avgLoss": -60.13094594594594, + "totalProfit": 4946.4400000000005, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 58, + "trailing_stop": 23, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -389.07000000000005, + "2025-03": -141.80999999999997, + "2025-04": 1235.14, + "2025-05": 1254.46, + "2025-06": 374.47, + "2025-07": 898.2699999999999, + "2025-08": 317.71000000000004, + "2025-09": 397.46000000000004, + "2025-10": 1761.85, + "2025-11": -352.46, + "2025-12": -1319.3699999999997 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 360, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.67, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 4, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + }, + { + "name": "baseline_seed251_c915", + "note": "baseline, WF=0.496414", + "totalReturn": 42.78, + "maxDrawdown": -13.45, + "sharpeRatio": 1.63, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.337172975052815, + "avgWin": 603.623125, + "avgLoss": -63.57430769230769, + "totalProfit": 5525.64, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -360.86, + "2025-03": -142.35000000000002, + "2025-04": 1269.0, + "2025-05": 1320.1200000000001, + "2025-06": 383.0799999999999, + "2025-07": 1096.37, + "2025-08": 328.32, + "2025-09": 410.73, + "2025-10": 1820.57, + "2025-11": -124.82000000000002, + "2025-12": -1384.3099999999997 + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } + } +] \ No newline at end of file diff --git a/quant/quantdinger/best_params/phase8a_full_year.json b/quant/quantdinger/best_params/phase8a_full_year.json new file mode 100644 index 0000000..3b5236d --- /dev/null +++ b/quant/quantdinger/best_params/phase8a_full_year.json @@ -0,0 +1,157 @@ +[ + { + "name": "baseline_seed251", + "note": "baseline, WF=0.496", + "totalReturn": 42.78, + "maxDrawdown": -13.45, + "sharpeRatio": 1.63, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.337172975052815, + "totalProfit": 5525.64, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -360.86, + "2025-03": -142.35000000000002, + "2025-04": 1269.0, + "2025-05": 1320.1200000000001, + "2025-06": 383.0799999999999, + "2025-07": 1096.37, + "2025-08": 328.32, + "2025-09": 410.73, + "2025-10": 1820.57, + "2025-11": -124.82000000000002, + "2025-12": -1384.3099999999997 + } + }, + { + "name": "seed280_c099_lever10", + "note": "lever=1.0 offset=1.04, WF=0.514", + "totalReturn": 45.0, + "maxDrawdown": -13.2, + "sharpeRatio": 1.72, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.4429414973442163, + "totalProfit": 5740.18, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 893.02, + "2025-02": -341.65000000000003, + "2025-03": -104.16, + "2025-04": 1299.1, + "2025-05": 1372.5300000000002, + "2025-06": 383.47, + "2025-07": 1110.5400000000002, + "2025-08": 331.88, + "2025-09": 415.17, + "2025-10": 1866.09, + "2025-11": -80.88, + "2025-12": -1404.93 + } + }, + { + "name": "seed280_c141_lever125", + "note": "lever=1.25 offset=1.04, WF=0.509", + "totalReturn": 44.28, + "maxDrawdown": -13.21, + "sharpeRatio": 1.69, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.4070124482356117, + "totalProfit": 5677.45, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -355.08000000000004, + "2025-03": -123.31, + "2025-04": 1285.3, + "2025-05": 1367.8300000000002, + "2025-06": 385.95, + "2025-07": 1107.0800000000002, + "2025-08": 330.8299999999999, + "2025-09": 413.87, + "2025-10": 1834.5200000000002, + "2025-11": -80.48000000000002, + "2025-12": -1398.85 + } + }, + { + "name": "seed280_c011_lever15", + "note": "lever=1.5 offset=1.04, WF=0.507", + "totalReturn": 43.71, + "maxDrawdown": -13.21, + "sharpeRatio": 1.67, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.386292189684377, + "totalProfit": 5622.51, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -360.86, + "2025-03": -142.35000000000002, + "2025-04": 1270.83, + "2025-05": 1362.43, + "2025-06": 384.42999999999995, + "2025-07": 1102.69, + "2025-08": 329.53000000000003, + "2025-09": 412.24, + "2025-10": 1827.27, + "2025-11": -80.16, + "2025-12": -1393.3300000000002 + } + }, + { + "name": "seed280_c023_lever20", + "note": "lever=2.0 offset=1.04, WF=0.505", + "totalReturn": 43.22, + "maxDrawdown": -13.32, + "sharpeRatio": 1.65, + "n_trades": 81, + "winRate": 19.753086419753085, + "profitFactor": 2.368592756970918, + "totalProfit": 5575.25, + "exitReasons": { + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + }, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -360.86, + "2025-03": -168.39999999999998, + "2025-04": 1263.01, + "2025-05": 1357.8100000000002, + "2025-06": 383.12, + "2025-07": 1098.9599999999996, + "2025-08": 328.41, + "2025-09": 410.84000000000003, + "2025-10": 1821.07, + "2025-11": -79.9, + "2025-12": -1388.6 + } + } +] \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed185_candidate_243/full_year_backtest.json b/quant/quantdinger/best_params/seed185_candidate_243/full_year_backtest.json new file mode 100644 index 0000000..641c93f --- /dev/null +++ b/quant/quantdinger/best_params/seed185_candidate_243/full_year_backtest.json @@ -0,0 +1,57 @@ +{ + "candidate": "seed185_candidate_243", + "train_score": 0.326515, + "full_year_backtest": { + "totalReturn": 28.03, + "maxDrawdown": -12.57, + "totalTrades": 72, + "winRate": 33.33, + "profitFactor": 1.73, + "sharpeRatio": 0.97, + "profitable_months": 9, + "monthlyPnL": { + "2025-01": 335.0, + "2025-02": -597.24, + "2025-03": 395.29, + "2025-04": 823.13, + "2025-05": 415.09, + "2025-06": 207.02, + "2025-07": 414.58, + "2025-08": 24.38, + "2025-09": 621.53, + "2025-10": 1481.21, + "2025-11": -466.72, + "2025-12": -850.34 + }, + "exit_reasons": { + "trailing_stop_pct": 31.944444444444443, + "protective_stop_pct": 0.0, + "breakeven_stop_pct": 14.583333333333334, + "time_stop_pct": 3.4722222222222223 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 8, + "up_line_span": 576, + "up_line_offset": 0.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 24, + "regime_slope_threshold": 0.003, + "regime_displace_threshold": 0.18, + "regime_vol_ema_span": 72, + "regime_compression_threshold": 0.45, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 4, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 8, + "entry_pullback_bars_min": 6, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 + } +} diff --git a/quant/quantdinger/best_params/seed185_candidate_243/params.json b/quant/quantdinger/best_params/seed185_candidate_243/params.json new file mode 100644 index 0000000..5415d6a --- /dev/null +++ b/quant/quantdinger/best_params/seed185_candidate_243/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 8, + "up_line_span": 576, + "up_line_offset": 0.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 24, + "regime_slope_threshold": 0.003, + "regime_displace_threshold": 0.18, + "regime_vol_ema_span": 72, + "regime_compression_threshold": 0.45, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 4, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 8, + "entry_pullback_bars_min": 6, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed185_candidate_243/strategy.py b/quant/quantdinger/best_params/seed185_candidate_243/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed185_candidate_243/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed190_candidate_011/full_year_backtest.json b/quant/quantdinger/best_params/seed190_candidate_011/full_year_backtest.json new file mode 100644 index 0000000..abb55fb --- /dev/null +++ b/quant/quantdinger/best_params/seed190_candidate_011/full_year_backtest.json @@ -0,0 +1,57 @@ +{ + "candidate": "seed190_candidate_candidate_011", + "train_score": 0.407354, + "full_year_backtest": { + "totalReturn": 28.83, + "maxDrawdown": -15.56, + "totalTrades": 99, + "winRate": 26.26, + "profitFactor": 1.66, + "sharpeRatio": 0.94, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 723.55, + "2025-02": -927.24, + "2025-03": 51.34, + "2025-04": 859.84, + "2025-05": 1546.36, + "2025-06": 273.19, + "2025-07": 896.59, + "2025-08": -371.6, + "2025-09": 542.07, + "2025-10": 813.86, + "2025-11": -73.03, + "2025-12": -1291.3 + }, + "exit_reasons": { + "trailing_stop_pct": 28.28282828282828, + "protective_stop_pct": 0.5050505050505051, + "breakeven_stop_pct": 18.181818181818183, + "time_stop_pct": 3.0303030303030303 + } + }, + "params": { + "strategy_lever_rate": 1.25, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 5, + "up_line_span": 576, + "up_line_offset": 0.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.003, + "regime_displace_threshold": 0.16, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.5, + "regime_expansion_threshold": 1.1, + "regime_hysteresis_bars": 4, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 7, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 + } +} diff --git a/quant/quantdinger/best_params/seed190_candidate_011/params.json b/quant/quantdinger/best_params/seed190_candidate_011/params.json new file mode 100644 index 0000000..f31672e --- /dev/null +++ b/quant/quantdinger/best_params/seed190_candidate_011/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.25, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 5, + "up_line_span": 576, + "up_line_offset": 0.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.003, + "regime_displace_threshold": 0.16, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.5, + "regime_expansion_threshold": 1.1, + "regime_hysteresis_bars": 4, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 7, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed190_candidate_011/strategy.py b/quant/quantdinger/best_params/seed190_candidate_011/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed190_candidate_011/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed191_candidate_106/full_year_backtest.json b/quant/quantdinger/best_params/seed191_candidate_106/full_year_backtest.json new file mode 100644 index 0000000..6274420 --- /dev/null +++ b/quant/quantdinger/best_params/seed191_candidate_106/full_year_backtest.json @@ -0,0 +1,57 @@ +{ + "candidate": "seed191_candidate_candidate_106", + "train_score": 0.408524, + "full_year_backtest": { + "totalReturn": 32.81, + "maxDrawdown": -17.06, + "totalTrades": 86, + "winRate": 27.91, + "profitFactor": 1.77, + "sharpeRatio": 1.08, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 775.85, + "2025-02": -450.57, + "2025-03": -87.41, + "2025-04": 796.12, + "2025-05": 1235.77, + "2025-06": 202.46, + "2025-07": 1102.42, + "2025-08": -228.33, + "2025-09": 704.12, + "2025-10": 1205.47, + "2025-11": 93.56, + "2025-12": -1902.76 + }, + "exit_reasons": { + "trailing_stop_pct": 29.069767441860467, + "protective_stop_pct": 0.0, + "breakeven_stop_pct": 17.441860465116278, + "time_stop_pct": 3.488372093023256 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 8, + "up_line_span": 432, + "up_line_offset": 0.96, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 2, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.005, + "regime_displace_threshold": 0.16, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.5, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 72, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 5, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 + } +} diff --git a/quant/quantdinger/best_params/seed191_candidate_106/params.json b/quant/quantdinger/best_params/seed191_candidate_106/params.json new file mode 100644 index 0000000..50dd073 --- /dev/null +++ b/quant/quantdinger/best_params/seed191_candidate_106/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 8, + "up_line_span": 432, + "up_line_offset": 0.96, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 2, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.005, + "regime_displace_threshold": 0.16, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.5, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 72, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 5, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed191_candidate_106/strategy.py b/quant/quantdinger/best_params/seed191_candidate_106/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed191_candidate_106/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed191_candidate_148/full_year_backtest.json b/quant/quantdinger/best_params/seed191_candidate_148/full_year_backtest.json new file mode 100644 index 0000000..b9044ae --- /dev/null +++ b/quant/quantdinger/best_params/seed191_candidate_148/full_year_backtest.json @@ -0,0 +1,57 @@ +{ + "candidate": "seed191_candidate_candidate_148", + "train_score": 0.455898, + "full_year_backtest": { + "totalReturn": 34.9, + "maxDrawdown": -17.06, + "totalTrades": 84, + "winRate": 28.57, + "profitFactor": 1.82, + "sharpeRatio": 1.14, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 775.85, + "2025-02": -450.57, + "2025-03": -52.23, + "2025-04": 798.86, + "2025-05": 1329.95, + "2025-06": 204.63, + "2025-07": 1135.98, + "2025-08": -232.06, + "2025-09": 712.79, + "2025-10": 1208.23, + "2025-11": 159.68, + "2025-12": -1932.71 + }, + "exit_reasons": { + "trailing_stop_pct": 27.976190476190478, + "protective_stop_pct": 0.0, + "breakeven_stop_pct": 18.452380952380953, + "time_stop_pct": 3.571428571428571 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 0.96, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 + } +} diff --git a/quant/quantdinger/best_params/seed191_candidate_148/params.json b/quant/quantdinger/best_params/seed191_candidate_148/params.json new file mode 100644 index 0000000..5e07a10 --- /dev/null +++ b/quant/quantdinger/best_params/seed191_candidate_148/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 0.96, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed191_candidate_148/strategy.py b/quant/quantdinger/best_params/seed191_candidate_148/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed191_candidate_148/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed235_candidate_395/full_year_backtest.json b/quant/quantdinger/best_params/seed235_candidate_395/full_year_backtest.json new file mode 100644 index 0000000..f18c440 --- /dev/null +++ b/quant/quantdinger/best_params/seed235_candidate_395/full_year_backtest.json @@ -0,0 +1,56 @@ +{ + "candidate": "seed235_candidate_395", + "train_score": 0.469515, + "full_year_backtest": { + "totalReturn": 40.18, + "maxDrawdown": -14.0, + "totalTrades": 80, + "winRate": 20.0, + "profitFactor": 2.24, + "sharpeRatio": 1.51, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.79, + "2025-02": -385.78, + "2025-03": -121.64, + "2025-04": 1244.62, + "2025-05": 1222.96, + "2025-06": 368.28, + "2025-07": 1066.38, + "2025-08": 324.61, + "2025-09": 396.6, + "2025-10": 1798.83, + "2025-11": -123.33, + "2025-12": -1457.8 + }, + "exit_reasons": { + "time_stop_pct": 8.75, + "breakeven_stop_pct": 65.0, + "trailing_stop_pct": 26.25 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.8, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed235_candidate_395/params.json b/quant/quantdinger/best_params/seed235_candidate_395/params.json new file mode 100644 index 0000000..af738b5 --- /dev/null +++ b/quant/quantdinger/best_params/seed235_candidate_395/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.8, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed235_candidate_395/strategy.py b/quant/quantdinger/best_params/seed235_candidate_395/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed235_candidate_395/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed236_candidate_270/full_year_backtest.json b/quant/quantdinger/best_params/seed236_candidate_270/full_year_backtest.json new file mode 100644 index 0000000..89ae720 --- /dev/null +++ b/quant/quantdinger/best_params/seed236_candidate_270/full_year_backtest.json @@ -0,0 +1,56 @@ +{ + "candidate": "seed236_candidate_270", + "train_score": 0.472776, + "full_year_backtest": { + "totalReturn": 41.55, + "maxDrawdown": -13.45, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.31, + "sharpeRatio": 1.57, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.79, + "2025-02": -360.86, + "2025-03": -142.35, + "2025-04": 1260.85, + "2025-05": 1220.81, + "2025-06": 379.8, + "2025-07": 1086.99, + "2025-08": 325.5, + "2025-09": 407.2, + "2025-10": 1804.98, + "2025-11": -123.76, + "2025-12": -1372.45 + }, + "exit_reasons": { + "time_stop_pct": 8.641975308641975, + "breakeven_stop_pct": 64.19753086419753, + "trailing_stop_pct": 27.160493827160494 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed236_candidate_270/params.json b/quant/quantdinger/best_params/seed236_candidate_270/params.json new file mode 100644 index 0000000..05d1bde --- /dev/null +++ b/quant/quantdinger/best_params/seed236_candidate_270/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed236_candidate_270/strategy.py b/quant/quantdinger/best_params/seed236_candidate_270/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed236_candidate_270/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed238_candidate_202/full_year_backtest.json b/quant/quantdinger/best_params/seed238_candidate_202/full_year_backtest.json new file mode 100644 index 0000000..eb91756 --- /dev/null +++ b/quant/quantdinger/best_params/seed238_candidate_202/full_year_backtest.json @@ -0,0 +1,56 @@ +{ + "candidate": "seed238_candidate_202", + "train_score": 0.46263, + "full_year_backtest": { + "totalReturn": 35.47, + "maxDrawdown": -13.68, + "totalTrades": 81, + "winRate": 20.99, + "profitFactor": 2.15, + "sharpeRatio": 1.45, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 934.98, + "2025-02": -312.45, + "2025-03": -138.3, + "2025-04": 930.94, + "2025-05": 1311.88, + "2025-06": 527.37, + "2025-07": 560.4, + "2025-08": 211.63, + "2025-09": 532.93, + "2025-10": 1674.71, + "2025-11": -62.01, + "2025-12": -1408.88 + }, + "exit_reasons": { + "time_stop_pct": 9.876543209876543, + "breakeven_stop_pct": 64.19753086419753, + "trailing_stop_pct": 25.925925925925924 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.8, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 576 + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed238_candidate_202/params.json b/quant/quantdinger/best_params/seed238_candidate_202/params.json new file mode 100644 index 0000000..373013f --- /dev/null +++ b/quant/quantdinger/best_params/seed238_candidate_202/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.8, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 576 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed238_candidate_202/strategy.py b/quant/quantdinger/best_params/seed238_candidate_202/strategy.py new file mode 100644 index 0000000..1971283 --- /dev/null +++ b/quant/quantdinger/best_params/seed238_candidate_202/strategy.py @@ -0,0 +1,541 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → breakeven → time + # Trailing before breakeven: when trailing stop rises above entry, it captures + # trend profits; breakeven only acts as safety net when trailing hasn't activated. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed250_candidate_213/full_year_backtest.json b/quant/quantdinger/best_params/seed250_candidate_213/full_year_backtest.json new file mode 100644 index 0000000..689d664 --- /dev/null +++ b/quant/quantdinger/best_params/seed250_candidate_213/full_year_backtest.json @@ -0,0 +1,57 @@ +{ + "candidate": "seed250_candidate_213", + "train_score": 0.475471, + "full_year_backtest": { + "totalReturn": 30.52, + "maxDrawdown": -15.3, + "totalTrades": 80, + "winRate": 18.75, + "profitFactor": 2.02, + "sharpeRatio": 1.25, + "profitable_months": 9, + "monthlyPnL": { + "2025-01": 198.66000000000003, + "2025-02": -360.62, + "2025-03": 90.60999999999999, + "2025-04": 1206.3400000000001, + "2025-05": 1032.1299999999999, + "2025-06": 348.17, + "2025-07": 1008.1300000000001, + "2025-08": 306.69999999999993, + "2025-09": 374.93, + "2025-10": 1700.54, + "2025-11": -340.21000000000004, + "2025-12": -1357.3400000000001 + }, + "exit_reasons": { + "lock_profit_stop": 6.25, + "breakeven_stop": 65.0, + "trailing_stop": 22.5, + "time_stop": 6.25 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.4, + "open_time_interval": 6, + "up_line_span": 504, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.8, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed250_candidate_213/params.json b/quant/quantdinger/best_params/seed250_candidate_213/params.json new file mode 100644 index 0000000..00967a0 --- /dev/null +++ b/quant/quantdinger/best_params/seed250_candidate_213/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.4, + "open_time_interval": 6, + "up_line_span": 504, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.8, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed250_candidate_213/strategy.py b/quant/quantdinger/best_params/seed250_candidate_213/strategy.py new file mode 100644 index 0000000..0705b8f --- /dev/null +++ b/quant/quantdinger/best_params/seed250_candidate_213/strategy.py @@ -0,0 +1,568 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_lock_profit(ctx, bar, params): + profit_line = float(params['profit_line']) + if profit_line <= 0: + return False + max_pp = ctx.get_state('max_profit_pct', 0.0) + if max_pp < profit_line: + return False + + lock_rate = float(params['lock_profit_rate']) + current_profit = ctx.unrealized_profit_pct(bar.close) + # Lock level = peak profit retained after allowing lock_rate drawdown + lock_level = max_pp * (1.0 - lock_rate) + # Only trigger while still in profit; let breakeven/protective handle losses + if current_profit > 0 and current_profit < lock_level: + ctx.close_position() + ctx.set_state('exit_reason', 'lock_profit_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → lock_profit → breakeven → time + # Lock profit sits between trailing and breakeven: once profit exceeds + # profit_line, it protects a fraction of peak gains before breakeven kicks in. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_lock_profit(ctx, bar, params): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + ctx.set_state('max_profit_pct', 0.0) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability and lock_profit exit + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + max_pp = ctx.get_state('max_profit_pct', 0.0) + if profit > max_pp: + ctx.set_state('max_profit_pct', profit) + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('max_profit_pct', 0.0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed251_candidate_915/full_year_backtest.json b/quant/quantdinger/best_params/seed251_candidate_915/full_year_backtest.json new file mode 100644 index 0000000..f9eac8d --- /dev/null +++ b/quant/quantdinger/best_params/seed251_candidate_915/full_year_backtest.json @@ -0,0 +1,57 @@ +{ + "candidate": "seed251_candidate_915", + "train_score": 0.485463, + "full_year_backtest": { + "totalReturn": 42.78, + "maxDrawdown": -13.45, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.34, + "sharpeRatio": 1.63, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.7900000000001, + "2025-02": -360.86, + "2025-03": -142.35000000000002, + "2025-04": 1269.0, + "2025-05": 1320.1200000000001, + "2025-06": 383.0799999999999, + "2025-07": 1096.37, + "2025-08": 328.32, + "2025-09": 410.73, + "2025-10": 1820.57, + "2025-11": -124.82000000000002, + "2025-12": -1384.3099999999997 + }, + "exit_reasons": { + "time_stop": 8.641975308641975, + "breakeven_stop": 64.19753086419753, + "trailing_stop": 25.925925925925924, + "lock_profit_stop": 1.2345679012345678 + } + }, + "params": { + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed251_candidate_915/params.json b/quant/quantdinger/best_params/seed251_candidate_915/params.json new file mode 100644 index 0000000..62a0a3b --- /dev/null +++ b/quant/quantdinger/best_params/seed251_candidate_915/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed251_candidate_915/strategy.py b/quant/quantdinger/best_params/seed251_candidate_915/strategy.py new file mode 100644 index 0000000..0705b8f --- /dev/null +++ b/quant/quantdinger/best_params/seed251_candidate_915/strategy.py @@ -0,0 +1,568 @@ +# @param strategy_lever_rate float 策略风险杠杆系数 +# @param profit_line float 锁盈触发收益率 +# @param lock_profit_rate float 锁盈回撤保护比例 +# @param open_time_interval float 冷却窗口小时数 +# @param up_line_span int 开仓突破通道周期 +# @param up_line_offset float 突破通道偏移倍数 +# @param buy_stop_profit_span int 动态止盈底线周期 +# @param buy_stop_profit_offset float 动态止盈底线偏移倍数 +# @param ma_span_long int 长均线天数 +# @param regime_slope_lookback int 趋势检测回看K线数 +# @param regime_slope_threshold float 趋势斜率阈值 +# @param regime_displace_threshold float 价格偏离阈值 +# @param regime_vol_ema_span int 波动率EMA周期 +# @param regime_compression_threshold float 压缩状态波动阈值 +# @param regime_expansion_threshold float 扩张状态波动阈值 +# @param regime_hysteresis_bars int 状态切换确认K线数 +# @param entry_up_line_span_short int 压缩突破短通道周期 +# @param entry_ma_span_short int 回调短均线天数 +# @param entry_pullback_bars_min int 回调最低K线数 +# @param exit_max_loss_pct float 保护止损最大亏损比例 +# @param exit_breakeven_buffer float 保本止损触发缓冲 +# @param exit_max_hold_bars int 时间止损最大持仓K线数 +# @strategy tradeDirection long + +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + + +def _ema(values): + value = None + span = float(len(values)) + for number in values: + number = float(number) + if value is None: + value = number + else: + value = 2 * number / (span + 1) + (span - 1) / (span + 1) * value + return value + + +def _history_bars(ctx, length, history=None): + length = int(length) + if length <= 0: + return [] + if history is None: + bars = ctx.bars(length + 1) + if len(bars) <= 1: + return [] + history = bars[:-1] + if len(history) < length: + return [] + return history[-length:] + + +def _n_value(history): + window = _history_bars(None, SPREAD_SPAN * N_VALUE_SPAN, history=history) + if len(window) < SPREAD_SPAN * N_VALUE_SPAN: + return None + spreads = [] + for i in range(N_VALUE_SPAN): + start = i * SPREAD_SPAN + chunk = window[start:start + SPREAD_SPAN] + high = max(bar.high for bar in chunk) + low = min(bar.low for bar in chunk) + spreads.append(high - low) + return _ema(spreads) + + +def _open_up_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return max(bar.high for bar in window) + + +def _stop_profit_bottom_line(history, span): + window = _history_bars(None, span, history=history) + if len(window) < int(span): + return None + return min(bar.low for bar in window) + + +def _ma_long(history, span_days): + length = int(span_days) * 24 * 6 + window = _history_bars(None, length, history=history) + if length <= 0 or len(window) < length: + return None + return sum(bar.close for bar in window) / float(length) + + +def _prepare_history(ctx, up_line_span, buy_stop_profit_span, ma_span_long, + regime_slope_lookback=0, entry_up_line_span_short=0, entry_ma_span_short=0): + ma_length = int(ma_span_long) * 24 * 6 + ma_short_length = int(entry_ma_span_short) * 24 * 6 if entry_ma_span_short else 0 + required = max( + SPREAD_SPAN * N_VALUE_SPAN, + int(up_line_span), + int(buy_stop_profit_span), + ma_length, + int(regime_slope_lookback) + ma_length, + int(entry_up_line_span_short), + ma_short_length, + ) + if required <= 0: + return [] + return _history_bars(ctx, required) + + +def _cached_indicators(ctx, params): + if hasattr(ctx, 'indicator_value'): + up_line_short = None + ma_short = None + if params.get('entry_up_line_span_short'): + up_line_short = ctx.indicator_value('up_line_short') + if params.get('entry_ma_span_short'): + ma_short = ctx.indicator_value('ma_short') + # Always compute history for regime signal MA slope calculation + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + return { + 'n_value': ctx.indicator_value('n_value'), + 'up_line': ctx.indicator_value('up_line'), + 'stop_profit_bottom': ctx.indicator_value('stop_profit_bottom'), + 'ma_long': ctx.indicator_value('ma_long'), + 'up_line_short': up_line_short, + 'ma_short': ma_short, + 'history': history, + } + history = _prepare_history( + ctx, + params['up_line_span'], + params['buy_stop_profit_span'], + params['ma_span_long'], + params.get('regime_slope_lookback', 0), + params.get('entry_up_line_span_short', 0), + params.get('entry_ma_span_short', 0), + ) + result = { + 'n_value': _n_value(history), + 'up_line': _open_up_line(history, params['up_line_span']), + 'stop_profit_bottom': _stop_profit_bottom_line(history, params['buy_stop_profit_span']), + 'ma_long': _ma_long(history, params['ma_span_long']), + 'history': history, + } + if params.get('entry_up_line_span_short'): + result['up_line_short'] = _open_up_line(history, params['entry_up_line_span_short']) + if params.get('entry_ma_span_short'): + result['ma_short'] = _ma_long(history, params['entry_ma_span_short']) + return result + + +def _strategy_params(ctx): + return { + 'strategy_lever_rate': float(ctx.param('strategy_lever_rate')), + 'profit_line': float(ctx.param('profit_line')), + 'lock_profit_rate': float(ctx.param('lock_profit_rate')), + 'open_time_interval': float(ctx.param('open_time_interval')), + 'up_line_span': int(ctx.param('up_line_span')), + 'up_line_offset': float(ctx.param('up_line_offset')), + 'buy_stop_profit_span': int(ctx.param('buy_stop_profit_span')), + 'buy_stop_profit_offset': float(ctx.param('buy_stop_profit_offset')), + 'ma_span_long': int(ctx.param('ma_span_long')), + 'regime_slope_lookback': int(ctx.param('regime_slope_lookback')), + 'regime_slope_threshold': float(ctx.param('regime_slope_threshold')), + 'regime_displace_threshold': float(ctx.param('regime_displace_threshold')), + 'regime_vol_ema_span': int(ctx.param('regime_vol_ema_span')), + 'regime_compression_threshold': float(ctx.param('regime_compression_threshold')), + 'regime_expansion_threshold': float(ctx.param('regime_expansion_threshold')), + 'regime_hysteresis_bars': int(ctx.param('regime_hysteresis_bars')), + 'entry_up_line_span_short': int(ctx.param('entry_up_line_span_short')), + 'entry_ma_span_short': int(ctx.param('entry_ma_span_short')), + 'entry_pullback_bars_min': int(ctx.param('entry_pullback_bars_min')), + 'exit_max_loss_pct': float(ctx.param('exit_max_loss_pct')), + 'exit_breakeven_buffer': float(ctx.param('exit_breakeven_buffer')), + 'exit_max_hold_bars': int(ctx.param('exit_max_hold_bars')), + } + + +def _ensure_indicator_cache(ctx, params): + if hasattr(ctx, 'set_indicator_cache'): + ctx.set_indicator_cache(params) + return True + return False + + +def _position_size_pct(ctx, n_value, strategy_lever_rate): + if n_value is None or n_value <= 0: + return 0.0 + price = ctx.current_price() + if price <= 0: + return 0.0 + stop_loss_pct = n_value / price + if stop_loss_pct <= 0: + return 0.0 + pct = 0.01 * float(strategy_lever_rate) / stop_loss_pct + return min(max(pct, 0.0), 1.0) + + +def _time_diff_ms(current_time, last_close_time): + if current_time is None or last_close_time is None: + return None + delta = current_time - last_close_time + if hasattr(delta, 'total_seconds'): + return delta.total_seconds() * 1000.0 + return None + + +# --------------------------------------------------------------------------- +# Market regime detection +# --------------------------------------------------------------------------- + +def _regime_state_init(ctx): + defaults = { + 'effective_regime': 'range', + 'regime_candidate': 'range', + 'regime_candidate_bars': 0, + 'n_value_ema': None, + 'prev_vol_ratio': None, + } + for key, val in defaults.items(): + if ctx.get_state(key, None) is None: + ctx.set_state(key, val) + + +def _regime_signals(ctx, bar, params, indicators): + ma_long = indicators['ma_long'] + n_value = indicators['n_value'] + history = indicators.get('history') + if ma_long is None or n_value is None: + return None + + if bar.close == 0: + return None + price_displacement = (bar.close - ma_long) / ma_long + + lookback = int(params['regime_slope_lookback']) + ma_slope = None + if history is not None and lookback > 0: + ma_length = int(params['ma_span_long']) * 24 * 6 + past_history = history[:max(0, len(history) - lookback)] + ma_past = _ma_long(past_history, params['ma_span_long']) + if ma_past is not None and ma_past != 0: + ma_slope = (ma_long - ma_past) / ma_past + + n_ema = ctx.get_state('n_value_ema', None) + ema_span = int(params['regime_vol_ema_span']) + if n_ema is None: + n_ema = n_value + else: + alpha = 2.0 / (ema_span + 1.0) + n_ema = alpha * n_value + (1.0 - alpha) * n_ema + ctx.set_state('n_value_ema', n_ema) + + vol_ratio = n_value / n_ema if n_ema and n_ema > 0 else 1.0 + + prev_vol_ratio = ctx.get_state('prev_vol_ratio', None) + ctx.set_state('prev_vol_ratio', vol_ratio) + + return { + 'ma_slope': ma_slope, + 'price_displacement': price_displacement, + 'vol_ratio': vol_ratio, + 'vol_ratio_rising': prev_vol_ratio is not None and vol_ratio > prev_vol_ratio, + } + + +def _classify_regime(signals, params): + if signals is None: + return 'range' + + vol_ratio = signals['vol_ratio'] + ma_slope = signals['ma_slope'] + price_displacement = signals['price_displacement'] + + if vol_ratio < float(params['regime_compression_threshold']): + return 'compression' + + if vol_ratio > float(params['regime_expansion_threshold']): + return 'expansion' + + slope_threshold = float(params['regime_slope_threshold']) + displace_threshold = float(params['regime_displace_threshold']) + if (ma_slope is not None + and abs(ma_slope) > slope_threshold + and abs(price_displacement) > displace_threshold): + return 'trend' + + return 'range' + + +def _effective_regime(ctx, new_regime, params): + prev_candidate = ctx.get_state('regime_candidate', 'range') + if new_regime == prev_candidate: + bars = ctx.get_state('regime_candidate_bars', 0) + 1 + ctx.set_state('regime_candidate_bars', bars) + else: + ctx.set_state('regime_candidate', new_regime) + ctx.set_state('regime_candidate_bars', 1) + return ctx.get_state('effective_regime', 'range') + + hysteresis = int(params['regime_hysteresis_bars']) + if ctx.get_state('regime_candidate_bars', 0) >= hysteresis: + ctx.set_state('effective_regime', new_regime) + return new_regime + + return ctx.get_state('effective_regime', 'range') + + +def _current_regime(ctx): + return ctx.get_state('effective_regime', 'range') + + +# --------------------------------------------------------------------------- +# Entry modes +# --------------------------------------------------------------------------- + +def _entry_cooldown_ok(ctx, params): + last_close_time = ctx.get_state('last_close_time', None) + time_diff_ms = _time_diff_ms(ctx.current_time, last_close_time) + if time_diff_ms is None: + return True + time_range_ms = max(float(params['open_time_interval']), 0.0) * 60 * 60 * 1000.0 + return time_range_ms <= 0 or time_diff_ms >= time_range_ms + + +def _entry_breakout_chase(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line = indicators['up_line'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or ma_long is None: + return False + + if bar.close <= ma_long: + return False + + threshold = up_line + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_compression_breakout(ctx, bar, params, indicators): + n_value = indicators['n_value'] + up_line_short = indicators.get('up_line_short') + if n_value is None or up_line_short is None: + return False + + n_ema = ctx.get_state('n_value_ema', None) + if n_ema is None or n_ema <= 0: + return False + current_vol = n_value / n_ema + + prev_vol = ctx.get_state('_prev_vol_saved', None) + ctx.set_state('_prev_vol_saved', current_vol) + if prev_vol is None: + return False + if current_vol <= prev_vol: + return False + + threshold = up_line_short + n_value * float(params['up_line_offset']) + if bar.close > threshold: + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + return False + + +def _entry_pullback_reentry(ctx, bar, params, indicators): + ma_short = indicators.get('ma_short') + if ma_short is None: + return False + + bars_below = ctx.get_state('pullback_bars_below', 0) + if bar.close < ma_short: + ctx.set_state('pullback_bars_below', bars_below + 1) + return False + + min_bars = int(params['entry_pullback_bars_min']) + if bars_below >= min_bars and bar.close > ma_short: + ctx.set_state('pullback_bars_below', 0) + n_value = indicators['n_value'] + position_pct = _position_size_pct(ctx, n_value, float(params['strategy_lever_rate'])) + if position_pct > 0: + ctx.buy(amount=position_pct) + return True + + ctx.set_state('pullback_bars_below', 0) + return False + + +def _entry_router(ctx, bar, params, indicators, regime): + if not _entry_cooldown_ok(ctx, params): + return False + + if regime == 'trend': + return _entry_breakout_chase(ctx, bar, params, indicators) + elif regime in ('compression', 'expansion'): + return _entry_compression_breakout(ctx, bar, params, indicators) + elif regime == 'range': + return _entry_pullback_reentry(ctx, bar, params, indicators) + return False + + +# --------------------------------------------------------------------------- +# Exit modules +# --------------------------------------------------------------------------- + +def _exit_protective_stop(ctx, bar, params): + loss_limit = (float(params['exit_max_loss_pct']) + * max(ctx.balance, 0.0) + * float(params['strategy_lever_rate'])) + unrealized_loss = max(ctx.entry_balance() - ctx.equity, 0.0) + if unrealized_loss > loss_limit: + ctx.close_position() + ctx.set_state('exit_reason', 'protective_stop') + return True + return False + + +def _exit_breakeven_stop(ctx, bar, params, indicators): + profit = ctx.unrealized_profit_pct(bar.close) + buffer_val = float(params['exit_breakeven_buffer']) + + if profit > buffer_val: + ctx.set_state('breakeven_armed', True) + + if ctx.get_state('breakeven_armed', False): + n_value = indicators.get('n_value') + entry = ctx.entry_price() + if n_value is not None and entry > 0: + # Give 0.3 N-value breathing room below entry so noise doesn't trigger exit + breakeven_level = entry - n_value * 0.3 + else: + breakeven_level = entry * 0.999 + if bar.close < breakeven_level: + ctx.close_position() + ctx.set_state('exit_reason', 'breakeven_stop') + return True + return False + + +def _exit_trailing_stop(ctx, bar, params, indicators): + stop_profit_bottom = indicators['stop_profit_bottom'] + n_value = indicators['n_value'] + if stop_profit_bottom is None or n_value is None: + return False + + buy_stop_profit = stop_profit_bottom + n_value * float(params['buy_stop_profit_offset']) + if bar.close < buy_stop_profit: + ctx.close_position() + ctx.set_state('exit_reason', 'trailing_stop') + return True + return False + + +def _exit_time_stop(ctx, bar, params): + bars_in_pos = ctx.get_state('bars_in_position', 0) + 1 + ctx.set_state('bars_in_position', bars_in_pos) + if bars_in_pos >= int(params['exit_max_hold_bars']): + ctx.close_position() + ctx.set_state('exit_reason', 'time_stop') + return True + return False + + +def _exit_lock_profit(ctx, bar, params): + profit_line = float(params['profit_line']) + if profit_line <= 0: + return False + max_pp = ctx.get_state('max_profit_pct', 0.0) + if max_pp < profit_line: + return False + + lock_rate = float(params['lock_profit_rate']) + current_profit = ctx.unrealized_profit_pct(bar.close) + # Lock level = peak profit retained after allowing lock_rate drawdown + lock_level = max_pp * (1.0 - lock_rate) + # Only trigger while still in profit; let breakeven/protective handle losses + if current_profit > 0 and current_profit < lock_level: + ctx.close_position() + ctx.set_state('exit_reason', 'lock_profit_stop') + return True + return False + + +def _exit_dispatcher(ctx, bar, params, indicators): + # Priority: protective → trailing → lock_profit → breakeven → time + # Lock profit sits between trailing and breakeven: once profit exceeds + # profit_line, it protects a fraction of peak gains before breakeven kicks in. + if _exit_protective_stop(ctx, bar, params): + return True + if _exit_trailing_stop(ctx, bar, params, indicators): + return True + if _exit_lock_profit(ctx, bar, params): + return True + if _exit_breakeven_stop(ctx, bar, params, indicators): + return True + if _exit_time_stop(ctx, bar, params): + return True + return False + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def on_init(ctx): + ctx.signal_timing = 'next_bar_open' + ctx.max_profit = 0.0 + ctx.last_close_time = None + ctx.last_close_index = None + params = _strategy_params(ctx) + _ensure_indicator_cache(ctx, params) + ctx.set_state('strategy_params_cache', params) + _regime_state_init(ctx) + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('exit_reason', None) + ctx.set_state('pullback_bars_below', 0) + ctx.set_state('_prev_vol_saved', None) + ctx.set_state('max_profit_pct', 0.0) + + +def on_bar(ctx, bar): + params = ctx.get_state('strategy_params_cache') or _strategy_params(ctx) + indicators = _cached_indicators(ctx, params) + + n_value = indicators['n_value'] + up_line = indicators['up_line'] + stop_profit_bottom = indicators['stop_profit_bottom'] + ma_long = indicators['ma_long'] + if n_value is None or up_line is None or stop_profit_bottom is None or ma_long is None: + return + + # 1. Classify regime + signals = _regime_signals(ctx, bar, params, indicators) + new_regime = _classify_regime(signals, params) + regime = _effective_regime(ctx, new_regime, params) + + # 2. If in position, check exits + if ctx.has_position() and ctx.is_long(): + # Track max profit for observability and lock_profit exit + profit = ctx.unrealized_profit_pct(bar.close) + if profit > ctx.max_profit: + ctx.max_profit = profit + max_pp = ctx.get_state('max_profit_pct', 0.0) + if profit > max_pp: + ctx.set_state('max_profit_pct', profit) + + _exit_dispatcher(ctx, bar, params, indicators) + return + + # 3. If flat, reset exit state then check entry + ctx.set_state('breakeven_armed', False) + ctx.set_state('bars_in_position', 0) + ctx.set_state('max_profit_pct', 0.0) + _entry_router(ctx, bar, params, indicators, regime) diff --git a/quant/quantdinger/best_params/seed260_candidate_066/full_year_backtest.json b/quant/quantdinger/best_params/seed260_candidate_066/full_year_backtest.json new file mode 100644 index 0000000..c87f89f --- /dev/null +++ b/quant/quantdinger/best_params/seed260_candidate_066/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "phase7a_top1", + "note": "Phase 7a #1: profit_line=0.05, lock_profit_rate=0.26, buy_stop_profit_offset=1.04. Walk-forward score=0.505735.", + "train_score": 0.505735, + "full_year_backtest": { + "totalReturn": 35.39, + "maxDrawdown": -13.21, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.23, + "sharpeRatio": 1.50, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 329.34, + "2025-02": -341.65, + "2025-03": 123.53, + "2025-04": 971.94, + "2025-05": 1062.13, + "2025-06": 405.49, + "2025-07": 1030.36, + "2025-08": 409.43, + "2025-09": 388.16, + "2025-10": 1729.12, + "2025-11": -75.51, + "2025-12": -1312.68 + }, + "exit_reasons": { + "lock_profit_stop_pct": 9.88, + "breakeven_stop_pct": 64.20, + "trailing_stop_pct": 22.22, + "time_stop_pct": 3.70 + } + } +} diff --git a/quant/quantdinger/best_params/seed260_candidate_066/params.json b/quant/quantdinger/best_params/seed260_candidate_066/params.json new file mode 100644 index 0000000..42dcc09 --- /dev/null +++ b/quant/quantdinger/best_params/seed260_candidate_066/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed260_candidate_077/full_year_backtest.json b/quant/quantdinger/best_params/seed260_candidate_077/full_year_backtest.json new file mode 100644 index 0000000..0d050f7 --- /dev/null +++ b/quant/quantdinger/best_params/seed260_candidate_077/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "phase7a_top2", + "note": "Phase 7a #2: profit_line=0.05, lock_profit_rate=0.26, buy_stop_profit_offset=1.00. Walk-forward score=0.499407.", + "train_score": 0.499407, + "full_year_backtest": { + "totalReturn": 34.75, + "maxDrawdown": -13.32, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.20, + "sharpeRatio": 1.48, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 329.34, + "2025-02": -341.65, + "2025-03": 123.53, + "2025-04": 970.19, + "2025-05": 1022.81, + "2025-06": 404.09, + "2025-07": 1026.76, + "2025-08": 408.00, + "2025-09": 386.80, + "2025-10": 1723.09, + "2025-11": -94.32, + "2025-12": -1306.44 + }, + "exit_reasons": { + "lock_profit_stop_pct": 9.88, + "breakeven_stop_pct": 64.20, + "trailing_stop_pct": 22.22, + "time_stop_pct": 3.70 + } + } +} diff --git a/quant/quantdinger/best_params/seed260_candidate_077/params.json b/quant/quantdinger/best_params/seed260_candidate_077/params.json new file mode 100644 index 0000000..2637ac6 --- /dev/null +++ b/quant/quantdinger/best_params/seed260_candidate_077/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.26, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.0, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed260_candidate_169/full_year_backtest.json b/quant/quantdinger/best_params/seed260_candidate_169/full_year_backtest.json new file mode 100644 index 0000000..f6ddfac --- /dev/null +++ b/quant/quantdinger/best_params/seed260_candidate_169/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "phase7a_top3", + "note": "Phase 7a #3: profit_line=0.05, lock_profit_rate=0.33, buy_stop_profit_offset=1.04. Walk-forward score=0.498957.", + "train_score": 0.498957, + "full_year_backtest": { + "totalReturn": 36.37, + "maxDrawdown": -13.21, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.25, + "sharpeRatio": 1.50, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 198.66, + "2025-02": -337.33, + "2025-03": 108.28, + "2025-04": 1300.30, + "2025-05": 1080.87, + "2025-06": 364.09, + "2025-07": 1044.39, + "2025-08": 337.43, + "2025-09": 391.18, + "2025-10": 1733.91, + "2025-11": -76.07, + "2025-12": -1322.14 + }, + "exit_reasons": { + "lock_profit_stop_pct": 6.17, + "breakeven_stop_pct": 64.20, + "trailing_stop_pct": 23.46, + "time_stop_pct": 6.17 + } + } +} diff --git a/quant/quantdinger/best_params/seed260_candidate_169/params.json b/quant/quantdinger/best_params/seed260_candidate_169/params.json new file mode 100644 index 0000000..6166b56 --- /dev/null +++ b/quant/quantdinger/best_params/seed260_candidate_169/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.05, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed270_candidate_002/full_year_backtest.json b/quant/quantdinger/best_params/seed270_candidate_002/full_year_backtest.json new file mode 100644 index 0000000..62afbac --- /dev/null +++ b/quant/quantdinger/best_params/seed270_candidate_002/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "seed270_candidate_002", + "note": "Phase 7b non-baseline: pullback=4, up_line_span=360, WF=0.385782", + "train_score": 0.385782, + "full_year_backtest": { + "totalReturn": 36.08, + "maxDrawdown": -14.76, + "totalTrades": 89, + "winRate": 16.85, + "profitFactor": 2.11, + "sharpeRatio": 1.4, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.79, + "2025-02": -389.07, + "2025-03": -141.81, + "2025-04": 1235.14, + "2025-05": 1254.46, + "2025-06": 374.47, + "2025-07": 898.27, + "2025-08": 317.71, + "2025-09": 397.46, + "2025-10": 1761.85, + "2025-11": -352.46, + "2025-12": -1319.37 + }, + "exit_reasons": { + "time_stop_pct": 7.87, + "breakeven_stop_pct": 65.17, + "trailing_stop_pct": 25.84, + "lock_profit_stop_pct": 1.12 + } + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed270_candidate_002/params.json b/quant/quantdinger/best_params/seed270_candidate_002/params.json new file mode 100644 index 0000000..25c7c26 --- /dev/null +++ b/quant/quantdinger/best_params/seed270_candidate_002/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 360, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.67, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 4, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed270_candidate_006/full_year_backtest.json b/quant/quantdinger/best_params/seed270_candidate_006/full_year_backtest.json new file mode 100644 index 0000000..be5a345 --- /dev/null +++ b/quant/quantdinger/best_params/seed270_candidate_006/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "seed270_candidate_006", + "note": "Phase 7b non-baseline: pullback=10, up_line_span=576, WF=0.429253", + "train_score": 0.429253, + "full_year_backtest": { + "totalReturn": 38.3, + "maxDrawdown": -13.9, + "totalTrades": 77, + "winRate": 18.18, + "profitFactor": 2.24, + "sharpeRatio": 1.51, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.79, + "2025-02": -359.35, + "2025-03": -142.38, + "2025-04": 1269.18, + "2025-05": 1340.2, + "2025-06": 384.16, + "2025-07": 1134.05, + "2025-08": 330.46, + "2025-09": -76.26, + "2025-10": 1772.54, + "2025-11": -219.31, + "2025-12": -1340.85 + }, + "exit_reasons": { + "time_stop_pct": 9.09, + "breakeven_stop_pct": 66.23, + "trailing_stop_pct": 23.38, + "lock_profit_stop_pct": 1.3 + } + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed270_candidate_006/params.json b/quant/quantdinger/best_params/seed270_candidate_006/params.json new file mode 100644 index 0000000..6d6b614 --- /dev/null +++ b/quant/quantdinger/best_params/seed270_candidate_006/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 576, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.63, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 10, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed270_candidate_008/full_year_backtest.json b/quant/quantdinger/best_params/seed270_candidate_008/full_year_backtest.json new file mode 100644 index 0000000..51fc433 --- /dev/null +++ b/quant/quantdinger/best_params/seed270_candidate_008/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "seed270_candidate_008", + "note": "Phase 7b non-baseline: pullback=6, up_line_span=720, WF=0.423832", + "train_score": 0.423832, + "full_year_backtest": { + "totalReturn": 38.74, + "maxDrawdown": -14.76, + "totalTrades": 84, + "winRate": 17.86, + "profitFactor": 2.19, + "sharpeRatio": 1.5, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 909.79, + "2025-02": -389.07, + "2025-03": -141.81, + "2025-04": 1264.03, + "2025-05": 1276.92, + "2025-06": 379.97, + "2025-07": 1031.71, + "2025-08": 323.94, + "2025-09": 405.24, + "2025-10": 1796.33, + "2025-11": -359.36, + "2025-12": -1345.22 + }, + "exit_reasons": { + "time_stop_pct": 8.33, + "breakeven_stop_pct": 64.29, + "trailing_stop_pct": 26.19, + "lock_profit_stop_pct": 1.19 + } + } +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed270_candidate_008/params.json b/quant/quantdinger/best_params/seed270_candidate_008/params.json new file mode 100644 index 0000000..8f76d42 --- /dev/null +++ b/quant/quantdinger/best_params/seed270_candidate_008/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 720, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.63, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 48, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 6, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} \ No newline at end of file diff --git a/quant/quantdinger/best_params/seed280_candidate_099/full_year_backtest.json b/quant/quantdinger/best_params/seed280_candidate_099/full_year_backtest.json new file mode 100644 index 0000000..792ec1d --- /dev/null +++ b/quant/quantdinger/best_params/seed280_candidate_099/full_year_backtest.json @@ -0,0 +1,34 @@ +{ + "candidate": "seed280_candidate_099", + "note": "Phase 8a BEST: lever=1.0, offset=1.04. Walk-forward score 0.514159, full-year +45.00%. Breakthrough: lower lever + looser trailing stop synergy.", + "train_score": 0.514159, + "full_year_backtest": { + "totalReturn": 45.00, + "maxDrawdown": -13.20, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.44, + "sharpeRatio": 1.72, + "profitable_months": 8, + "monthlyPnL": { + "2025-01": 893.02, + "2025-02": -341.65, + "2025-03": -104.16, + "2025-04": 1299.10, + "2025-05": 1372.53, + "2025-06": 383.47, + "2025-07": 1110.54, + "2025-08": 331.88, + "2025-09": 415.17, + "2025-10": 1866.09, + "2025-11": -80.88, + "2025-12": -1404.93 + }, + "exit_reasons": { + "time_stop_pct": 8.64, + "breakeven_stop_pct": 64.20, + "trailing_stop_pct": 25.93, + "lock_profit_stop_pct": 1.23 + } + } +} diff --git a/quant/quantdinger/best_params/seed280_candidate_099/params.json b/quant/quantdinger/best_params/seed280_candidate_099/params.json new file mode 100644 index 0000000..7b4a03e --- /dev/null +++ b/quant/quantdinger/best_params/seed280_candidate_099/params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.0, + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/best_params/seed290_candidate_385/full_year_backtest.json b/quant/quantdinger/best_params/seed290_candidate_385/full_year_backtest.json new file mode 100644 index 0000000..c1cd381 --- /dev/null +++ b/quant/quantdinger/best_params/seed290_candidate_385/full_year_backtest.json @@ -0,0 +1,38 @@ +{ + "label": "offset=1.5 lever=1.0", + "seed": 290, + "candidate": "candidate_385", + "trainScore": 0.512908, + "totalReturn": 44.83, + "maxDrawdown": -13.2, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.44, + "sharpeRatio": 1.7, + "annualReturn": 44.84, + "totalProfit": 4483.42, + "totalCommission": 2489.85, + "nWins": 16, + "nLosses": 65, + "monthlyPnL": { + "2025-01": 856.76, + "2025-02": -453.18, + "2025-03": -166.95, + "2025-04": 1217.9, + "2025-05": 1269.08, + "2025-06": 261.46, + "2025-07": 913.47, + "2025-08": 246.28, + "2025-09": 539.14, + "2025-10": 1581.64, + "2025-11": -177.68, + "2025-12": -1604.5 + }, + "exitReasons": { + "unknown": 81, + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + } +} diff --git a/quant/quantdinger/best_params/seed290_candidate_385/params.json b/quant/quantdinger/best_params/seed290_candidate_385/params.json new file mode 100644 index 0000000..24770e4 --- /dev/null +++ b/quant/quantdinger/best_params/seed290_candidate_385/params.json @@ -0,0 +1,24 @@ +{ + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720, + "strategy_lever_rate": 1.0, + "up_line_offset": 1.5, + "buy_stop_profit_span": 240 +} diff --git a/quant/quantdinger/best_params/seed290_candidate_405/full_year_backtest.json b/quant/quantdinger/best_params/seed290_candidate_405/full_year_backtest.json new file mode 100644 index 0000000..d3ec1f3 --- /dev/null +++ b/quant/quantdinger/best_params/seed290_candidate_405/full_year_backtest.json @@ -0,0 +1,38 @@ +{ + "label": "offset=2.0 lever=1.0", + "seed": 290, + "candidate": "candidate_405", + "trainScore": 0.514159, + "totalReturn": 45.0, + "maxDrawdown": -13.2, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.44, + "sharpeRatio": 1.72, + "annualReturn": 45.0, + "totalProfit": 4499.8, + "totalCommission": 2489.18, + "nWins": 16, + "nLosses": 65, + "monthlyPnL": { + "2025-01": 856.76, + "2025-02": -453.18, + "2025-03": -166.95, + "2025-04": 1230.85, + "2025-05": 1270.52, + "2025-06": 261.76, + "2025-07": 914.5, + "2025-08": 246.56, + "2025-09": 539.75, + "2025-10": 1583.43, + "2025-11": -177.88, + "2025-12": -1606.32 + }, + "exitReasons": { + "unknown": 81, + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + } +} diff --git a/quant/quantdinger/best_params/seed290_candidate_405/params.json b/quant/quantdinger/best_params/seed290_candidate_405/params.json new file mode 100644 index 0000000..4af7778 --- /dev/null +++ b/quant/quantdinger/best_params/seed290_candidate_405/params.json @@ -0,0 +1,24 @@ +{ + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720, + "strategy_lever_rate": 1.0, + "up_line_offset": 2.0, + "buy_stop_profit_span": 240 +} diff --git a/quant/quantdinger/best_params/seed292_candidate_291/full_year_backtest.json b/quant/quantdinger/best_params/seed292_candidate_291/full_year_backtest.json new file mode 100644 index 0000000..7449ab5 --- /dev/null +++ b/quant/quantdinger/best_params/seed292_candidate_291/full_year_backtest.json @@ -0,0 +1,38 @@ +{ + "label": "offset=1.7 lever=1.0", + "seed": 292, + "candidate": "candidate_291", + "trainScore": 0.514159, + "totalReturn": 45.0, + "maxDrawdown": -13.2, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.44, + "sharpeRatio": 1.72, + "annualReturn": 45.0, + "totalProfit": 4499.8, + "totalCommission": 2489.18, + "nWins": 16, + "nLosses": 65, + "monthlyPnL": { + "2025-01": 856.76, + "2025-02": -453.18, + "2025-03": -166.95, + "2025-04": 1230.85, + "2025-05": 1270.52, + "2025-06": 261.76, + "2025-07": 914.5, + "2025-08": 246.56, + "2025-09": 539.75, + "2025-10": 1583.43, + "2025-11": -177.88, + "2025-12": -1606.32 + }, + "exitReasons": { + "unknown": 81, + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 21, + "lock_profit_stop": 1 + } +} diff --git a/quant/quantdinger/best_params/seed292_candidate_291/params.json b/quant/quantdinger/best_params/seed292_candidate_291/params.json new file mode 100644 index 0000000..ec86d6f --- /dev/null +++ b/quant/quantdinger/best_params/seed292_candidate_291/params.json @@ -0,0 +1,24 @@ +{ + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720, + "strategy_lever_rate": 1.0, + "up_line_offset": 1.7, + "buy_stop_profit_span": 240 +} diff --git a/quant/quantdinger/best_params/seed292_candidate_421/full_year_backtest.json b/quant/quantdinger/best_params/seed292_candidate_421/full_year_backtest.json new file mode 100644 index 0000000..85b62d6 --- /dev/null +++ b/quant/quantdinger/best_params/seed292_candidate_421/full_year_backtest.json @@ -0,0 +1,39 @@ +{ + "label": "lever=0.8 offset=1.8", + "seed": 292, + "candidate": "candidate_421", + "trainScore": 0.513198, + "totalReturn": 43.73, + "maxDrawdown": -12.62, + "totalTrades": 81, + "winRate": 19.75, + "profitFactor": 2.48, + "sharpeRatio": 1.74, + "annualReturn": 43.73, + "totalProfit": 4372.55, + "totalCommission": 2381.71, + "nWins": 16, + "nLosses": 65, + "monthlyPnL": { + "2025-01": 685.89, + "2025-02": -424.66, + "2025-03": -145.51, + "2025-04": 1189.9, + "2025-05": 1252.58, + "2025-06": 217.71, + "2025-07": 898.75, + "2025-08": 242.32, + "2025-09": 530.45, + "2025-10": 1585.12, + "2025-11": -160.96, + "2025-12": -1499.04 + }, + "exitReasons": { + "unknown": 81, + "time_stop": 7, + "breakeven_stop": 52, + "trailing_stop": 20, + "lock_profit_stop": 1, + "protective_stop": 1 + } +} diff --git a/quant/quantdinger/best_params/seed292_candidate_421/params.json b/quant/quantdinger/best_params/seed292_candidate_421/params.json new file mode 100644 index 0000000..7749d50 --- /dev/null +++ b/quant/quantdinger/best_params/seed292_candidate_421/params.json @@ -0,0 +1,24 @@ +{ + "profit_line": 0.08, + "lock_profit_rate": 0.33, + "open_time_interval": 7, + "up_line_span": 504, + "buy_stop_profit_offset": 1.04, + "ma_span_long": 1, + "regime_slope_lookback": 48, + "regime_slope_threshold": 0.008, + "regime_displace_threshold": 0.14, + "regime_vol_ema_span": 24, + "regime_compression_threshold": 0.59, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 5, + "entry_up_line_span_short": 60, + "entry_ma_span_short": 7, + "entry_pullback_bars_min": 8, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.001, + "exit_max_hold_bars": 720, + "strategy_lever_rate": 0.8, + "up_line_offset": 1.8, + "buy_stop_profit_span": 240 +} diff --git a/quant/quantdinger/channel_breakout.strategy_defaults.json b/quant/quantdinger/channel_breakout.strategy_defaults.json new file mode 100644 index 0000000..40a9f5d --- /dev/null +++ b/quant/quantdinger/channel_breakout.strategy_defaults.json @@ -0,0 +1,11 @@ +{ + "strategy_lever_rate": 1.0, + "profit_line": 0.02, + "lock_profit_rate": 0.2, + "open_time_interval": 8, + "up_line_span": 432, + "up_line_offset": 0.0, + "buy_stop_profit_span": 144, + "buy_stop_profit_offset": 0.0, + "ma_span_long": 1 +} diff --git a/quant/quantdinger/channel_breakout.yaml b/quant/quantdinger/channel_breakout.yaml new file mode 100644 index 0000000..013c86d --- /dev/null +++ b/quant/quantdinger/channel_breakout.yaml @@ -0,0 +1,12 @@ +strategy: ../strategies/channel_breakout.py +data: ../../data/channel_breakout_sample_10m.csv +strategy_type: script +symbol: BTCUSDT +market: Crypto +timeframe: 10m +start: 2024-01-01T00:00:00 +end: 2024-01-02T15:50:00 +commission: 0.0005 +strategy_config: + execution: + signalTiming: next_bar_open diff --git a/quant/quantdinger/config.json b/quant/quantdinger/config.json new file mode 100644 index 0000000..aff4f6e --- /dev/null +++ b/quant/quantdinger/config.json @@ -0,0 +1,141 @@ +{ + "max_open_trades": 5, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": false, + "timeframe": "5m", + "stoploss": -0.10, + "cancel_open_orders_on_exit": false, + "trading_mode": "futures", + "margin_mode": "isolated", + "collateral": "USDT", + "unfilledtimeout": { + "entry": 10, + "exit": 10, + "exit_timeout_count": 0, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "ask", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0 + }, + "exchange": { + "name": "okx", + "key": "e756108d-f14c-4777-8351-cd989f389dd2", + "secret": "BC4579AEE9816ED4A38218910B6EC278", + "password": "30l9L666.", + "ccxt_config": { + "hostname": "www.okx.cab" + }, + "ccxt_async_config": { + "hostname": "www.okx.cab" + }, + "pair_whitelist": [ + "BTC/USDT:USDT", + "ETH/USDT:USDT", + "TRX/USDT:USDT" + ], + "pair_blacklist": [] + }, + "pairlists": [ + {"method": "StaticPairList"} + ], + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080, + "verbosity": "error", + "enable_openapi": true, + "jwt_secret_key": "somethingRandomSomethingRandom123", + "CORS_origins": ["*"], + "username": "liam", + "password": "30l9L666" + }, + "bot_name": "freqtrade", + "force_entry_enable": true, + "webhook": { + "enabled": true, + "url": "https://open.feishu.cn/open-apis/bot/v2/hook/2b1bc9a6-b470-447f-b783-1b253dcf522c", + "format": "json", + "timeout": 10, + "entry": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "🟢 交易信号: {pair}", "tag": "plain_text"}, + "template": "blue" + }, + "elements": [ + {"tag": "div", "text": {"content": "方向: {direction}\n入场价: {open_rate}\n金额: {stake_amount:.2f} {stake_currency}\n杠杆: {leverage}x\n信号: {enter_tag}", "tag": "lark_md"}}, + {"tag": "hr"}, + {"tag": "div", "text": {"content": "时间: {open_date}", "tag": "lark_md"}} + ] + } + }, + "entry_fill": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "✅ 入场成交: {pair}", "tag": "plain_text"}, + "template": "green" + }, + "elements": [ + {"tag": "div", "text": {"content": "成交价: {open_rate}\n数量: {amount}\n金额: {stake_amount:.2f} {stake_currency}\n杠杆: {leverage}x", "tag": "lark_md"}} + ] + } + }, + "exit": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "🔴 退出信号: {pair}", "tag": "plain_text"}, + "template": "red" + }, + "elements": [ + {"tag": "div", "text": {"content": "当前价: {current_rate}\n盈亏率: {profit_ratio:.2%}\n原因: {exit_reason}", "tag": "lark_md"}} + ] + } + }, + "exit_fill": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "✅ 退出成交: {pair}", "tag": "plain_text"}, + "template": "green" + }, + "elements": [ + {"tag": "div", "text": {"content": "退出价: {close_rate}\n盈亏率: {profit_ratio:.2%}\n盈亏额: {profit_amount:.4f}\n原因: {exit_reason}", "tag": "lark_md"}} + ] + } + }, + "entry_cancel": { + "msg_type": "interactive", + "card": { + "header": { + "title": {"content": "⚠ 入场取消: {pair}", "tag": "plain_text"}, + "template": "yellow" + }, + "elements": [ + {"tag": "div", "text": {"content": "原因: {reason}\n数量: {amount}\n价格: {open_rate}", "tag": "lark_md"}} + ] + } + } + }, + "initial_state": "running", + "strategy": "QuantDingerStrategy", + "strategy_path": "user_data/strategies/" +} diff --git a/quant/quantdinger/live_params.json b/quant/quantdinger/live_params.json new file mode 100644 index 0000000..5415d6a --- /dev/null +++ b/quant/quantdinger/live_params.json @@ -0,0 +1,24 @@ +{ + "strategy_lever_rate": 1.5, + "profit_line": 0.056, + "lock_profit_rate": 0.26, + "open_time_interval": 8, + "up_line_span": 576, + "up_line_offset": 0.8, + "buy_stop_profit_span": 192, + "buy_stop_profit_offset": 0.96, + "ma_span_long": 1, + "regime_slope_lookback": 24, + "regime_slope_threshold": 0.003, + "regime_displace_threshold": 0.18, + "regime_vol_ema_span": 72, + "regime_compression_threshold": 0.45, + "regime_expansion_threshold": 1.0, + "regime_hysteresis_bars": 4, + "entry_up_line_span_short": 96, + "entry_ma_span_short": 8, + "entry_pullback_bars_min": 6, + "exit_max_loss_pct": 0.03, + "exit_breakeven_buffer": 0.005, + "exit_max_hold_bars": 720 +} diff --git a/quant/quantdinger/quantdinger_strategy.live.py b/quant/quantdinger/quantdinger_strategy.live.py new file mode 100644 index 0000000..b77f97a --- /dev/null +++ b/quant/quantdinger/quantdinger_strategy.live.py @@ -0,0 +1,521 @@ +import numpy as np +import pandas as pd +import logging +from datetime import datetime, timedelta, timezone +from pandas import DataFrame +from typing import Optional + +from freqtrade.enums import CandleType +from freqtrade.strategy import IStrategy, IntParameter, RealParameter +from freqtrade.strategy import stoploss_from_absolute +from freqtrade.persistence import Trade + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# N-value constants (mirrors quantdinger strategy.py) +# --------------------------------------------------------------------------- +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + +# Regime enum +REGIME_COMPRESSION = 0 +REGIME_EXPANSION = 1 +REGIME_TREND = 2 +REGIME_RANGE = 3 + + +def _ema_of(values): + """EMAp of a list of values, matching the quantdinger _ema function.""" + if not values: + return 0.0 + result = None + n = float(len(values)) + for v in values: + v = float(v) + if result is None: + result = v + else: + result = 2.0 * v / (n + 1.0) + (n - 1.0) / (n + 1.0) * result + return result or 0.0 + + +class QuantDingerStrategy(IStrategy): + """ + Freqtrade port of the quantdinger live strategy. + + Original strategy: ~/agents/quantdinger/live/strategy.py + Trading: USDT-M perpetual futures (long only) + Timeframe: 5m (OKX does not support 10m; parameters scaled from 10m original) + Exchange: OKX isolated futures + + Core approach: + - N-value (custom ATR) for dynamic position sizing and stop placement + - Market regime detection (compression/expansion/trend/range) with hysteresis + - Regime-gated entries: breakout chase (trend), compression breakout, + pullback reentry (range) + - Priority-ordered exits: protective → trailing → lock-profit → breakeven → time + """ + + INTERFACE_VERSION = 3 + timeframe = "5m" + can_short = False + use_custom_stoploss = True + process_only_new_candles = True + + # OKX limits 5m candles to 300 per request, 5 calls max = 1499 candles. + # We use 1400 (passes validation: ceil(1401/300)=5) and pre-fetch the + # remaining required history in bot_start() by bumping _startup_candle_count + # on the exchange, so retention keeps 300 + 5000 = 5300 candles. + startup_candle_count = 1400 + + # Bars per day for 5m candles: 24 * 60 / 5 = 288 + _BARS_PER_DAY = 288 + + # ROI disabled — exits are driven entirely by custom_stoploss / custom_exit + minimal_roi = {"0": 1.0} + + # Hard stop-loss floor; custom_stoploss tightens from here + stoploss = -0.10 + + # Approximate cooldown (7 h × 12 candles/h at 5m = 84 candles) + ignore_buying_expired_candle_after = 84 + + # ----------------------------------------------------------------------- + # Strategy parameters (scaled from original 10m defaults to 5m ×2) + # ----------------------------------------------------------------------- + strategy_lever_rate = RealParameter(0.5, 3.0, default=1.0, space="buy", load=True) + profit_line = RealParameter(0.02, 0.20, default=0.08, space="sell", load=True) + lock_profit_rate = RealParameter(0.10, 0.50, default=0.33, space="sell", load=True) + open_time_interval = RealParameter(1.0, 24.0, default=7.0, space="buy", load=True) + up_line_span = IntParameter(200, 1600, default=1008, space="buy", load=True) + up_line_offset = RealParameter(0.5, 3.0, default=1.8, space="buy", load=True) + buy_stop_profit_span = IntParameter(100, 1000, default=480, space="sell", load=True) + buy_stop_profit_offset = RealParameter(0.5, 2.0, default=1.04, space="sell", + load=True) + ma_span_long = IntParameter(1, 10, default=1, space="buy", load=True) + regime_slope_lookback = IntParameter(20, 200, default=96, space="buy", load=True) + regime_slope_threshold = RealParameter(0.001, 0.05, default=0.008, space="buy", + load=True) + regime_displace_threshold = RealParameter(0.05, 0.30, default=0.14, space="buy", + load=True) + regime_vol_ema_span = IntParameter(10, 200, default=48, space="buy", load=True) + regime_compression_threshold = RealParameter(0.2, 1.0, default=0.59, space="buy", + load=True) + regime_expansion_threshold = RealParameter(0.8, 3.0, default=1.0, space="buy", + load=True) + regime_hysteresis_bars = IntParameter(2, 40, default=10, space="buy", load=True) + entry_up_line_span_short = IntParameter(20, 400, default=120, space="buy", load=True) + entry_ma_span_short = IntParameter(1, 30, default=7, space="buy", load=True) + entry_pullback_bars_min = IntParameter(4, 60, default=16, space="buy", load=True) + exit_max_loss_pct = RealParameter(0.01, 0.10, default=0.03, space="sell", load=True) + exit_breakeven_buffer = RealParameter(0.0005, 0.02, default=0.001, space="sell", + load=True) + exit_max_hold_bars = IntParameter(200, 4000, default=1440, space="sell", load=True) + + # ----------------------------------------------------------------------- + # Pre-fetch sufficient historical data to satisfy indicator warmup needs. + # OKX 5m candle limit is 300/request, validated max 5 calls → ~1500 candles. + # We need 2016+ for ma_short (7 days × 288 bars/day), so we fetch further + # history and raise the exchange retention limit after validation passes. + # ----------------------------------------------------------------------- + + def bot_start(self, **kwargs) -> None: + exchange = self.dp._exchange + exchange._startup_candle_count = max(exchange._startup_candle_count, 5000) + + # Only fetch if cache is empty (first start, not restart with warm cache) + candle_type = CandleType.FUTURES + pairs = self.config["exchange"]["pair_whitelist"] + pairs_missing = [ + p for p in pairs + if (p, self.timeframe, candle_type) not in exchange._klines + ] + if not pairs_missing: + return + + since_ms = int((datetime.now(timezone.utc) - timedelta(days=12)).timestamp() * 1000) + logger.info( + f"Pre-fetching ~12 days of history for {len(pairs_missing)} pairs " + f"to satisfy indicator warmup..." + ) + for pair in pairs_missing: + try: + df = exchange.get_historic_ohlcv( + pair=pair, + timeframe=self.timeframe, + since_ms=since_ms, + candle_type=candle_type, + ) + if not df.empty: + exchange._klines[(pair, self.timeframe, candle_type)] = df + exchange._pairs_last_refresh_time[ + (pair, self.timeframe, candle_type) + ] = int(df.iloc[-1]["date"].timestamp() * 1000) + logger.info( + f" {pair}: pre-loaded {len(df)} candles " + f"(from {df.iloc[0]['date']} to {df.iloc[-1]['date']})" + ) + except Exception as e: + logger.warning(f" {pair}: pre-fetch failed ({e}), " + f"will rely on normal data loading") + + # ----------------------------------------------------------------------- + # Indicator calculation + # ----------------------------------------------------------------------- + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # Resolve all parameter values once + up_line_span = self.up_line_span.value + up_line_offset = self.up_line_offset.value + buy_stop_profit_span = self.buy_stop_profit_span.value + buy_stop_profit_offset = self.buy_stop_profit_offset.value + ma_span_long = self.ma_span_long.value + regime_slope_lookback = self.regime_slope_lookback.value + regime_slope_threshold = self.regime_slope_threshold.value + regime_displace_threshold = self.regime_displace_threshold.value + regime_vol_ema_span = self.regime_vol_ema_span.value + regime_compression_threshold = self.regime_compression_threshold.value + regime_expansion_threshold = self.regime_expansion_threshold.value + regime_hysteresis_bars = self.regime_hysteresis_bars.value + entry_up_line_span_short = self.entry_up_line_span_short.value + entry_ma_span_short = self.entry_ma_span_short.value + entry_pullback_bars_min = self.entry_pullback_bars_min.value + + # -- Vectorized channel indicators ---------------------------------- + dataframe["up_line"] = ( + dataframe["high"].rolling(up_line_span).max() + ) + dataframe["up_line_short"] = ( + dataframe["high"].rolling(entry_up_line_span_short).max() + ) + dataframe["stop_profit_bottom"] = ( + dataframe["low"].rolling(buy_stop_profit_span).min() + ) + + ma_len = int(ma_span_long) * self._BARS_PER_DAY + dataframe["ma_long"] = dataframe["close"].rolling(ma_len).mean() + + ma_short_len = int(entry_ma_span_short) * self._BARS_PER_DAY + dataframe["ma_short"] = dataframe["close"].rolling(ma_short_len).mean() + + # -- Stateful N-value + regime (iterate through dataframe) ---------- + n_values = [0.0] * len(dataframe) + regimes = [REGIME_RANGE] * len(dataframe) + vol_expanding = [False] * len(dataframe) + pullback_trigger = [False] * len(dataframe) + + n_ema = None + regime_candidate = REGIME_RANGE + regime_candidate_bars = 0 + effective_regime = REGIME_RANGE + prev_vol_ratio = None + pullback_bars_below = 0 + + close = dataframe["close"].values + high = dataframe["high"].values + low = dataframe["low"].values + ma_long_arr = dataframe["ma_long"].values + ma_short_arr = dataframe["ma_short"].values + + chunk_count = N_VALUE_SPAN + n_window = SPREAD_SPAN * N_VALUE_SPAN # 60 + + for i in range(len(dataframe)): + # --- N-value --------------------------------------------------- + if i >= n_window: # Need 60 full bars of history BEFORE current + spreads = [] + for j in range(chunk_count): + start = i - n_window + j * SPREAD_SPAN + end = start + SPREAD_SPAN + chunk_high = high[start:end].max() + chunk_low = low[start:end].min() + spreads.append(chunk_high - chunk_low) + n_val = _ema_of(spreads) + else: + n_val = 0.0 + n_values[i] = n_val + + # --- N-value EMA for vol_ratio -------------------------------- + if n_ema is None: + n_ema = n_val + else: + ema_alpha = 2.0 / (regime_vol_ema_span + 1.0) + n_ema = ema_alpha * n_val + (1.0 - ema_alpha) * n_ema + + vol_ratio = n_val / n_ema if n_ema and n_ema > 0 else 1.0 + vol_expanding[i] = prev_vol_ratio is not None and vol_ratio > prev_vol_ratio + prev_vol_ratio = vol_ratio + + # --- Regime classification ------------------------------------ + ma_l = ma_long_arr[i] + regime = REGIME_RANGE + if ma_l is not None and not np.isnan(ma_l) and n_val > 0: + # Price displacement from MA + if ma_l != 0: + price_displacement = (close[i] - ma_l) / ma_l + else: + price_displacement = 0.0 + + # MA slope + ma_slope = None + if i >= int(regime_slope_lookback): + past_idx = i - int(regime_slope_lookback) + ma_past = ma_long_arr[past_idx] + if (ma_past is not None and not np.isnan(ma_past) + and ma_past != 0): + ma_slope = (ma_l - ma_past) / ma_past + + # Classify + if vol_ratio < regime_compression_threshold: + regime = REGIME_COMPRESSION + elif vol_ratio > regime_expansion_threshold: + regime = REGIME_EXPANSION + elif (ma_slope is not None + and abs(ma_slope) > regime_slope_threshold + and abs(price_displacement) > regime_displace_threshold): + regime = REGIME_TREND + else: + regime = REGIME_RANGE + + # --- Regime hysteresis ----------------------------------------- + if regime == regime_candidate: + regime_candidate_bars += 1 + else: + regime_candidate = regime + regime_candidate_bars = 1 + + if regime_candidate_bars >= int(regime_hysteresis_bars): + effective_regime = regime_candidate + + regimes[i] = effective_regime + + # --- Pullback reentry tracking (for range entries) ------------- + if ma_short_arr[i] is not None and not np.isnan(ma_short_arr[i]): + if close[i] < ma_short_arr[i]: + pullback_bars_below += 1 + else: + if (pullback_bars_below >= entry_pullback_bars_min + and close[i] > ma_short_arr[i]): + pullback_trigger[i] = True + pullback_bars_below = 0 + + dataframe["n_value"] = n_values + dataframe["regime"] = regimes + dataframe["vol_expanding"] = vol_expanding + dataframe["pullback_trigger"] = pullback_trigger + dataframe["_n_ema"] = n_ema # Store reference value for diagnostics + + # Diagnostics: regime distribution and indicator health + recent = dataframe.tail(288) # last 24h + r_counts = recent["regime"].value_counts().to_dict() + regime_names = {0: "COMPR", 1: "EXPAN", 2: "TREND", 3: "RANGE"} + parts = [] + for r, name in regime_names.items(): + if r in r_counts: + parts.append(f"{name}={r_counts[r]}") + ma_ok = int(not dataframe["ma_short"].isna().all()) + n_ok = int((dataframe["n_value"] > 0).any()) + entry_count = int(dataframe["enter_long"].sum()) if "enter_long" in dataframe else 0 + logger.info( + f"{metadata['pair']}: candles={len(dataframe)}, " + f"regime_24h=[{', '.join(parts)}], " + f"ma_short_ok={ma_ok}, n_ok={n_ok}, entries={entry_count}" + ) + + return dataframe + + # ----------------------------------------------------------------------- + # Entry signals + # ----------------------------------------------------------------------- + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + up_line_offset = self.up_line_offset.value + buy_stop_profit_offset = self.buy_stop_profit_offset.value # noqa (kept for future use) + + n_val = dataframe["n_value"] + close = dataframe["close"] + regime = dataframe["regime"] + + # -- Trend: breakout chase ------------------------------------------ + trend_cond = ( + (regime == REGIME_TREND) + & (close > dataframe["ma_long"]) + & (close > dataframe["up_line"] + n_val * up_line_offset) + ) + + # -- Compression / Expansion: compression breakout ------------------ + ce_cond = ( + ((regime == REGIME_COMPRESSION) | (regime == REGIME_EXPANSION)) + & (dataframe["vol_expanding"]) + & (close > dataframe["up_line_short"] + n_val * up_line_offset) + ) + + # -- Range: pullback reentry ---------------------------------------- + range_cond = ( + (regime == REGIME_RANGE) + & (dataframe["pullback_trigger"]) + ) + + dataframe.loc[trend_cond | ce_cond | range_cond, "enter_long"] = 1 + return dataframe + + # ----------------------------------------------------------------------- + # Exit signals (stub — real exits in custom_stoploss / custom_exit) + # ----------------------------------------------------------------------- + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + return dataframe + + # ----------------------------------------------------------------------- + # Dynamic stoploss: protective → trailing → breakeven + # ----------------------------------------------------------------------- + + def custom_stoploss( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + after_fill: bool, + **kwargs, + ) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + + last = dataframe.iloc[-1] + n_value = last.get("n_value", 0) + if not n_value or n_value <= 0: + return None + + exit_max_loss_pct = self.exit_max_loss_pct.value + exit_breakeven_buffer = self.exit_breakeven_buffer.value + buy_stop_profit_offset = self.buy_stop_profit_offset.value + strategy_lever_rate = self.strategy_lever_rate.value + + # 1. Protective stop — absolute price floor + protective_price = trade.open_rate * ( + 1.0 - exit_max_loss_pct * strategy_lever_rate + ) + + # 2. Trailing stop + stop_profit_bottom = last.get("stop_profit_bottom", 0) + trailing_price = stop_profit_bottom + n_value * buy_stop_profit_offset + + # 3. Breakeven stop + breakeven_price = None + if current_profit > exit_breakeven_buffer: + trade.set_custom_data("breakeven_armed", True) + + if trade.get_custom_data("breakeven_armed"): + breakeven_price = trade.open_rate - n_value * 0.3 + + # Choose the highest (tightest) stop price among active stops + candidates = [protective_price] + if trailing_price and trailing_price > 0: + candidates.append(trailing_price) + if breakeven_price is not None: + candidates.append(breakeven_price) + + stop_price = max(candidates) + + # Convert to relative stoploss and ensure it's below current_rate + sl = stoploss_from_absolute( + stop_price, current_rate, + is_short=trade.is_short, + leverage=trade.leverage, + ) + # Only tighten — never let stoploss go above previous + return sl + + # ----------------------------------------------------------------------- + # Custom exits: lock profit → time stop + # ----------------------------------------------------------------------- + + def custom_exit( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + **kwargs, + ) -> Optional[str]: + profit_line = self.profit_line.value + lock_profit_rate = self.lock_profit_rate.value + exit_max_hold_bars = self.exit_max_hold_bars.value + + # --- Track peak profit -------------------------------------------- + max_pp = trade.get_custom_data("max_profit_pct") or 0.0 + if current_profit > max_pp: + trade.set_custom_data("max_profit_pct", current_profit) + max_pp = current_profit + + # --- Lock profit -------------------------------------------------- + if (max_pp >= profit_line + and current_profit > 0 + and current_profit < max_pp * (1.0 - lock_profit_rate)): + return "lock_profit" + + # --- Time stop ---------------------------------------------------- + bars_held = ( + (current_time.replace(tzinfo=timezone.utc) + - trade.open_date_utc).total_seconds() / 600.0 + ) + if bars_held >= exit_max_hold_bars: + return "time_stop" + + return None + + # ----------------------------------------------------------------------- + # N-value based dynamic position sizing + # ----------------------------------------------------------------------- + + def custom_stake_amount( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_stake: float, + min_stake: float | None, + max_stake: float, + leverage: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return 0.0 + + n_value = dataframe.iloc[-1].get("n_value", 0) + if n_value <= 0 or current_rate <= 0: + return 0.0 + + strategy_lever_rate = self.strategy_lever_rate.value + stop_loss_pct = n_value / current_rate + pct = 0.01 * strategy_lever_rate / stop_loss_pct + pct = max(0.0, min(pct, 1.0)) + + stake = max(float(min_stake or 0), min(max_stake, max_stake * pct)) + return stake + + # ----------------------------------------------------------------------- + # Fixed leverage for OKX USDT-M futures + # ----------------------------------------------------------------------- + + def leverage( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_leverage: float, + max_leverage: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + return 10.0 diff --git a/quant/quantdinger/quantdinger_strategy.py b/quant/quantdinger/quantdinger_strategy.py new file mode 100644 index 0000000..b77f97a --- /dev/null +++ b/quant/quantdinger/quantdinger_strategy.py @@ -0,0 +1,521 @@ +import numpy as np +import pandas as pd +import logging +from datetime import datetime, timedelta, timezone +from pandas import DataFrame +from typing import Optional + +from freqtrade.enums import CandleType +from freqtrade.strategy import IStrategy, IntParameter, RealParameter +from freqtrade.strategy import stoploss_from_absolute +from freqtrade.persistence import Trade + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# N-value constants (mirrors quantdinger strategy.py) +# --------------------------------------------------------------------------- +SPREAD_SPAN = 6 +N_VALUE_SPAN = 10 + +# Regime enum +REGIME_COMPRESSION = 0 +REGIME_EXPANSION = 1 +REGIME_TREND = 2 +REGIME_RANGE = 3 + + +def _ema_of(values): + """EMAp of a list of values, matching the quantdinger _ema function.""" + if not values: + return 0.0 + result = None + n = float(len(values)) + for v in values: + v = float(v) + if result is None: + result = v + else: + result = 2.0 * v / (n + 1.0) + (n - 1.0) / (n + 1.0) * result + return result or 0.0 + + +class QuantDingerStrategy(IStrategy): + """ + Freqtrade port of the quantdinger live strategy. + + Original strategy: ~/agents/quantdinger/live/strategy.py + Trading: USDT-M perpetual futures (long only) + Timeframe: 5m (OKX does not support 10m; parameters scaled from 10m original) + Exchange: OKX isolated futures + + Core approach: + - N-value (custom ATR) for dynamic position sizing and stop placement + - Market regime detection (compression/expansion/trend/range) with hysteresis + - Regime-gated entries: breakout chase (trend), compression breakout, + pullback reentry (range) + - Priority-ordered exits: protective → trailing → lock-profit → breakeven → time + """ + + INTERFACE_VERSION = 3 + timeframe = "5m" + can_short = False + use_custom_stoploss = True + process_only_new_candles = True + + # OKX limits 5m candles to 300 per request, 5 calls max = 1499 candles. + # We use 1400 (passes validation: ceil(1401/300)=5) and pre-fetch the + # remaining required history in bot_start() by bumping _startup_candle_count + # on the exchange, so retention keeps 300 + 5000 = 5300 candles. + startup_candle_count = 1400 + + # Bars per day for 5m candles: 24 * 60 / 5 = 288 + _BARS_PER_DAY = 288 + + # ROI disabled — exits are driven entirely by custom_stoploss / custom_exit + minimal_roi = {"0": 1.0} + + # Hard stop-loss floor; custom_stoploss tightens from here + stoploss = -0.10 + + # Approximate cooldown (7 h × 12 candles/h at 5m = 84 candles) + ignore_buying_expired_candle_after = 84 + + # ----------------------------------------------------------------------- + # Strategy parameters (scaled from original 10m defaults to 5m ×2) + # ----------------------------------------------------------------------- + strategy_lever_rate = RealParameter(0.5, 3.0, default=1.0, space="buy", load=True) + profit_line = RealParameter(0.02, 0.20, default=0.08, space="sell", load=True) + lock_profit_rate = RealParameter(0.10, 0.50, default=0.33, space="sell", load=True) + open_time_interval = RealParameter(1.0, 24.0, default=7.0, space="buy", load=True) + up_line_span = IntParameter(200, 1600, default=1008, space="buy", load=True) + up_line_offset = RealParameter(0.5, 3.0, default=1.8, space="buy", load=True) + buy_stop_profit_span = IntParameter(100, 1000, default=480, space="sell", load=True) + buy_stop_profit_offset = RealParameter(0.5, 2.0, default=1.04, space="sell", + load=True) + ma_span_long = IntParameter(1, 10, default=1, space="buy", load=True) + regime_slope_lookback = IntParameter(20, 200, default=96, space="buy", load=True) + regime_slope_threshold = RealParameter(0.001, 0.05, default=0.008, space="buy", + load=True) + regime_displace_threshold = RealParameter(0.05, 0.30, default=0.14, space="buy", + load=True) + regime_vol_ema_span = IntParameter(10, 200, default=48, space="buy", load=True) + regime_compression_threshold = RealParameter(0.2, 1.0, default=0.59, space="buy", + load=True) + regime_expansion_threshold = RealParameter(0.8, 3.0, default=1.0, space="buy", + load=True) + regime_hysteresis_bars = IntParameter(2, 40, default=10, space="buy", load=True) + entry_up_line_span_short = IntParameter(20, 400, default=120, space="buy", load=True) + entry_ma_span_short = IntParameter(1, 30, default=7, space="buy", load=True) + entry_pullback_bars_min = IntParameter(4, 60, default=16, space="buy", load=True) + exit_max_loss_pct = RealParameter(0.01, 0.10, default=0.03, space="sell", load=True) + exit_breakeven_buffer = RealParameter(0.0005, 0.02, default=0.001, space="sell", + load=True) + exit_max_hold_bars = IntParameter(200, 4000, default=1440, space="sell", load=True) + + # ----------------------------------------------------------------------- + # Pre-fetch sufficient historical data to satisfy indicator warmup needs. + # OKX 5m candle limit is 300/request, validated max 5 calls → ~1500 candles. + # We need 2016+ for ma_short (7 days × 288 bars/day), so we fetch further + # history and raise the exchange retention limit after validation passes. + # ----------------------------------------------------------------------- + + def bot_start(self, **kwargs) -> None: + exchange = self.dp._exchange + exchange._startup_candle_count = max(exchange._startup_candle_count, 5000) + + # Only fetch if cache is empty (first start, not restart with warm cache) + candle_type = CandleType.FUTURES + pairs = self.config["exchange"]["pair_whitelist"] + pairs_missing = [ + p for p in pairs + if (p, self.timeframe, candle_type) not in exchange._klines + ] + if not pairs_missing: + return + + since_ms = int((datetime.now(timezone.utc) - timedelta(days=12)).timestamp() * 1000) + logger.info( + f"Pre-fetching ~12 days of history for {len(pairs_missing)} pairs " + f"to satisfy indicator warmup..." + ) + for pair in pairs_missing: + try: + df = exchange.get_historic_ohlcv( + pair=pair, + timeframe=self.timeframe, + since_ms=since_ms, + candle_type=candle_type, + ) + if not df.empty: + exchange._klines[(pair, self.timeframe, candle_type)] = df + exchange._pairs_last_refresh_time[ + (pair, self.timeframe, candle_type) + ] = int(df.iloc[-1]["date"].timestamp() * 1000) + logger.info( + f" {pair}: pre-loaded {len(df)} candles " + f"(from {df.iloc[0]['date']} to {df.iloc[-1]['date']})" + ) + except Exception as e: + logger.warning(f" {pair}: pre-fetch failed ({e}), " + f"will rely on normal data loading") + + # ----------------------------------------------------------------------- + # Indicator calculation + # ----------------------------------------------------------------------- + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # Resolve all parameter values once + up_line_span = self.up_line_span.value + up_line_offset = self.up_line_offset.value + buy_stop_profit_span = self.buy_stop_profit_span.value + buy_stop_profit_offset = self.buy_stop_profit_offset.value + ma_span_long = self.ma_span_long.value + regime_slope_lookback = self.regime_slope_lookback.value + regime_slope_threshold = self.regime_slope_threshold.value + regime_displace_threshold = self.regime_displace_threshold.value + regime_vol_ema_span = self.regime_vol_ema_span.value + regime_compression_threshold = self.regime_compression_threshold.value + regime_expansion_threshold = self.regime_expansion_threshold.value + regime_hysteresis_bars = self.regime_hysteresis_bars.value + entry_up_line_span_short = self.entry_up_line_span_short.value + entry_ma_span_short = self.entry_ma_span_short.value + entry_pullback_bars_min = self.entry_pullback_bars_min.value + + # -- Vectorized channel indicators ---------------------------------- + dataframe["up_line"] = ( + dataframe["high"].rolling(up_line_span).max() + ) + dataframe["up_line_short"] = ( + dataframe["high"].rolling(entry_up_line_span_short).max() + ) + dataframe["stop_profit_bottom"] = ( + dataframe["low"].rolling(buy_stop_profit_span).min() + ) + + ma_len = int(ma_span_long) * self._BARS_PER_DAY + dataframe["ma_long"] = dataframe["close"].rolling(ma_len).mean() + + ma_short_len = int(entry_ma_span_short) * self._BARS_PER_DAY + dataframe["ma_short"] = dataframe["close"].rolling(ma_short_len).mean() + + # -- Stateful N-value + regime (iterate through dataframe) ---------- + n_values = [0.0] * len(dataframe) + regimes = [REGIME_RANGE] * len(dataframe) + vol_expanding = [False] * len(dataframe) + pullback_trigger = [False] * len(dataframe) + + n_ema = None + regime_candidate = REGIME_RANGE + regime_candidate_bars = 0 + effective_regime = REGIME_RANGE + prev_vol_ratio = None + pullback_bars_below = 0 + + close = dataframe["close"].values + high = dataframe["high"].values + low = dataframe["low"].values + ma_long_arr = dataframe["ma_long"].values + ma_short_arr = dataframe["ma_short"].values + + chunk_count = N_VALUE_SPAN + n_window = SPREAD_SPAN * N_VALUE_SPAN # 60 + + for i in range(len(dataframe)): + # --- N-value --------------------------------------------------- + if i >= n_window: # Need 60 full bars of history BEFORE current + spreads = [] + for j in range(chunk_count): + start = i - n_window + j * SPREAD_SPAN + end = start + SPREAD_SPAN + chunk_high = high[start:end].max() + chunk_low = low[start:end].min() + spreads.append(chunk_high - chunk_low) + n_val = _ema_of(spreads) + else: + n_val = 0.0 + n_values[i] = n_val + + # --- N-value EMA for vol_ratio -------------------------------- + if n_ema is None: + n_ema = n_val + else: + ema_alpha = 2.0 / (regime_vol_ema_span + 1.0) + n_ema = ema_alpha * n_val + (1.0 - ema_alpha) * n_ema + + vol_ratio = n_val / n_ema if n_ema and n_ema > 0 else 1.0 + vol_expanding[i] = prev_vol_ratio is not None and vol_ratio > prev_vol_ratio + prev_vol_ratio = vol_ratio + + # --- Regime classification ------------------------------------ + ma_l = ma_long_arr[i] + regime = REGIME_RANGE + if ma_l is not None and not np.isnan(ma_l) and n_val > 0: + # Price displacement from MA + if ma_l != 0: + price_displacement = (close[i] - ma_l) / ma_l + else: + price_displacement = 0.0 + + # MA slope + ma_slope = None + if i >= int(regime_slope_lookback): + past_idx = i - int(regime_slope_lookback) + ma_past = ma_long_arr[past_idx] + if (ma_past is not None and not np.isnan(ma_past) + and ma_past != 0): + ma_slope = (ma_l - ma_past) / ma_past + + # Classify + if vol_ratio < regime_compression_threshold: + regime = REGIME_COMPRESSION + elif vol_ratio > regime_expansion_threshold: + regime = REGIME_EXPANSION + elif (ma_slope is not None + and abs(ma_slope) > regime_slope_threshold + and abs(price_displacement) > regime_displace_threshold): + regime = REGIME_TREND + else: + regime = REGIME_RANGE + + # --- Regime hysteresis ----------------------------------------- + if regime == regime_candidate: + regime_candidate_bars += 1 + else: + regime_candidate = regime + regime_candidate_bars = 1 + + if regime_candidate_bars >= int(regime_hysteresis_bars): + effective_regime = regime_candidate + + regimes[i] = effective_regime + + # --- Pullback reentry tracking (for range entries) ------------- + if ma_short_arr[i] is not None and not np.isnan(ma_short_arr[i]): + if close[i] < ma_short_arr[i]: + pullback_bars_below += 1 + else: + if (pullback_bars_below >= entry_pullback_bars_min + and close[i] > ma_short_arr[i]): + pullback_trigger[i] = True + pullback_bars_below = 0 + + dataframe["n_value"] = n_values + dataframe["regime"] = regimes + dataframe["vol_expanding"] = vol_expanding + dataframe["pullback_trigger"] = pullback_trigger + dataframe["_n_ema"] = n_ema # Store reference value for diagnostics + + # Diagnostics: regime distribution and indicator health + recent = dataframe.tail(288) # last 24h + r_counts = recent["regime"].value_counts().to_dict() + regime_names = {0: "COMPR", 1: "EXPAN", 2: "TREND", 3: "RANGE"} + parts = [] + for r, name in regime_names.items(): + if r in r_counts: + parts.append(f"{name}={r_counts[r]}") + ma_ok = int(not dataframe["ma_short"].isna().all()) + n_ok = int((dataframe["n_value"] > 0).any()) + entry_count = int(dataframe["enter_long"].sum()) if "enter_long" in dataframe else 0 + logger.info( + f"{metadata['pair']}: candles={len(dataframe)}, " + f"regime_24h=[{', '.join(parts)}], " + f"ma_short_ok={ma_ok}, n_ok={n_ok}, entries={entry_count}" + ) + + return dataframe + + # ----------------------------------------------------------------------- + # Entry signals + # ----------------------------------------------------------------------- + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + up_line_offset = self.up_line_offset.value + buy_stop_profit_offset = self.buy_stop_profit_offset.value # noqa (kept for future use) + + n_val = dataframe["n_value"] + close = dataframe["close"] + regime = dataframe["regime"] + + # -- Trend: breakout chase ------------------------------------------ + trend_cond = ( + (regime == REGIME_TREND) + & (close > dataframe["ma_long"]) + & (close > dataframe["up_line"] + n_val * up_line_offset) + ) + + # -- Compression / Expansion: compression breakout ------------------ + ce_cond = ( + ((regime == REGIME_COMPRESSION) | (regime == REGIME_EXPANSION)) + & (dataframe["vol_expanding"]) + & (close > dataframe["up_line_short"] + n_val * up_line_offset) + ) + + # -- Range: pullback reentry ---------------------------------------- + range_cond = ( + (regime == REGIME_RANGE) + & (dataframe["pullback_trigger"]) + ) + + dataframe.loc[trend_cond | ce_cond | range_cond, "enter_long"] = 1 + return dataframe + + # ----------------------------------------------------------------------- + # Exit signals (stub — real exits in custom_stoploss / custom_exit) + # ----------------------------------------------------------------------- + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + return dataframe + + # ----------------------------------------------------------------------- + # Dynamic stoploss: protective → trailing → breakeven + # ----------------------------------------------------------------------- + + def custom_stoploss( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + after_fill: bool, + **kwargs, + ) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + + last = dataframe.iloc[-1] + n_value = last.get("n_value", 0) + if not n_value or n_value <= 0: + return None + + exit_max_loss_pct = self.exit_max_loss_pct.value + exit_breakeven_buffer = self.exit_breakeven_buffer.value + buy_stop_profit_offset = self.buy_stop_profit_offset.value + strategy_lever_rate = self.strategy_lever_rate.value + + # 1. Protective stop — absolute price floor + protective_price = trade.open_rate * ( + 1.0 - exit_max_loss_pct * strategy_lever_rate + ) + + # 2. Trailing stop + stop_profit_bottom = last.get("stop_profit_bottom", 0) + trailing_price = stop_profit_bottom + n_value * buy_stop_profit_offset + + # 3. Breakeven stop + breakeven_price = None + if current_profit > exit_breakeven_buffer: + trade.set_custom_data("breakeven_armed", True) + + if trade.get_custom_data("breakeven_armed"): + breakeven_price = trade.open_rate - n_value * 0.3 + + # Choose the highest (tightest) stop price among active stops + candidates = [protective_price] + if trailing_price and trailing_price > 0: + candidates.append(trailing_price) + if breakeven_price is not None: + candidates.append(breakeven_price) + + stop_price = max(candidates) + + # Convert to relative stoploss and ensure it's below current_rate + sl = stoploss_from_absolute( + stop_price, current_rate, + is_short=trade.is_short, + leverage=trade.leverage, + ) + # Only tighten — never let stoploss go above previous + return sl + + # ----------------------------------------------------------------------- + # Custom exits: lock profit → time stop + # ----------------------------------------------------------------------- + + def custom_exit( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + **kwargs, + ) -> Optional[str]: + profit_line = self.profit_line.value + lock_profit_rate = self.lock_profit_rate.value + exit_max_hold_bars = self.exit_max_hold_bars.value + + # --- Track peak profit -------------------------------------------- + max_pp = trade.get_custom_data("max_profit_pct") or 0.0 + if current_profit > max_pp: + trade.set_custom_data("max_profit_pct", current_profit) + max_pp = current_profit + + # --- Lock profit -------------------------------------------------- + if (max_pp >= profit_line + and current_profit > 0 + and current_profit < max_pp * (1.0 - lock_profit_rate)): + return "lock_profit" + + # --- Time stop ---------------------------------------------------- + bars_held = ( + (current_time.replace(tzinfo=timezone.utc) + - trade.open_date_utc).total_seconds() / 600.0 + ) + if bars_held >= exit_max_hold_bars: + return "time_stop" + + return None + + # ----------------------------------------------------------------------- + # N-value based dynamic position sizing + # ----------------------------------------------------------------------- + + def custom_stake_amount( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_stake: float, + min_stake: float | None, + max_stake: float, + leverage: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return 0.0 + + n_value = dataframe.iloc[-1].get("n_value", 0) + if n_value <= 0 or current_rate <= 0: + return 0.0 + + strategy_lever_rate = self.strategy_lever_rate.value + stop_loss_pct = n_value / current_rate + pct = 0.01 * strategy_lever_rate / stop_loss_pct + pct = max(0.0, min(pct, 1.0)) + + stake = max(float(min_stake or 0), min(max_stake, max_stake * pct)) + return stake + + # ----------------------------------------------------------------------- + # Fixed leverage for OKX USDT-M futures + # ----------------------------------------------------------------------- + + def leverage( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_leverage: float, + max_leverage: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + return 10.0 diff --git a/quant/stoploss_leverage_adjustment.md b/quant/stoploss_leverage_adjustment.md new file mode 100644 index 0000000..21583aa --- /dev/null +++ b/quant/stoploss_leverage_adjustment.md @@ -0,0 +1,193 @@ +# 止盈止损策略调整文档 + +## 一、背景 + +策略 `MultiAssetChannelBreakoutV5` 运行在 10x 杠杆的期货模式下(OKX 隔离保证金), + timeframe 15m,交易 BTC/USDT、ETH/USDT、TRX/USDT。 + +原策略的止损阈值是按 **1x 杠杆(无杠杆)** 设计的,但实际运行在 10x 杠杆下。 +Freqtrade 的 `current_profit` 和 `stoploss` 都是**保证金维度**的值,10x 杠杆会把价格 +变动放大 10 倍,导致所有止损/保本阈值被压缩到原来的 1/10。 + +## 二、问题:止损被杠杆压缩到极窄 + +### Freqtrade 的杠杆计算公式 + +``` +current_profit = (价格变动比例) × leverage ← 保证金维度 +stop_loss_price = current_price × (1 - stoploss / leverage) ← 实际止损价 +``` + +### 改前:阈值被压缩 10 倍 + +| 参数 | 值(保证金维度) | 实际价格含义 | 问题 | +|---|---|---|---| +| `stoploss` | -0.08 | 价格跌 **0.8%** 就止损 | 一根 15m K 线正常波动 0.5~1.5%,一根 K 线就被扫出去 | +| 保本触发 `>0.05` | 保证金+5% | 价格涨 **0.5%** 就保本 | 刚涨一点就锁死,任何回调都被止 | +| 回撤容忍 `return 0.10` | 保证金回撤10% | 容忍价格回撤 **1%** | 正常波动就触发 | +| breakeven `open×1.005` | 入场+0.5% | 保本线在入场+**0.5%** | 几乎等于入场价 | + +### 实际后果 + +``` +入场 → 价格涨 0.5% → 保本启动 → 一根 K 线回调 0.3% → 被止损出局 → 错过后面的趋势 +``` + +策略设计是通道突破吃趋势,但止损参数相当于高频炒菜级别,两者严重不匹配。 + +## 三、改后:阈值 ×10 还原到价格维度 + +### 逐项对照 + +| 参数 | 改前 | 改后 | 改前价格含义 | 改后价格含义 | +|---|---|---|---|---| +| `stoploss` | -0.08 | **-0.80** | 价格跌 0.8% 止损 | 价格跌 **8%** 止损 | +| 保本触发 | `>0.05` | **`>0.50`** | 价格涨 0.5% 保本 | 价格涨 **5%** 保本 | +| 第二档 | `>0.15` | **`>1.50`** | 价格涨 1.5% | 价格涨 **15%** | +| 第三档 | `>0.30` | **`>3.00`** | 价格涨 3% | 价格涨 **30%** | +| 回撤容忍 | `return 0.10` | **`return 1.0`** | 容忍回撤 1% | 容忍回撤 **10%** | +| breakeven | `open×1.005` | **`open×1.05`** | 保本在入场+0.5% | 保本在入场+**5%** | +| 兜底 | `return 1.0` | **`return 10.0`** | 止损=$0→被挡→用硬止损 | 同理($0→被挡→用硬止损)| + +### 关于 `stoploss` = -0.80 + +完整 ×10 是 -0.80(保证金-80% = 价格-8%)。10x 杠杆下 OKX 隔离保证金维持保证金率 +约 1~2%,价格跌 8~9% 接近强平线。止损设在 -80% 意味着: + +- 价格跌 8% 时触发止损,离强平还有约 1% 的缓冲 +- 最大限度给趋势空间,不会被任何正常波动扫出去 +- **风险**:如果止损滑点或急速下跌,可能被交易所强平而止损单未成交 + +### 关于兜底 `return 10.0` + +`return 10.0` 的计算:`stop_price = current × (1 - 10/10) = $0`。止损设在 $0, +比任何现有止损都低,被 Freqtrade 的"止损只升不降"规则挡住,硬止损保留。 +这与 1x 下 `return 1.0`(`1 - 1/1 = 0`,也是 $0)行为完全等价。 + +## 四、改后代码(trade.leverage 动态联动版) + +所有阈值通过 `trade.leverage` 动态计算,当前 10x 时行为与写死值完全一致, +如果未来杠杆变化会自动适配。 + +```python +# 硬止损(类属性,拿不到 trade.leverage,因 leverage() 固定返回 10.0 故 -0.80 正确) +stoploss = -0.80 + +def custom_stoploss(self, pair, trade, current_time, current_rate, current_profit, **kwargs): + L = trade.leverage + + # 盈利 > 价格+30%: 允许 10% 价格回撤 + if current_profit > 0.30 * L: + return 0.10 * L + + # 盈利 > 价格+15%: 允许 10% 价格回撤 + if current_profit > 0.15 * L: + return 0.10 * L + + # 盈利 > 价格+5%: 止损上移到保本+5% + if current_profit > 0.05 * L: + breakeven_target = trade.open_rate * 1.05 + return (current_rate - breakeven_target) / current_rate * L + + # 兜底:$0→被挡→用硬止损(-80% 保证金 = -8% 价格) + return L +``` + +### 逐项对照(L=10 时与写死值一致) + +| 表达式 | L=10 | 原写死值 | 价格含义 | +|---|---|---|---| +| `0.30 × L` | 3.00 | 3.00 | 价格+30% | +| `0.15 × L` | 1.50 | 1.50 | 价格+15% | +| `0.05 × L` | 0.50 | 0.50 | 价格+5% | +| `0.10 × L` | 1.0 | 1.0 | 允许价格回撤10% | +| `return L` | 10.0 | 10.0 | $0→被挡→用硬止损 | + +## 五、Breakeven 返回值 Bug 修复 + +### Bug 描述 + +freqtrade 内部对 `custom_stoploss` 返回值的处理公式(`trade_model.py:837`): + +``` +stop_price = current_rate × (1 - 返回值 / leverage) +``` + +返回值是**保证金维度**的,freqtrade 内部会再除以 leverage。 + +原代码的 breakeven 返回值缺少 ×10: + +```python +# 原代码(有 bug) +return (current_rate - breakeven_target) / current_rate +# 返回的是价格维度比例,但 freqtrade 当作保证金维度再除以 10 +``` + +### Bug 影响 + +以入场 $100,000、当前价 $108,000 为例: + +| | 原代码(bug) | 修复后 | +|---|---|---| +| 返回值 | 0.0278 | 0.278 | +| freqtrade 计算 | `108000 × (1 - 0.0278/10)` | `108000 × (1 - 0.278/10)` | +| 实际止损价 | **$107,700**(比预期高 $2,700) | **$105,000** ✓ | +| 效果 | 止损线被杠杆压缩 10 倍,过于贴近当前价 | 止损线正确钉在保本+5% | + +修复方式:返回值乘以 `trade.leverage`(`× L`)。 + +## 六、改后行为对比(以 BTC $100,000 入场为例) + +| 场景 | 改前 | 改后 | +|---|---|---| +| 入场后跌 0.8% | 保证金-8%,**触发硬止损,亏 $800 出局** | 保证金-8%,离 -80% 远,**继续持有** | +| 涨 0.5% 后回调 0.3% | 保本启动,**被止损出局,白干** | 还没到 5% 保本线,**继续持有** | +| 涨 5% 后回调 1% | 早被止损了 | 保本启动(止损在+5%),回调 1% **还在持有** | +| 涨 15% 后回调 10% | 不可能走到这 | 触发第二档止损,**仍盈利 +5% 出局** | +| 涨 30% 后回调 10% | 不可能走到这 | 触发第三档止损,**仍盈利 +20% 出局** | +| 跌 8% | 不可能走到这 | **触发硬止损,亏保证金 80% 出局** | + +## 七、风险提示 + +1. **单次止损亏损极大**:从保证金-8% 变为-80%,每次止损亏掉保证金的 80%, + 需要严格控制仓位和并发持仓数 +2. **接近强平线**:价格-8% 止损与 OKX 强平线(约-8~9%)非常接近, + 急速下跌时止损单可能来不及成交就被强平 +3. **建议调低仓位**:`max_open_trades` 或 `stake_amount` 适当降低,确保单次止损 + 不至于严重影响总资金 +4. **需观察实盘表现**:参数调整后建议密切观察 1~2 周的交易记录,看止损频率是否下降、 + 盈亏比是否改善 +5. **未改 ROI 止盈**:`minimal_roi = {"0": 100}` 保持不变(等于关闭 ROI 止盈), + 仍然依赖信号出场(跌破 141 周期低点),趋势策略让利润跑是合理的 + +## 八、部署记录 + +### 2026-08-21:止损阈值 ×10 +- 将所有保证金维度的止损阈值乘以 10,抵消 10x 杠杆压缩 +- `stoploss`: -0.08 → -0.30 → -0.80 +- 部署方式:scp + systemctl restart freqtrade +- 服务状态:active (running) + +### 2026-08-22:trade.leverage 动态联动 + breakeven bug 修复 +- 所有写死阈值改为通过 `trade.leverage` 动态计算(6 处改动) +- 修复 breakeven 返回值缺少 ×leverage 的 bug(止损线被杠杆压缩 10 倍) +- `stoploss` 类属性保持 -0.80(`leverage()` 固定返回 10.0,类属性值正确) +- 部署方式:scp + systemctl restart freqtrade +- 服务状态:active (running) + +### 2026-08-22:config.json stoploss 覆盖修复 +- **问题**:前两次部署只改了策略文件,未改 `config.json`。Freqtrade 中 config 的 + `stoploss` 会覆盖策略类的 `stoploss` 属性,导致线上实际硬止损仍是旧的 -0.10 + (保证金 -10% = 价格 -1%),修改后的 -0.80 从未生效。 +- **后果**:trade id=43(ETH/USDT,8/21 22:00 开仓)最高仅 +1.28% 价格, + 未达保本触发线,custom_stoploss 返回兜底值被挡,价格跌 1% 即被旧硬止损扫出, + 亏保证金 12.45%(-$13.53)。正是本文第二节描述的"改前"失败模式。 +- **修复**:`config.json` 的 `"stoploss": -0.1` → `"stoploss": -0.80` +- 部署方式:ssh sed + systemctl restart freqtrade +- 服务状态:active (running),启动日志 RPC 确认 `Stoploss: -0.8` +- **教训**:改策略的 `stoploss` 类属性时,必须同步检查 config.json 是否有 `stoploss` + 字段覆盖;两者不一致时 config 优先。后续部署清单应包含此项校验。 + +- 本地文件:`user_data/strategies/multi_asset_channel_breakout_v5.py` +- 远程路径:`vultr:/opt/freqtrade/user_data/strategies/multi_asset_channel_breakout_v5.py` +- 远程配置:`vultr:/opt/freqtrade/user_data/config.json` diff --git a/工作记录/标准数仓治理与指标平台建设 · 会议要点-2026年08月20日.md b/工作记录/标准数仓治理与指标平台建设 · 会议要点-2026年08月20日.md new file mode 100644 index 0000000..89d520e --- /dev/null +++ b/工作记录/标准数仓治理与指标平台建设 · 会议要点-2026年08月20日.md @@ -0,0 +1,62 @@ +--- +title: "标准数仓治理与指标平台建设 · 会议要点" +author: "Get达人" +date: "2026-08-20" +--- + +**一、新老数仓职责划分** + +财务域数仓迁移完成后,新老数仓将划分为两大独立模块,职责边界清晰、互不干扰: + +- **新数仓**:负责输出所有报表类统计数据,报表统一从新库出。 +- **老数仓**:保留 API 服务能力,以及直接写入业务库的数据同步能力;API 调用和回写业务库的数据都从老库走。 + +**二、财务域迁移的多重意义** + +优先选择财务域作为首个治理对象,有三层考虑: + +- **标准化基础好**:财务域的指标口径本身具备较高的标准化程度,治理起来起点高、阻力小。 +- **数据分布有代表性**:财务数据存储在老数仓,对全域数据治理有很强的参考价值,能验证老数仓侧的治理路径。 +- **打样沉淀经验**:通过财务域迁移和标准化治理,输出可复用的标准模板,后续家庭域及其他业务域直接参照该模板推进,降低重复试错成本。 + +**三、业务牵引的数仓标准化技术改造** + +摒弃纯技术驱动的自下而上五层整合方案(预估周期长达 3 年,极端评估甚至 10 年),转为以业务痛点为核心的迭代推进思路: + +- **核心原则**:以业务作为牵引去做治理,技术内部的混乱只要不影响业务使用,就不强行清理,在服务业务的过程中逐步解决。 +- **推进节奏**:一周一迭代,在增量开发过程中同步发现并解决存量问题,持续优化。 +- **性价比导向**:不做无意义的纯技术改造。比如带 V 与非带 V 指标只是技术命名差异,业务用户只关心数据是否可用,不需要投入大量人力强行改造所有非带 V 指标,只需要把口径梳理对齐、确保输出准确即可。 +- **对外统一出口**:标准数仓的对外输出统一通过指标平台呈现,业务侧不需要感知底层是新库还是老库、是带 V 还是非带 V,只看指标是否统一、是否有争议、交互是否更快。 + +**四、家庭域的改造方式** + +家庭域的治理参照财务域的标准模板推进,但不搞一刀切: + +- **已有基础**:家庭域此前已经完成过一次改造,具备相对标准的基础,不需要强行清除所有存量老数据。 +- **治理思路**:在整理指标接口的过程中逐步发现问题、解决问题,而不是先全部推翻重建。 +- **案例参考**:此前嘉玲在整理下单量指标时,发现该指标口径与商城侧未统一,完成对齐后彻底解决了该指标的复用争议。家庭域指标治理就参照这个思路——以指标口径对齐为核心,遇到问题再针对性治理底层数据。 +- **不强改技术属性**:不追求把所有非带 V 指标都改成带 V,改起来人力成本太高,业务侧也感知不到差异。核心是把指标口径治理到能对得上。 + +**五、指标口径问题作为业务牵引,驱动指标治理与数仓标准化** + +项目发起的源头,是业务侧反馈大量指标口径不一致,影响高层决策,同时指标复用率低。这一业务痛点成为整个数仓治理和标准化的核心牵引: + +- **指标平台先行**:先把公司级、部门级所有指标统一收口,确保所有对外输出的指标口径完全对齐,再同步推进技术内部的治理工作。 +- **流程化守护**:通过固定流程持续守护指标口径的一致性,每周迭代优化,在增量开发中同步解决存量问题。 +- **治理路径反转**:传统路径是先做技术治理(ODS→DWD→DWS 逐层往上),再输出指标;现在反过来——从业务最关心的指标口径问题切入,用指标梳理倒逼底层数据治理,每一步都有明确的业务价值反馈。 +- **年底目标**:完成财务域、家庭域等多个业务域的指标改造,实现全公司指标信息化统一管理,对外输出标准数仓治理的落地成果。 + +**六、ODS 贴源层问题对查表智能体效果的影响** + +OBS 贴源层存在一批未标注表名的数据表,直接影响跑表智能化功能的使用效率: + +- **问题表现**:查表智能体在检索目标表时,因为缺少表名标注,无法快速精准定位,导致查表成功率和效率下降。 +- **处理方式**:结合查表智能体的上线进度,优先补全这批缺失的表名,避免后续长期遇到查表定位失败的问题。 +- **治理逻辑**:这也是"业务牵引治理"思路的体现——不是先花几年把所有底层表都规范一遍,而是哪个环节影响了业务功能(查表智能体),就优先治理哪个环节,及时反馈、及时调整。 + +**待办事项** + +- 老谢:跟进性能优化相关工作,对接相关人员推进治理板块高资源占用任务的优化 +- 相关负责人:补全 OBS 天贴源层缺失的表名,保障查表智能体的正常使用 +- 相关人员:借助 AI 工具读取所有客户级任务的运行数据,生成性能优化解决方案 +