在 GitHub 上查看

True Scalper Profit Lock 策略

概述

True Scalper Profit Lock 策略 是 MetaTrader 5 智能交易系统 “True Scalper Profit Lock” 的 StockSharp 移植版本。策略以超短线交易为目标,结合快速指数移动平均线、2 周期 RSI 过滤器以及将止损推向保本位置的利润保护机制。当持仓在设定的 K 线数量内没有达到目标时,“弃单” 模块会强制离场。

实现代码仅订阅一个 K 线数据流,并且只在 K 线收盘后做出决策。默认适用于日内剥头皮场景,但所有参数都可以调整,从而适配不同品种与周期。

指标与数据

  • EMA(快线) – 默认周期为 3,用于识别向上的动量。
  • EMA(慢线) – 默认周期为 7,定义短期趋势方向。
  • RSI – 默认周期为 2,提供两种判定模式:
    • Method A(默认关闭):仅在 RSI 穿越阈值时改变信号方向。
    • Method B(默认开启):根据 RSI 位于阈值之上或之下决定信号方向。
  • K 线 – 默认时间框架为 1 分钟,可通过 CandleType 参数配置。

入场逻辑

  1. 在最新收盘 K 线上计算快慢 EMA 与 RSI。
  2. 根据所选模式确定 RSI 极性:
    • Method A:比较当前值与上一根 K 线的值,识别阈值穿越。
    • Method B:直接判断当前 RSI 是否高于或低于阈值。
  3. 做多条件 – 当快 EMA 至少高于慢 EMA 一个最小报价步长,并且 RSI 极性为 “负” 时入场;若弃单逻辑要求反向做多,也会忽略当前信号直接建仓。
  4. 做空条件 – 当快 EMA 至少低于慢 EMA 一个最小报价步长,并且 RSI 极性为 “正” 时入场;弃单反向信号同样会触发做空。
  5. 反手时,使用一笔市场单在同一时间平掉旧仓并建立新仓。

离场逻辑

  • 止损 / 止盈 – 以 StopLossPointsTakeProfitPoints 指定的价格步长距离在入场后立即生效。
  • 利润锁定 – 启用时,持仓盈利达到 BreakEvenTriggerPoints 指定的步长后,将止损移动到入场价再加上 BreakEvenPoints 的安全垫(做空时为入场价减去该距离)。每笔交易只执行一次。
  • 弃单逻辑 – 根据入场后经历的收盘 K 线数量触发:
    • Method A:达到 AbandonBars 后平仓,并在下一次机会强制反向建仓。
    • Method B:达到阈值后仅平仓,后续方向继续依赖信号。
    • 当两种方法同时开启时,Method A 拥有优先级。
  • 所有离场均使用市场单(ClosePosition)完成,并在执行后重置内部状态。

资金管理

  • 启用 UseMoneyManagement 时,按照原始 EA 的公式动态计算手数:Ceiling(Balance * RiskPercent / 10000) / 10
  • 计算结果会遵守 MT5 版的边界条件:低于 0.1 手时回落到 InitialVolume,大于 1 手时向上取整,迷你账户可乘以 10,总手数上限为 100。
  • 关闭资金管理时始终使用固定的 InitialVolume

参数说明

  • InitialVolume – 关闭资金管理时的基础手数。
  • TakeProfitPoints / StopLossPoints – 以 Security.PriceStep 表示的止盈、止损距离。
  • FastPeriodSlowPeriodRsiLengthRsiThreshold – 指标配置。
  • UseRsiMethodAUseRsiMethodB – 选择 RSI 判定逻辑。
  • UseAbandonMethodAUseAbandonMethodBAbandonBars – 弃单模块设置。
  • UseMoneyManagementRiskPercentLiveTradingIsMiniAccount – 资金管理相关选项,与原 EA 保持一致。
  • UseProfitLockBreakEvenTriggerPointsBreakEvenPoints – 保本移动参数。
  • MaxPositions – 为兼容 MQL 版本保留。当前移植采用净仓制度,仍然一次只管理一个净头寸。
  • CandleType – 信号所使用的周期或自定义 K 线类型。

使用提示

  • 策略只需绑定一个交易品种,GetWorkingSecurities 会自动订阅所选的 K 线类型。
  • 利润锁定与弃单逻辑依赖收盘价,因此同一根 K 线内的瞬时穿越不会触发。
  • 原 MT5 参数 Slippage 在源码中未被使用,因此移植版本未包含该设置。
  • 请根据标的的最小报价步长调节相关参数,以维持原本的点差距离。

