在 GitHub 上查看

Carbophos 网格策略

概述

Carbophos 网格策略是对 MetaTrader 5 专家顾问“Carbophos”的直接移植。它会在当前买入/卖出价附近持续维护对称的限价单梯队,并持续监控整个网格的浮动盈亏。当达到既定盈利目标或者浮亏超过允许阈值时,策略会立即关闭所有头寸并撤销挂单;当市场重新回到空仓状态并且没有未完成订单时,网格会被重新建立。

交易逻辑

  1. 策略启动且当前没有持仓或挂单时,根据用户配置的“步长(以点为单位)”以及标的的价格精度计算出具体的价格间距,然后在最优买价上方放置若干(可配置)卖出限价单,并在最优卖价下方放置同样数量的买入限价单。
  2. 任意挂单成交后,策略会通过 Level1 行情逐笔跟踪仓位。浮动盈亏根据当前平仓价(多头使用买一价,空头使用卖一价)与加权平均持仓价格的差值计算得出。
  3. 当浮动盈利超过目标值,或浮动亏损突破保护阈值时,策略会发送市价单平掉剩余仓位,并撤销所有仍在排队的限价单,然后清空内部标记,以便在下一次价格更新时重新搭建网格。
  4. 如果所有挂单都成交但净持仓重新回到零(例如市场穿越整个网格来回震荡),下一笔 Level1 行情会触发新的网格布置。

参数说明

参数 说明
ProfitTarget 触发整体平仓的浮动盈利金额。
MaxLoss 触发紧急止损的最大浮动亏损金额。
StepPips 相邻网格层之间的距离,单位为点。策略会结合交易品种的最小跳动单位自动换算成价格距离。
OrdersPerSide 在当前价格上方和下方各自布置的限价单数量。
OrderVolume 每一张网格挂单的下单数量。

所有参数都预设了优化区间,便于在 StockSharp 优化器中进行批量测试。

风险控制与保护

策略调用一次 StartProtection() 并在策略层面设置硬性的资金止盈/止损。当任一阈值触发时,会使用市价单关闭现有仓位并调用 CancelActiveOrders() 撤销所有挂单。浮动盈亏通过 PriceStepStepPrice 计算,因此需要保证所选标的在连接端已正确配置这些交易参数。

转换说明

  • 原始 MQL5 版本会针对 3 位或 5 位小数的外汇品种调整点值。StockSharp 版本检测标的的 Decimals 字段,在为 3 或 5 时自动将 PriceStep 乘以 10,从而复现该行为。
  • MQL5 会按魔术号统计仓位盈利、手续费与隔夜利息。StockSharp 版本直接通过当前买卖价与平均持仓价计算浮动盈亏,因此无需显式处理手续费。
  • 订单的提交、撤销与头寸管理全部使用高层 API(BuyLimitSellLimitBuyMarketSellMarketCancelActiveOrders),符合仓库的实现规范。
  • 策略完全依赖 Level1 行情驱动,等价于原策略的 OnTick 行为,未引入额外的计时器或自定义集合。

使用方法

  1. 在启动策略前为其实例指定 Security(交易标的)和 Portfolio(账户)。
  2. 根据标的的波动性与风险偏好调整上述参数。
  3. 启动策略。策略会立即订阅 Level1 行情,在同时收到买一和卖一价格后搭建初始网格,并自动管理仓位。
  4. 关注日志中的提示,例如“Profit target reached”或“Maximum loss reached”,以了解网格何时被重置。

请确保所选交易品种能够提供实时的买卖价 Level1 行情,否则策略无法计算网格位置。

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>
/// Grid strategy converted from the Carbophos MetaTrader 5 expert advisor.
/// Simulates symmetric grid levels and manages profit and loss on the aggregated position.
/// </summary>
public class CarbophosGridStrategy : Strategy
{
	private readonly StrategyParam<decimal> _profitTarget;
	private readonly StrategyParam<decimal> _maxLoss;
	private readonly StrategyParam<int> _stepPips;
	private readonly StrategyParam<int> _ordersPerSide;
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<DataType> _candleType;

	private decimal? _entryPrice;
	private decimal _gridCenterPrice;
	private bool _gridPlaced;
	private int _cooldownRemaining;

	private readonly List<decimal> _buyLevels = new();
	private readonly List<decimal> _sellLevels = new();

	/// <summary>
	/// Floating profit level (in absolute price * volume) that triggers closing of all positions.
	/// </summary>
	public decimal ProfitTarget
	{
		get => _profitTarget.Value;
		set => _profitTarget.Value = value;
	}

	/// <summary>
	/// Maximum allowed floating loss before the grid is closed.
	/// </summary>
	public decimal MaxLoss
	{
		get => _maxLoss.Value;
		set => _maxLoss.Value = value;
	}

	/// <summary>
	/// Distance between grid levels expressed in pips.
	/// </summary>
	public int StepPips
	{
		get => _stepPips.Value;
		set => _stepPips.Value = value;
	}

	/// <summary>
	/// Number of limit orders to place above and below the market price.
	/// </summary>
	public int OrdersPerSide
	{
		get => _ordersPerSide.Value;
		set => _ordersPerSide.Value = value;
	}

	/// <summary>
	/// Volume for each grid level order.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

