在 GitHub 上查看

多品种利润平仓策略

概述

多品种利润平仓策略 重现了原始的 MetaTrader 脚本:持续监控一组货币对,在组合浮动利润达到目标或总浮亏超过容忍范围时立即平掉所有仓位。该转换基于 StockSharp 的高级 API,实现了利润跟踪、持仓最小时间控制以及跨多个标的的集中平仓。

策略流程

  1. 从逗号分隔的 WatchedSymbols 参数解析需要监控的标的,若留空则退回到主 Security。
  2. 为每个标的订阅所选的 K 线类型(默认 1 分钟)。每根收盘的 K 线都会触发一次组合利润检查。
  3. 对于每个标的,策略保存:
    • Positions[i].PnL 提供的当前浮动盈亏。
    • 首次持仓出现的时间,用于满足 MinAgeSeconds 的最小持仓时长。
  4. 累计全部标的的净利润后执行判断:
    • 净利润达到 ProfitTarget 时,调用 BuyMarket / SellMarket 平掉所有达到最小时长的仓位。
    • 净利润跌破 -MaxLoss 时,视为风险控制,同样执行集中平仓。
  5. 日志输出会列出每个标的的盈亏及组合总盈亏,方便对比原脚本的 Comment 信息。

参数

参数 说明 默认值
WatchedSymbols 需要监控的证券 ID 列表(逗号分隔)。为空时使用主 Security。 "GBPUSD,USDCAD,USDCHF,USDSEK"
ProfitTarget 触发平仓的组合净利润(账户货币)。 60
MaxLoss 触发保护性平仓的最大可承受亏损(账户货币)。 60
Slippage 与原脚本一致的滑点设置。由于使用市价单平仓,该参数仅作信息展示。 10
MinAgeSeconds 允许平仓前的最小持仓秒数。 60
CandleType 用于定期检查的 K 线类型(默认 1 分钟)。 1 minute

注意事项

  • 策略直接使用 StockSharp Positions 集合提供的盈亏数据,无需额外的成交历史。
  • 如果策略启动时已经有持仓,则以启动时刻作为首次观察时间,需等待 MinAgeSeconds 后才会被强制平仓。
  • 所有退出均通过市价单完成,以确保尽快离场;Slippage 参数主要用于保留原脚本配置。
  • 在日志中可看到每个标的的盈亏以及组合净值,便于实时监控。

使用要求

  • 需要一个可解析 WatchedSymbols 中标的的 SecurityProvider 或连接器。
  • 请确保每个标的的下单数量设置合理,使得平仓市价单可以完全对冲当前仓位。
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

using Ecng.Common;

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

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Supervises the account positions of a basket of instruments and closes them when their combined floating
/// profit, as the connector reports it, reaches the profit target or drops below the loss limit.
/// This utility never opens positions.
/// </summary>
public class MultiPairCloserStrategy : Strategy
{
	private const string _profitTargetReason = "reached the profit target";
	private const string _maxLossReason = "fell below the loss limit";

	private readonly StrategyParam<string> _watchedSymbols;
	private readonly StrategyParam<decimal> _profitTarget;
	private readonly StrategyParam<decimal> _maxLoss;
	private readonly StrategyParam<int> _slippage;
	private readonly StrategyParam<int> _minAgeSeconds;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<Security> _watched = [];
	private readonly Dictionary<string, DateTime?> _firstSeen = new(StringComparer.OrdinalIgnoreCase);
	private readonly Dictionary<string, Order> _exitOrders = new(StringComparer.OrdinalIgnoreCase);
	private IConnector _positionSource;
	private DateTime? _startTime;

	/// <summary>
	/// Comma-separated identifiers of the securities to supervise. Empty means the assigned <see cref="Strategy.Security"/>.
	/// </summary>
	public string WatchedSymbols
	{
		get => _watchedSymbols.Value;
		set => _watchedSymbols.Value = value;
	}

