Estrategia Two Per Bar
Descripción general
El experto original de MetaTrader "Two PerBar" abre una posición larga y una corta al comienzo de cada nueva barra, cierra toda la cesta en la siguiente barra y opcionalmente aplica un multiplicador de volumen similar al martingala. El puerto de StockSharp mantiene el mismo ritmo rastreando explícitamente ambas patas cubiertas y reaccionando una vez por vela finalizada. Todas las órdenes se crean a través de la API de alto nivel y respetan los metadatos del instrumento (paso de precio, paso de volumen y restricciones de lote mínimo/máximo).
Ciclo de trading
- Detección de nueva vela – la estrategia se suscribe a la serie de velas configurada a través de
SubscribeCandles. Cuando la vela llega con State == CandleStates.Finished, ha comenzado una nueva barra y el ciclo se ejecuta.
- Evaluar hits de take-profit – cada pata almacenada lleva su propio precio de entrada y nivel de take-profit. Si el máximo o mínimo de la vela completada alcanza ese nivel, la pata se cierra inmediatamente con una orden de mercado y se elimina de la lista de seguimiento.
- Liquidación forzada de sobrantes – cualquier pata que sobreviva el escaneo de take-profit se liquida al mercado antes de abrir el siguiente par. Esto refleja el código de MetaTrader que llama a
PositionClose en cada apertura de barra.
- Determinar el siguiente tamaño de lote –
- Cuando un ciclo anterior todavía tenía patas abiertas, el mayor volumen entre ellas se multiplica por
VolumeMultiplier.
- Cuando la cesta terminó plana (por ejemplo, ambas patas alcanzaron su take-profit), el ciclo se reinicia a
InitialVolume.
PrepareVolume normaliza el lote candidato redondeando a dos decimales, ajustándolo al VolumeStep del instrumento, verificando contra el MinVolume de la bolsa, y finalmente reiniciando a InitialVolume si supera el MaxVolume definido por el usuario o el Security.MaxVolume.
- Actualizar valores predeterminados – el lote calculado se almacena dentro de
_lastCycleVolume y se escribe en Strategy.Volume para que los métodos auxiliares reutilicen la misma cantidad.
- Generar un nuevo par cubierto –
BuyMarket(volume) abre la pata larga y SellMarket(volume) abre la pata corta. Cada pata recuerda el precio de cierre de la vela finalizada y el nivel absoluto de take-profit (entry ± TakeProfitPoints * pointSize). Un TakeProfitPoints cero o negativo deshabilita el take-profit y solo el paso de liquidación forzada cerrará la cesta.
El resultado es un straddle perpetuo: cada vela comienza con un par largo + corto, ambos se inspeccionan para objetivos de beneficio durante la barra, y todo queda plano antes del siguiente ciclo.
Gestión del dinero y protección
- Escalado similar al martingala –
VolumeMultiplier replica el multiplicador de MetaTrader. Cuando cualquier pata sobrevive hasta el paso de liquidación forzada, el siguiente ciclo usa el tamaño de la pata más grande multiplicado por este valor. Un ciclo profitable completado (ambas patas cerradas vía take-profit) reinicia el lote a InitialVolume.
- Tope de volumen –
MaxVolume es un tope duro que fuerza el lote de vuelta a InitialVolume una vez que el multiplicador lo superaría. El mismo reinicio ocurre si el instrumento reporta un Security.MaxVolume más restrictivo.
- Cumplimiento de la bolsa – todos los volúmenes se ajustan al
VolumeStep del valor y se rechazan cuando caen por debajo de MinVolume. Establecer InitialVolume en un tamaño negociable garantiza que la ruta de reinicio siempre permanezca válida.
- Cálculo de puntos – el desplazamiento de take-profit usa
Security.PriceStep (o MinPriceStep como respaldo). Los instrumentos sin un paso definido efectivamente deshabilitan el take-profit porque el desplazamiento calculado es cero.
Parámetros
| Nombre |
Tipo |
Predeterminado |
Descripción |
CandleType |
DataType |
Marco temporal de 1 minuto |
Marco temporal principal que activa el flujo de trabajo una vez por barra. |
InitialVolume |
decimal |
1 |
Tamaño de lote usado al iniciar un nuevo ciclo sin patas supervivientes. |
VolumeMultiplier |
decimal |
2 |
Multiplicador aplicado a la pata superviviente más grande del ciclo anterior. |
MaxVolume |
decimal |
10 |
Tamaño máximo de lote permitido antes de reiniciar a InitialVolume. |
TakeProfitPoints |
int |
50 |
Distancia en puntos de precio usada para construir el objetivo de take-profit por pata. 0 deshabilita el take-profit y se basa únicamente en la liquidación al cierre de barra. |
Notas de implementación y diferencias
- Las patas cubiertas se rastrean manualmente dentro de
_legs para que la estrategia pueda razonar sobre exposiciones largas/cortas individuales aunque StockSharp reporte solo la posición neta.
- En lugar de depender de ticks individuales, la lógica de take-profit verifica el rango alto/bajo de la vela completada. Esto mantiene la implementación determinista mientras permanece fiel al comportamiento original "por barra".
- Los ajustes de deslizamiento y número mágico de MetaTrader no están expuestos; StockSharp maneja los detalles de enrutamiento de órdenes, y la estrategia se ejecuta en el portfolio asociado con la instancia de estrategia padre.
- La colocación de órdenes usa los métodos auxiliares de
Strategy (BuyMarket, SellMarket) sin agregar indicadores directamente a Strategy.Indicators, cumpliendo con las pautas del repositorio.
Consejos de uso
- Ajuste
InitialVolume al paso de lote del instrumento antes de iniciar la estrategia. El constructor no intenta redondear automáticamente su entrada.
- Si el instrumento tiene un paso de precio muy pequeño, considere reducir
TakeProfitPoints; de lo contrario, el take-profit calculado puede ubicarse irrealistamente lejos.
- Dado que la estrategia abre órdenes en dirección opuesta al mismo tiempo, ejecútela en conectores/bolsas que permitan posiciones cubiertas. En entornos que netan posiciones inmediatamente, la lista
_legs aún refleja la lógica pretendida, pero el comportamiento real del broker puede diferir.
- Agregue la estrategia a un gráfico para visualizar velas y operaciones ejecutadas (
DrawCandles + DrawOwnTrades están habilitados en 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()