在 GitHub 上查看

双品种相关性策略

概述

双品种相关性策略 将 MetaTrader 专家顾问 “2-Pair Correlation EA”(目录 MQL/52043)移植到 StockSharp 高级 API。策略同时监听两只高度相关的加密资产(主腿为 BTCUSD,对冲腿为 ETHUSD)的买价,一旦价差突破阈值,便构建市场中性组合。

核心流程

  1. 风险闸门:持续跟踪投资组合权益。一旦权益从历史峰值回撤超过 MaxDrawdownPercent,策略暂停开仓,直到权益恢复到峰值的 RecoveryPercent 以上。
  2. 波动过滤:两只标的的 5 分钟 K 线被送入 AtrPeriod 长度的 AverageTrueRange 指标。若任一 ATR 超过 PriceDifferenceThreshold * 0.01,则视为波动过大,本轮信号被跳过。
  3. 价差检测:订阅两只标的的 Level1 数据并在每次更新时评估买价差。当 Bid(BTCUSD) - Bid(ETHUSD) > PriceDifferenceThreshold 时,做多 BTCUSD、做空 ETHUSD;当价差跌破 -PriceDifferenceThreshold 时执行反向操作。
  4. 动态手数:下单量来自账户权益的 RiskPercent,再除以合成止损距离 StopLossPips * PriceStep。结果会依据交易所的数量步长/上下限进行归一化,与原始 EA 的“动态手数”一致。
  5. 篮子止盈:实时计算两条腿的总浮盈(以账户货币计价)。当达到 MinimumTotalProfit 时,无论方向如何都立即平掉整组持仓。

所需行情

  • Level1(最优买卖价):主标的 Security 与对冲标的 SecondSecurity 均需提供。
  • K 线:两只标的的 AtrCandleType(默认 5 分钟)用于计算 ATR。

请确保证券对象提供合理的 PriceStepStepPriceVolumeStep 以及数量上下限,以便手数换算与盈亏折算准确还原 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;
		}
	}
}