在 GitHub 上查看

挂单定时策略(Pending Orders By Time)

本策略把 MetaTrader 上的“Pending orders by time”专家顾问移植到 StockSharp。它按照固定时间表运行:在设定的开仓小时到来时,于行情上下各放置一张对称的止损单;到达设定的收盘小时后,会撤销所有未成交的挂单并强制平掉持仓。所有距离参数继续以“点”为单位输入,内部根据品种的最小报价步长转换成真实价格。

策略流程

  1. 时间触发。 当收盘时间等于开仓小时的蜡烛到达时,策略会在当前最优买价上方放置 Buy Stop,在最优卖价下方放置 Sell Stop,偏移量由 Distance (pips) 参数换算得出。
  2. 保护性订单。 StartProtection 根据设定的点数自动附加止损和止盈,同时 ManageRisk 会在每根收盘蜡烛上再次检查,如果价格已经越过止损或目标则手动市价平仓。
  3. 日内清算。 当时间进入收盘小时,策略会撤销所有剩余挂单,并无条件平掉持仓,以便下一交易日重新开始。
  4. 点值换算。 为了兼容三位或五位小数的外汇报价,策略会在计算点值时把价格步长乘以十,与原版 MetaTrader 保持一致。

默认使用 30 分钟蜡烛,以符合原策略“周期小于等于 H1” 的约束。你也可以选择其它时间框架,只要产生的小时标签与需要的开收盘时间匹配即可。

参数

名称 说明 默认值
Opening Hour 在该小时(0-23)放置一对挂单。 9
Closing Hour 在该小时(0-23)撤销挂单并平仓。 2
Distance (pips) 挂单距离当前价的偏移(点)。 20
Stop Loss (pips) 开仓后止损的点数。 20
Take Profit (pips) 开仓后止盈的点数。 500
Order Volume 每张挂单的下单量。 0.1
Candle Type 用来驱动时间判断的蜡烛周期。 30 分钟

所有参数都可以用于优化。点值会通过品种的 PriceStep 自动换算,因此可以在不同小数位的外汇品种上复用。

日内循环

  1. 每根蜡烛收盘 时检查价格是否已经穿越止损或止盈阈值,若满足条件则立即市价平仓。
  2. 进入收盘小时 时撤销所有挂单,并把当前持仓清零,避免隔夜风险。
  3. 进入开仓小时 且没有持仓时,会先保险性地撤销旧挂单,然后重新在买卖价外侧各挂一张止损单,以捕捉向上或向下的突破。
  4. 运行过程中StartProtection 创建的平台级保护会在盘中价格触及止损或止盈时立即触发,无需等待蜡烛收盘。

使用提示

  • 适用于最小跳动单位等同于“点”的外汇或差价合约。若品种的 tick 值较特殊,需要相应调整距离参数。
  • 策略假设每天只进行一次开仓/平仓循环。如使用的时间序列在一天内多次命中设定小时,需要相应调整时间参数。
  • 所有决策基于蜡烛收盘,因此应选择与你的交易节奏相符的周期;例如使用 1 小时蜡烛可以完全复现原策略。
  • 只有在没有持仓时才会挂出新的止损单,防止在已有突破交易尚未结束时累积风险。

与 MQL 版本的差异

  • 止损/止盈通过 StartProtection 与显式检查实现,而不是在挂单上直接设置票据属性。
  • 报价来自 Security.BestBidSecurity.BestAsk,若没有报价则回退到蜡烛收盘价。
  • 收盘小时平仓时使用市价单,避免不同经纪商对挂单撤销的差异处理。
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>
/// Places simulated symmetric stop entries at scheduled hours and manages them with daily resets.
/// </summary>
public class PendingOrdersByTimeStrategy : Strategy
{
	private readonly StrategyParam<int> _openingHour;
	private readonly StrategyParam<int> _closingHour;
	private readonly StrategyParam<decimal> _distancePips;
	private readonly StrategyParam<decimal> _stopLossPips;
	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<DataType> _candleType;

	private decimal _pipSize;
	private decimal? _pendingBuyPrice;
	private decimal? _pendingSellPrice;
	private decimal? _entryPrice;

	public int OpeningHour
	{
		get => _openingHour.Value;
		set => _openingHour.Value = value;
	}

	public int ClosingHour
	{
		get => _closingHour.Value;
		set => _closingHour.Value = value;
	}

