在 GitHub 上查看

挂单稳定策略

概述

挂单稳定策略 是对 MetaTrader 专家顾问 hjueiisyx8lp2o379e_www_forex-instruments_info.mq4 的移植。原策略会在现价上下放置一对止损挂单,并等待价格突破。一旦触发开仓,系统会监控最近几根蜡烛的实体大小,用来判断行情是否进入横盘,并在动能减弱或利润达到预设目标时退出仓位。

该 C# 版本使用 StockSharp 的高级 API,只在蜡烛收盘后做出决策,从而保证回测与实盘表现的一致性。

交易规则

  1. 当没有持仓或挂单时,策略会在收盘价上方放置 buy stop,下方放置 sell stop,间距为 OrderDistancePoints 个 MetaTrader 点。
  2. 任一止损挂单被触发时:
    • 以 OrderVolume 手的固定仓位开仓;
    • 另一侧的止损挂单保留,用于捕捉反向突破。
  3. 持仓期间持续检查最近两根已完成蜡烛的实体:
    • 如果最新蜡烛实体小于 StabilizationPoints,且浮动利润大于 ProfitThreshold,则平仓并取消对侧挂单;
    • 如果连续两根蜡烛都小于 StabilizationPoints,无论盈亏都立即平仓;
    • 当利润达到 AbsoluteFixation 时立刻平仓。
  4. 若 ExpirationMinutes 大于零,则挂单在到期后取消并重新创建。

参数

名称 说明 默认值
OrderVolume 每次交易的手数。 0.1
OrderDistancePoints 与当前价格的距离(MetaTrader 点)。 20
ProfitThreshold 允许根据“稳定”信号离场所需的最低浮动利润(账户货币)。 -2
AbsoluteFixation 达到该利润(账户货币)时无条件平仓。 30
StabilizationPoints 判断市场进入盘整的最大蜡烛实体(点)。 25
ExpirationMinutes 挂单有效期(分钟),0 表示无限期。 20
CandleType 用于判断稳定性的蜡烛类型(默认 5 分钟)。 TimeFrame(5m)

转换说明

  • 原始程序基于逐笔行情;移植版本只处理已完成蜡烛,使结果更易复现。
  • MetaTrader 的“点”映射到 StockSharp 的 PriceStep,若无定义则使用 1。
  • 利润通过 PriceStep 与 StepPrice 换算为账户货币的估算值。
  • 代码中的注释与参数描述全部改写为英文,以符合仓库要求。

使用方法

  1. 将策略添加到 StockSharp 项目中,并指定交易品种与账户。
  2. 根据交易品种调整参数,尤其是蜡烛周期与挂单间距。
  3. 启动策略,系统会自动下达成对的止损挂单,并按上述规则管理仓位。

可扩展方向

  • 根据市场波动性调整蜡烛周期,以在敏感度与噪音过滤之间取得平衡。
  • 引入 ATR、布林带等波动性过滤器,避免在极端平静的时段交易。
  • 在接近目标利润时加入移动止损或分批减仓机制。
using System;
using System.Collections.Generic;

using Ecng.Common;

using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.MatchingEngine;
using StockSharp.Messages;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Paired breakout stop orders with stabilization-based exits.
/// </summary>
public class OrderStabilizationStrategy : Strategy
{
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<decimal> _orderDistancePoints;
	private readonly StrategyParam<decimal> _profitThreshold;
	private readonly StrategyParam<decimal> _absoluteFixation;
	private readonly StrategyParam<decimal> _stabilizationPoints;
	private readonly StrategyParam<int> _expirationMinutes;
	private readonly StrategyParam<DataType> _candleType;

	private Order _buyStop;
	private Order _sellStop;
	private DateTime? _createdAt;
	private decimal _filledPosition;
	private decimal _entryValue;
	private decimal _previousBody;
	private bool _hasPreviousBody;

