在 GitHub 上查看

App Price Level Cross 策略

概述

  • 将 MetaTrader 4 专家顾问 BT_v4(位于 MQL/8543/BT_v4.mq4)转换到 StockSharp 平台。
  • 全面采用 StockSharp 的高层策略 API:烛线订阅、无指标的数据绑定以及内置的风控工具。
  • 核心思想:监控收盘价是否突破用户设定的水平价位 AppPrice,并按原脚本的逻辑开仓和平仓。

交易逻辑

  1. 仅处理已经完成的蜡烛(CandleStates.Finished),与 MQL 版本一致,不会读取正在形成的 Close[0]
  2. 如果当前收盘价 上穿 AppPrice,同时上一根收盘价仍在该水平之下或相等:
    • 只有在 BuyOnly = true 时才会执行(对应原版默认的“只做多”模式)。
    • 撤销所有挂单,若当前持有空单则通过同一笔市价单一并平掉,再建立新的多头仓位。
  3. 如果当前收盘价 下穿 AppPrice,同时上一根收盘价仍在该水平之上或相等:
    • 只有在 BuyOnly = false 时才会执行(对应原版的“只做空”模式)。
    • 撤销挂单,若当前持有多单则同时反向平仓,再建立新的空头仓位。
  4. 由于直接在 Bind 回调中处理数据,策略无需附加指标,也不会调用任何历史值遍历函数。

仓位管理

  • EnableMoneyManagement = false 时,始终下单 FixedVolume 手数(等价于 MQL 中的 Lots)。

  • EnableMoneyManagement = true 时,按原脚本公式计算手数:

    [ \text = \text_{\text} \left( \frac{\text}{100} \times \frac{\text}{\text} \right) ]

    • LotPrecision = 1divisor = 1000,当 LotPrecision = 2divisor = 100,完全复刻 LotPrec 的处理方式。
    • 计算结果会被约束在 [MinLot, MaxLot] 区间内,然后再次按照品种的 VolumeStepVolumeMinVolumeMax 对齐。
    • 若投资组合暂未返回余额数据,则自动回退到固定手数。

风险控制

  • StopLossPointsTakeProfitPoints 以“价格点”(最小报价跳动)表示。
  • 当任意一个值大于 0 时,策略会调用 StartProtection,并使用 Security.PriceStep 转换为真实价差。
  • 将距离设为 0 即可关闭对应的保护(与 MQL 中的行为一致)。

主要参数

参数 说明 默认值
AppPrice 触发交易的价格水平。 0
BuyOnly true=只做多,false=只做空。 true
FixedVolume MM 关闭时的固定手数。 0.1
EnableMoneyManagement 是否启用余额百分比算法。 false
LotBalancePercent MM 模式下使用的余额百分比。 10
MinLot / MaxLot 计算结果的上下限。 0.1 / 5
LotPrecision 手数的小数位数。 1
StopLossPoints 止损距离(点,0=关闭)。 140
TakeProfitPoints 止盈距离(点,0=关闭)。 180
CandleType 用于判定的蜡烛周期。 1 分钟

实现细节

  • 通过 SubscribeCandles(...).Bind(...) 直接在回调中获取收盘价,无需额外指标,也避免了 GetValue() 之类的禁用方法。
  • 市价单数量会加上当前持仓的绝对值,从而在一笔订单内完成反向平仓与开仓,效果与原 EA “先平仓再开仓”一致。
  • 在每次下单前调用 CancelActiveOrders(),防止旧有挂单被意外触发。
  • MQL 输入参数中与颜色、滑点或 Magic 相关的设置在 StockSharp 中没有必要,因此未保留。
  • 请确保证券元数据(PriceStep, VolumeStep, VolumeMin, VolumeMax)已正确填写,以便止损/止盈和手数对齐与券商规则一致。

