在 GitHub 上查看

基于时间的区间突破策略

概述

本策略移植自 MetaTrader 4 智能交易程序 Tttttt_www_forex-instruments_info.mq4。它在每天的指定时间截取当日的最高价和最低价,依据历史区间的平均值构建上下突破带。之后若收盘价突破这些带,则顺势开仓,并按照动态盈亏目标在蜡烛收盘时平仓。

核心逻辑

  1. 每日快照时间:在 CheckHour:CheckMinute 时刻冻结当天的最高/最低价,并关闭所有已有持仓。
  2. 平均区间计算:聚合最近 DaysToCheck 天的数据:
    • CheckMode = 1:使用每个完整交易日的最高价减最低价。
    • CheckMode = 2:使用相邻两个交易日在快照时间的收盘价绝对差。
  3. 构建突破带:把平均值除以 OffsetFactor,得到围绕当日高低点的上下偏移,同时用 ProfitFactorLossFactor 把同一平均值转换为盈利/亏损目标距离。
  4. 入场窗口:快照之后直到 23:00,只要收盘价向上突破上轨且当前无仓位,就买入;向下突破下轨则卖出。每天允许的入场次数由 TradesPerDay 限制。
  5. 离场管理:持仓期间通过 Strategy.PositionPrice 估算平均持仓价,当收盘价相对该价格的变动达到盈利或亏损阈值时,立即以市价对冲平仓。如果 CloseMode = 2,跨日时也会强制平仓。

参数

名称 说明 默认值
CheckHour 进行区间快照的小时 (0-23)。 8
CheckMinute 进行区间快照的分钟 (0-59)。 0
DaysToCheck 参与平均的历史天数。 7
CheckMode 1 = 使用每日高低区间,2 = 使用相邻收盘价差。 1
ProfitFactor 把平均值换算为盈利目标距离时的除数。 2
LossFactor 把平均值换算为亏损阈值时的除数。 2
OffsetFactor 把平均值换算为突破带偏移量时的除数。 2
CloseMode 1 = 允许隔夜持仓,2 = 跨日即平仓。 1
TradesPerDay 每天允许的最大开仓次数。 1
CandleType 计算所用的蜡烛周期(默认 15 分钟)。 15m 时间框架

所有参数均通过 Strategy.Param 创建,可直接用于优化。

与 MQL 版本的差异

  • MQL 通过交易核心直接获取浮动盈亏;移植版利用 PositionPositionPrice 在蜡烛收盘时自行估算。
  • 原程序用订单循环统计未平仓单数量;移植版结合 TradesPerDay 与净头寸,实现每日交易次数限制。
  • 原始脚本依赖 HighestLowest 等历史缓冲区;移植版在策略内部维护每日统计,避免了显式缓冲并符合高阶 API 规范。
  • MQL 下单时同时设置止损止盈;移植版通过监控蜡烛收盘并在达到阈值时发送市价单来复现相同的风险控制。

使用提示

  • 建议使用与原始脚本一致的周期(参考文件使用 15 分钟 K 线)。
  • 在启动前至少准备 DaysToCheck 个完整交易日的历史数据,否则突破带不会激活。
  • 优化参数时保持因子为正值,以保证突破带和风险阈值具备实际意义。
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>
/// Breakout strategy that prepares daily buy/sell levels at a specified time.
/// The offset and profit targets are derived from the average range of previous days.
/// </summary>
public class TimeBasedRangeBreakoutStrategy : Strategy
{

	private readonly StrategyParam<int> _checkHour;
	private readonly StrategyParam<int> _checkMinute;
	private readonly StrategyParam<int> _daysToCheck;
	private readonly StrategyParam<int> _checkMode;
	private readonly StrategyParam<decimal> _profitFactor;
	private readonly StrategyParam<decimal> _lossFactor;
	private readonly StrategyParam<decimal> _offsetFactor;
	private readonly StrategyParam<int> _closeMode;
	private readonly StrategyParam<int> _tradesPerDay;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _lastOpenHour;

	private Queue<decimal> _rangeHistory;
	private Queue<decimal> _closeDiffHistory;

	private DateTime? _currentDay;
	private DateTime? _levelsDay;
	private decimal _dayHigh;
	private decimal _dayLow;
	private decimal _buyBreakout;
	private decimal _sellBreakout;
	private decimal _profitDistance;
	private decimal _lossDistance;
	private decimal? _previousCheckClose;
	private decimal? _currentCheckClose;
	private int _tradesOpenedToday;
	private bool _levelsReady;
	private decimal _entryPrice;

	/// <summary>
	/// Hour of the day when the reference range is calculated.
	/// </summary>
	public int CheckHour
	{
		get => _checkHour.Value;
		set => _checkHour.Value = value;
	}

	/// <summary>
	/// Minute of the hour when the reference range is calculated.
	/// </summary>
	public int CheckMinute
	{
		get => _checkMinute.Value;
		set => _checkMinute.Value = value;
	}