	public decimal OrderVolume { get => _orderVolume.Value; set => _orderVolume.Value = value; }
	public decimal OrderDistancePoints { get => _orderDistancePoints.Value; set => _orderDistancePoints.Value = value; }
	public decimal ProfitThreshold { get => _profitThreshold.Value; set => _profitThreshold.Value = value; }
	public decimal AbsoluteFixation { get => _absoluteFixation.Value; set => _absoluteFixation.Value = value; }
	public decimal StabilizationPoints { get => _stabilizationPoints.Value; set => _stabilizationPoints.Value = value; }
	public int ExpirationMinutes { get => _expirationMinutes.Value; set => _expirationMinutes.Value = value; }
	public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }

	public OrderStabilizationStrategy()
	{
		_orderVolume = Param(nameof(OrderVolume), 0.1m).SetGreaterThanZero()
			.SetDisplay("Order Volume", "Trade volume in lots used for both stop entries", "Trading");
		_orderDistancePoints = Param(nameof(OrderDistancePoints), 20m).SetGreaterThanZero()
			.SetDisplay("Order Distance", "Distance between the current close price and each stop order, in points", "Trading");
		_profitThreshold = Param(nameof(ProfitThreshold), -2m)
			.SetDisplay("Profit Threshold", "Minimum floating profit (account currency) required before an exit triggered by stabilization is allowed", "Exit");
		_absoluteFixation = Param(nameof(AbsoluteFixation), 30m).SetNotNegative()
			.SetDisplay("Absolute Fixation", "Profit level (account currency) that forces an immediate exit", "Exit");
		_stabilizationPoints = Param(nameof(StabilizationPoints), 25m).SetGreaterThanZero()
			.SetDisplay("Stabilization", "Maximum candle body size (points) that signals a flat market", "Exit");
		_expirationMinutes = Param(nameof(ExpirationMinutes), 20).SetNotNegative()
			.SetDisplay("Expiration", "Lifetime of pending stop orders in minutes, 0 disables expiration", "Trading");
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Candle type used to evaluate stabilization", "General");
	}

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

	protected override void OnReseted()
	{
		base.OnReseted();
		_buyStop = null;
		_sellStop = null;
		_createdAt = null;
		_filledPosition = 0m;
		_entryValue = 0m;
		_previousBody = 0m;
		_hasPreviousBody = false;
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);
		// Stop orders are matched against trade prints, so they fire between candle closes.
		Subscribe(new Subscription(DataType.Ticks, Security));
		SubscribeCandles(CandleType).Bind(ProcessCandle).Start();
	}

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

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

		var body = Math.Abs(candle.ClosePrice - candle.OpenPrice);

		if (Position != 0m && IsExitDue(candle.ClosePrice, body, point))
		{
			CancelIfActive(_buyStop);
			CancelIfActive(_sellStop);
			Flatten();
		}
		else if ((Position == 0m && !IsWorking(_buyStop) && !IsWorking(_sellStop)) || IsExpired(candle.OpenTime))
		{
			ArmStops(candle.ClosePrice, candle.OpenTime, point);
		}

		_previousBody = body;
		_hasPreviousBody = true;
	}

	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		var price = trade.Trade.Price;
		var volume = trade.Trade.Volume;
		var signed = trade.Order.Side == Sides.Buy ? volume : -volume;
		var previous = _filledPosition;

		_filledPosition += signed;

		if (_filledPosition == 0m)
			_entryValue = 0m;
		else if (previous == 0m || Math.Sign(previous) == Math.Sign(signed))
			_entryValue += price * volume;
		else if (Math.Sign(_filledPosition) != Math.Sign(previous))
			_entryValue = price * Math.Abs(_filledPosition);
		else
			_entryValue -= _entryValue / Math.Abs(previous) * volume;
	}

	private bool IsExitDue(decimal price, decimal body, decimal point)
	{
		var bodyLimit = StabilizationPoints * point;
		var pnl = FloatingPnL(price, point);
		var oneSmall = body <= bodyLimit;
		var twoSmall = oneSmall && _hasPreviousBody && _previousBody <= bodyLimit;

		return (oneSmall && pnl > ProfitThreshold) ||
			twoSmall ||
			(AbsoluteFixation > 0m && pnl >= AbsoluteFixation);
	}

	private bool IsExpired(DateTime time)
		=> ExpirationMinutes > 0 &&
			_createdAt is DateTime created &&
			time - created >= TimeSpan.FromMinutes(ExpirationMinutes);

	// Flat: both stops are placed around the close. With a position open only the pending
	// opposite stop is moved; the side that opened the position is never placed again.
	private void ArmStops(decimal center, DateTime time, decimal point)
	{
		var distance = OrderDistancePoints * point;

		if (Position == 0m || (Position < 0m && IsWorking(_buyStop)))
			_buyStop = RenewStop(_buyStop, Sides.Buy, center + distance);

		if (Position == 0m || (Position > 0m && IsWorking(_sellStop)))
			_sellStop = RenewStop(_sellStop, Sides.Sell, center - distance);

		_createdAt = IsWorking(_buyStop) || IsWorking(_sellStop) ? time : null;
	}

	private Order RenewStop(Order order, Sides side, decimal activationPrice)
	{
		if (order is { State: OrderStates.Active })
			CancelOrder(order);
		else if (IsWorking(order))
			return order;

		var stop = new Order
		{
			Security = Security,
			Portfolio = Portfolio,
			Side = side,
			Volume = OrderVolume,
			Type = OrderTypes.Conditional,
			Condition = new StopOrderCondition { ActivationPrice = activationPrice },
		};

		RegisterOrder(stop);
		return stop;
	}

	private static bool IsWorking(Order order)
		=> order is not null && order.State is not (OrderStates.Done or OrderStates.Failed);

	private void CancelIfActive(Order order)
	{
		if (order is { State: OrderStates.Active })
			CancelOrder(order);
	}

	private decimal FloatingPnL(decimal price, decimal point)
	{
		if (Position == 0m || _filledPosition == 0m)
			return 0m;

		var entryPrice = _entryValue / Math.Abs(_filledPosition);
		var direction = Position > 0m ? 1m : -1m;
		var move = (price - entryPrice) * direction;
		var stepPrice = Security?.StepPrice ?? 0m;

		return stepPrice > 0m
			? move / point * stepPrice * Math.Abs(Position)
			: move * (Security?.Multiplier ?? 1m) * Math.Abs(Position);
	}

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