在 GitHub 上查看

Tuyul Gap End Of Week

概述

Tuyul Gap End Of Week 将 MetaTrader 5 智能交易系统 TuyulGAP 迁移到 StockSharp。策略在周五晚间扫描可配置数量的最近K线,在最高价上方和最低价下方各放置一张突破止损单,为周末跳空做好准备。每周只允许触发一次,当挂单就位后策略等待价格向任意方向突破。如果持仓的浮动利润达到设定的安全目标,策略立即以市价平仓,并在周一取消所有剩余的挂单,为下一周重新初始化。

策略逻辑

  • 每周触发窗口 – 在可配置的星期几(默认周五)并且到达指定小时后,于设定的分钟窗口内(默认23:00–23:15)准备突破价位,每周仅执行一次。
  • 动态突破价 – 取最近 Lookback Bars 根已完成K线的最高价与最低价,分别在高点上方一个最小跳动、低点下方一个最小跳动放置买入止损和卖出止损,重现 MetaTrader 的 point 偏移逻辑。
  • 挂单管理 – 如果本周已经存在相应的止损挂单则不会重复下单。某一方向触发后,另一张挂单仍保持有效,以便参与任意方向的跳空。
  • 安全利润平仓 – 在每根收盘K线上检查所有持仓,只要浮动盈利(以账户货币计价)达到安全利润阈值,即刻以市价离场。
  • 每周重置 – 周一出现的第一根K线会取消所有仍然激活的挂单,并重置会话标志,以便下一次周五设置。

参数

  • Volume – 突破止损单使用的下单量。
  • Stop Loss (points) – 入场价到保护止损之间的距离,单位为合约最小跳动。为 0 时不使用止损。
  • Lookback Bars – 计算周内高点和低点所需的已完成K线数量。
  • Setup Day Of Week – 触发设置流程的星期索引(0=周日 … 6=周六),默认值 5 对应原策略的周五行为。
  • Setup Hour – 用于布置突破挂单的交易所时间小时。
  • Setup Minute Window – 在 Setup Hour 之后允许执行设置的分钟数,默认 15 表示23:00至23:15(含)之间。
  • Secure Profit Target – 触发立即平仓的最小浮动盈利,单位为账户货币。
  • Candle Type – 用于高低点扫描和监控循环的K线时间框架。

其他说明

  • 由于 StockSharp 无法直接在挂单上附带保护止损,止损会在仓位真正打开后才提交。
  • 订单数量、价格和止损都会按照交易品种的最小变动和精度信息进行规范化。
  • 本策略仅提供 C# 实现,不包含 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;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Port of the MetaTrader 5 expert advisor TuyulGAP.
/// Places weekly breakout stop orders around the recent high/low range and closes positions once secure profit is reached.
/// </summary>
public class TuyulGapEndOfWeekStrategy : Strategy
{
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _lookbackBars;
	private readonly StrategyParam<int> _setupDayOfWeek;
	private readonly StrategyParam<int> _setupHour;
	private readonly StrategyParam<int> _setupMinuteWindow;
	private readonly StrategyParam<decimal> _secureProfitTarget;
	private readonly StrategyParam<DataType> _candleType;

	private Highest _highestHigh;
	private Lowest _lowestLow;

	private decimal _tickSize;
	private decimal _entryPrice;
	private decimal? _virtualStopPrice;
	private decimal _prevHighest;
	private decimal _prevLowest;

	/// <summary>
	/// Initializes a new instance of the <see cref="TuyulGapEndOfWeekStrategy"/> class.
	/// </summary>
	public TuyulGapEndOfWeekStrategy()
	{

		_stopLossPoints = Param(nameof(StopLossPoints), 60)
		.SetRange(0, 5000)
		.SetDisplay("Stop Loss (points)", "Distance from entry used for protective stops", "Risk");

		_lookbackBars = Param(nameof(LookbackBars), 12)
		.SetRange(2, 500)
		.SetDisplay("Lookback Bars", "Number of finished candles inspected for highs/lows", "Setup");

		_setupDayOfWeek = Param(nameof(SetupDayOfWeek), 5)
		.SetRange(0, 6)
		.SetDisplay("Setup Day Of Week", "Day index (0=Sunday) that stages the weekly orders", "Setup");

		_setupHour = Param(nameof(SetupHour), 23)
		.SetRange(0, 23)
		.SetDisplay("Setup Hour", "Exchange hour when the weekly setup is evaluated", "Setup");

		_setupMinuteWindow = Param(nameof(SetupMinuteWindow), 15)
		.SetRange(0, 59)
		.SetDisplay("Setup Minute Window", "Minutes after the setup hour when staging is allowed", "Setup");

		_secureProfitTarget = Param(nameof(SecureProfitTarget), 5m)
		.SetRange(0m, 100000m)
		.SetDisplay("Secure Profit Target", "Unrealized profit per position that triggers an immediate exit", "Risk");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
		.SetDisplay("Candle Type", "Timeframe used for the high/low scan and monitoring", "Data");
	}