使用建议

  • AppPrice 设置为希望监控的关键价位(如枢轴位、重要整数位等)。
  • 若想运行原脚本的“只做空”模式,请将 BuyOnly 设为 false;保持默认则执行“只做多”。
  • 启用资金管理时,请确认投资组合连接能返回余额;否则策略会自动使用固定手数。
  • 根据要求,本次仅提供 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;

using StockSharp.Algo;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Strategy that opens trades when the close price crosses a configured level.
/// Converted from the MQL4 expert advisor BT_v4.
/// </summary>
public class AppPriceLevelCrossStrategy : Strategy
{
	private readonly StrategyParam<decimal> _appPrice;
	private readonly StrategyParam<bool> _buyOnly;
	private readonly StrategyParam<decimal> _fixedVolume;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<bool> _enableMoneyManagement;
	private readonly StrategyParam<decimal> _lotBalancePercent;
	private readonly StrategyParam<decimal> _minLot;
	private readonly StrategyParam<decimal> _maxLot;
	private readonly StrategyParam<int> _lotPrecision;
	private readonly StrategyParam<DataType> _candleType;

	private decimal? _previousClose;

	/// <summary>
	/// Initializes strategy parameters with defaults mirroring the MQL version.
	/// </summary>
	public AppPriceLevelCrossStrategy()
	{
		_appPrice = Param(nameof(AppPrice), 65000m)
			.SetDisplay("App Price", "Reference level that generates trades when the close crosses it", "Trading");

		_buyOnly = Param(nameof(BuyOnly), true)
			.SetDisplay("Buy Only", "Enable to trade only long entries (set to false for sell-only mode)", "Trading");

		_fixedVolume = Param(nameof(FixedVolume), 0.1m)
			.SetGreaterThanZero()
			.SetDisplay("Fixed Volume", "Lot size used when money management is disabled", "Risk");

		_stopLossPoints = Param(nameof(StopLossPoints), 140)
			.SetDisplay("Stop Loss (points)", "Distance in price points for the protective stop (0 disables)", "Risk");

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 180)
			.SetDisplay("Take Profit (points)", "Distance in price points for the profit target (0 disables)", "Risk");

		_enableMoneyManagement = Param(nameof(EnableMoneyManagement), false)
			.SetDisplay("Enable MM", "Toggle balance-based position sizing", "Risk");

		_lotBalancePercent = Param(nameof(LotBalancePercent), 10m)
			.SetDisplay("Balance %", "Percentage of balance used to compute the lot when MM is enabled", "Risk");

		_minLot = Param(nameof(MinLot), 0.1m)
			.SetDisplay("Minimum Lot", "Lower bound for the calculated lot size", "Risk");

		_maxLot = Param(nameof(MaxLot), 5m)
			.SetDisplay("Maximum Lot", "Upper bound for the calculated lot size", "Risk");

