在 GitHub 上查看

OpenTiks 策略

概述

OpenTiks 策略将 MetaTrader 顾问 OpenTiks.mq4 迁移到 StockSharp 平台。原始机器人通过寻找高点和开盘价都严格 单调的阶梯形 K 线序列来捕捉早期突破。出现信号后,它会立即以市价建仓,可选地设置止损,并在行情向有利方向运行时 不断上移止损并分批减仓。StockSharp 版本保留了这些思路,利用高层 API、蜡烛图订阅以及内置下单工具,使策略可以在 Designer、Runner 或任何自建 S# 应用中运行。

模式识别

当出现以下两种模式之一时将发出交易信号(共需 四根连续 K 线):

  • 多头突破:当前 K 线及前三根 K 线的 High 值严格递增,同时 Open 值也严格递增。
  • 空头突破:同一窗口内的四根 K 线 High 值严格递减,并且 Open 值严格递减。

策略使用所选 CandleType 的已完成蜡烛进行判断。一旦条件成立,便按设定的手数发送市价单,并根据交易品种的 VolumeStepMinVolumeMaxVolume 自动调整实际下单量。MaxOrders 用于限制同一时间最多允许的持仓次数, 设为 0 表示不限制,正整数则在净头寸绝对值除以标准下单量达到阈值时阻止新的加仓。

风险控制与出场

  • 止损:当 StopLossPoints 大于 0 时,策略会监控最新收盘 K 线。多头在最低价跌破 entryPrice - StopLossPoints × PriceStep 时平仓;空头在最高价触及 entryPrice + StopLossPoints × PriceStep 时离场。
  • 移动止损:当价格向有利方向运行至少 TrailingStopPoints × PriceStep 后,策略会启动追踪止损,并保持 相同的距离(多头在价格下方、空头在价格上方)。每次止损水平被提升时,都可以进一步锁定利润。
  • 逐步减仓:启用 UsePartialClose 后,只要移动止损再次上移,策略就会把当前仓位减半。下单量会按照 VolumeStep 取整,如果得到的半仓小于 MinVolume,则改为一次性平仓,与原版 EA 的处理保持一致。

所有止损与追踪逻辑均基于收盘数据执行,因此实际离场发生在下一根 K 线收盘时,而不是每笔成交都响应。这种实现方式 既符合 StockSharp 的高层 API 设计,又贴近原始策略“以新 K 线为驱动”的思路。

参数

名称 类型 默认值 说明
OrderVolume decimal 0.1 每次建仓的基础手数,会根据品种的成交量步长及限制自动调整。
StopLossPoints decimal 0 止损距离(以价格点/最小价位计)。为 0 时不开启止损。
TrailingStopPoints decimal 30 当仓位盈利后所维持的追踪止损距离,同样以价格点表示。
MaxOrders int 1 同时存在的最大建仓次数。0 表示不限次数。
UsePartialClose bool true 启用逐步减仓,在追踪止损更新时自动对冲仓位。
CandleType DataType 1 分钟时间框架 用于信号判断和止损监控的主要蜡烛类型。

实现细节

  • StockSharp 使用 净头寸模型,同一品种的所有交易会合并为一个多头或空头仓位。因此 MaxOrders 实际上限制的是 合并后的净头寸,而不是 MetaTrader 中的单独订单数量。
  • 移动止损依赖蜡烛收盘进行计算,如需更细粒度的保护,可以选择更短的 CandleType,或扩展策略订阅实时成交数据。
  • 逐步减仓会参考交易品种的 VolumeStepMinVolumeMaxVolume,以尽量避免因数量不合规而被交易所拒单。
  • 代码中的英文注释标注了关键决策点,方便在二次开发或研究不同突破/资金管理方法时参考。

使用建议

  1. 选择与原始 EA 相符的蜡烛时间框架(例如 M1 或 M5),以复现同样的交易节奏。
  2. 根据交易品种检查价格步长和最小手数,默认的 0.1 更适合外汇合约,如需交易期货、股票或加密货币可自行调整。
  3. 调整 TrailingStopPointsUsePartialClose,寻找在快速锁盈与让利润奔跑之间的最佳平衡。
  4. 搭配 StockSharp 图表观察阶梯形走势与分批止盈过程,便于理解和优化策略行为。