	/// <summary>
	/// Combined floating profit, in portfolio currency, that closes every watched position.
	/// </summary>
	public decimal ProfitTarget
	{
		get => _profitTarget.Value;
		set => _profitTarget.Value = value;
	}

	/// <summary>
	/// Maximum acceptable combined floating loss, in portfolio currency, before the basket is force-closed.
	/// </summary>
	public decimal MaxLoss
	{
		get => _maxLoss.Value;
		set => _maxLoss.Value = value;
	}

	/// <summary>
	/// Slippage allowed by the original script. Exits are market orders, so the value is only logged.
	/// </summary>
	public int Slippage
	{
		get => _slippage.Value;
		set => _slippage.Value = value;
	}

	/// <summary>
	/// Minimum lifetime of a position, in seconds, before the strategy may close it.
	/// </summary>
	public int MinAgeSeconds
	{
		get => _minAgeSeconds.Value;
		set => _minAgeSeconds.Value = value;
	}

	/// <summary>
	/// Candle type whose finished candles trigger the profit evaluation.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="MultiPairCloserStrategy"/>.
	/// </summary>
	public MultiPairCloserStrategy()
	{
		_watchedSymbols = Param(nameof(WatchedSymbols), "GBPUSD,USDCAD,USDCHF,USDSEK")
			.SetDisplay("Watched Symbols", "Comma-separated security identifiers to supervise; empty means the assigned security", "Basket");

		_profitTarget = Param(nameof(ProfitTarget), 60m)
			.SetNotNegative()
			.SetDisplay("Profit Target", "Combined floating profit in portfolio currency that closes every watched position", "Risk");

		_maxLoss = Param(nameof(MaxLoss), 60m)
			.SetNotNegative()
			.SetDisplay("Max Loss", "Maximum acceptable combined floating loss in portfolio currency before the basket is force-closed", "Risk");

		_slippage = Param(nameof(Slippage), 10)
			.SetNotNegative()
			.SetDisplay("Slippage", "Slippage allowed by the original script; exits are market orders, so it is only logged", "Execution");

		_minAgeSeconds = Param(nameof(MinAgeSeconds), 60)
			.SetNotNegative()
			.SetDisplay("Min Age (s)", "Minimum lifetime of a position before the strategy may close it", "Execution");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
			.SetDisplay("Candle Type", "Every finished candle of this type triggers a profit evaluation", "General");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
		=> ResolveWatched(false).Select(security => (security, CandleType));

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

		DetachPositionSource();
		_watched.Clear();
		_firstSeen.Clear();
		_exitOrders.Clear();
		_startTime = null;
	}

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

		_watched.Clear();
		_watched.AddRange(ResolveWatched(true));
		_firstSeen.Clear();
		_exitOrders.Clear();

		// The start is taken on the market clock, which a backtest sets only once the replay begins.
		_startTime = GetMarketTime();

		foreach (var security in _watched)
		{
			if (GetReportedPosition(security).volume != 0m)
				_firstSeen[security.Id] = _startTime;
		}

		_positionSource = Connector;
		_positionSource.PositionChanged += OnReportedPositionChanged;