转换差异

  • StockSharp 采用净仓模式,即便 MaxPositions 大于 1 也不会同时开启多笔同向持仓,这与原 EA 在 maxTradesPerPair = 1 时的表现一致。
  • 订单管理改用 BuyMarketSellMarketClosePosition 等高阶 API,不再直接操作交易票据。
  • 指标数据通过 Bind 回调传入,无需手动访问指标缓冲区。

测试建议

  • 在与原 EA 相同的时间框架(推荐 1 分钟)上进行历史回测,验证行为是否一致。
  • 针对目标品种优化 TakeProfitPointsStopLossPointsBreakEvenTriggerPoints,因为原始数值针对外汇点值设定。
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>
/// True Scalper Profit Lock strategy converted from MetaTrader 5.
/// Combines short-term exponential moving averages with RSI filters, profit locking and abandon logic.
/// </summary>
public class TrueScalperProfitLockStrategy : Strategy
{
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<decimal> _takeProfitPoints;
	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<int> _fastPeriod;
	private readonly StrategyParam<int> _slowPeriod;
	private readonly StrategyParam<int> _rsiLength;
	private readonly StrategyParam<decimal> _rsiThreshold;
	private readonly StrategyParam<bool> _useRsiMethodA;
	private readonly StrategyParam<bool> _useRsiMethodB;
	private readonly StrategyParam<bool> _useAbandonMethodA;
	private readonly StrategyParam<bool> _useAbandonMethodB;
	private readonly StrategyParam<int> _abandonBars;
	private readonly StrategyParam<bool> _useMoneyManagement;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<bool> _useProfitLock;
	private readonly StrategyParam<decimal> _breakEvenTriggerPoints;
	private readonly StrategyParam<decimal> _breakEvenPoints;
	private readonly StrategyParam<bool> _liveTrading;
	private readonly StrategyParam<bool> _isMiniAccount;
	private readonly StrategyParam<int> _maxPositions;
	private readonly StrategyParam<DataType> _candleType;

	private decimal? _entryPrice;
	private decimal? _stopLossPrice;
	private decimal? _takeProfitPrice;
	private decimal? _previousRsi;
	private decimal _currentVolume;
	private bool _isLongPosition;
	private bool _pendingReverseToBuy;
	private bool _pendingReverseToSell;
	private int _barsSinceEntry;
	private DateTimeOffset? _lastCandleTime;
	private bool _breakEvenApplied;

	/// <summary>
	/// Base order size expressed in lots.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Take profit distance in price steps.
	/// </summary>
	public decimal TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Stop loss distance in price steps.
	/// </summary>
	public decimal StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Fast EMA period.
	/// </summary>
	public int FastPeriod
	{
		get => _fastPeriod.Value;
		set => _fastPeriod.Value = value;
	}

	/// <summary>
	/// Slow EMA period.
	/// </summary>
	public int SlowPeriod
	{
		get => _slowPeriod.Value;
		set => _slowPeriod.Value = value;
	}

	/// <summary>
	/// RSI calculation length.
	/// </summary>
	public int RsiLength
	{
		get => _rsiLength.Value;
		set => _rsiLength.Value = value;
	}

	/// <summary>
	/// RSI decision threshold.
	/// </summary>
	public decimal RsiThreshold
	{
		get => _rsiThreshold.Value;
		set => _rsiThreshold.Value = value;
	}

	/// <summary>
	/// Enable RSI crossing logic.
	/// </summary>
	public bool UseRsiMethodA
	{
		get => _useRsiMethodA.Value;
		set => _useRsiMethodA.Value = value;
	}

	/// <summary>
	/// Enable RSI polarity logic.
	/// </summary>
	public bool UseRsiMethodB
	{
		get => _useRsiMethodB.Value;
		set => _useRsiMethodB.Value = value;
	}

	/// <summary>
	/// Force reverse direction after abandon timeout.
	/// </summary>
	public bool UseAbandonMethodA
	{
		get => _useAbandonMethodA.Value;
		set => _useAbandonMethodA.Value = value;
	}

	/// <summary>
	/// Close the trade after abandon timeout without forcing direction.
	/// </summary>
	public bool UseAbandonMethodB
	{
		get => _useAbandonMethodB.Value;
		set => _useAbandonMethodB.Value = value;
	}

	/// <summary>
	/// Number of finished candles before abandon logic triggers.
	/// </summary>
	public int AbandonBars
	{
		get => _abandonBars.Value;
		set => _abandonBars.Value = value;
	}

