在 GitHub 上查看

Butterfly Pattern 策略

概览

Butterfly Pattern Strategy 将 MetaTrader「Cypher EA」谐波交易思想迁移到 StockSharp 高阶 API。策略在指定周期的 K 线中寻找多头/空头蝴蝶形态,验证关键斐波那契比例,并以三段式止盈开仓。与原版 EA 一样,还提供了移动至保本和移动止损的仓位管理功能。

工作流程

  1. 策略按照 PivotLeft / PivotRight 设置缓存 K 线,确认局部高低点(Pivot)。
  2. 当最新的五个 Pivot 满足蝴蝶形态的排列时,检查所有必要的比例约束。
  3. 通过 MinPatternQuality 计算的质量分数过滤劣质形态;若启用 RevalidatePattern,还会在下单前再次验证价格。
  4. 在信号确认的收盘时:
    • 按照固定手数或风险百分比下达市价单。
    • 将持仓按照 TP1/TP2/TP3 参数划分为三个止盈目标。
    • 根据形态几何关系计算保护性止损。
  5. 在持仓期间,策略跟踪价格是否到达分段止盈,必要时把止损推至保本并按设定的步长进行跟踪。

提示: 原始 EA 同时处理多个周期。若要在 StockSharp 中实现同样的效果,可启动多个策略实例,并为它们指定不同的 CandleType

关键参数

参数 说明
CandleType 用于检测形态的 K 线类型(时间周期)。
PivotLeft / PivotRight 确认 Pivot 所需的左/右侧 K 线数量。
Tolerance 允许的斐波那契比例误差。
AllowTrading 是否在检测到形态后发出交易。
UseFixedVolume / FixedVolume 是否使用固定手数;关闭时将根据 RiskPercent 动态计算。
RiskPercent 单笔交易愿意承受的账户风险百分比(仅在动态手数模式下有效)。
AdjustLotsForTakeProfits 重新归一化分段手数,使其总和等于下单量。
Tp1Percent / Tp2Percent / Tp3Percent 三个止盈目标对应的手数分配比例。
MinPatternQuality 接受形态所需的最小质量评分(0–1)。
UseSessionFilter, SessionStartHour, SessionEndHour 限定策略只在指定交易时段工作。
RevalidatePattern 在下单前再次确认形态没有被价格破坏。
UseBreakEven, BreakEvenAfterTp, BreakEvenTrigger, BreakEvenProfit 控制在达到特定止盈后将止损移动到保本附近的逻辑。
UseTrailingStop, TrailAfterTp, TrailStart, TrailStep 达到指定止盈且满足最小盈利后启动跟踪止损。

风险控制

  • 策略内部管理止损、保本和跟踪逻辑,不会额外挂出保护单;出场使用市价指令以贴合原始 EA 行为。
  • 当关闭 UseFixedVolume 时,持仓手数依据止损距离、合约价格步长以及 RiskPercent 自动计算。

使用建议

  • 请确认标的证券支持所选的 CandleType 以及足够的报价精度,否则最小距离校验可能导致信号被拒绝。
  • BreakEvenAfterTpTrailAfterTp 需在对应分段止盈成交后才会生效。
  • 如需同时监控多个周期,可运行多个策略实例并分别设置不同的 CandleType
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>
/// Detects bullish and bearish butterfly harmonic patterns on a configurable timeframe.
/// Distributes positions across three take-profit levels and supports optional break-even
/// and trailing-stop management.
/// </summary>
public class ButterflyPatternStrategy : Strategy
{
	private sealed class Pivot
	{
		public Pivot(DateTimeOffset time, decimal price, bool isHigh)
		{
			Time = time;
			Price = price;
			IsHigh = isHigh;
		}

		public DateTimeOffset Time { get; }

		public decimal Price { get; }

		public bool IsHigh { get; }
	}

	private sealed class PatternState
	{
		private readonly List<ICandleMessage> _candles = new();
		private readonly List<Pivot> _pivots = new();

		public Sides? Side { get; set; }

		public decimal RemainingVolume { get; set; }