	/// <summary>
	/// Number of previous days used for averaging.
	/// </summary>
	public int DaysToCheck
	{
		get => _daysToCheck.Value;
		set => _daysToCheck.Value = value;
	}

	/// <summary>
	/// Mode of averaging: 1 - daily range, 2 - absolute close-to-close difference.
	/// </summary>
	public int CheckMode
	{
		get => _checkMode.Value;
		set => _checkMode.Value = value;
	}

	/// <summary>
	/// Divisor applied to convert the average range into a take-profit distance.
	/// </summary>
	public decimal ProfitFactor
	{
		get => _profitFactor.Value;
		set => _profitFactor.Value = value;
	}

	/// <summary>
	/// Divisor applied to convert the average range into a stop-loss distance.
	/// </summary>
	public decimal LossFactor
	{
		get => _lossFactor.Value;
		set => _lossFactor.Value = value;
	}

	/// <summary>
	/// Divisor applied to convert the average range into the breakout offset.
	/// </summary>
	public decimal OffsetFactor
	{
		get => _offsetFactor.Value;
		set => _offsetFactor.Value = value;
	}

	/// <summary>
	/// Defines whether to flatten at the daily boundary (2 = close on new day).
	/// </summary>
	public int CloseMode
	{
		get => _closeMode.Value;
		set => _closeMode.Value = value;
	}

	/// <summary>
	/// Maximum number of trades allowed per day.
	/// </summary>
	public int TradesPerDay
	{
		get => _tradesPerDay.Value;
		set => _tradesPerDay.Value = value;
	}

	/// <summary>
	/// Candle type used by the strategy.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}
	/// <summary>
	/// Last hour of the day when breakout orders are allowed to remain open.
	/// </summary>
	public int LastOpenHour
	{
		get => _lastOpenHour.Value;
		set => _lastOpenHour.Value = value;
	}

	/// <summary>
	/// Initializes strategy parameters.
	/// </summary>
	public TimeBasedRangeBreakoutStrategy()
	{
		_checkHour = Param(nameof(CheckHour), 8)
		.SetDisplay("Check Hour", "Hour of the day used for daily calculations", "Schedule")
		.SetRange(0, 23);

		_checkMinute = Param(nameof(CheckMinute), 0)
		.SetDisplay("Check Minute", "Minute of the hour used for daily calculations", "Schedule")
		.SetRange(0, 59);

		_daysToCheck = Param(nameof(DaysToCheck), 7)
		.SetGreaterThanZero()
		.SetDisplay("Days To Check", "Number of previous days used in averaging", "Averaging")
		
		.SetOptimize(3, 15, 1);

		_checkMode = Param(nameof(CheckMode), 1)
		.SetDisplay("Check Mode", "1 - use daily range, 2 - use absolute close difference", "Averaging")
		.SetRange(1, 2);

		_profitFactor = Param(nameof(ProfitFactor), 2m)
		.SetGreaterThanZero()
		.SetDisplay("Profit Factor", "Divisor applied to average range for take-profit", "Risk")
		
		.SetOptimize(1m, 4m, 0.5m);

		_lossFactor = Param(nameof(LossFactor), 2m)
		.SetGreaterThanZero()
		.SetDisplay("Loss Factor", "Divisor applied to average range for stop-loss", "Risk")
		
		.SetOptimize(1m, 4m, 0.5m);

		_offsetFactor = Param(nameof(OffsetFactor), 2m)
		.SetGreaterThanZero()
		.SetDisplay("Offset Factor", "Divisor applied to average range for breakout levels", "Entries")
		
		.SetOptimize(1m, 4m, 0.5m);

		_closeMode = Param(nameof(CloseMode), 1)
		.SetDisplay("Close Mode", "1 - keep positions overnight, 2 - close on new day", "Risk")
		.SetRange(1, 2);

		_tradesPerDay = Param(nameof(TradesPerDay), 1)
		.SetGreaterThanZero()
		.SetDisplay("Trades Per Day", "Maximum entries allowed within one day", "Risk")
		
		.SetOptimize(1, 3, 1);

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Primary candle series used by the strategy", "Data");
		_lastOpenHour = Param(nameof(LastOpenHour), 23)
			.SetDisplay("Last Open Hour", "Hour after which new trades are not opened", "Schedule")
			.SetRange(0, 23);
	}

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

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