	public decimal DistancePips
	{
		get => _distancePips.Value;
		set => _distancePips.Value = value;
	}

	public decimal StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	public decimal TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public PendingOrdersByTimeStrategy()
	{
		_openingHour = Param(nameof(OpeningHour), 2)
			.SetDisplay("Opening Hour", "Hour to activate pending orders", "Schedule")
			.SetRange(0, 23);

		_closingHour = Param(nameof(ClosingHour), 22)
			.SetDisplay("Closing Hour", "Hour to cancel orders and flat positions", "Schedule")
			.SetRange(0, 23);

		_distancePips = Param(nameof(DistancePips), 500m)
			.SetDisplay("Distance (pips)", "Offset for entry stop orders", "Orders")
			.SetGreaterThanZero();

		_stopLossPips = Param(nameof(StopLossPips), 500m)
			.SetDisplay("Stop Loss (pips)", "Protective stop distance", "Risk")
			.SetGreaterThanZero();

		_takeProfitPips = Param(nameof(TakeProfitPips), 2000m)
			.SetDisplay("Take Profit (pips)", "Profit target distance", "Risk")
			.SetGreaterThanZero();

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Working timeframe for the schedule", "General");
	}

	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

	protected override void OnReseted()
	{
		base.OnReseted();

		_pipSize = 0m;
		_pendingBuyPrice = null;
		_pendingSellPrice = null;
		_entryPrice = null;
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		_pipSize = CalculatePipSize();

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

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}

	private decimal CalculatePipSize()
	{
		var step = Security?.PriceStep ?? 0.01m;

		if (step <= 0m)
			return 0.01m;

		return step;
	}

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

		var hour = candle.OpenTime.Hour;

		// Check pending stop entries
		CheckPendingEntries(candle);

		// Manage existing position
		ManageRisk(candle);

		if (hour == ClosingHour)
		{
			// Closing hour: cancel pending and exit any open trades.
			_pendingBuyPrice = null;
			_pendingSellPrice = null;
			ExitPosition();
		}

		if (hour == OpeningHour && hour != ClosingHour && Position == 0m && !_pendingBuyPrice.HasValue)
		{
			// Opening hour: set up new pending entries.
			SetupPendingEntries(candle.ClosePrice);
		}
	}

	private void CheckPendingEntries(ICandleMessage candle)
	{
		if (_pendingBuyPrice is decimal buyPrice && candle.HighPrice >= buyPrice && Position <= 0)
		{
			if (Position < 0)
				BuyMarket();
			BuyMarket();
			_entryPrice = buyPrice;
			_pendingBuyPrice = null;
			_pendingSellPrice = null;
			return;
		}

		if (_pendingSellPrice is decimal sellPrice && candle.LowPrice <= sellPrice && Position >= 0)
		{
			if (Position > 0)
				SellMarket();
			SellMarket();
			_entryPrice = sellPrice;
			_pendingBuyPrice = null;
			_pendingSellPrice = null;
		}
	}

	private void ManageRisk(ICandleMessage candle)
	{
		if (_pipSize <= 0m || _entryPrice is not decimal entry)
			return;

		var takeProfitDistance = TakeProfitPips * _pipSize;
		var stopLossDistance = StopLossPips * _pipSize;

		if (Position > 0m)
		{
			if (takeProfitDistance > 0m && candle.HighPrice - entry >= takeProfitDistance)
			{
				SellMarket();
				_entryPrice = null;
				return;
			}

			if (stopLossDistance > 0m && entry - candle.LowPrice >= stopLossDistance)
			{
				SellMarket();
				_entryPrice = null;
			}
		}
		else if (Position < 0m)
		{
			if (takeProfitDistance > 0m && entry - candle.LowPrice >= takeProfitDistance)
			{
				BuyMarket();
				_entryPrice = null;
				return;
			}

			if (stopLossDistance > 0m && candle.HighPrice - entry >= stopLossDistance)
			{
				BuyMarket();
				_entryPrice = null;
			}
		}
	}

	private void ExitPosition()
	{
		if (Position > 0m)
			SellMarket();
		else if (Position < 0m)
			BuyMarket();
		_entryPrice = null;
	}

	private void SetupPendingEntries(decimal referencePrice)
	{
		if (_pipSize <= 0m)
			return;

		var distance = DistancePips * _pipSize;
		if (distance <= 0m)
			return;

		_pendingBuyPrice = referencePrice + distance;
		_pendingSellPrice = referencePrice - distance;
	}
}