		public decimal Lot1 { get; set; }

		public decimal Lot2 { get; set; }

		public decimal Lot3 { get; set; }

		public bool Tp1Filled { get; set; }

		public bool Tp2Filled { get; set; }

		public bool Tp3Filled { get; set; }

		public decimal? EntryPrice { get; set; }

		public decimal? StopPrice { get; set; }

		public decimal Tp1Price { get; set; }

		public decimal Tp2Price { get; set; }

		public decimal Tp3Price { get; set; }

		public bool BreakEvenApplied { get; set; }

		public bool TrailingActivated { get; set; }

		public DateTimeOffset? LastPatternTime { get; set; }

		public void ResetPosition()
		{
			Side = null;
			RemainingVolume = 0m;
			Lot1 = 0m;
			Lot2 = 0m;
			Lot3 = 0m;
			Tp1Filled = false;
			Tp2Filled = false;
			Tp3Filled = false;
			EntryPrice = null;
			StopPrice = null;
			Tp1Price = 0m;
			Tp2Price = 0m;
			Tp3Price = 0m;
			BreakEvenApplied = false;
			TrailingActivated = false;
		}

		public void ResetSeries()
		{
			ResetPosition();
			_candles.Clear();
			_pivots.Clear();
			LastPatternTime = null;
		}

		public void AddCandle(ICandleMessage candle)
		{
			_candles.Add(candle);
		}

		public bool TryExtractPivot(int left, int right, out Pivot pivot)
		{
			pivot = default;

			var required = left + right + 1;
			if (_candles.Count < required)
				return false;

			var index = _candles.Count - 1 - right;
			if (index < left)
				return false;

			var middle = _candles[index];
			if (middle == null)
				return false;
			var isHigh = true;
			var isLow = true;
			var from = index - left;
			var to = index + right;

			for (var i = from; i <= to; i++)
			{
				if (i < 0 || i >= _candles.Count)
					continue;

				if (i == index)
					continue;

				var c = _candles[i];
				if (c == null)
					continue;
				if (c.HighPrice > middle.HighPrice)
					isHigh = false;

				if (c.LowPrice < middle.LowPrice)
					isLow = false;
			}

			if (!isHigh && !isLow)
				return false;

			pivot = new Pivot(middle.OpenTime, isHigh ? middle.HighPrice : middle.LowPrice, isHigh);

			if (_candles.Count > required)
				_candles.RemoveAt(0);

			return true;
		}

		public void AddPivot(Pivot pivot)
		{
			_pivots.Add(pivot);
			if (_pivots.Count > 5)
				_pivots.RemoveAt(0);
		}

		public bool TryGetPattern(out Pivot x, out Pivot a, out Pivot b, out Pivot c, out Pivot d)
		{
			x = default;
			a = default;
			b = default;
			c = default;
			d = default;

			if (_pivots.Count < 5)
				return false;

			x = _pivots[^5];
			a = _pivots[^4];
			b = _pivots[^3];
			c = _pivots[^2];
			d = _pivots[^1];
			return true;
		}
	}

	private PatternState _state;

	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _pivotLeft;
	private readonly StrategyParam<int> _pivotRight;
	private readonly StrategyParam<decimal> _tolerance;
	private readonly StrategyParam<bool> _allowTrading;
	private readonly StrategyParam<bool> _useFixedVolume;
	private readonly StrategyParam<decimal> _fixedVolume;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<bool> _adjustLots;
	private readonly StrategyParam<decimal> _tp1Percent;
	private readonly StrategyParam<decimal> _tp2Percent;
	private readonly StrategyParam<decimal> _tp3Percent;
	private readonly StrategyParam<decimal> _minPatternQuality;
	private readonly StrategyParam<bool> _useSessionFilter;
	private readonly StrategyParam<int> _sessionStartHour;
	private readonly StrategyParam<int> _sessionEndHour;
	private readonly StrategyParam<bool> _revalidatePattern;
	private readonly StrategyParam<bool> _useBreakEven;
	private readonly StrategyParam<int> _breakEvenAfterTp;
	private readonly StrategyParam<decimal> _breakEvenTrigger;
	private readonly StrategyParam<decimal> _breakEvenProfit;
	private readonly StrategyParam<bool> _useTrailingStop;
	private readonly StrategyParam<int> _trailAfterTp;
	private readonly StrategyParam<decimal> _trailStart;
	private readonly StrategyParam<decimal> _trailStep;