		_lotPrecision = Param(nameof(LotPrecision), 1)
			.SetDisplay("Lot Precision", "Number of decimal places to round the calculated lot size", "Risk");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used for signal generation", "General");
	}

	/// <summary>
	/// Target price level for the close-cross rule.
	/// </summary>
	public decimal AppPrice
	{
		get => _appPrice.Value;
		set => _appPrice.Value = value;
	}

	/// <summary>
	/// When true only long trades are allowed; set to false to trade only shorts.
	/// </summary>
	public bool BuyOnly
	{
		get => _buyOnly.Value;
		set => _buyOnly.Value = value;
	}

	/// <summary>
	/// Fixed lot size used when money management is disabled.
	/// </summary>
	public decimal FixedVolume
	{
		get => _fixedVolume.Value;
		set => _fixedVolume.Value = value;
	}

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

	/// <summary>
	/// Take-profit distance expressed in price points.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Enables the balance-percentage position sizing block.
	/// </summary>
	public bool EnableMoneyManagement
	{
		get => _enableMoneyManagement.Value;
		set => _enableMoneyManagement.Value = value;
	}

	/// <summary>
	/// Percentage of account balance used for lot calculation when MM is active.
	/// </summary>
	public decimal LotBalancePercent
	{
		get => _lotBalancePercent.Value;
		set => _lotBalancePercent.Value = value;
	}

	/// <summary>
	/// Minimum allowed lot for the calculated value.
	/// </summary>
	public decimal MinLot
	{
		get => _minLot.Value;
		set => _minLot.Value = value;
	}

	/// <summary>
	/// Maximum allowed lot for the calculated value.
	/// </summary>
	public decimal MaxLot
	{
		get => _maxLot.Value;
		set => _maxLot.Value = value;
	}

	/// <summary>
	/// Decimal precision applied to the calculated lot size.
	/// </summary>
	public int LotPrecision
	{
		get => _lotPrecision.Value;
		set => _lotPrecision.Value = value;
	}

	/// <summary>
	/// Candle type used to evaluate the cross conditions.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

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

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

		// Reset stored close value so the next formed candle rebuilds the history.
		_previousClose = null;
	}

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

		var dummySma = new SimpleMovingAverage { Length = 2 };
		var subscription = SubscribeCandles(CandleType);
		subscription.Bind(dummySma, ProcessCandle).Start();

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

		var step = Security?.PriceStep ?? 1m;
		if (TakeProfitPoints > 0 || StopLossPoints > 0)
		{
			var takeDistance = TakeProfitPoints > 0 ? new Unit(TakeProfitPoints * step, UnitTypes.Absolute) : new Unit(0m);
			var stopDistance = StopLossPoints > 0 ? new Unit(StopLossPoints * step, UnitTypes.Absolute) : new Unit(0m);

			StartProtection(takeProfit: takeDistance, stopLoss: stopDistance);
		}
	}

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

		var previousClose = _previousClose;
		_previousClose = candle.ClosePrice;

		// Need at least one completed candle to compare against the configured level.
		if (previousClose is null)
			return;

		var crossedAbove = candle.ClosePrice > AppPrice && previousClose <= AppPrice;
		var crossedBelow = candle.ClosePrice < AppPrice && previousClose >= AppPrice;

		if (crossedAbove)
		{
			ExecuteBuy();
		}
		else if (crossedBelow)
		{
			ExecuteSell();
		}
	}

	private void ExecuteBuy()
	{
		// Skip if we already hold a long position.
		if (Position > 0)
			return;

		var baseVolume = CalculateBaseVolume();
		if (baseVolume <= 0m)
			return;

		var volume = baseVolume;
		if (Position < 0)
			volume += Math.Abs(Position);

		volume = AlignVolume(volume);
		if (volume <= 0m)
			return;

BuyMarket(volume);
	}

	private void ExecuteSell()
	{
		// Skip if we already hold a short position.
		if (Position < 0)
			return;

		var baseVolume = CalculateBaseVolume();
		if (baseVolume <= 0m)
			return;

		var volume = baseVolume;
		if (Position > 0)
			volume += Math.Abs(Position);

		volume = AlignVolume(volume);
		if (volume <= 0m)
			return;

SellMarket(volume);
	}

	private decimal CalculateBaseVolume()
	{
		if (!EnableMoneyManagement)
			return FixedVolume;

		var balance = Portfolio?.CurrentValue ?? Portfolio?.BeginValue;
		if (balance is null || balance <= 0m)
			return FixedVolume;

		var divisor = LotPrecision == 2 ? 100m : 1000m;
		if (LotPrecision <= 0)
			divisor = 1m;

		var precision = LotPrecision;
		if (precision < 0)
			precision = 0;

		var volume = LotBalancePercent / 100m * balance.Value / divisor;
		volume = Math.Round(volume, precision, MidpointRounding.AwayFromZero);

		if (volume < MinLot)
			volume = MinLot;
		if (volume > MaxLot)
			volume = MaxLot;

		return volume;
	}

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

		var minVolume = security.MinVolume ?? 0m;
		var maxVolume = security.MaxVolume ?? decimal.MaxValue;
		var step = security.VolumeStep ?? 0m;

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

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

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

		return volume;
	}
}