Ver no GitHub

Estratégia Two Per Bar

Visão geral

O expert original do MetaTrader "Two PerBar" abre uma posição comprada e uma vendida no início de cada nova barra, fecha toda a cesta na próxima barra e opcionalmente aplica um multiplicador de volume similar ao martingale. O port do StockSharp mantém o mesmo ritmo rastreando explicitamente ambas as pernas cobertas e reagindo uma vez por vela finalizada. Todas as ordens são criadas através da API de alto nível e respeitam os metadados do instrumento (passo de preço, passo de volume e restrições de lote mínimo/máximo).

Ciclo de trading

  1. Detecção de nova vela – a estratégia assina a série de velas configurada via SubscribeCandles. Quando a vela chega com State == CandleStates.Finished, uma nova barra começou e o ciclo é executado.
  2. Avaliar hits de take-profit – cada perna armazenada carrega seu próprio preço de entrada e nível de take-profit. Se o máximo ou mínimo da vela completada tocar esse nível, a perna é fechada imediatamente com uma ordem de mercado e removida da lista de rastreamento.
  3. Liquidação forçada de sobras – quaisquer pernas que sobreviveram ao scan de take-profit são liquidadas ao mercado antes de abrir o próximo par. Isso espelha o código do MetaTrader que chama PositionClose em cada abertura de barra.
  4. Determinar o próximo tamanho de lote
    • Quando um ciclo anterior ainda tinha pernas abertas, o maior volume entre elas é multiplicado por VolumeMultiplier.
    • Quando a cesta terminou plana (por exemplo, ambas as pernas atingiram seu take-profit), o ciclo redefine para InitialVolume.
    • PrepareVolume normaliza o lote candidato arredondando para dois decimais, ajustando-o ao VolumeStep do instrumento, verificando contra o MinVolume da bolsa, e finalmente redefinindo para InitialVolume se exceder o MaxVolume definido pelo usuário ou o Security.MaxVolume.
  5. Atualizar valores padrão – o lote calculado é armazenado dentro de _lastCycleVolume e escrito em Strategy.Volume para que os métodos auxiliares reutilizem a mesma quantidade.
  6. Criar um novo par cobertoBuyMarket(volume) abre a perna comprada e SellMarket(volume) abre a perna vendida. Cada perna lembra o preço de fechamento da vela finalizada e o nível absoluto de take-profit (entry ± TakeProfitPoints * pointSize). Um TakeProfitPoints zero ou negativo desabilita o take-profit e apenas o passo de liquidação forçada encerrará a cesta.

O resultado é um straddle perpétuo: cada vela começa com um par comprado + vendido, ambos são inspecionados para alvos de lucro durante a barra, e tudo fica plano antes do próximo ciclo.

Gestão de dinheiro e proteção

  • Escalonamento similar ao martingaleVolumeMultiplier replica o multiplicador do MetaTrader. Quando qualquer perna sobrevive até o passo de liquidação forçada, o próximo ciclo usa o tamanho da perna mais pesada multiplicado por este valor. Um ciclo lucrativo completado (ambas as pernas fechadas via take-profit) redefine o lote para InitialVolume.
  • Teto de volumeMaxVolume é um teto rígido que força o lote de volta para InitialVolume assim que o multiplicador o excederia. O mesmo reset acontece se o instrumento reportar um Security.MaxVolume mais restritivo.
  • Conformidade com a bolsa – todos os volumes são ajustados ao VolumeStep do valor e rejeitados quando ficam abaixo de MinVolume. Definir InitialVolume para um tamanho negociável garante que o caminho de reset sempre permaneça válido.
  • Cálculo de pontos – o offset de take-profit usa Security.PriceStep (ou MinPriceStep como fallback). Instrumentos sem um passo definido efetivamente desabilitam o take-profit porque o offset calculado é zero.

Parâmetros

Nome Tipo Padrão Descrição
CandleType DataType Período de 1 minuto Período principal que aciona o fluxo de trabalho uma vez por barra.
InitialVolume decimal 1 Tamanho do lote usado ao iniciar um novo ciclo sem pernas sobreviventes.
VolumeMultiplier decimal 2 Multiplicador aplicado à maior perna sobrevivente do ciclo anterior.
MaxVolume decimal 10 Tamanho máximo de lote permitido antes de redefinir para InitialVolume.
TakeProfitPoints int 50 Distância em pontos de preço usada para construir o alvo de take-profit por perna. 0 desabilita o take-profit e se baseia apenas na liquidação no fechamento da barra.

Notas de implementação e diferenças

  • As pernas cobertas são rastreadas manualmente dentro de _legs para que a estratégia possa raciocinar sobre exposições longas/curtas individuais, embora o StockSharp reporte apenas a posição líquida.
  • Em vez de depender de ticks individuais, a lógica de take-profit verifica o intervalo alto/baixo da vela completada. Isso mantém a implementação determinista enquanto permanece fiel ao comportamento original "por barra".
  • As configurações de slippage e número mágico do MetaTrader não estão expostas; o StockSharp gerencia os detalhes de roteamento de ordens, e a estratégia é executada no portfólio associado à instância de estratégia pai.
  • A colocação de ordens usa os métodos auxiliares de Strategy (BuyMarket, SellMarket) sem adicionar indicadores diretamente a Strategy.Indicators, cumprindo as diretrizes do repositório.

Dicas de uso

  • Ajuste InitialVolume ao passo de lote do instrumento antes de iniciar a estratégia. O construtor não tenta arredondar automaticamente sua entrada.
  • Se o instrumento tiver um passo de preço muito pequeno, considere reduzir TakeProfitPoints; caso contrário, o take-profit calculado pode ficar irrealisticamente distante.
  • Como a estratégia abre ordens em direções opostas ao mesmo tempo, execute-a em conectores/bolsas que permitam posições cobertas. Em ambientes que neteiam posições imediatamente, a lista _legs ainda reflete a lógica pretendida, mas o comportamento real do broker pode diferir.
  • Adicione a estratégia a um gráfico para visualizar velas e operações executadas (DrawCandles + DrawOwnTrades estão habilitados em 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;
	}
}