Советник MetaTrader "Two PerBar" открывает длинную и короткую позицию в самом начале каждой новой свечи, закрывает весь пакет на следующей свече и при необходимости умножает объём по принципу мартингейла. Порт на StockSharp воспроизводит тот же ритм: обе разнонаправленные ноги учитываются явно, а обработка выполняется один раз на закрытии свечи. Все заявки отправляются через высокоуровневые методы Strategy и учитывают биржевые ограничения инструмента (шаг цены, шаг объёма, минимальные и максимальные лоты).
Торговый цикл
Фиксация новой свечи. Подписка создаётся через SubscribeCandles. Как только приходит свеча со статусом CandleStates.Finished, начинается очередной цикл.
Проверка тейк-профитов. Каждая нога хранит цену входа и целевой уровень. Если максимум или минимум завершившейся свечи достигает уровня, нога немедленно закрывается рыночной заявкой и удаляется из списка.
Принудительное закрытие остатка. Все ноги, которые выжили после шага тейк-профита, ликвидируются по рынку до открытия нового пакета. Это повторяет вызов PositionClose в оригинале.
Расчёт следующего объёма:
Если в прошлом цикле остались незакрытые ноги, берётся максимальный объём среди них и умножается на VolumeMultiplier.
Если обе ноги закрылись (например, по тейк-профиту), стратегия возвращается к InitialVolume.
Метод PrepareVolume округляет кандидат до двух знаков, совмещает со VolumeStep, проверяет на MinVolume и сбрасывает на InitialVolume, если ограничение MaxVolume или Security.MaxVolume превышено.
Обновление значений по умолчанию. Рассчитанный объём сохраняется в _lastCycleVolume и записывается в Strategy.Volume, чтобы вспомогательные методы использовали ту же величину.
Открытие новой пары. BuyMarket(volume) создаёт длинную ногу, SellMarket(volume) — короткую. Для каждой ноги фиксируется цена закрывшейся свечи и абсолютный уровень тейк-профита (entry ± TakeProfitPoints * pointSize). Если TakeProfitPoints <= 0, тейк-профит отключается и закрытие происходит только на следующем баре.
Таким образом формируется непрерывный "страддл": в начале каждой свечи открывается пара позиций, в течение бара они отслеживаются на предмет тейк-профита, а к старту следующей свечи позиция всегда обнуляется.
Управление объёмом и защита
Мартингейл. Параметр VolumeMultiplier повторяет множитель из MetaTrader. Если какая-либо нога дожила до принудительного закрытия, следующий цикл использует объём крупнейшей ноги, умноженный на этот коэффициент. Профитный цикл (обе ноги закрыты по цели) возвращает объём к InitialVolume.
Ограничение объёма. MaxVolume — жёсткий предел: как только расчёт превысил его (или Security.MaxVolume), объём сбрасывается к базовому значению.
Соответствие бирже. Все объёмы подгоняются под VolumeStep и отклоняются, если меньше MinVolume. Убедитесь, что InitialVolume укладывается в требования площадки.
Расчёт шага цены. Смещение тейк-профита умножает TakeProfitPoints на Security.PriceStep (или MinPriceStep, если основной шаг не задан). При нулевом шаге тейк-профит фактически отключается.
Параметры
Название
Тип
Значение по умолчанию
Описание
CandleType
DataType
Таймфрейм 1 минута
Основная свечная серия, задающая частоту входов.
InitialVolume
decimal
1
Объём первой пары, если прошлый цикл завершился без открытых ног.
VolumeMultiplier
decimal
2
Множитель для максимальной ноги предыдущего цикла.
MaxVolume
decimal
10
Верхний предел объёма перед сбросом на InitialVolume.
TakeProfitPoints
int
50
Дистанция в пунктах для расчёта тейк-профита каждой ноги. Значение 0 отключает тейк-профит и оставляет только принудительное закрытие на следующем баре.
Особенности реализации и отличия от MQL5
Ноги портфеля хранятся во внутреннем списке _legs, чтобы стратегия могла различать длинную и короткую часть, даже если коннектор поддерживает только неттинг.
Тейк-профит определяется по диапазону завершившейся свечи (High/Low), что соответствует "побарному" подходу и делает поведение детерминированным.
Параметры slippage и magic number из MetaTrader не используются: маршрутизация заявок полностью возлагается на StockSharp.
Заявки отправляются через BuyMarket и SellMarket без добавления индикаторов в Strategy.Indicators, полностью следуя рекомендациям репозитория.
Практические рекомендации
Подбирайте InitialVolume согласно шагу объёма инструмента — стратегия не нормализует параметр автоматически.
Для инструментов с маленьким шагом цены уменьшите TakeProfitPoints, иначе цель может оказаться слишком далёкой.
Стратегия одновременно открывает встречные позиции. Используйте коннекторы и счета, где разрешено хеджирование. В неттинговой среде логика _legs сохраняется, но фактическое исполнение брокера может отличаться.
Добавьте стратегию на график, чтобы видеть свечи и совершённые сделки (DrawCandles и DrawOwnTrades включены в OnStarted).
namespace StockSharp.Samples.Strategies;
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;
/// <summary>
/// Strategy that opens a hedged pair of market orders on every new bar.
/// </summary>
public class TwoPerBarStrategy : Strategy
{
private sealed class HedgeLeg
{
public bool IsLong;
public decimal Volume;
public decimal EntryPrice;
public decimal? TakeProfitPrice;
}
private readonly List<HedgeLeg> _legs = new();
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<decimal> _initialVolume;
private readonly StrategyParam<decimal> _volumeMultiplier;
private readonly StrategyParam<decimal> _maxVolume;
private readonly StrategyParam<int> _takeProfitPoints;
private decimal _pointSize;
private decimal _lastCycleVolume;
/// <summary>
/// Initializes a new instance of <see cref="TwoPerBarStrategy"/>.
/// </summary>
public TwoPerBarStrategy()
{
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
.SetDisplay("Candle Type", "Primary timeframe used to detect new bars.", "General");
_initialVolume = Param(nameof(InitialVolume), 1m)
.SetGreaterThanZero()
.SetDisplay("Initial Volume", "Lot size used when no previous positions exist.", "Trading")
;
_volumeMultiplier = Param(nameof(VolumeMultiplier), 2m)
.SetGreaterThanZero()
.SetDisplay("Volume Multiplier", "Factor applied to the heaviest remaining leg after closing a cycle.", "Trading")
;
_maxVolume = Param(nameof(MaxVolume), 10m)
.SetGreaterThanZero()
.SetDisplay("Maximum Volume", "Upper limit for the calculated lot size before resetting to the initial value.", "Risk")
;
_takeProfitPoints = Param(nameof(TakeProfitPoints), 50)
.SetNotNegative()
.SetDisplay("Take Profit (points)", "Distance to the take profit expressed in instrument points.", "Risk")
;
}
/// <summary>
/// Candle type that drives the strategy.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Base lot size for a fresh cycle.
/// </summary>
public decimal InitialVolume
{
get => _initialVolume.Value;
set => _initialVolume.Value = value;
}
/// <summary>
/// Multiplier applied to the previous maximum lot size.
/// </summary>
public decimal VolumeMultiplier
{
get => _volumeMultiplier.Value;
set => _volumeMultiplier.Value = value;
}
/// <summary>
/// Hard limit for the calculated lot size.
/// </summary>
public decimal MaxVolume
{
get => _maxVolume.Value;
set => _maxVolume.Value = value;
}
/// <summary>
/// Take profit distance in price points.
/// </summary>
public int TakeProfitPoints
{
get => _takeProfitPoints.Value;
set => _takeProfitPoints.Value = value;
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
yield return (Security, CandleType);
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_legs.Clear();
_lastCycleVolume = 0m;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_pointSize = CalculatePointSize();
_lastCycleVolume = PrepareVolume(InitialVolume);
if (_lastCycleVolume > 0m)
Volume = _lastCycleVolume;
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;
CheckTakeProfitHits(candle);
var hadLegs = _legs.Count > 0;
var maxVolume = 0m;
for (var i = 0; i < _legs.Count; i++)
{
var leg = _legs[i];
if (leg.Volume > maxVolume)
maxVolume = leg.Volume;
}
if (_legs.Count > 0)
CloseAllLegs();
var nextVolume = hadLegs ? maxVolume * VolumeMultiplier : InitialVolume;
nextVolume = PrepareVolume(nextVolume);
if (nextVolume <= 0m)
return;
_lastCycleVolume = nextVolume;
Volume = nextVolume;
if (!IsFormedAndOnlineAndAllowTrading())
return;
var offset = TakeProfitPoints > 0 && _pointSize > 0m ? TakeProfitPoints * _pointSize : 0m;
OpenHedgePair(candle.ClosePrice, offset);
}
private void CheckTakeProfitHits(ICandleMessage candle)
{
if (TakeProfitPoints <= 0)
return;
for (var i = _legs.Count - 1; i >= 0; i--)
{
var leg = _legs[i];
var target = leg.TakeProfitPrice;
if (target is null)
continue;
if (leg.IsLong)
{
if (candle.HighPrice >= target.Value)
{
SellMarket(leg.Volume);
_legs.RemoveAt(i);
}
}
else
{
if (candle.LowPrice <= target.Value)
{
BuyMarket(leg.Volume);
_legs.RemoveAt(i);
}
}
}
}
private void CloseAllLegs()
{
for (var i = _legs.Count - 1; i >= 0; i--)
{
var leg = _legs[i];
if (leg.IsLong)
SellMarket(leg.Volume);
else
BuyMarket(leg.Volume);
}
_legs.Clear();
}
private void OpenHedgePair(decimal entryPrice, decimal takeProfitOffset)
{
var volume = _lastCycleVolume;
if (volume <= 0m)
return;
var longOrder = BuyMarket(volume);
if (longOrder is not null)
{
_legs.Add(new HedgeLeg
{
IsLong = true,
Volume = volume,
EntryPrice = entryPrice,
TakeProfitPrice = takeProfitOffset > 0m ? entryPrice + takeProfitOffset : null
});
}
var shortOrder = SellMarket(volume);
if (shortOrder is not null)
{
_legs.Add(new HedgeLeg
{
IsLong = false,
Volume = volume,
EntryPrice = entryPrice,
TakeProfitPrice = takeProfitOffset > 0m ? entryPrice - takeProfitOffset : null
});
}
}
private decimal PrepareVolume(decimal candidate)
{
if (candidate <= 0m)
return 0m;
if (ShouldResetVolume(candidate))
candidate = InitialVolume;
var normalized = NormalizeVolume(candidate);
if (normalized <= 0m)
return 0m;
if (ShouldResetVolume(normalized))
normalized = NormalizeVolume(InitialVolume);
return normalized;
}
private bool ShouldResetVolume(decimal volume)
{
if (volume <= 0m)
return false;
if (MaxVolume > 0m && volume > MaxVolume)
return true;
var security = Security;
var maxFromSecurity = security?.MaxVolume;
return maxFromSecurity != null && volume > maxFromSecurity.Value;
}
private decimal NormalizeVolume(decimal volume)
{
if (volume <= 0m)
return 0m;
var normalized = decimal.Round(volume, 2, MidpointRounding.ToZero);
var security = Security;
if (security != null)
{
var step = security.VolumeStep ?? 0m;
if (step > 0m)
normalized = step * Math.Floor(normalized / step);
var min = security.MinVolume ?? 0m;
if (min > 0m && normalized < min)
return 0m;
var max = security.MaxVolume;
if (max != null && normalized > max.Value)
normalized = max.Value;
}
return normalized > 0m ? normalized : 0m;
}
private decimal CalculatePointSize()
{
var security = Security;
if (security?.PriceStep is decimal step && step > 0m)
return step;
return 0m;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
from datatype_extensions import *
from indicator_extensions import *
class two_per_bar_strategy(Strategy):
"""Hedged pair strategy: opens buy+sell on each bar with TP management and volume multiplier."""
def __init__(self):
super(two_per_bar_strategy, self).__init__()
self._tp_points = self.Param("TakeProfitPoints", 50).SetNotNegative().SetDisplay("Take Profit", "TP in points", "Risk")
self._vol_mult = self.Param("VolumeMultiplier", 2).SetGreaterThanZero().SetDisplay("Volume Multiplier", "Multiplier after cycle", "Trading")
self._max_vol = self.Param("MaxVolume", 10).SetGreaterThanZero().SetDisplay("Max Volume", "Upper limit for lot size", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1))).SetDisplay("Candle Type", "Timeframe", "General")
@property
def CandleType(self): return self._candle_type.Value
@CandleType.setter
def CandleType(self, value): self._candle_type.Value = value
def OnReseted(self):
super(two_per_bar_strategy, self).OnReseted()
self._legs = []
self._last_cycle_vol = 1.0
self._bar_count = 0
def OnStarted2(self, time):
super(two_per_bar_strategy, self).OnStarted2(time)
self._legs = []
self._last_cycle_vol = 1.0
self._bar_count = 0
sub = self.SubscribeCandles(self.CandleType)
sub.Bind(self.OnProcess).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, sub)
self.DrawOwnTrades(area)
def OnProcess(self, candle):
if candle.State != CandleStates.Finished:
return
self._bar_count += 1
close = float(candle.ClosePrice)
high = float(candle.HighPrice)
low = float(candle.LowPrice)
tp_pts = self._tp_points.Value
# Check TP hits
if tp_pts > 0:
i = len(self._legs) - 1
while i >= 0:
leg = self._legs[i]
if leg['is_long'] and leg['tp'] is not None and high >= leg['tp']:
self.SellMarket()
self._legs.pop(i)
elif not leg['is_long'] and leg['tp'] is not None and low <= leg['tp']:
self.BuyMarket()
self._legs.pop(i)
i -= 1
# Close remaining legs
had_legs = len(self._legs) > 0
max_vol = 0.0
for leg in self._legs:
if leg['vol'] > max_vol:
max_vol = leg['vol']
if len(self._legs) > 0:
for leg in reversed(self._legs):
if leg['is_long']:
self.SellMarket()
else:
self.BuyMarket()
self._legs = []
# Calculate next volume
if had_legs:
next_vol = max_vol * self._vol_mult.Value
else:
next_vol = 1.0
max_v = self._max_vol.Value
if max_v > 0 and next_vol > max_v:
next_vol = 1.0
self._last_cycle_vol = next_vol
# Skip first few bars to gather data
if self._bar_count < 3:
return
# Open hedged pair
tp_offset = tp_pts if tp_pts > 0 else 0
self.BuyMarket()
self._legs.append({'is_long': True, 'vol': next_vol, 'entry': close, 'tp': close + tp_offset if tp_offset > 0 else None})
self.SellMarket()
self._legs.append({'is_long': False, 'vol': next_vol, 'entry': close, 'tp': close - tp_offset if tp_offset > 0 else None})
def CreateClone(self):
return two_per_bar_strategy()