在 GitHub 上查看

锁仓网格(Locker)

基于网格的对冲策略,通过交替建立买入和卖出市价单来锁定浮亏,并在账户余额上获取一小部分百分比收益。

交易逻辑

  • 第一个完成的K线收盘后,立即按照起始手数开出第一笔多单来启动网格。
  • 记录之后的每一笔交易,在策略内部维护多空腿列表,以估算总体的未实现盈亏与已实现盈亏。
  • 当活跃腿数达到八笔时,优先关闭最早的一对买卖腿,在继续处理本根K线的其他逻辑之前先降低敞口。
  • 当综合利润高于账户价值目标百分比时,平掉所有剩余仓位并重置内部状态。
  • 当综合利润跌破目标的负值时,比较最近开仓价与当前市场价:若价格上行超过设定步长,补充一笔空单;若价格下行超过同样的距离,则加仓多单。
  • 平仓时总是发送与记录腿方向相反的市价单,从而立即解除锁仓。

参数

  • Profit % – 达到该百分比(基于当前账户价值)即锁定利润并全部平仓。
  • Start Volume – 启动网格时首笔多单使用的手数。
  • Step Volume – 触发亏损阈值后,每笔对冲单使用的手数。
  • Step Points – 网格层级之间的价格步数,会与品种的最小报价步长相乘得到实际价格距离。
  • Enable Automation – 全局开关,关闭后暂停所有交易逻辑。
  • Candle Type – 用于驱动策略的K线类型,只有在K线收盘后才会做出决策。

该移植版本保留了原始 MetaTrader 专家的思路,同时利用 StockSharp 的高级 API 下单,并在策略内部保存详细的交易状态,从而保持盈亏计算与 MQL 程序一致。

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;

public class LockerStrategy : Strategy
{
	private readonly struct PositionEntry
	{
		public PositionEntry(Sides side, decimal price, decimal volume)
		{
			Side = side;
			Price = price;
			Volume = volume;
		}

		public Sides Side { get; }
		public decimal Price { get; }
		public decimal Volume { get; }
	}

	private readonly StrategyParam<decimal> _profitTargetPercent;
	private readonly StrategyParam<decimal> _startVolume;
	private readonly StrategyParam<decimal> _stepVolume;
	private readonly StrategyParam<decimal> _stepPoints;
	private readonly StrategyParam<bool> _enableAutomation;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _maxOpenPositions;

	private readonly List<PositionEntry> _entries = new();

	private decimal _realizedPnL;
	private decimal _lastEntryPrice;
	private Sides? _lastEntrySide;
	private int _cooldown;

	public decimal ProfitTargetPercent { get => _profitTargetPercent.Value; set => _profitTargetPercent.Value = value; }
	public decimal StartVolume { get => _startVolume.Value; set => _startVolume.Value = value; }
	public decimal StepVolume { get => _stepVolume.Value; set => _stepVolume.Value = value; }
	public decimal StepPoints { get => _stepPoints.Value; set => _stepPoints.Value = value; }
	public bool EnableAutomation { get => _enableAutomation.Value; set => _enableAutomation.Value = value; }
	public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
	public int MaxOpenPositions { get => _maxOpenPositions.Value; set => _maxOpenPositions.Value = value; }

	public LockerStrategy()
	{
		_profitTargetPercent = Param(nameof(ProfitTargetPercent), 0.001m)
			.SetGreaterThanZero()
			.SetDisplay("Profit %", "Target profit percent of balance", "General")
			;

		_startVolume = Param(nameof(StartVolume), 0.5m)
			.SetGreaterThanZero()
			.SetDisplay("Start Volume", "Initial trade volume", "General")
			;

		_stepVolume = Param(nameof(StepVolume), 0.2m)
			.SetGreaterThanZero()
			.SetDisplay("Step Volume", "Volume for subsequent trades", "General")
			;

		_stepPoints = Param(nameof(StepPoints), 15000m)
			.SetGreaterThanZero()
			.SetDisplay("Step Points", "Number of price steps between new trades", "General")
			;

		_enableAutomation = Param(nameof(EnableAutomation), true)
			.SetDisplay("Enable Automation", "Allow the strategy to place trades", "General");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles for processing", "Data");

		_maxOpenPositions = Param(nameof(MaxOpenPositions), 2)
			.SetGreaterThanZero()
			.SetDisplay("Max Open Positions", "Maximum number of hedged legs allowed", "Risk")
			;
	}

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