	public ButterflyPatternStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Timeframe used for pattern detection", "General");

		_pivotLeft = Param(nameof(PivotLeft), 1)
		.SetGreaterThanZero()
		.SetDisplay("Pivot Left", "Bars to the left when validating a pivot", "Pattern");

		_pivotRight = Param(nameof(PivotRight), 1)
		.SetGreaterThanZero()
		.SetDisplay("Pivot Right", "Bars to the right when validating a pivot", "Pattern");

		_tolerance = Param(nameof(Tolerance), 0.50m)
		.SetGreaterThanZero()
		.SetDisplay("Ratio Tolerance", "Maximum deviation allowed for Fibonacci ratios", "Pattern");

		_allowTrading = Param(nameof(AllowTrading), true)
		.SetDisplay("Allow Trading", "Enable order generation when patterns are confirmed", "Trading");

		_useFixedVolume = Param(nameof(UseFixedVolume), true)
		.SetDisplay("Use Fixed Volume", "Use fixed trade volume instead of risk-based sizing", "Risk");

		_fixedVolume = Param(nameof(FixedVolume), 1m)
		.SetGreaterThanZero()
		.SetDisplay("Fixed Volume", "Volume to trade when fixed sizing is active", "Risk");

		_riskPercent = Param(nameof(RiskPercent), 1m)
		.SetGreaterThanZero()
		.SetDisplay("Risk Percent", "Risk per trade as a percentage of portfolio value", "Risk");

		_adjustLots = Param(nameof(AdjustLotsForTakeProfits), true)
		.SetDisplay("Adjust Lots", "Normalize take-profit allocations to match total volume", "Risk");

		_tp1Percent = Param(nameof(Tp1Percent), 50m)
		.SetNotNegative()
		.SetDisplay("TP1 %", "Share of volume closed at the first take-profit", "Targets");

		_tp2Percent = Param(nameof(Tp2Percent), 30m)
		.SetNotNegative()
		.SetDisplay("TP2 %", "Share of volume closed at the second take-profit", "Targets");

		_tp3Percent = Param(nameof(Tp3Percent), 20m)
		.SetNotNegative()
		.SetDisplay("TP3 %", "Share of volume closed at the third take-profit", "Targets");

		_minPatternQuality = Param(nameof(MinPatternQuality), 0.01m)
		.SetDisplay("Minimum Quality", "Minimum harmonic score required to trade", "Pattern");

		_useSessionFilter = Param(nameof(UseSessionFilter), false)
		.SetDisplay("Use Session Filter", "Only trade within configured session hours", "Trading");

		_sessionStartHour = Param(nameof(SessionStartHour), 8)
		.SetDisplay("Session Start", "Session start hour in exchange time", "Trading");

		_sessionEndHour = Param(nameof(SessionEndHour), 16)
		.SetDisplay("Session End", "Session end hour in exchange time", "Trading");

		_revalidatePattern = Param(nameof(RevalidatePattern), false)
		.SetDisplay("Revalidate Pattern", "Confirm that price has not invalidated the setup", "Pattern");

		_useBreakEven = Param(nameof(UseBreakEven), false)
		.SetDisplay("Use Break-Even", "Enable break-even management", "Risk");

		_breakEvenAfterTp = Param(nameof(BreakEvenAfterTp), 1)
		.SetGreaterThanZero()
		.SetDisplay("Break-Even After TP", "Activate break-even after the specified take-profit", "Risk");

		_breakEvenTrigger = Param(nameof(BreakEvenTrigger), 30m)
		.SetDisplay("Break-Even Trigger", "Points required to lock break-even", "Risk");