	/// <summary>
	/// Distance from entry used for protective stops, measured in instrument points.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Number of finished candles inspected for highs and lows.
	/// </summary>
	public int LookbackBars
	{
		get => _lookbackBars.Value;
		set => _lookbackBars.Value = value;
	}

	/// <summary>
	/// Day index (0=Sunday … 6=Saturday) that stages the weekly setup.
	/// </summary>
	public int SetupDayOfWeek
	{
		get => _setupDayOfWeek.Value;
		set => _setupDayOfWeek.Value = value;
	}

	/// <summary>
	/// Exchange hour when the weekly setup is evaluated.
	/// </summary>
	public int SetupHour
	{
		get => _setupHour.Value;
		set => _setupHour.Value = value;
	}

	/// <summary>
	/// Minutes after the setup hour when staging is allowed.
	/// </summary>
	public int SetupMinuteWindow
	{
		get => _setupMinuteWindow.Value;
		set => _setupMinuteWindow.Value = value;
	}

	/// <summary>
	/// Unrealized profit per position that triggers an immediate exit.
	/// </summary>
	public decimal SecureProfitTarget
	{
		get => _secureProfitTarget.Value;
		set => _secureProfitTarget.Value = value;
	}

	/// <summary>
	/// Timeframe used for the high/low scan and monitoring.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

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

		_highestHigh = null;
		_lowestLow = null;
		_tickSize = 0m;
		_entryPrice = 0m;
		_virtualStopPrice = null;
		_prevHighest = 0m;
		_prevLowest = 0m;
	}

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

		_tickSize = Security?.PriceStep ?? 0m;

		_highestHigh = new Highest
		{
			Length = Math.Max(2, LookbackBars)
		};

		_lowestLow = new Lowest
		{
			Length = Math.Max(2, LookbackBars)
		};

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

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

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

		if (!_highestHigh.IsFormed || !_lowestLow.IsFormed)
			return;

		// Check virtual stop
		if (Position > 0m && _virtualStopPrice.HasValue && candle.LowPrice <= _virtualStopPrice.Value)
		{
			SellMarket(Math.Abs(Position));
			_virtualStopPrice = null;
			_entryPrice = 0m;
			return;
		}
		if (Position < 0m && _virtualStopPrice.HasValue && candle.HighPrice >= _virtualStopPrice.Value)
		{
			BuyMarket(Math.Abs(Position));
			_virtualStopPrice = null;
			_entryPrice = 0m;
			return;
		}

		// Close on profit
		if (Position != 0m && SecureProfitTarget > 0m && PnL >= SecureProfitTarget)
		{
			if (Position > 0m)
				SellMarket(Math.Abs(Position));
			else
				BuyMarket(Math.Abs(Position));
			_virtualStopPrice = null;
			_entryPrice = 0m;
			return;
		}

		if (Position == 0m && _prevHighest > 0m && _prevLowest > 0m)
		{
			// Breakout above previous highest
			if (candle.ClosePrice > _prevHighest)
			{
				BuyMarket(Volume);
				_entryPrice = candle.ClosePrice;
				var stopDist = GetStopLossDistance();
				if (stopDist > 0m)
					_virtualStopPrice = _entryPrice - stopDist;
			}
			// Breakout below previous lowest
			else if (candle.ClosePrice < _prevLowest)
			{
				SellMarket(Volume);
				_entryPrice = candle.ClosePrice;
				var stopDist = GetStopLossDistance();
				if (stopDist > 0m)
					_virtualStopPrice = _entryPrice + stopDist;
			}
		}

		_prevHighest = highestValue;
		_prevLowest = lowestValue;
	}

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

	private decimal GetStopLossDistance()
	{
		var tick = _tickSize > 0m ? _tickSize : Security?.PriceStep ?? 0m;
		if (tick <= 0m)
		return 0m;

		return StopLossPoints > 0 ? StopLossPoints * tick : 0m;
	}

	private decimal NormalizePrice(decimal price)
	{
		var tick = _tickSize > 0m ? _tickSize : Security?.PriceStep ?? 0m;
		if (tick <= 0m)
		return price;

		return Math.Round(price / tick, MidpointRounding.AwayFromZero) * tick;
	}

	private decimal NormalizeVolume(decimal volume)
	{
		var security = Security;
		if (security == null)
		return volume;

		if (security.VolumeStep is { } volumeStep && volumeStep > 0m)
		volume = Math.Round(volume / volumeStep, MidpointRounding.AwayFromZero) * volumeStep;

		if (security.MinVolume is { } minVolume && minVolume > 0m && volume < minVolume)
		volume = minVolume;

		if (security.MaxVolume is { } maxVolume && maxVolume > 0m && volume > maxVolume)
		volume = maxVolume;

		return volume;
	}

	private DayOfWeek GetSetupDay()
	{
		var day = SetupDayOfWeek % 7;
		if (day < 0)
		day += 7;

		return (DayOfWeek)day;
	}
}