	protected override void OnReseted()
	{
		base.OnReseted();
		_entries.Clear();
		_realizedPnL = 0m;
		_lastEntryPrice = 0m;
		_lastEntrySide = null;
		_cooldown = 0;
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);
		SubscribeCandles(CandleType).Bind(Process).Start();
	}

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

		if (!EnableAutomation)
			return;

		if (_cooldown > 0)
		{
			_cooldown--;
			return;
		}

		var closePrice = candle.ClosePrice;
		// Use the candle close as a proxy for bid/ask because we operate on finished bars.
		var bid = closePrice;
		var ask = closePrice;

		var currentProfit = _realizedPnL + CalculateUnrealizedProfit(bid, ask);
		var openCount = _entries.Count;

		if (openCount == 0)
		{
			// Start the grid with an initial buy order.
			OpenPosition(Sides.Buy, StartVolume, ask);
			return;
		}

		if (openCount >= MaxOpenPositions && TryClosePair(bid, ask))
		{
			// Reduce exposure when too many hedged orders are active.
			return;
		}

		var portfolioValue = Portfolio?.CurrentValue ?? 0m;
		if (portfolioValue <= 0m)
			portfolioValue = 1000000m;
		var targetProfit = portfolioValue * ProfitTargetPercent;

		if (targetProfit > 0m && currentProfit >= targetProfit)
		{
			// Target reached, flatten the book.
			CloseAllPositions(bid, ask);
			_cooldown = 20;
			return;
		}

		if (targetProfit <= 0m)
			return;

		if (currentProfit <= -targetProfit)
		{
			var lastPrice = _lastEntryPrice;
			if (lastPrice == 0m)
				return;

			var stepDistance = GetStepDistance();
			if (stepDistance <= 0m)
				return;

			// Add a hedging order whenever price travels far enough from the latest entry.
			if (ask > lastPrice + stepDistance)
				OpenPosition(Sides.Sell, StepVolume, ask);
			else if (bid < lastPrice - stepDistance)
				OpenPosition(Sides.Buy, StepVolume, bid);
		}
	}

	private decimal CalculateUnrealizedProfit(decimal bid, decimal ask)
	{
		var profit = 0m;
		for (var i = 0; i < _entries.Count; i++)
		{
			var entry = _entries[i];
			var exitPrice = entry.Side == Sides.Buy ? bid : ask;
			var direction = entry.Side == Sides.Buy ? 1m : -1m;
			profit += (exitPrice - entry.Price) * direction * entry.Volume;
		}
		return profit;
	}

	private bool TryClosePair(decimal bid, decimal ask)
	{
		var buyIndex = -1;
		var sellIndex = -1;

		for (var i = 0; i < _entries.Count; i++)
		{
			var entry = _entries[i];
			if (entry.Side == Sides.Buy && buyIndex == -1)
				buyIndex = i;
			else if (entry.Side == Sides.Sell && sellIndex == -1)
				sellIndex = i;

			if (buyIndex != -1 && sellIndex != -1)
				break;
		}

		if (buyIndex == -1 || sellIndex == -1)
			return false;

		if (buyIndex > sellIndex)
		{
			CloseEntry(buyIndex, bid, ask);
			CloseEntry(sellIndex, bid, ask);
		}
		else
		{
			CloseEntry(sellIndex, bid, ask);
			CloseEntry(buyIndex, bid, ask);
		}

		UpdateLastEntry();
		return true;
	}

	private void CloseAllPositions(decimal bid, decimal ask)
	{
		while (_entries.Count > 0)
		{
			CloseEntry(_entries.Count - 1, bid, ask);
		}

		UpdateLastEntry();
	}

	private void CloseEntry(int index, decimal bid, decimal ask)
	{
		if (index < 0 || index >= _entries.Count)
			return;

		var entry = _entries[index];
		var exitPrice = entry.Side == Sides.Buy ? bid : ask;
		var direction = entry.Side == Sides.Buy ? Sides.Sell : Sides.Buy;

		// Send the offsetting market order to neutralize the entry.
		if (direction == Sides.Sell)
			SellMarket();
		else
			BuyMarket();

		var pnl = (exitPrice - entry.Price) * (entry.Side == Sides.Buy ? 1m : -1m) * entry.Volume;
		_realizedPnL += pnl;

		try { _entries.RemoveAt(index); } catch { }
	}

	private void OpenPosition(Sides side, decimal volume, decimal price)
	{
		if (volume <= 0m)
			return;

		if (side == Sides.Buy)
			BuyMarket();
		else
			SellMarket();

		_entries.Add(new PositionEntry(side, price, volume));
		_lastEntryPrice = price;
		_lastEntrySide = side;
	}

	private decimal GetStepDistance()
	{
		var priceStep = Security?.PriceStep ?? 0m;
		return priceStep > 0m ? StepPoints * priceStep : StepPoints;
	}

	private void UpdateLastEntry()
	{
		if (_entries.Count == 0)
		{
			_lastEntryPrice = 0m;
			_lastEntrySide = null;
			return;
		}

		var entry = _entries[_entries.Count - 1];
		_lastEntryPrice = entry.Price;
		_lastEntrySide = entry.Side;
	}
}