		_breakEvenProfit = Param(nameof(BreakEvenProfit), 5m)
		.SetDisplay("Break-Even Profit", "Profit offset applied to break-even", "Risk");

		_useTrailingStop = Param(nameof(UseTrailingStop), false)
		.SetDisplay("Use Trailing", "Enable trailing stop management", "Risk");

		_trailAfterTp = Param(nameof(TrailAfterTp), 2)
		.SetGreaterThanZero()
		.SetDisplay("Trail After TP", "Activate trailing after the specified take-profit", "Risk");

		_trailStart = Param(nameof(TrailStart), 20m)
		.SetDisplay("Trail Start", "Points required before trailing", "Risk");

		_trailStep = Param(nameof(TrailStep), 5m)
		.SetDisplay("Trail Step", "Trailing step in price points", "Risk");
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public int PivotLeft
	{
		get => _pivotLeft.Value;
		set => _pivotLeft.Value = value;
	}

	public int PivotRight
	{
		get => _pivotRight.Value;
		set => _pivotRight.Value = value;
	}

	public decimal Tolerance
	{
		get => _tolerance.Value;
		set => _tolerance.Value = value;
	}

	public bool AllowTrading
	{
		get => _allowTrading.Value;
		set => _allowTrading.Value = value;
	}

	public bool UseFixedVolume
	{
		get => _useFixedVolume.Value;
		set => _useFixedVolume.Value = value;
	}

