多品种利润平仓策略
概述
多品种利润平仓策略 重现了原始的 MetaTrader 脚本:持续监控一组货币对,在组合浮动利润达到目标或总浮亏超过容忍范围时立即平掉所有仓位。该转换基于 StockSharp 的高级 API,实现了利润跟踪、持仓最小时间控制以及跨多个标的的集中平仓。
策略流程
- 从逗号分隔的
WatchedSymbols参数解析需要监控的标的,若留空则退回到主Security。 - 为每个标的订阅所选的 K 线类型(默认 1 分钟)。每根收盘的 K 线都会触发一次组合利润检查。
- 对于每个标的,策略保存:
Positions[i].PnL提供的当前浮动盈亏。- 首次持仓出现的时间,用于满足
MinAgeSeconds的最小持仓时长。
- 累计全部标的的净利润后执行判断:
- 净利润达到
ProfitTarget时,调用BuyMarket/SellMarket平掉所有达到最小时长的仓位。 - 净利润跌破
-MaxLoss时,视为风险控制,同样执行集中平仓。
- 净利润达到
- 日志输出会列出每个标的的盈亏及组合总盈亏,方便对比原脚本的
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);
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
from System import TimeSpan, Math, Decimal, DateTime, Action, InvalidOperationException
from System.Globalization import CultureInfo
from StockSharp.Messages import DataType, CandleStates, OrderStates
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Position, EntitiesExtensions
PROFIT_TARGET_REASON = "reached the profit target"
MAX_LOSS_REASON = "fell below the loss limit"
class multi_pair_closer_strategy(Strategy):
"""
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.
"""
def __init__(self):
super(multi_pair_closer_strategy, self).__init__()
self._watched_symbols = self.Param("WatchedSymbols", "GBPUSD,USDCAD,USDCHF,USDSEK") \
.SetDisplay("Watched Symbols", "Comma-separated security identifiers to supervise; empty means the assigned security", "Basket")
self._profit_target = self.Param("ProfitTarget", 60.0) \
.SetNotNegative() \
.SetDisplay("Profit Target", "Combined floating profit in portfolio currency that closes every watched position", "Risk")
self._max_loss = self.Param("MaxLoss", 60.0) \
.SetNotNegative() \
.SetDisplay("Max Loss", "Maximum acceptable combined floating loss in portfolio currency before the basket is force-closed", "Risk")
self._slippage = self.Param("Slippage", 10) \
.SetNotNegative() \
.SetDisplay("Slippage", "Slippage allowed by the original script; exits are market orders, so it is only logged", "Execution")
self._min_age_seconds = self.Param("MinAgeSeconds", 60) \
.SetNotNegative() \
.SetDisplay("Min Age (s)", "Minimum lifetime of a position before the strategy may close it", "Execution")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1))) \
.SetDisplay("Candle Type", "Every finished candle of this type triggers a profit evaluation", "General")
self._watched = []
self._first_seen = {}
self._exit_orders = {}
self._position_source = None
self._start_time = None
self._position_handler = Action[Position](self._on_reported_position_changed)
def GetWorkingSecurities(self):
return [(security, self._candle_type.Value) for security in self._resolve_watched(False)]
def OnReseted(self):
super(multi_pair_closer_strategy, self).OnReseted()
self._detach_position_source()
self._watched = []
self._first_seen = {}
self._exit_orders = {}
self._start_time = None
def OnStarted2(self, time):
super(multi_pair_closer_strategy, self).OnStarted2(time)
self._watched = self._resolve_watched(True)
self._first_seen = {}
self._exit_orders = {}
# The start is taken on the market clock, which a backtest sets only once the replay begins.
self._start_time = self._market_time()
for security in self._watched:
volume, _ = self._reported_position(security)
if volume != Decimal.Zero:
self._first_seen[security.Id.upper()] = self._start_time
self._position_source = self.Connector
self._position_source.PositionChanged += self._position_handler
for security in self._watched:
self.SubscribeCandles(self._candle_type.Value, security=security) \
.Bind(self._process_candle) \
.Start()
def OnStopped(self):
self._detach_position_source()
super(multi_pair_closer_strategy, self).OnStopped()
def _detach_position_source(self):
if self._position_source is None:
return
self._position_source.PositionChanged -= self._position_handler
self._position_source = None
def _resolve_watched(self, strict):
ids = []
seen = set()
for item in str(self._watched_symbols.Value or "").split(","):
sec_id = item.strip()
if sec_id and sec_id.upper() not in seen:
seen.add(sec_id.upper())
ids.append(sec_id)
securities = []
if not ids:
if self.Security is not None:
securities.append(self.Security)
elif strict:
raise InvalidOperationException("WatchedSymbols is empty and no Security is assigned.")
return securities
if self.Connector is None:
return securities
for sec_id in ids:
# IronPython does not see C# extension methods, so the lookup is called on its static class.
security = EntitiesExtensions.LookupById(self, sec_id)
if security is not None:
securities.append(security)
elif strict:
raise InvalidOperationException("Security '{0}' is not available through the connector.".format(sec_id))
return securities
def _on_reported_position_changed(self, position):
security = self._find_watched(position)
if security is None:
return
key = security.Id.upper()
volume, _ = self._reported_position(security)
if volume == Decimal.Zero:
self._first_seen.pop(key, None)
elif key not in self._first_seen:
now = self._market_time()
self._first_seen[key] = now if now is not None else self._start_time
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
self._evaluate_basket()
def _evaluate_basket(self):
now = self.CurrentTime
if self._start_time is None:
self._start_time = now
summary = []
open_positions = []
total = Decimal.Zero
for security in self._watched:
volume, profit = self._reported_position(security)
if volume == Decimal.Zero:
summary.append("{0}: {1}".format(security.Id, self._format(Decimal.Zero)))
continue
open_positions.append((security, volume))
total = None if total is None or profit is None else Decimal.Add(total, profit)
summary.append("{0}: {1}".format(security.Id, "n/a" if profit is None else self._format(profit)))
summary.append("Basket: {0}".format("n/a" if total is None else self._format(total)))
self.LogInfo("; ".join(summary))
# A position without a reported floating profit leaves the basket result unknown, so nothing is decided.
if not open_positions or total is None:
return
if total >= Decimal(float(self._profit_target.Value)):
reason = PROFIT_TARGET_REASON
elif total < Decimal.Negate(Decimal(float(self._max_loss.Value))):
reason = MAX_LOSS_REASON
else:
return
min_age = int(self._min_age_seconds.Value)
for security, volume in open_positions:
key = security.Id.upper()
pending = self._exit_orders.get(key)
if pending is not None and pending.State != OrderStates.Done and pending.State != OrderStates.Failed:
continue
first_seen = self._first_seen.get(key)
if first_seen is None:
first_seen = self._start_time
if (now - first_seen).TotalSeconds < min_age:
continue
quantity = Math.Abs(volume)
self.LogInfo("Closing {0}: basket {1} {2}, {3} {4} at market (slippage {5}).".format(
security.Id, self._format(total), reason, "sell" if volume > Decimal.Zero else "buy",
self._format(quantity), int(self._slippage.Value)))
if volume > Decimal.Zero:
self._exit_orders[key] = self.SellMarket(quantity, security)
else:
self._exit_orders[key] = self.BuyMarket(quantity, security)
def _find_watched(self, position):
if not self._is_account_position(position):
return None
position_id = position.Security.Id.upper()
for security in self._watched:
if security.Id.upper() == position_id:
return security
return None
# Floating profit is summed as the connector reports it; one missing value makes the whole sum unknown.
def _reported_position(self, security):
volume = Decimal.Zero
profit = Decimal.Zero
security_id = security.Id.upper()
for position in self.Connector.Positions:
if not self._is_account_position(position) or position.Security.Id.upper() != security_id:
continue
value = position.CurrentValue
if value is None or value == Decimal.Zero:
continue
volume = Decimal.Add(volume, value)
pnl = position.UnrealizedPnL
profit = None if profit is None or pnl is None else Decimal.Add(profit, pnl)
return volume, profit
# Rows a connector keeps per strategy repeat part of the account row, so only account rows are read.
def _is_account_position(self, position):
portfolio = self.Portfolio
if position.Security is None or position.StrategyId or portfolio is None:
return False
name = position.PortfolioName
return name is not None and name.upper() == portfolio.Name.upper()
def _market_time(self):
now = self.CurrentTime
return None if now == DateTime.MinValue else now
@staticmethod
def _format(value):
return value.ToString(CultureInfo.InvariantCulture)
def CreateClone(self):
return multi_pair_closer_strategy()