在 GitHub 上查看

Surfing 3.0 策略

概述

该 C# 策略忠实移植自 MetaTrader 4 专家顾问 Surfing 3.0。它监控由蜡烛最高价和最低价构成的指数移动平均线(EMA)通道,只要上一根蜡烛仍位于通道内部,而最新收盘价突破上下边界,就按突破方向开仓。移植版完全依赖 StockSharp 的高级 API、蜡烛订阅以及内置指标,而不是手动维护缓存数组。

算法只处理选定聚合周期的已收盘蜡烛,并保留最少量的历史数据来模拟原始代码中的 iMAiClose 调用。每根蜡烛只计算一次信号,这与 MQL 版本“以收盘价决策”的风格保持一致。

指标

  • 最高价 EMA / 最低价 EMA – 以蜡烛最高价和最低价为输入的两条指数移动平均线,形成一个动态通道,用于判断向上或向下突破。
  • 相对强弱指数 (RSI) – 趋势过滤器。只有当 RSI 高于 LongRsiThreshold 时才允许做多,低于 ShortRsiThreshold 时才允许做空。

交易规则

  1. 订阅 CandleType 指定的蜡烛,并在每根收盘蜡烛上更新 EMA 与 RSI 指标。
  2. 保存上一根蜡烛的收盘价以及对应的 EMA 数值,它们分别对应原始专家中的 PriceClose_2PriceHigh_2PriceLow_2
  3. 当最新收盘价 (PriceClose_1) 向上 突破最高价 EMA,且上一根收盘价位于通道内部或边界,同时 RSI 满足多头阈值时:
    • 平掉现有的空头仓位(如存在)。
    • OrderVolume 的数量买入做多。
    • 按照点数计算止损与止盈价格。
  4. 当最新收盘价 向下 突破最低价 EMA,且上一根收盘价位于通道内部或边界,同时 RSI 低于空头阈值时:
    • 平掉现有的多头仓位。
    • OrderVolume 的数量卖出做空。
    • 套用相同点数距离的保护性止损与止盈。
  5. 同一时间只保持一个净头寸。反向信号会先平仓再开立反方向头寸。
  6. [TradeStartHour, TradeEndHour) 交易时段之外不会开新仓。一旦时间达到 TradeEndHour,策略会强制平仓并清空内部历史,重现 MQL 中 closeAllPos() 的行为。

风险控制

  • 止损 / 止盈 – 使用合约最小变动价位(Price Step)将点数距离转换为绝对价格。设置为 0 即可关闭对应的保护水平。
  • 交易时段平仓 – 当达到 TradeEndHour 时立即平仓并清除目标价,避免头寸隔夜,完全符合原策略的交易时间限制。

参数

名称 说明 默认值
OrderVolume 每笔市场订单的交易量。 1
TakeProfitPoints 止盈距离(点数)。 80
StopLossPoints 止损距离(点数)。 50
MaPeriod 计算高低价 EMA 的周期。 50
RsiPeriod RSI 指标的周期长度。 10
LongRsiThreshold 允许做多所需的最小 RSI 值。 40
ShortRsiThreshold 允许做空所需的最大 RSI 值。 65
TradeStartHour 允许开仓的起始小时(交易所时间)。 8
TradeEndHour 平仓并停止开仓的小时(不含该刻)。 18
CandleType 用于计算的蜡烛聚合方式(默认 15 分钟)。 15m

说明

  • 仅基于已收盘蜡烛计算信号,忽略盘中波动,与 MetaTrader 中的行为一致。
  • 交易时段结束后会重置 EMA 历史,避免不同交易日之间的数据混淆。
  • 根据项目要求,本策略未提供 Python 版本。
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;

using StockSharp.Algo;
using StockSharp.Algo.Candles;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Strategy that reproduces the Surfing 3.0 expert advisor logic from MetaTrader.
/// </summary>
public class Surfing30Strategy : Strategy
{
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _maPeriod;
	private readonly StrategyParam<int> _rsiPeriod;
	private readonly StrategyParam<decimal> _longRsiThreshold;
	private readonly StrategyParam<decimal> _shortRsiThreshold;
	private readonly StrategyParam<int> _tradeStartHour;
	private readonly StrategyParam<int> _tradeEndHour;
	private readonly StrategyParam<DataType> _candleType;