		foreach (var security in _watched)
		{
			SubscribeCandles(CandleType, security: security)
				.Bind(ProcessCandle)
				.Start();
		}
	}

	/// <inheritdoc />
	protected override void OnStopped()
	{
		DetachPositionSource();

		base.OnStopped();
	}

	private void DetachPositionSource()
	{
		if (_positionSource is null)
			return;

		_positionSource.PositionChanged -= OnReportedPositionChanged;
		_positionSource = null;
	}

	private List<Security> ResolveWatched(bool strict)
	{
		var ids = (WatchedSymbols ?? string.Empty)
			.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
			.Distinct(StringComparer.OrdinalIgnoreCase)
			.ToArray();

		var securities = new List<Security>();

		if (ids.Length == 0)
		{
			if (Security is not null)
				securities.Add(Security);
			else if (strict)
				throw new InvalidOperationException("WatchedSymbols is empty and no Security is assigned.");

			return securities;
		}

		if (Connector is null)
			return securities;

		foreach (var id in ids)
		{
			var security = this.LookupById(id);

			if (security is not null)
				securities.Add(security);
			else if (strict)
				throw new InvalidOperationException($"Security '{id}' is not available through the connector.");
		}

		return securities;
	}

	private void OnReportedPositionChanged(Position position)
	{
		var security = FindWatched(position);

		if (security is null)
			return;

		if (GetReportedPosition(security).volume == 0m)
			_firstSeen.Remove(security.Id);
		else if (!_firstSeen.ContainsKey(security.Id))
			_firstSeen[security.Id] = GetMarketTime() ?? _startTime;
	}

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

		EvaluateBasket();
	}

	private void EvaluateBasket()
	{
		var now = CurrentTime;
		_startTime ??= now;

		var summary = new List<string>(_watched.Count + 1);
		var open = new List<(Security security, decimal volume)>();
		decimal? total = 0m;

		foreach (var security in _watched)
		{
			var (volume, profit) = GetReportedPosition(security);

			if (volume == 0m)
			{
				summary.Add($"{security.Id}: {Format(0m)}");
				continue;
			}

			open.Add((security, volume));
			total += profit;
			summary.Add($"{security.Id}: {(profit is decimal value ? Format(value) : "n/a")}");
		}

		summary.Add($"Basket: {(total is decimal sum ? Format(sum) : "n/a")}");
		LogInfo(string.Join("; ", summary));

		// A position without a reported floating profit leaves the basket result unknown, so nothing is decided.
		if (open.Count == 0 || total is not decimal basket)
			return;

		string reason;

		if (basket >= ProfitTarget)
			reason = _profitTargetReason;
		else if (basket < -MaxLoss)
			reason = _maxLossReason;
		else
			return;

		foreach (var (security, volume) in open)
		{
			if (_exitOrders.TryGetValue(security.Id, out var pending) && !pending.State.IsFinal())
				continue;

			var firstSeen = (_firstSeen.TryGetValue(security.Id, out var seen) ? seen : null) ?? _startTime.Value;

			if ((now - firstSeen).TotalSeconds < MinAgeSeconds)
				continue;

			var quantity = Math.Abs(volume);
			LogInfo($"Closing {security.Id}: basket {Format(basket)} {reason}, {(volume > 0m ? "sell" : "buy")} {Format(quantity)} at market (slippage {Slippage}).");

			_exitOrders[security.Id] = volume > 0m
				? SellMarket(quantity, security)
				: BuyMarket(quantity, security);
		}
	}

	private Security FindWatched(Position position)
		=> IsAccountPosition(position)
			? _watched.FirstOrDefault(security => security.Id.EqualsIgnoreCase(position.Security.Id))
			: null;

	// Floating profit is summed as the connector reports it; one missing value makes the whole sum unknown.
	private (decimal volume, decimal? profit) GetReportedPosition(Security security)
	{
		var volume = 0m;
		decimal? profit = 0m;

		foreach (var position in Connector.Positions)
		{
			if (!IsAccountPosition(position) || !position.Security.Id.EqualsIgnoreCase(security.Id))
				continue;

			if (position.CurrentValue is not decimal value || value == 0m)
				continue;

			volume += value;
			profit += position.UnrealizedPnL;
		}

		return (volume, profit);
	}

	// Rows a connector keeps per strategy repeat part of the account row, so only account rows are read.
	private bool IsAccountPosition(Position position)
		=> position.Security is not null
			&& position.StrategyId.IsEmpty()
			&& Portfolio is not null
			&& position.PortfolioName.EqualsIgnoreCase(Portfolio.Name);

	private DateTime? GetMarketTime()
	{
		var now = CurrentTime;
		return now == default ? null : now;
	}

	private static string Format(decimal value)
		=> value.ToString(CultureInfo.InvariantCulture);
}