namespace StockSharp.Samples.Strategies;

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;

/// <summary>
/// Reimplementation of the MetaTrader expert advisor "OpenTiks" for StockSharp.
/// Detects four consecutive candles with strictly monotonic opens and highs to trigger entries,
/// then manages the position with optional stop-loss, trailing stop and progressive partial exits.
/// </summary>
public class OpenTiksStrategy : Strategy
{
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _trailingStopPoints;
	private readonly StrategyParam<int> _maxOrders;
	private readonly StrategyParam<bool> _usePartialClose;
	private readonly StrategyParam<DataType> _candleType;

	private decimal _priceStep;
	private decimal _volumeStep;
	private decimal _minVolumeLimit;
	private decimal _maxVolumeLimit;

	private decimal? _high1;
	private decimal? _high2;
	private decimal? _high3;

	private decimal? _open1;
	private decimal? _open2;
	private decimal? _open3;

	private decimal? _longEntryPrice;
	private decimal? _shortEntryPrice;
	private decimal? _longTrailingStop;
	private decimal? _shortTrailingStop;

	private SimpleMovingAverage _dummySma;
	private decimal _previousPosition;
	private decimal? _lastTradePrice;

	/// <summary>
	/// Initializes a new instance of the <see cref="OpenTiksStrategy"/> class.
	/// </summary>
	public OpenTiksStrategy()
	{
		_orderVolume = Param(nameof(OrderVolume), 0.1m)
			.SetGreaterThanZero()
			.SetDisplay("Order Volume", "Volume of each market entry in lots.", "Trading");

		_stopLossPoints = Param(nameof(StopLossPoints), 0m)
			.SetNotNegative()
			.SetDisplay("Stop Loss (points)", "Protective stop distance expressed in price points.", "Risk");

		_trailingStopPoints = Param(nameof(TrailingStopPoints), 30m)
			.SetNotNegative()
			.SetDisplay("Trailing Stop (points)", "Trailing distance expressed in price points.", "Risk");

		_maxOrders = Param(nameof(MaxOrders), 1)
			.SetNotNegative()
			.SetDisplay("Max Orders", "Maximum number of simultaneously open entries. Zero disables the limit.", "Trading");

		_usePartialClose = Param(nameof(UsePartialClose), true)
			.SetDisplay("Use Partial Close", "Close half of the position whenever the trailing stop advances.", "Risk");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Primary timeframe used for pattern detection.", "General");
	}

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

	/// <summary>
	/// Stop-loss distance expressed in price points.
	/// </summary>
	public decimal StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Trailing stop distance expressed in price points.
	/// </summary>
	public decimal TrailingStopPoints
	{
		get => _trailingStopPoints.Value;
		set => _trailingStopPoints.Value = value;
	}

	/// <summary>
	/// Maximum number of simultaneously open entries.
	/// </summary>
	public int MaxOrders
	{
		get => _maxOrders.Value;
		set => _maxOrders.Value = value;
	}

	/// <summary>
	/// Enables progressive partial exits when true.
	/// </summary>
	public bool UsePartialClose
	{
		get => _usePartialClose.Value;
		set => _usePartialClose.Value = value;
	}

	/// <summary>
	/// Candle type requested from the market data feed.
	/// </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();