	private RelativeStrengthIndex _rsi = null!;

	private decimal? _previousClose;
	private decimal? _previousHighEma;
	private decimal? _previousLowEma;

	private decimal? _stopLossPrice;
	private decimal? _takeProfitPrice;

	/// <summary>
	/// Initialize <see cref="Surfing30Strategy"/>.
	/// </summary>
	public Surfing30Strategy()
	{
		_orderVolume = Param(nameof(OrderVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Order Volume", "Volume applied to every trade.", "Trading")
			
			.SetOptimize(0.1m, 5m, 0.1m);

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 80)
			.SetNotNegative()
			.SetDisplay("Take Profit Points", "Distance to the take profit in instrument points.", "Risk Management")
			
			.SetOptimize(10, 200, 10);

		_stopLossPoints = Param(nameof(StopLossPoints), 50)
			.SetNotNegative()
			.SetDisplay("Stop Loss Points", "Distance to the stop loss in instrument points.", "Risk Management")
			
			.SetOptimize(10, 150, 10);

		_maPeriod = Param(nameof(MaPeriod), 50)
			.SetRange(1, 1000)
			.SetDisplay("EMA Period", "Length of the exponential moving averages calculated over highs and lows.", "Indicators")
			
			.SetOptimize(10, 120, 5);

		_rsiPeriod = Param(nameof(RsiPeriod), 10)
			.SetRange(1, 1000)
			.SetDisplay("RSI Period", "Length of the RSI filter.", "Indicators")
			
			.SetOptimize(5, 30, 1);

		_longRsiThreshold = Param(nameof(LongRsiThreshold), 30m)
			.SetDisplay("Long RSI Threshold", "Minimum RSI value required for long entries.", "Filters")
			
			.SetOptimize(20m, 60m, 5m);

		_shortRsiThreshold = Param(nameof(ShortRsiThreshold), 70m)
			.SetDisplay("Short RSI Threshold", "Maximum RSI value allowed for short entries.", "Filters")
			
			.SetOptimize(40m, 80m, 5m);

		_tradeStartHour = Param(nameof(TradeStartHour), 0)
			.SetDisplay("Trade Start Hour", "Hour of the day when new trades may start.", "Sessions")
			;

		_tradeEndHour = Param(nameof(TradeEndHour), 23)
			.SetDisplay("Trade End Hour", "Hour of the day when all positions are closed.", "Sessions")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
			.SetDisplay("Candle Type", "Aggregation used for calculations.", "Data");
	}

	/// <summary>
	/// Volume used for every trade.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set
		{
			_orderVolume.Value = value;
			Volume = value;
		}
	}

	/// <summary>
	/// Distance to the take profit in instrument points.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Distance to the stop loss in instrument points.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Length of the exponential moving averages calculated over candle highs and lows.
	/// </summary>
	public int MaPeriod
	{
		get => _maPeriod.Value;
		set => _maPeriod.Value = value;
	}

	/// <summary>
	/// Length of the RSI filter.
	/// </summary>
	public int RsiPeriod
	{
		get => _rsiPeriod.Value;
		set => _rsiPeriod.Value = value;
	}

	/// <summary>
	/// Minimum RSI value required for long entries.
	/// </summary>
	public decimal LongRsiThreshold
	{
		get => _longRsiThreshold.Value;
		set => _longRsiThreshold.Value = value;
	}

	/// <summary>
	/// Maximum RSI value allowed for short entries.
	/// </summary>
	public decimal ShortRsiThreshold
	{
		get => _shortRsiThreshold.Value;
		set => _shortRsiThreshold.Value = value;
	}

	/// <summary>
	/// Hour of the day when new trades may start.
	/// </summary>
	public int TradeStartHour
	{
		get => _tradeStartHour.Value;
		set => _tradeStartHour.Value = value;
	}

	/// <summary>
	/// Hour of the day when all positions are closed.
	/// </summary>
	public int TradeEndHour
	{
		get => _tradeEndHour.Value;
		set => _tradeEndHour.Value = value;
	}