	public decimal FixedVolume
	{
		get => _fixedVolume.Value;
		set => _fixedVolume.Value = value;
	}

	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}

	public bool AdjustLotsForTakeProfits
	{
		get => _adjustLots.Value;
		set => _adjustLots.Value = value;
	}

	public decimal Tp1Percent
	{
		get => _tp1Percent.Value;
		set => _tp1Percent.Value = value;
	}

	public decimal Tp2Percent
	{
		get => _tp2Percent.Value;
		set => _tp2Percent.Value = value;
	}

	public decimal Tp3Percent
	{
		get => _tp3Percent.Value;
		set => _tp3Percent.Value = value;
	}

	public decimal MinPatternQuality
	{
		get => _minPatternQuality.Value;
		set => _minPatternQuality.Value = value;
	}

	public bool UseSessionFilter
	{
		get => _useSessionFilter.Value;
		set => _useSessionFilter.Value = value;
	}

	public int SessionStartHour
	{
		get => _sessionStartHour.Value;
		set => _sessionStartHour.Value = value;
	}

	public int SessionEndHour
	{
		get => _sessionEndHour.Value;
		set => _sessionEndHour.Value = value;
	}

	public bool RevalidatePattern
	{
		get => _revalidatePattern.Value;
		set => _revalidatePattern.Value = value;
	}

	public bool UseBreakEven
	{
		get => _useBreakEven.Value;
		set => _useBreakEven.Value = value;
	}

	public int BreakEvenAfterTp
	{
		get => _breakEvenAfterTp.Value;
		set => _breakEvenAfterTp.Value = value;
	}

	public decimal BreakEvenTrigger
	{
		get => _breakEvenTrigger.Value;
		set => _breakEvenTrigger.Value = value;
	}

	public decimal BreakEvenProfit
	{
		get => _breakEvenProfit.Value;
		set => _breakEvenProfit.Value = value;
	}

	public bool UseTrailingStop
	{
		get => _useTrailingStop.Value;
		set => _useTrailingStop.Value = value;
	}

	public int TrailAfterTp
	{
		get => _trailAfterTp.Value;
		set => _trailAfterTp.Value = value;
	}

	public decimal TrailStart
	{
		get => _trailStart.Value;
		set => _trailStart.Value = value;
	}

	public decimal TrailStep
	{
		get => _trailStep.Value;
		set => _trailStep.Value = value;
	}

	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		yield return (Security, CandleType);
	}

	protected override void OnReseted()
	{
		base.OnReseted();
		_state = null;
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);
		_state = new PatternState();

		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;

		UpdateRiskManagement(candle);

		if (!IsWithinSession(candle.OpenTime))
		return;

		_state.AddCandle(candle);

		if (_state.TryExtractPivot(PivotLeft, PivotRight, out var pivot))
		{
		_state.AddPivot(pivot);
		TryDetectPattern(candle);
		}
	}

	private bool IsWithinSession(DateTimeOffset time)
	{
		if (!UseSessionFilter)
		return true;

		var hour = time.Hour;
		if (SessionStartHour < SessionEndHour)
		return hour >= SessionStartHour && hour < SessionEndHour;

		return hour >= SessionStartHour || hour < SessionEndHour;
	}

	private void TryDetectPattern(ICandleMessage candle)
	{
		if (!_state.TryGetPattern(out var x, out var a, out var b, out var c, out var d))
		return;

		if (_state.LastPatternTime is DateTimeOffset last && last == d.Time)
		return;

		var side = DetectPatternType(x, a, b, c, d);
		if (side == null)
		return;

		var quality = AssessPatternQuality(x, a, b, c, d, side.Value);
		if (quality < MinPatternQuality)
		{
		LogInfo($"Pattern discarded: quality {quality:F3} below threshold {MinPatternQuality:F3}.");
		return;
		}

		if (RevalidatePattern && !RevalidateBeforeTrading(candle.ClosePrice, c.Price, a.Price, x.Price, side.Value))
		{
		LogInfo("Pattern invalidated by price action.");
		return;
		}

		_state.LastPatternTime = d.Time;

		if (!AllowTrading)
		{
		LogInfo("Trading disabled. Pattern ignored.");
		return;
		}

		if (_state.Side != null && _state.RemainingVolume > 0m)
		{
		LogInfo("Active position detected. New signal skipped.");
		return;
		}

		ExecutePattern(candle, side.Value, a, c);
	}

	private Sides? DetectPatternType(Pivot x, Pivot a, Pivot b, Pivot c, Pivot d)
	{
		var diffBear = x.Price - a.Price;
		if (x.IsHigh && !a.IsHigh && b.IsHigh && !c.IsHigh && d.IsHigh && diffBear > 0m)
		{
		var idealB = a.Price + 0.786m * diffBear;
		if (Math.Abs(b.Price - idealB) <= Tolerance * diffBear)
		{
		var bc = b.Price - c.Price;
		if (bc >= 0.1m * diffBear && bc <= 2m * diffBear)
		{
		var cd = d.Price - c.Price;
		if (cd >= 0.5m * diffBear && cd <= 3m * diffBear)
		return Sides.Sell;
		}
		}
		}

		var diffBull = a.Price - x.Price;
		if (!x.IsHigh && a.IsHigh && !b.IsHigh && c.IsHigh && !d.IsHigh && diffBull > 0m)
		{
		var idealB = a.Price - 0.786m * diffBull;
		if (Math.Abs(b.Price - idealB) <= Tolerance * diffBull)
		{
		var bc = c.Price - b.Price;
		if (bc >= 0.1m * diffBull && bc <= 2m * diffBull)
		{
		var cd = c.Price - d.Price;
		if (cd >= 0.5m * diffBull && cd <= 3m * diffBull)
		return Sides.Buy;
		}
		}
		}

		return null;
	}

	private decimal AssessPatternQuality(Pivot x, Pivot a, Pivot b, Pivot c, Pivot d, Sides side)
	{
		var diff = side == Sides.Buy ? a.Price - x.Price : x.Price - a.Price;
		if (diff == 0m)
		return 0m;

		var score = 1m;
		var idealB = side == Sides.Buy ? a.Price - 0.786m * diff : a.Price + 0.786m * diff;
		var bDeviation = Math.Abs(b.Price - idealB) / diff;
		score -= bDeviation * 0.2m;

		var idealC = side == Sides.Buy
		? b.Price + 0.618m * (a.Price - b.Price)
		: b.Price - 0.618m * (b.Price - a.Price);
		var cDeviation = Math.Abs(c.Price - idealC) / diff;
		score -= cDeviation * 0.2m;

		var idealD = side == Sides.Buy
		? c.Price - 1.414m * (c.Price - b.Price)
		: c.Price + 1.414m * (b.Price - c.Price);
		var dDeviation = Math.Abs(d.Price - idealD) / diff;
		score -= dDeviation * 0.2m;

		var abDuration = (b.Time - a.Time).TotalSeconds;
		var cdDuration = (d.Time - c.Time).TotalSeconds;
		if (abDuration > 0 && cdDuration > 0)
		score -= (decimal)Math.Abs(1.0 - abDuration / cdDuration) * 0.1m;

		var xaDuration = (a.Time - x.Time).TotalSeconds;
		var bcDuration = (c.Time - b.Time).TotalSeconds;
		if (xaDuration > 0 && bcDuration > 0)
		score -= (decimal)Math.Abs(1.0 - xaDuration / bcDuration) * 0.1m;

		return Math.Max(0m, Math.Min(1m, score));
	}

	private bool RevalidateBeforeTrading(decimal currentPrice, decimal dPrice, decimal aPrice, decimal xPrice, Sides side)
	{
		var direction = side == Sides.Buy ? 1m : -1m;
		var diff = side == Sides.Buy ? aPrice - xPrice : xPrice - aPrice;
		if (diff <= 0m)
		return false;

		var priceMovement = (currentPrice - dPrice) * direction;
		if (priceMovement < 0m)
		return false;

		return Math.Abs(priceMovement) <= 0.3m * diff;
	}

	private void ExecutePattern(ICandleMessage candle, Sides side, Pivot a, Pivot c)
	{
		var entryPrice = candle.ClosePrice;
		var tp3 = c.Price;
		var diff = side == Sides.Buy ? tp3 - entryPrice : entryPrice - tp3;
		if (diff <= 0m)
		{
		LogInfo("Pattern skipped: invalid take-profit distance.");
		return;
		}

		var tp1 = side == Sides.Buy ? entryPrice + diff / 3m : entryPrice - diff / 3m;
		var tp2 = side == Sides.Buy ? entryPrice + diff * 2m / 3m : entryPrice - diff * 2m / 3m;
		var stop = side == Sides.Buy ? entryPrice - (tp2 - entryPrice) * 3m : entryPrice + (entryPrice - tp2) * 3m;

		var step = Security.PriceStep ?? 0.0001m;
		var minDistance = step;
		if (Math.Abs(entryPrice - stop) < minDistance || Math.Abs(tp1 - entryPrice) < minDistance || Math.Abs(tp2 - entryPrice) < minDistance || Math.Abs(tp3 - entryPrice) < minDistance)
		{
		LogInfo("Pattern skipped: protective distances below minimal step.");
		return;
		}

		var volume = CalculatePositionVolume(entryPrice, stop);
		if (volume <= 0m)
		{
		LogInfo("Pattern skipped: volume calculation returned zero.");
		return;
		}

		SplitVolumes(volume, out var lot1, out var lot2, out var lot3);
		var total = lot1 + lot2 + lot3;
		if (total <= 0m)
		{
		LogInfo("Pattern skipped: no tradable volume.");
		return;
		}

		var order = side == Sides.Buy ? BuyMarket(total) : SellMarket(total);
		if (order == null)
		{
		LogInfo("Failed to place entry order.");
		return;
		}

		_state.Side = side;
		_state.EntryPrice = entryPrice;
		_state.StopPrice = stop;
		_state.Lot1 = lot1;
		_state.Lot2 = lot2;
		_state.Lot3 = lot3;
		_state.RemainingVolume = total;
		_state.Tp1Filled = lot1 <= 0m;
		_state.Tp2Filled = lot2 <= 0m;
		_state.Tp3Filled = lot3 <= 0m;
		_state.Tp1Price = tp1;
		_state.Tp2Price = tp2;
		_state.Tp3Price = tp3;
		_state.BreakEvenApplied = false;
		_state.TrailingActivated = false;

		LogInfo($"{side} entry at {entryPrice:F5}, stop {stop:F5}, TP1 {tp1:F5}, TP2 {tp2:F5}, TP3 {tp3:F5}, volume {total:F2}.");
	}

	private decimal CalculatePositionVolume(decimal entryPrice, decimal stopPrice)
	{
		var minVolume = Security.MinVolume ?? 0.01m;
		var maxVolume = Security.MaxVolume ?? 0m;
		var step = Security.VolumeStep ?? 0.01m;

		decimal volume;
		if (UseFixedVolume)
		{
		volume = FixedVolume;
		}
		else
		{
		var portfolioValue = Portfolio?.CurrentValue ?? 0m;
		var riskAmount = portfolioValue * RiskPercent / 100m;
		var stepPrice = 1m;
		var priceStep = Security.PriceStep ?? 1m;
		var distance = Math.Abs(entryPrice - stopPrice);
		if (distance <= 0m || priceStep <= 0m || stepPrice <= 0m)
		return 0m;

		var riskPerUnit = distance / priceStep * stepPrice;
		if (riskPerUnit <= 0m)
		return 0m;

		volume = riskAmount / riskPerUnit;
		}

		if (step > 0m)
		volume = Math.Floor(volume / step) * step;

		if (maxVolume > 0m)
		volume = Math.Min(volume, maxVolume);

		return Math.Max(volume, minVolume);
	}

	private void SplitVolumes(decimal total, out decimal lot1, out decimal lot2, out decimal lot3)
	{
		var percents = Tp1Percent + Tp2Percent + Tp3Percent;
		if (percents <= 0m)
		{
		lot1 = total / 3m;
		lot2 = total / 3m;
		lot3 = total - lot1 - lot2;
		}
		else
		{
		lot1 = total * Tp1Percent / percents;
		lot2 = total * Tp2Percent / percents;
		lot3 = total - lot1 - lot2;
		}

		if (AdjustLotsForTakeProfits)
		{
		var sum = lot1 + lot2 + lot3;
		if (sum != 0m)
		{
		var scale = total / sum;
		lot1 *= scale;
		lot2 *= scale;
		lot3 = total - lot1 - lot2;
		}
		}

		var step = Security.VolumeStep ?? 0.01m;
		if (step > 0m)
		{
		lot1 = Math.Round(lot1 / step) * step;
		lot2 = Math.Round(lot2 / step) * step;
		lot3 = Math.Round(lot3 / step) * step;
		}

		var minVolume = Security.MinVolume ?? 0.01m;
		if (lot1 > 0m && lot1 < minVolume)
		lot1 = minVolume;
		if (lot2 > 0m && lot2 < minVolume)
		lot2 = minVolume;
		if (lot3 > 0m && lot3 < minVolume)
		lot3 = minVolume;

		var sumAfter = lot1 + lot2 + lot3;
		if (sumAfter > total && sumAfter > 0m)
		{
		var scale = total / sumAfter;
		lot1 *= scale;
		lot2 *= scale;
		lot3 = total - lot1 - lot2;
		}

		lot1 = Math.Max(0m, lot1);
		lot2 = Math.Max(0m, lot2);
		lot3 = Math.Max(0m, lot3);
	}

	private void UpdateRiskManagement(ICandleMessage candle)
	{
		if (_state.Side == null || _state.RemainingVolume <= 0m || _state.EntryPrice is not decimal entry)
		return;

		var side = _state.Side.Value;
		var direction = side == Sides.Buy ? 1m : -1m;
		var step = Security.PriceStep ?? 1m;

		if (_state.StopPrice is decimal stop)
		{
		var hit = side == Sides.Buy ? candle.LowPrice <= stop : candle.HighPrice >= stop;
		if (hit)
		{
		ExitAll();
		LogInfo($"Stop-loss hit at {stop:F5}.");
		return;
		}
		}

		if (!_state.Tp1Filled && _state.Lot1 > 0m)
		{
		var reached = side == Sides.Buy ? candle.HighPrice >= _state.Tp1Price : candle.LowPrice <= _state.Tp1Price;
		if (reached)
		ExitPartial(_state.Lot1, _state.Tp1Price, 1);
		}

		if (!_state.Tp2Filled && _state.Lot2 > 0m)
		{
		var reached = side == Sides.Buy ? candle.HighPrice >= _state.Tp2Price : candle.LowPrice <= _state.Tp2Price;
		if (reached)
		ExitPartial(_state.Lot2, _state.Tp2Price, 2);
		}

		if (!_state.Tp3Filled && _state.Lot3 > 0m)
		{
		var reached = side == Sides.Buy ? candle.HighPrice >= _state.Tp3Price : candle.LowPrice <= _state.Tp3Price;
		if (reached)
		ExitPartial(_state.Lot3, _state.Tp3Price, 3);
		}

		if (_state.RemainingVolume <= 0m)
		{
		_state.ResetPosition();
		return;
		}

		ApplyBreakEven(candle, entry, direction, step);
		ApplyTrailing(candle, entry, direction, step);
	}

	private void ExitPartial(decimal volume, decimal price, int tpIndex)
	{
		if (volume <= 0m)
		return;

		Order order = _state.Side == Sides.Buy ? SellMarket(volume) : BuyMarket(volume);
		if (order == null)
		{
		LogInfo($"Failed to exit partial position for TP{tpIndex}.");
		return;
		}

		_state.RemainingVolume = Math.Max(0m, _state.RemainingVolume - volume);

		switch (tpIndex)
		{
		case 1:
		_state.Tp1Filled = true;
		break;
		case 2:
		_state.Tp2Filled = true;
		break;
		case 3:
		_state.Tp3Filled = true;
		break;
		}

		LogInfo($"TP{tpIndex} executed at {price:F5}.");
	}

	private void ExitAll()
	{
		if (_state.Side == null || _state.RemainingVolume <= 0m)
		{
		_state.ResetPosition();
		return;
		}

		var volume = _state.RemainingVolume;
		Order order = _state.Side == Sides.Buy ? SellMarket(volume) : BuyMarket(volume);
		if (order == null)
		{
		LogInfo("Failed to close position at stop.");
		return;
		}

		_state.ResetPosition();
	}

	private void ApplyBreakEven(ICandleMessage candle, decimal entry, decimal direction, decimal step)
	{
		if (!UseBreakEven || _state.BreakEvenApplied || _state.StopPrice is not decimal currentStop)
		return;

		if (!IsGatePassed(BreakEvenAfterTp, _state.Tp1Filled, _state.Tp2Filled))
		return;

		if (BreakEvenTrigger <= 0m)
		return;

		var movement = (candle.ClosePrice - entry) * direction;
		if (movement < BreakEvenTrigger * step)
		return;

		var newStop = entry + direction * BreakEvenProfit * step;
		if (direction > 0m)
		{
		if (newStop <= currentStop)
		return;
		}
		else if (newStop >= currentStop)
		{
		return;
		}

		_state.StopPrice = newStop;
		_state.BreakEvenApplied = true;
		LogInfo($"Break-even adjusted to {newStop:F5}.");
	}

	private void ApplyTrailing(ICandleMessage candle, decimal entry, decimal direction, decimal step)
	{
		if (!UseTrailingStop || _state.StopPrice is not decimal currentStop)
		return;

		if (!IsGatePassed(TrailAfterTp, _state.Tp1Filled, _state.Tp2Filled))
		return;

		if (TrailStart <= 0m || TrailStep <= 0m)
		return;

		var movement = (candle.ClosePrice - entry) * direction;
		if (movement < TrailStart * step)
		return;

		var newStop = candle.ClosePrice - direction * TrailStep * step;
		if (direction > 0m)
		{
		if (newStop <= currentStop)
		return;
		}
		else if (newStop >= currentStop)
		{
		return;
		}

		_state.StopPrice = newStop;
		_state.TrailingActivated = true;
		LogInfo($"Trailing stop updated to {newStop:F5}.");
	}

	private static bool IsGatePassed(int gate, bool tp1Filled, bool tp2Filled)
	{
		var normalized = gate < 1 ? 1 : gate > 2 ? 2 : gate;
		return normalized switch
		{
		1 => tp1Filled,
		2 => tp2Filled,
		_ => false,
		};
	}
}