	/// <summary>
	/// Candle type used by the strategy.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes <see cref="CarbophosGridStrategy"/>.
	/// </summary>
	public CarbophosGridStrategy()
	{
		_profitTarget = Param(nameof(ProfitTarget), 500m)
			.SetGreaterThanZero()
			.SetDisplay("Profit Target", "Floating profit target in money", "Risk")
			.SetOptimize(100m, 1000m, 50m);

		_maxLoss = Param(nameof(MaxLoss), 100m)
			.SetGreaterThanZero()
			.SetDisplay("Max Loss", "Maximum floating loss before closing", "Risk")
			.SetOptimize(50m, 500m, 25m);

		_stepPips = Param(nameof(StepPips), 2000)
			.SetGreaterThanZero()
			.SetDisplay("Step (pips)", "Distance between grid levels in pips", "Grid")
			.SetOptimize(10, 150, 10);

		_ordersPerSide = Param(nameof(OrdersPerSide), 1)
			.SetGreaterThanZero()
			.SetDisplay("Orders Per Side", "Number of pending orders on each side", "Grid")
			.SetOptimize(1, 10, 1);

		_orderVolume = Param(nameof(OrderVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Order Volume", "Volume for each pending order", "Trading");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles to use", "General");
	}

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

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

		_entryPrice = null;
		_gridCenterPrice = 0m;
		_gridPlaced = false;
		_cooldownRemaining = 0;
		_buyLevels.Clear();
		_sellLevels.Clear();
	}

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

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

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

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

		var currentPrice = candle.ClosePrice;

		// Check if any grid levels were hit by this candle
		CheckGridFills(candle);

		// Check profit/loss on position
		if (Position != 0 && _entryPrice is decimal entry)
		{
			var floatingPnL = (currentPrice - entry) * Position;

			if (floatingPnL >= ProfitTarget)
			{
				CloseAll("Profit target reached.");
				return;
			}

			if (floatingPnL <= -MaxLoss)
			{
				CloseAll("Maximum loss reached.");
				return;
			}
		}

		// Cooldown after closing
		if (_cooldownRemaining > 0)
		{
			_cooldownRemaining--;
			return;
		}

		// Place grid if none is active
		if (!_gridPlaced || (Position == 0 && _buyLevels.Count == 0 && _sellLevels.Count == 0))
		{
			PlaceGrid(currentPrice);
		}
	}

	private void PlaceGrid(decimal centerPrice)
	{
		_buyLevels.Clear();
		_sellLevels.Clear();

		var stepSize = GetGridStep();
		if (stepSize <= 0m || centerPrice <= 0m)
			return;

		for (var i = 1; i <= OrdersPerSide; i++)
		{
			var offset = stepSize * i;
			var buyPrice = centerPrice - offset;
			var sellPrice = centerPrice + offset;

			if (buyPrice > 0m)
				_buyLevels.Add(buyPrice);

			_sellLevels.Add(sellPrice);
		}

		_gridCenterPrice = centerPrice;
		_gridPlaced = true;
	}

	private void CheckGridFills(ICandleMessage candle)
	{
		// Check buy levels (price goes down to the level)
		for (var i = _buyLevels.Count - 1; i >= 0; i--)
		{
			if (i >= _buyLevels.Count) continue;
			if (candle.LowPrice <= _buyLevels[i])
			{
				var level = _buyLevels[i];
				BuyMarket();
				UpdateEntryPrice(level, OrderVolume, true);
				try { _buyLevels.RemoveAt(i); } catch { }
			}
		}

		// Check sell levels (price goes up to the level)
		for (var i = _sellLevels.Count - 1; i >= 0; i--)
		{
			if (i >= _sellLevels.Count) continue;
			if (candle.HighPrice >= _sellLevels[i])
			{
				var level = _sellLevels[i];
				SellMarket();
				UpdateEntryPrice(level, OrderVolume, false);
				try { _sellLevels.RemoveAt(i); } catch { }
			}
		}
	}

	private void UpdateEntryPrice(decimal fillPrice, decimal volume, bool isBuy)
	{
		if (_entryPrice is not decimal existingEntry || Position == 0)
		{
			_entryPrice = fillPrice;
			return;
		}

		// Weighted average entry price calculation
		var existingPos = Position;
		var newPos = isBuy ? existingPos + volume : existingPos - volume;

		if (newPos == 0)
		{
			_entryPrice = null;
			return;
		}

		// Only update if adding to position in same direction
		if ((isBuy && existingPos > 0) || (!isBuy && existingPos < 0))
		{
			var totalVolume = Math.Abs(existingPos) + volume;
			_entryPrice = (existingEntry * Math.Abs(existingPos) + fillPrice * volume) / totalVolume;
		}
		else
		{
			// Reducing position - keep same entry price
			if (Math.Abs(newPos) > 0)
				_entryPrice = existingEntry;
			else
				_entryPrice = null;
		}
	}

	private void CloseAll(string reason)
	{
		if (Position > 0)
			SellMarket();
		else if (Position < 0)
			BuyMarket();

		_buyLevels.Clear();
		_sellLevels.Clear();
		_gridPlaced = false;
		_entryPrice = null;
		_cooldownRemaining = 10;

		LogInfo(reason);
	}

	private decimal GetGridStep()
	{
		var security = Security;

		var priceStep = security?.PriceStep ?? 0m;
		if (priceStep <= 0m)
			priceStep = 0.01m;

		var decimals = security?.Decimals ?? 2;
		var multiplier = (decimals == 3 || decimals == 5) ? 10m : 1m;
		return StepPips * priceStep * multiplier;
	}
}