		_priceStep = 0;
		_volumeStep = 0;
		_minVolumeLimit = 0;
		_maxVolumeLimit = 0;
		_high1 = null;
		_high2 = null;
		_high3 = null;
		_open1 = null;
		_open2 = null;
		_open3 = null;
		_longEntryPrice = null;
		_shortEntryPrice = null;
		_longTrailingStop = null;
		_shortTrailingStop = null;
		_dummySma = null;
		_previousPosition = 0m;
		_lastTradePrice = null;
	}

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

		var security = Security;
		_priceStep = security?.PriceStep ?? 1m;
		if (_priceStep <= 0m)
			_priceStep = 1m;

		_volumeStep = security?.VolumeStep ?? 0m;
		_minVolumeLimit = security?.MinVolume ?? 0m;
		_maxVolumeLimit = security?.MaxVolume ?? 0m;

		Volume = NormalizeEntryVolume(OrderVolume);

		_dummySma = new SimpleMovingAverage { Length = 2 };

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

	/// <inheritdoc />
	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		_lastTradePrice = trade.Trade?.Price ?? trade.Order.Price;
	}

	/// <inheritdoc />
	protected override void OnPositionReceived(Position position)
	{
		base.OnPositionReceived(position);

		var delta = Position - _previousPosition;

		if (Position > 0m)
		{
			if (_previousPosition <= 0m)
			{
				_longEntryPrice = _lastTradePrice;
				_longTrailingStop = null;
				_shortEntryPrice = null;
				_shortTrailingStop = null;
			}
			else if (delta > 0m && _lastTradePrice is decimal priceLong)
			{
				var previousVolume = Math.Max(0m, _previousPosition);
				var currentVolume = Math.Max(0m, Position);
				if (currentVolume > 0m)
				{
					var currentEntry = _longEntryPrice ?? priceLong;
					_longEntryPrice = (currentEntry * previousVolume + priceLong * delta) / currentVolume;
				}
			}
		}
		else if (Position < 0m)
		{
			if (_previousPosition >= 0m)
			{
				_shortEntryPrice = _lastTradePrice;
				_shortTrailingStop = null;
				_longEntryPrice = null;
				_longTrailingStop = null;
			}
			else if (delta < 0m && _lastTradePrice is decimal priceShort)
			{
				var previousVolume = Math.Max(0m, Math.Abs(_previousPosition));
				var currentVolume = Math.Max(0m, Math.Abs(Position));
				if (currentVolume > 0m)
				{
					var currentEntry = _shortEntryPrice ?? priceShort;
					_shortEntryPrice = (currentEntry * previousVolume + priceShort * Math.Abs(delta)) / currentVolume;
				}
			}
		}
		else
		{
			_longEntryPrice = null;
			_shortEntryPrice = null;
			_longTrailingStop = null;
			_shortTrailingStop = null;
		}

		_previousPosition = Position;
	}

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

		UpdateTrailing(candle);

		var buySignal = false;
		var sellSignal = false;

		if (_high1 is decimal h1 && _high2 is decimal h2 && _high3 is decimal h3 &&
		_open1 is decimal o1 && _open2 is decimal o2 && _open3 is decimal o3)
		{
		var high = candle.HighPrice;
		var open = candle.OpenPrice;

		buySignal = high > h1 && h1 > h2 && h2 > h3 &&
		open > o1 && o1 > o2 && o2 > o3;

		sellSignal = high < h1 && h1 < h2 && h2 < h3 &&
		open < o1 && o1 < o2 && o2 < o3;
		}

		_high3 = _high2;
		_high2 = _high1;
		_high1 = candle.HighPrice;

		_open3 = _open2;
		_open2 = _open1;
		_open1 = candle.OpenPrice;

		if (buySignal)
			TryEnterLong();

		if (sellSignal)
			TryEnterShort();
	}

	private void TryEnterLong()
	{
		if (MaxOrders > 0 && EstimateOrdersCount(Position) >= MaxOrders)
			return;

		var volume = NormalizeEntryVolume(OrderVolume);
		if (volume <= 0m)
			return;

		BuyMarket(volume);
	}

	private void TryEnterShort()
	{
		if (MaxOrders > 0 && EstimateOrdersCount(Position) >= MaxOrders)
			return;

		var volume = NormalizeEntryVolume(OrderVolume);
		if (volume <= 0m)
			return;

		SellMarket(volume);
	}

	private int EstimateOrdersCount(decimal positionVolume)
	{
		var baseVolume = NormalizeEntryVolume(OrderVolume);
		if (baseVolume <= 0m)
			return positionVolume != 0m ? 1 : 0;

		var ratio = Math.Abs(positionVolume) / baseVolume;
		if (ratio <= 0m)
			return 0;

		return (int)Math.Ceiling(ratio);
	}

	private void UpdateTrailing(ICandleMessage candle)
	{
		var close = candle.ClosePrice;
		var low = candle.LowPrice;
		var high = candle.HighPrice;

		var stopDistance = StopLossPoints * _priceStep;
		var trailingDistance = TrailingStopPoints * _priceStep;

		if (Position > 0m && _longEntryPrice is decimal entryLong)
		{
			if (stopDistance > 0m && low <= entryLong - stopDistance)
			{
			SellMarket(Position);
			return;
			}

			if (trailingDistance > 0m && close - entryLong >= trailingDistance)
			{
			var desiredStop = close - trailingDistance;
			if (_longTrailingStop is not decimal currentStop || desiredStop > currentStop)
			{
			_longTrailingStop = desiredStop;
			TryReduceLongPosition();
			}

			if (_longTrailingStop is decimal trailingStop && low <= trailingStop)
			SellMarket(Position);
			}
		}
		else if (Position < 0m && _shortEntryPrice is decimal entryShort)
		{
			var positionVolume = Math.Abs(Position);

			if (stopDistance > 0m && high >= entryShort + stopDistance)
			{
			BuyMarket(positionVolume);
			return;
			}

			if (trailingDistance > 0m && entryShort - close >= trailingDistance)
			{
			var desiredStop = close + trailingDistance;
			if (_shortTrailingStop is not decimal currentStop || desiredStop < currentStop)
			{
			_shortTrailingStop = desiredStop;
			TryReduceShortPosition();
			}

			if (_shortTrailingStop is decimal trailingStop && high >= trailingStop)
			BuyMarket(positionVolume);
			}
		}
	}

	private void TryReduceLongPosition()
	{
		if (!UsePartialClose)
			return;

		if (Position <= 0m)
			return;

		var positionVolume = Position;
		var half = positionVolume / 2m;
		var normalizedHalf = NormalizeExitVolume(half, positionVolume);

		if (_minVolumeLimit > 0m && normalizedHalf < _minVolumeLimit)
		{
			SellMarket(positionVolume);
			return;
		}

		if (normalizedHalf > 0m)
		SellMarket(normalizedHalf);
	}

	private void TryReduceShortPosition()
	{
		if (!UsePartialClose)
			return;

		if (Position >= 0m)
			return;

		var positionVolume = Math.Abs(Position);
		var half = positionVolume / 2m;
		var normalizedHalf = NormalizeExitVolume(half, positionVolume);

		if (_minVolumeLimit > 0m && normalizedHalf < _minVolumeLimit)
		{
			BuyMarket(positionVolume);
			return;
		}

		if (normalizedHalf > 0m)
		BuyMarket(normalizedHalf);
	}

	private decimal NormalizeEntryVolume(decimal volume)
	{
		if (volume <= 0m)
		return 0m;

		if (_volumeStep > 0m)
		{
			var steps = Math.Round(volume / _volumeStep, MidpointRounding.AwayFromZero);
			if (steps <= 0m)
			steps = 1m;
			volume = steps * _volumeStep;
		}

		if (_minVolumeLimit > 0m && volume < _minVolumeLimit)
		volume = _minVolumeLimit;

		if (_maxVolumeLimit > 0m && volume > _maxVolumeLimit)
		volume = _maxVolumeLimit;

		return volume;
	}

	private decimal NormalizeExitVolume(decimal desired, decimal currentPosition)
	{
		if (desired <= 0m || currentPosition <= 0m)
		return 0m;

		var volume = desired;

		if (_volumeStep > 0m)
		{
			var steps = Math.Round(volume / _volumeStep, MidpointRounding.AwayFromZero);
			if (steps <= 0m)
			steps = 1m;
			volume = steps * _volumeStep;
		}

		if (volume > currentPosition)
		volume = currentPosition;

		return volume;
	}
}