双品种相关性策略
概述
双品种相关性策略 将 MetaTrader 专家顾问 “2-Pair Correlation EA”(目录 MQL/52043)移植到 StockSharp 高级 API。策略同时监听两只高度相关的加密资产(主腿为 BTCUSD,对冲腿为 ETHUSD)的买价,一旦价差突破阈值,便构建市场中性组合。
核心流程
- 风险闸门:持续跟踪投资组合权益。一旦权益从历史峰值回撤超过
MaxDrawdownPercent,策略暂停开仓,直到权益恢复到峰值的RecoveryPercent以上。 - 波动过滤:两只标的的 5 分钟 K 线被送入
AtrPeriod长度的AverageTrueRange指标。若任一 ATR 超过PriceDifferenceThreshold * 0.01,则视为波动过大,本轮信号被跳过。 - 价差检测:订阅两只标的的 Level1 数据并在每次更新时评估买价差。当
Bid(BTCUSD) - Bid(ETHUSD) > PriceDifferenceThreshold时,做多 BTCUSD、做空 ETHUSD;当价差跌破-PriceDifferenceThreshold时执行反向操作。 - 动态手数:下单量来自账户权益的
RiskPercent,再除以合成止损距离StopLossPips * PriceStep。结果会依据交易所的数量步长/上下限进行归一化,与原始 EA 的“动态手数”一致。 - 篮子止盈:实时计算两条腿的总浮盈(以账户货币计价)。当达到
MinimumTotalProfit时,无论方向如何都立即平掉整组持仓。
所需行情
- Level1(最优买卖价):主标的
Security与对冲标的SecondSecurity均需提供。 - K 线:两只标的的
AtrCandleType(默认 5 分钟)用于计算 ATR。
请确保证券对象提供合理的 PriceStep、StepPrice、VolumeStep 以及数量上下限,以便手数换算与盈亏折算准确还原 MQL 行为。
参数
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
SecondSecurity |
Security |
— | 对冲腿(原始 EA 中为 ETHUSD)。 |
MaxDrawdownPercent |
decimal |
20 |
超过该回撤后暂停开仓。 |
RiskPercent |
decimal |
2 |
每次交易占用的权益百分比。 |
PriceDifferenceThreshold |
decimal |
100 |
触发进场的买价差阈值。 |
MinimumTotalProfit |
decimal |
0.30 |
触发篮子平仓的总浮盈(账户货币)。 |
AtrPeriod |
int |
14 |
ATR 波动过滤的周期。 |
RecoveryPercent |
decimal |
95 |
回撤后恢复到该百分比才重新开仓。 |
StopLossPips |
int |
50 |
将 RiskPercent 转换为手数的合成止损距离。 |
AtrCandleType |
DataType |
TimeSpan.FromMinutes(5).TimeFrame() |
用于 ATR 的 K 线类型。 |
文件
CS/TwoPairCorrelationStrategy.cs— 策略实现。README.md— 英文说明。README_zh.md— 中文说明。README_ru.md— 俄文说明。
using System;
using System.Linq;
using System.Collections.Generic;
using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Mean-reversion strategy with ATR volatility filter and drawdown control.
/// Simplified from the two-pair correlation EA to single security.
/// </summary>
public class TwoPairCorrelationStrategy : Strategy
{
private readonly StrategyParam<decimal> _maxDrawdownPercent;
private readonly StrategyParam<decimal> _priceDifferenceThreshold;
private readonly StrategyParam<decimal> _minimumTotalProfit;
private readonly StrategyParam<int> _atrPeriod;
private readonly StrategyParam<DataType> _candleType;
private AverageTrueRange _atr;
private SimpleMovingAverage _sma;
private decimal _atrValue;
private decimal _entryPrice;
private decimal _peakEquity;
private bool _tradingPaused;
/// <summary>
/// Maximum drawdown percentage that pauses new entries.
/// </summary>
public decimal MaxDrawdownPercent
{
get => _maxDrawdownPercent.Value;
set => _maxDrawdownPercent.Value = value;
}
/// <summary>
/// Price deviation threshold from SMA for entry.
/// </summary>
public decimal PriceDifferenceThreshold
{
get => _priceDifferenceThreshold.Value;
set => _priceDifferenceThreshold.Value = value;
}
/// <summary>
/// Floating profit target for closing.
/// </summary>
public decimal MinimumTotalProfit
{
get => _minimumTotalProfit.Value;
set => _minimumTotalProfit.Value = value;
}
/// <summary>
/// ATR period for volatility filter.
/// </summary>
public int AtrPeriod
{
get => _atrPeriod.Value;
set => _atrPeriod.Value = value;
}
/// <summary>
/// Candle type for signals and ATR.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Initializes strategy parameters.
/// </summary>
public TwoPairCorrelationStrategy()
{
_maxDrawdownPercent = Param(nameof(MaxDrawdownPercent), 20m)
.SetGreaterThanZero()
.SetDisplay("Max Drawdown %", "Maximum drawdown before trading is paused", "Risk")
.SetOptimize(5m, 50m, 5m);
_priceDifferenceThreshold = Param(nameof(PriceDifferenceThreshold), 5m)
.SetGreaterThanZero()
.SetDisplay("Price Deviation", "Distance from SMA required to enter", "Signals")
.SetOptimize(1m, 20m, 1m);
_minimumTotalProfit = Param(nameof(MinimumTotalProfit), 3m)
.SetGreaterThanZero()
.SetDisplay("Profit Target", "Floating profit required to close position", "Risk")
.SetOptimize(1m, 10m, 1m);
_atrPeriod = Param(nameof(AtrPeriod), 14)
.SetGreaterThanZero()
.SetDisplay("ATR Period", "Number of candles for volatility filter", "Indicators")
.SetOptimize(5, 40, 1);
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
.SetDisplay("Candle Type", "Candle series for signals", "Indicators");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
yield return (Security, CandleType);
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_atr = null;
_sma = null;
_atrValue = 0m;
_entryPrice = 0m;
_peakEquity = 0m;
_tradingPaused = false;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_peakEquity = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;
_atr = new AverageTrueRange { Length = AtrPeriod };
_sma = new SimpleMovingAverage { Length = 20 };
SubscribeCandles(CandleType)
.Bind(_atr, _sma, ProcessCandle)
.Start();
}
private void ProcessCandle(ICandleMessage candle, decimal atrValue, decimal smaValue)
{
if (candle.State != CandleStates.Finished)
return;
if (_atr == null || _sma == null || !_atr.IsFormed || !_sma.IsFormed)
return;
_atrValue = atrValue;
var price = candle.ClosePrice;
// Drawdown control
UpdateDrawdownState();
// Check profit target
if (Position != 0 && _entryPrice > 0m)
{
var pnl = Position > 0
? price - _entryPrice
: _entryPrice - price;
var profitTarget = Math.Max(MinimumTotalProfit, _atrValue * 0.5m);
if (profitTarget > 0m && pnl >= profitTarget)
{
if (Position > 0)
SellMarket(Math.Abs(Position));
else
BuyMarket(Math.Abs(Position));
_entryPrice = 0m;
return;
}
}
if (_tradingPaused)
return;
if (Position != 0)
return;
var deviation = price - smaValue;
var entryThreshold = Math.Max(PriceDifferenceThreshold, _atrValue);
if (deviation > entryThreshold)
{
SellMarket();
_entryPrice = price;
}
else if (deviation < -entryThreshold)
{
BuyMarket();
_entryPrice = price;
}
}
private void UpdateDrawdownState()
{
if (Portfolio == null)
return;
var equity = Portfolio.CurrentValue ?? Portfolio.BeginValue ?? 0m;
if (equity <= 0m)
return;
if (equity > _peakEquity)
_peakEquity = equity;
if (MaxDrawdownPercent <= 0m || _peakEquity <= 0m)
{
_tradingPaused = false;
return;
}
var drawdown = (_peakEquity - equity) / _peakEquity * 100m;
if (!_tradingPaused && drawdown >= MaxDrawdownPercent)
{
_tradingPaused = true;
}
else if (_tradingPaused && drawdown < MaxDrawdownPercent * 0.5m)
{
_tradingPaused = false;
}
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Indicators import AverageTrueRange, SimpleMovingAverage
from StockSharp.Algo.Strategies import Strategy
class two_pair_correlation_strategy(Strategy):
def __init__(self):
super(two_pair_correlation_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(1)))
self._price_difference_threshold = self.Param("PriceDifferenceThreshold", 5.0)
self._minimum_total_profit = self.Param("MinimumTotalProfit", 3.0)
self._atr_period = self.Param("AtrPeriod", 14)
self._atr_value = 0.0
self._entry_price = 0.0
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def PriceDifferenceThreshold(self):
return self._price_difference_threshold.Value
@PriceDifferenceThreshold.setter
def PriceDifferenceThreshold(self, value):
self._price_difference_threshold.Value = value
@property
def MinimumTotalProfit(self):
return self._minimum_total_profit.Value
@MinimumTotalProfit.setter
def MinimumTotalProfit(self, value):
self._minimum_total_profit.Value = value
@property
def AtrPeriod(self):
return self._atr_period.Value
@AtrPeriod.setter
def AtrPeriod(self, value):
self._atr_period.Value = value
def OnReseted(self):
super(two_pair_correlation_strategy, self).OnReseted()
self._atr_value = 0.0
self._entry_price = 0.0
def OnStarted2(self, time):
super(two_pair_correlation_strategy, self).OnStarted2(time)
self._atr_value = 0.0
self._entry_price = 0.0
atr = AverageTrueRange()
atr.Length = self.AtrPeriod
sma = SimpleMovingAverage()
sma.Length = 20
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(atr, sma, self._process_candle).Start()
def _process_candle(self, candle, atr_value, sma_value):
if candle.State != CandleStates.Finished:
return
atr_val = float(atr_value)
sma_val = float(sma_value)
price = float(candle.ClosePrice)
self._atr_value = atr_val
# Check profit target
if self.Position != 0 and self._entry_price > 0:
if self.Position > 0:
pnl = price - self._entry_price
else:
pnl = self._entry_price - price
profit_target = max(float(self.MinimumTotalProfit), atr_val * 0.5)
if profit_target > 0 and pnl >= profit_target:
if self.Position > 0:
self.SellMarket()
else:
self.BuyMarket()
self._entry_price = 0.0
return
if self.Position != 0:
return
deviation = price - sma_val
entry_threshold = max(float(self.PriceDifferenceThreshold), atr_val)
if deviation > entry_threshold:
self.SellMarket()
self._entry_price = price
elif deviation < -entry_threshold:
self.BuyMarket()
self._entry_price = price
def CreateClone(self):
return two_pair_correlation_strategy()