	/// <summary>
	/// Enable balance based position sizing.
	/// </summary>
	public bool UseMoneyManagement
	{
		get => _useMoneyManagement.Value;
		set => _useMoneyManagement.Value = value;
	}

	/// <summary>
	/// Risk percentage used in money management.
	/// </summary>
	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}

	/// <summary>
	/// Enable break even stop adjustment.
	/// </summary>
	public bool UseProfitLock
	{
		get => _useProfitLock.Value;
		set => _useProfitLock.Value = value;
	}

	/// <summary>
	/// Profit distance that triggers break even move.
	/// </summary>
	public decimal BreakEvenTriggerPoints
	{
		get => _breakEvenTriggerPoints.Value;
		set => _breakEvenTriggerPoints.Value = value;
	}

	/// <summary>
	/// Stop offset applied once break even activates.
	/// </summary>
	public decimal BreakEvenPoints
	{
		get => _breakEvenPoints.Value;
		set => _breakEvenPoints.Value = value;
	}

	/// <summary>
	/// Use live trading sizing adjustments.
	/// </summary>
	public bool LiveTrading
	{
		get => _liveTrading.Value;
		set => _liveTrading.Value = value;
	}

	/// <summary>
	/// Treat account as mini when applying live adjustments.
	/// </summary>
	public bool IsMiniAccount
	{
		get => _isMiniAccount.Value;
		set => _isMiniAccount.Value = value;
	}

	/// <summary>
	/// Maximum simultaneous trades allowed by the original logic.
	/// </summary>
	public int MaxPositions
	{
		get => _maxPositions.Value;
		set => _maxPositions.Value = value;
	}

	/// <summary>
	/// Candle type used for signal generation.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="TrueScalperProfitLockStrategy"/> class.
	/// </summary>
	public TrueScalperProfitLockStrategy()
	{
		_initialVolume = Param(nameof(InitialVolume), 1m)
		.SetGreaterThanZero()
		.SetDisplay("Lots", "Base trade volume", "Risk");

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 44m)
		.SetGreaterThanZero()
		.SetDisplay("Take Profit", "Take profit distance in steps", "Risk")
		
		.SetOptimize(20m, 80m, 5m);

		_stopLossPoints = Param(nameof(StopLossPoints), 90m)
		.SetGreaterThanZero()
		.SetDisplay("Stop Loss", "Stop loss distance in steps", "Risk")
		
		.SetOptimize(50m, 150m, 10m);

		_fastPeriod = Param(nameof(FastPeriod), 3)
		.SetGreaterThanZero()
		.SetDisplay("Fast EMA", "Fast EMA length", "Signals");

		_slowPeriod = Param(nameof(SlowPeriod), 7)
		.SetGreaterThanZero()
		.SetDisplay("Slow EMA", "Slow EMA length", "Signals");

		_rsiLength = Param(nameof(RsiLength), 2)
		.SetGreaterThanZero()
		.SetDisplay("RSI Length", "RSI calculation length", "Signals");

		_rsiThreshold = Param(nameof(RsiThreshold), 50m)
		.SetDisplay("RSI Threshold", "RSI boundary for polarity", "Signals")
		
		.SetOptimize(40m, 60m, 5m);

		_useRsiMethodA = Param(nameof(UseRsiMethodA), true)
		.SetDisplay("RSI Method A", "Use RSI crossing logic", "Signals");

		_useRsiMethodB = Param(nameof(UseRsiMethodB), false)
		.SetDisplay("RSI Method B", "Use RSI polarity logic", "Signals");

		_useAbandonMethodA = Param(nameof(UseAbandonMethodA), true)
		.SetDisplay("Abandon Method A", "Force reverse after timeout", "Management");

		_useAbandonMethodB = Param(nameof(UseAbandonMethodB), false)
		.SetDisplay("Abandon Method B", "Only close after timeout", "Management");

		_abandonBars = Param(nameof(AbandonBars), 101)
		.SetGreaterThanZero()
		.SetDisplay("Abandon Bars", "Bars before abandon logic", "Management");

		_useMoneyManagement = Param(nameof(UseMoneyManagement), true)
		.SetDisplay("Money Management", "Enable balance based sizing", "Risk");

		_riskPercent = Param(nameof(RiskPercent), 2m)
		.SetGreaterThanZero()
		.SetDisplay("Risk %", "Risk percentage per trade", "Risk");

		_useProfitLock = Param(nameof(UseProfitLock), true)
		.SetDisplay("Use Profit Lock", "Move stop to break even", "Risk");

		_breakEvenTriggerPoints = Param(nameof(BreakEvenTriggerPoints), 25m)
		.SetGreaterThanZero()
		.SetDisplay("BreakEven Trigger", "Profit distance before break even", "Risk");

		_breakEvenPoints = Param(nameof(BreakEvenPoints), 3m)
		.SetGreaterThanZero()
		.SetDisplay("BreakEven Offset", "Offset applied at break even", "Risk");

		_liveTrading = Param(nameof(LiveTrading), false)
		.SetDisplay("Live Trading", "Apply live sizing adjustments", "Risk");

		_isMiniAccount = Param(nameof(IsMiniAccount), false)
		.SetDisplay("Mini Account", "Treat account as mini", "Risk");

		_maxPositions = Param(nameof(MaxPositions), 1)
		.SetGreaterThanZero()
		.SetDisplay("Max Positions", "Maximum simultaneous trades", "Management");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(30).TimeFrame())
		.SetDisplay("Candle Type", "Candle type for processing", "General");
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		_entryPrice = null;
		_stopLossPrice = null;
		_takeProfitPrice = null;
		_previousRsi = null;
		_currentVolume = 0m;
		_isLongPosition = false;
		_pendingReverseToBuy = false;
		_pendingReverseToSell = false;
		_barsSinceEntry = 0;
		_lastCandleTime = null;
		_breakEvenApplied = false;
	}

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

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

		var fastEma = new EMA { Length = FastPeriod };
		var slowEma = new EMA { Length = SlowPeriod };
		var rsi = new RSI { Length = RsiLength };

		SubscribeCandles(CandleType)
		.Bind(fastEma, slowEma, rsi, ProcessCandle)
		.Start();
	}

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

		UpdateBarCounter(candle);

		var step = GetPriceStep();

		ApplyAbandonLogic();

		if (Position != 0)
		{
			ApplyProfitLock(step, candle);

			if (TryExitByTargets(candle))
			{
				_pendingReverseToBuy = false;
				_pendingReverseToSell = false;
			}
		}

		if (!IsFormedAndOnlineAndAllowTrading())
		{
			_previousRsi = rsi;
			return;
		}

		var (rsiPositive, rsiNegative) = EvaluateRsiSignals(rsi);

		var buySignal = fastEma > slowEma + step && rsiNegative;
		var sellSignal = fastEma < slowEma - step && rsiPositive;

		TryEnterPosition(candle, step, buySignal, sellSignal);

		_previousRsi = rsi;
	}

	private void UpdateBarCounter(ICandleMessage candle)
	{
		if (_lastCandleTime == candle.OpenTime)
		return;

		if (Position != 0 && _lastCandleTime != null)
		_barsSinceEntry++;
		else if (Position == 0)
		_barsSinceEntry = 0;

		_lastCandleTime = candle.OpenTime;
	}

	private decimal GetPriceStep()
	{
		var step = Security?.PriceStep ?? 0m;

		if (step <= 0)
		step = 0.0001m;

		return step;
	}

	private void ApplyAbandonLogic()
	{
		if (Position == 0 || AbandonBars <= 0)
		return;

		if (_barsSinceEntry < AbandonBars)
		return;

		if (UseAbandonMethodA)
		{
			if (_isLongPosition && Position > 0)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				ResetTradeState();
				_pendingReverseToSell = true;
				_pendingReverseToBuy = false;
			}
			else if (!_isLongPosition && Position < 0)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				ResetTradeState();
				_pendingReverseToBuy = true;
				_pendingReverseToSell = false;
			}
		}
		else if (UseAbandonMethodB)
		{
			if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
			ResetTradeState();
			_pendingReverseToBuy = false;
			_pendingReverseToSell = false;
		}
	}

	private void ApplyProfitLock(decimal step, ICandleMessage candle)
	{
		if (!UseProfitLock || _entryPrice is not decimal entry || _stopLossPrice is not decimal stop)
		return;

		if (_isLongPosition && Position > 0)
		{
			if (!_breakEvenApplied && stop < entry && BreakEvenTriggerPoints > 0m && candle.HighPrice >= entry + step * BreakEvenTriggerPoints)
			{
				_stopLossPrice = entry + step * BreakEvenPoints;
				_breakEvenApplied = true;
			}
		}
		else if (!_isLongPosition && Position < 0)
		{
			if (!_breakEvenApplied && stop > entry && BreakEvenTriggerPoints > 0m && candle.LowPrice <= entry - step * BreakEvenTriggerPoints)
			{
				_stopLossPrice = entry - step * BreakEvenPoints;
				_breakEvenApplied = true;
			}
		}
	}

	private bool TryExitByTargets(ICandleMessage candle)
	{
		if (_entryPrice is null || _stopLossPrice is null || _takeProfitPrice is null)
		return false;

		if (_isLongPosition && Position > 0)
		{
			if (candle.HighPrice >= _takeProfitPrice)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				ResetTradeState();
				return true;
			}

			if (candle.LowPrice <= _stopLossPrice)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				ResetTradeState();
				return true;
			}
		}
		else if (!_isLongPosition && Position < 0)
		{
			if (candle.LowPrice <= _takeProfitPrice)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				ResetTradeState();
				return true;
			}

			if (candle.HighPrice >= _stopLossPrice)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				ResetTradeState();
				return true;
			}
		}

		return false;
	}

	private (bool positive, bool negative) EvaluateRsiSignals(decimal currentRsi)
	{
		var positive = false;
		var negative = false;

		if (UseRsiMethodA && _previousRsi is decimal prev)
		{
			if (currentRsi > RsiThreshold && prev < RsiThreshold)
			{
				positive = true;
				negative = false;
			}
			else if (currentRsi < RsiThreshold && prev > RsiThreshold)
			{
				positive = false;
				negative = true;
			}
		}

		if (UseRsiMethodB)
		{
			if (currentRsi > RsiThreshold)
			{
				positive = true;
				negative = false;
			}
			else if (currentRsi < RsiThreshold)
			{
				positive = false;
				negative = true;
			}
		}

		return (positive, negative);
	}

	private void TryEnterPosition(ICandleMessage candle, decimal step, bool buySignal, bool sellSignal)
	{
		if (MaxPositions <= 0)
		return;

		var volume = CalculateEntryVolume();

		if (volume <= 0)
		return;

		if ((_pendingReverseToBuy || buySignal) && Position <= 0)
		{
			var totalVolume = volume + (Position < 0 ? Math.Abs(Position) : 0m);

			if (totalVolume <= 0)
			return;

			BuyMarket();
			InitializeTradeState(candle, step, volume, true);
			_pendingReverseToBuy = false;
			_pendingReverseToSell = false;
		}
		else if ((_pendingReverseToSell || sellSignal) && Position >= 0)
		{
			var totalVolume = volume + (Position > 0 ? Math.Abs(Position) : 0m);

			if (totalVolume <= 0)
			return;

			SellMarket();
			InitializeTradeState(candle, step, volume, false);
			_pendingReverseToBuy = false;
			_pendingReverseToSell = false;
		}
	}

	private decimal CalculateEntryVolume()
	{
		var volume = InitialVolume;

		if (UseMoneyManagement)
		{
			var balance = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;

			if (balance > 0)
			{
				var managed = Math.Ceiling(balance * RiskPercent / 10000m) / 10m;

				if (managed < 0.1m)
				managed = InitialVolume;

				if (managed > 1m)
				managed = Math.Ceiling(managed);

				if (LiveTrading)
				{
					if (IsMiniAccount)
					managed *= 10m;
					else if (managed < 1m)
					managed = 1m;
				}

				if (managed > 100m)
				managed = 100m;

				volume = managed;
			}
		}

		return Math.Max(volume, 0m);
	}

	private void InitializeTradeState(ICandleMessage candle, decimal step, decimal volume, bool isLong)
	{
		_isLongPosition = isLong;
		_entryPrice = candle.ClosePrice;
		_currentVolume = volume;
		_breakEvenApplied = false;
		_barsSinceEntry = 0;
		_lastCandleTime = candle.OpenTime;

		if (isLong)
		{
			_stopLossPrice = _entryPrice - step * StopLossPoints;
			_takeProfitPrice = _entryPrice + step * TakeProfitPoints;
		}
		else
		{
			_stopLossPrice = _entryPrice + step * StopLossPoints;
			_takeProfitPrice = _entryPrice - step * TakeProfitPoints;
		}
	}

	private void ResetTradeState()
	{
		_entryPrice = null;
		_stopLossPrice = null;
		_takeProfitPrice = null;
		_currentVolume = 0m;
		_breakEvenApplied = false;
		_barsSinceEntry = 0;
	}
}