複数ペア一括決済戦略
概要
複数ペア一括決済戦略は、通貨ペアのバスケットを監視し、合算された浮動利益が目標に達するか、または累積損失がセーフティ閾値を超えた場合にすべてのオープンポジションを清算する元の MetaTrader スクリプトを反映しています。この変換は StockSharp の高レベル API を活用して利益を追跡し、最小保有時間を強制し、1 つのアクションで複数の銘柄のポジションを閉じます。
ロジック
- コンマ区切りの
WatchedSymbolsパラメーターから監視する銘柄を解決します。リストが空の場合は、メインのSecurityが使用されます。 - 各銘柄に対して選択されたローソク足タイプ(デフォルト:1 分タイムフレーム)を購読します。完成した各ローソク足が利益評価をトリガーします。
- 各銘柄について戦略は以下を保存します:
- 最後に計算された利益(
Positions[i].PnL)。 - ポジションが最初に非ゼロになったときのタイムスタンプ(
MinAgeSeconds要件を尊重するため)。
- 最後に計算された利益(
- 各更新後、すべての監視シンボルの純利益が計算されます:
ProfitTargetに達すると、最小年齢より古いすべてのポジションはBuyMarket/SellMarket注文を使用してフラット化されます。- 純利益が
-MaxLossを下回ると、同じ清算ロジックが保護ストップとして適用されます。
- 詳細なログが各評価後の銘柄ごとの利益と現在のバスケット結果を要約します。
パラメーター
| パラメーター | 説明 | デフォルト |
|---|---|---|
WatchedSymbols |
監視する銘柄識別子のコンマ区切りリスト。空の場合、戦略は割り当てられた Security にフォールバックします。 |
"GBPUSD,USDCAD,USDCHF,USDSEK" |
ProfitTarget |
すべての監視ポジションのグローバルクローズをトリガーするために必要な純利益(ポートフォリオ通貨)。 | 60 |
MaxLoss |
戦略がバスケットを強制決済する前の最大許容損失(ポートフォリオ通貨)。 | 60 |
Slippage |
元のスクリプトの許容スリッページを反映する互換性パラメーター。成行注文が決済に使用されるため、値は情報的です。 | 10 |
MinAgeSeconds |
戦略が閉じることを許可される前のポジションの最小存続時間。 | 60 |
CandleType |
定期的な監視に使用されるローソク足タイプ(デフォルト:1 分ローソク足)。 | 1 minute |
注意事項
- 戦略は浮動利益を測定するために StockSharp が提供する
Positions[i].PnLに依存します。取引履歴を引き出したり価格を手動で計算したりしません。 - 戦略が開始する前に開かれたポジションは、最初に見られたタイムスタンプとして開始時間を継承します。これらは戦略の開始から
MinAgeSeconds間隔が経過した後にのみ閉じられます。 - 決済は即時清算の確率を最大化するために成行注文で実行されます。
Slippageは MQL バージョンとの同等性のためにログに記録されますが、価格計算には適用されません。 - ログ出力は、各シンボルの利益に続いてバスケット合計を印刷することで MetaTrader の「Comment」ウィンドウを再現します。
要件
- 有効な
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()