	/// <summary>
	/// Candle aggregation used for calculations.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		yield return (Security, CandleType);
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();

		_previousClose = null;
		_previousHighEma = null;
		_previousLowEma = null;
		_stopLossPrice = null;
		_takeProfitPrice = null;
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		Volume = OrderVolume;

		var sma = new SimpleMovingAverage { Length = MaPeriod };
		_rsi = new RelativeStrengthIndex { Length = RsiPeriod };

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(sma, _rsi, ProcessCandle)
			.Start();
	}

	private void ProcessCandle(ICandleMessage candle, decimal smaValue, decimal rsiValue)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var currentClose = candle.ClosePrice;

		if (ManageActivePosition(candle))
		{
			UpdateHistory(currentClose, smaValue, smaValue);
			return;
		}

		if (_previousClose is null || _previousHighEma is null)
		{
			UpdateHistory(currentClose, smaValue, smaValue);
			return;
		}

		var previousClose = _previousClose.Value;
		var previousSma = _previousHighEma.Value;

		var buySignal = previousClose <= previousSma && currentClose > smaValue && rsiValue > LongRsiThreshold;
		var sellSignal = previousClose >= previousSma && currentClose < smaValue && rsiValue < ShortRsiThreshold;

		if (buySignal && Position <= 0)
		{
			if (Position < 0)
			{
				CloseCurrentPosition();
				ResetTargets();
			}

			BuyMarket(OrderVolume);
			SetTargets(currentClose, true);
		}
		else if (sellSignal && Position >= 0)
		{
			if (Position > 0)
			{
				CloseCurrentPosition();
				ResetTargets();
			}

			SellMarket(OrderVolume);
			SetTargets(currentClose, false);
		}

		UpdateHistory(currentClose, smaValue, smaValue);
	}

	private bool ManageActivePosition(ICandleMessage candle)
	{
		if (Position > 0)
		{
			if (_stopLossPrice is not null && candle.LowPrice <= _stopLossPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}

			if (_takeProfitPrice is not null && candle.HighPrice >= _takeProfitPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}
		}
		else if (Position < 0)
		{
			if (_stopLossPrice is not null && candle.HighPrice >= _stopLossPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}

			if (_takeProfitPrice is not null && candle.LowPrice <= _takeProfitPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}
		}

		return false;
	}

	private void CloseCurrentPosition()
	{
		if (Position > 0m)
			SellMarket(Position);
		else if (Position < 0m)
			BuyMarket(Math.Abs(Position));
	}

	private void SetTargets(decimal entryPrice, bool isLong)
	{
		var priceStep = Security?.PriceStep ?? 0m;
		if (priceStep <= 0m)
			priceStep = 1m;

		if (isLong)
		{
			_stopLossPrice = StopLossPoints > 0 ? entryPrice - StopLossPoints * priceStep : null;
			_takeProfitPrice = TakeProfitPoints > 0 ? entryPrice + TakeProfitPoints * priceStep : null;
		}
		else
		{
			_stopLossPrice = StopLossPoints > 0 ? entryPrice + StopLossPoints * priceStep : null;
			_takeProfitPrice = TakeProfitPoints > 0 ? entryPrice - TakeProfitPoints * priceStep : null;
		}
	}

	private void ResetTargets()
	{
		_stopLossPrice = null;
		_takeProfitPrice = null;
	}

	private void UpdateHistory(decimal currentClose, decimal currentHighEma, decimal currentLowEma)
	{
		_previousClose = currentClose;
		_previousHighEma = currentHighEma;
		_previousLowEma = currentLowEma;
	}

	private void ResetHistory()
	{
		_previousClose = null;
		_previousHighEma = null;
		_previousLowEma = null;
	}

	private bool IsWithinTradeHours(DateTimeOffset time)
	{
		var startHour = TradeStartHour;
		var endHour = TradeEndHour;

		if (endHour <= startHour)
			return time.Hour >= startHour || time.Hour < endHour;

		return time.Hour >= startHour && time.Hour < endHour;
	}
}