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()