		_rangeHistory = null;
		_closeDiffHistory = null;
		_currentDay = null;
		_levelsDay = null;
		_dayHigh = 0m;
		_dayLow = 0m;
		_buyBreakout = 0m;
		_sellBreakout = 0m;
		_profitDistance = 0m;
		_lossDistance = 0m;
		_previousCheckClose = null;
		_currentCheckClose = null;
		_tradesOpenedToday = 0;
		_levelsReady = false;
		_entryPrice = 0m;
	}

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

		_rangeHistory = new();
		_closeDiffHistory = new();

		StartProtection(null, null);

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

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

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

		UpdateDailyState(candle);
		TryCalculateLevels(candle);

		

		ManageOpenPosition(candle);
		TryEnterPosition(candle);
	}

	private void UpdateDailyState(ICandleMessage candle)
	{
		var candleDate = candle.OpenTime.Date;

		if (_currentDay is null || candleDate != _currentDay.Value)
		{
			if (_currentDay is not null)
			FinalizePreviousDay();

			if (CloseMode == 2 && Position != 0m)
			ClosePosition();

			_currentDay = candleDate;
			_dayHigh = candle.HighPrice;
			_dayLow = candle.LowPrice;
			_levelsReady = false;
			_levelsDay = null;
			_currentCheckClose = null;
			_tradesOpenedToday = 0;
		}
		else
		{
			if (candle.HighPrice > _dayHigh)
			_dayHigh = candle.HighPrice;

			if (candle.LowPrice < _dayLow)
			_dayLow = candle.LowPrice;
		}
	}

	private void FinalizePreviousDay()
	{
		var dayRange = _dayHigh - _dayLow;
		if (dayRange > 0m)
		if (_rangeHistory != null)
		EnqueueWithLimit(_rangeHistory, dayRange, DaysToCheck);

		if (_currentCheckClose is decimal checkClose)
		{
			if (_previousCheckClose is decimal previousClose)
			{
				var difference = Math.Abs(checkClose - previousClose);
				if (difference > 0m)
				if (_closeDiffHistory != null)
				EnqueueWithLimit(_closeDiffHistory, difference, DaysToCheck);
			}

			_previousCheckClose = checkClose;
		}

		_currentCheckClose = null;
	}

	private void TryCalculateLevels(ICandleMessage candle)
	{
		if (candle.OpenTime.Hour != CheckHour || candle.OpenTime.Minute != CheckMinute)
		return;

		_currentCheckClose = candle.ClosePrice;

		if (Position != 0m)
		ClosePosition();

		if (!TryGetAverage(out var average))
		{
			_levelsReady = false;
			_levelsDay = null;
			return;
		}

		var offset = OffsetFactor > 0m ? average / OffsetFactor : 0m;
		_profitDistance = ProfitFactor > 0m ? average / ProfitFactor : 0m;
		_lossDistance = LossFactor > 0m ? average / LossFactor : 0m;

		_buyBreakout = _dayHigh + offset;
		_sellBreakout = _dayLow - offset;
		_levelsReady = true;
		_levelsDay = _currentDay;

		LogInfo($"Levels prepared for {candle.OpenTime:yyyy-MM-dd}. High={_dayHigh}, Low={_dayLow}, Avg={average}, BuyLevel={_buyBreakout}, SellLevel={_sellBreakout}.");
	}

	private void ManageOpenPosition(ICandleMessage candle)
	{
		if (Position == 0m)
		return;

		var entryPrice = _entryPrice;
		if (entryPrice == 0m)
		return;

		if (Position > 0m)
		{
			var reachedProfit = _profitDistance > 0m && candle.ClosePrice - entryPrice >= _profitDistance;
			var reachedLoss = _lossDistance > 0m && entryPrice - candle.ClosePrice >= _lossDistance;

			if (reachedProfit || reachedLoss)
			SellMarket();
		}
		else if (Position < 0m)
		{
			var reachedProfit = _profitDistance > 0m && entryPrice - candle.ClosePrice >= _profitDistance;
			var reachedLoss = _lossDistance > 0m && candle.ClosePrice - entryPrice >= _lossDistance;

			if (reachedProfit || reachedLoss)
			BuyMarket();
		}
	}

	private void TryEnterPosition(ICandleMessage candle)
	{
		if (!_levelsReady || _levelsDay is null || _currentDay is null)
		return;

		if (_levelsDay != _currentDay)
		return;

		if (_tradesOpenedToday >= TradesPerDay)
		return;

		if (candle.OpenTime.Hour > LastOpenHour)
		return;

		if (Position != 0m)
		return;

		if (candle.ClosePrice >= _buyBreakout)
		{
			BuyMarket();
			_entryPrice = candle.ClosePrice;
			_tradesOpenedToday++;
		}
		else if (candle.ClosePrice <= _sellBreakout)
		{
			SellMarket();
			_entryPrice = candle.ClosePrice;
			_tradesOpenedToday++;
		}
	}

	private bool TryGetAverage(out decimal average)
	{
		average = 0m;
		var source = CheckMode == 2 ? _closeDiffHistory : _rangeHistory;
		if (source == null)
		return false;

		var sum = 0m;
		var count = 0;

		foreach (var value in source)
		{
			sum += value;
			count++;
		}

		if (count == 0)
		return false;

		average = sum / count;
		return true;
	}

	private static void EnqueueWithLimit(Queue<decimal> queue, decimal value, int limit)
	{
		queue.Enqueue(value);

		while (queue.Count > limit)
		queue.Dequeue();
	}

	private void ClosePosition()
	{
		if (Position > 0m)
		{
			SellMarket();
		}
		else if (Position < 0m)
		{
			BuyMarket();
		}
	}
}