GitHub で見る

Exp Hans Indicator クラウドシステム Tm Plus 戦略

概要

Exp Hans Indicator クラウドシステム Tm Plus は、オリジナルの MQL5 エキスパートアドバイザーの動作を再現するセッションベースのブレイクアウト戦略です。アルゴリズムは設定可能な時間軸で Hans インジケーターが生成するカラー状態を監視します。強気 (色 0/1) または弱気 (色 3/4) のブレイクアウトが終わり、価格がチャネル内に戻った後に新しいポジションを開きます。実装はすべての取引決定をクローズしたローソク足で保持し、ピップベースのリスク制限を使用し、MQL バージョンの時間ベースの清算ルールを反映します。

戦略は GetWorkingSecurities() から取得した単一のインストゥルメント/ローソク足フィードペアで動作します。すべての注文サイズは戦略の Volume プロパティとパラメーターが公開する資金管理の割合から導出されます。

インジケーターのロジック

  1. ローソク足のタイムスタンプはブローカー時間 (LocalTimeZone) から宛先タイムゾーン (DestinationTimeZone) に変換されます。デフォルトでスクリプトは GMT+4 で動作し、これは参照実装と一致します。
  2. 毎取引日に2つのロンドンセッション範囲が収集されます:
    • 範囲 1: 宛先時間 04:00–08:00。この期間の高値/安値が最初のブレイクアウトチャネルになります。
    • 範囲 2: 宛先時間 08:00–12:00。完了すると、その日の残りの時間で最初の範囲に取って代わります。
  3. 各範囲は両側で PipsForEntry ピップ分拡張されます。1ピップはインストゥルメントの PriceStep に等しく、セキュリティが小数点以下 3 桁または 5 桁の場合は 10 倍されます (MetaTrader スタイルの小数点ピップ)。
  4. ローソク足の色はインジケーターと全く同じように導出されます:
    • 上限バンドより上でクローズ → 色 0 (強気クローズ) または 1 (弱気クローズ)。
    • 下限バンドより下でクローズ → 色 4 (弱気クローズ) または 3 (強気クローズ)。
    • チャネル内でクローズ → ニュートラル色 2

取引ルール

  • エントリー: 前のクローズしたローソク足が強気の色 (0/1) を持ち、最新のものが強気でない場合、戦略はロングポジションを開きます (有効な場合)。対称的に、前の弱気の色 (3/4) の後にニュートラル/反対の色が続くとショートエントリーがトリガーされます。
  • 決済:
    • 前の色が現在のポジションに反して変わった場合の方向的な決済 (ショートに対して 0/1、ロングに対して 3/4)。
    • 保有期間が HoldingMinutes を超えたときのオプションの時間ベースの決済。
    • ポイントで表されるオプションのストップロス/テイクプロフィットレベル (StopLossPointsTakeProfitPoints)。セキュリティが正の PriceStep を公開しない場合、レベルはスキップされます。
  • 決済は新しいエントリーの前に処理されるため、反転注文が送信される前にポジションがフラット化されます。

パラメーター

パラメーター 説明 デフォルト
MoneyManagement トレードごとに使用する戦略 Volume の割合。値 ≤ 0 はフルボリュームに戻ります。 0.1
MoneyMode オリジナルの資金管理モードのプレースホルダー。現在は Lot のみが適用されます。 Lot
StopLossPoints / TakeProfitPoints ポイント (ピップ) で表される保護ストップと利益目標。無効にするには 0 に設定。 1000 / 2000
DeviationPoints ポイント単位の最大許容実行偏差。互換性のために存在; StockSharp の注文レイヤーでは適用されません。 10
AllowBuyEntries / AllowSellEntries ロング/ショートエントリーを有効化。 true
AllowBuyExits / AllowSellExits ロング/ショートポジションの自動決済を有効化。 true
UseTimeExit 時間ベースの清算フィルターをオン/オフ。 true
HoldingMinutes 任意のポジションの最大保有時間 (分)。 1500
PipsForEntry ブレイクアウト範囲の上下に追加するピップオフセット。 100
SignalBar シグナルに使用するクローズしたローソク足のオフセット。MT5 ロジックと整合させるために値 ≥ 1 を使用。 1
LocalTimeZone ブローカー/サーバーのタイムゾーン (UTC からの時間)。 0
DestinationTimeZone セッション境界に使用するターゲットタイムゾーン。 4
CandleType Hans 計算に使用する時間軸。 30m ローソク足

資金管理と実行

  • 注文サイズ = Volume * MoneyManagement、インストゥルメントの VolumeStep に正規化。計算値が非正の場合、ロジックは1つのボリュームステップにデフォルト設定されます。
  • 反転シグナルが現れると、戦略は新しいボリュームに加えて任意の反対側の開いている数量に等しい単一の成行注文を送信します。これは MQL ヘルパーの BuyPositionOpen/SellPositionOpen の動作を再現します。
  • ストップロスとテイクプロフィットのレベルは各エントリーで再計算され、ポジションがクローズまたは反転されると削除されます。

使用ガイドライン

  1. 有効な PriceStepDecimals、および VolumeStep メタデータを公開するセキュリティに戦略を添付します。
  2. 戦略を開始する前に戦略の希望する Volume を設定します。資金管理の割合がその上に適用されます。
  3. MetaTrader で使用されているものと同じローソク足タイプを選択します (デフォルトは M30)。すべての計算は完了したローソク足に基づいています。
  4. マーケットデータソースが Hans インジケーターが使用するデフォルトの GMT+4 宛先時間と異なる場合はタイムゾーンを調整します。
  5. ピップサイズの欠落に関するメッセージのログを監視します; PriceStep が利用可能でない場合、リスクレベルはスキップされます。

実装上の注意

  • カラー検出は高レベルの SubscribeCandles API を通じて完了したローソク足でのみ実行され、手動のインジケーターバッファを回避します。
  • ブレイクアウトレベルはローソク足ごとに1回再計算されてメモリにキャッシュされます; 履歴コレクションは作成されません。
  • DeviationPoints は設定の完全性のために保持されますが、StockSharp での単純な成行注文では適用できません。
  • 戦略は OnReseted() で内部状態をリセットし、古いセッションデータなしで繰り返しバックテストをサポートします。

制限事項

  • 現在の実装は SignalBar ≥ 1 のみをサポートし、新しいバーイベントでの元の EA の動作と一致します。0 を使用するにはティックレベルのアクセスが必要で、高レベルポートには存在しません。
  • Lot 以外の資金管理モードは実装されていません。ワークフローがバランスベースのサイジングに依存する場合は GetOrderVolume() を拡張してください。
  • 有効な PriceStep 値がない場合、ピップベースの距離 (ストップ、テイクプロフィット、Hans オフセット) は計算できず、無視されます。
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>
/// Port of the Exp_Hans_Indicator_Cloud_System_Tm_Plus MQL5 expert advisor.
/// Replicates the Hans indicator breakout logic, including time-based exits and pip-based risk limits.
/// </summary>
public class ExpHansIndicatorCloudSystemTmPlusStrategy : Strategy
{
	private readonly StrategyParam<int> _maxHistory;

	private static readonly TimeSpan Session1Start = TimeSpan.FromHours(4);
	private static readonly TimeSpan Session1End = TimeSpan.FromHours(8);
	private static readonly TimeSpan Session2End = TimeSpan.FromHours(12);

	private readonly StrategyParam<decimal> _moneyManagement;
	private readonly StrategyParam<MoneyManagementModes> _moneyMode;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<int> _deviationPoints;
	private readonly StrategyParam<bool> _allowBuyEntries;
	private readonly StrategyParam<bool> _allowSellEntries;
	private readonly StrategyParam<bool> _allowBuyExits;
	private readonly StrategyParam<bool> _allowSellExits;
	private readonly StrategyParam<bool> _useTimeExit;
	private readonly StrategyParam<int> _holdingMinutes;
	private readonly StrategyParam<int> _pipsForEntry;
	private readonly StrategyParam<int> _signalBar;
	private readonly StrategyParam<int> _localTimeZone;
	private readonly StrategyParam<int> _destinationTimeZone;
	private readonly StrategyParam<int> _entryCooldownBars;
	private readonly StrategyParam<DataType> _candleType;

	private DailySessionState _dayState;
	private readonly List<int> _colorHistory = new();
	private decimal? _stopPrice;
	private decimal? _takePrice;
	private DateTimeOffset? _entryTime;
	private decimal _prevClosePrice;
	private int _cooldownRemaining;
	private bool _hasPrevClose;

	/// <summary>
	/// Enumeration matching the money management modes of the original expert.
	/// Currently only the Lot mode is applied; other options are reserved for future extensions.
	/// </summary>
	public enum MoneyManagementModes
	{
		FreeMargin,
		Balance,
		LossFreeMargin,
		LossBalance,
		Lot,
	}

	/// <summary>
	/// Portion of the base strategy volume used for each order.
	/// </summary>
	public decimal MoneyManagement
	{
		get => _moneyManagement.Value;
		set => _moneyManagement.Value = value;
	}

	/// <summary>
	/// Selected money management interpretation.
	/// </summary>
	public MoneyManagementModes MoneyMode
	{
		get => _moneyMode.Value;
		set => _moneyMode.Value = value;
	}

	/// <summary>
	/// Stop loss distance expressed in instrument points.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Take profit distance expressed in instrument points.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Allowed execution deviation in points (kept for compatibility).
	/// </summary>
	public int DeviationPoints
	{
		get => _deviationPoints.Value;
		set => _deviationPoints.Value = value;
	}

	/// <summary>
	/// Enables long entries when the bullish breakout sequence completes.
	/// </summary>
	public bool AllowBuyEntries
	{
		get => _allowBuyEntries.Value;
		set => _allowBuyEntries.Value = value;
	}

	/// <summary>
	/// Enables short entries when the bearish breakout sequence completes.
	/// </summary>
	public bool AllowSellEntries
	{
		get => _allowSellEntries.Value;
		set => _allowSellEntries.Value = value;
	}

	/// <summary>
	/// Allows closing long positions on bearish Hans colors.
	/// </summary>
	public bool AllowBuyExits
	{
		get => _allowBuyExits.Value;
		set => _allowBuyExits.Value = value;
	}

	/// <summary>
	/// Allows closing short positions on bullish Hans colors.
	/// </summary>
	public bool AllowSellExits
	{
		get => _allowSellExits.Value;
		set => _allowSellExits.Value = value;
	}

	/// <summary>
	/// Enables the time-based exit filter.
	/// </summary>
	public bool UseTimeExit
	{
		get => _useTimeExit.Value;
		set => _useTimeExit.Value = value;
	}

	/// <summary>
	/// Maximum holding time in minutes before the position is liquidated.
	/// </summary>
	public int HoldingMinutes
	{
		get => _holdingMinutes.Value;
		set => _holdingMinutes.Value = value;
	}

	/// <summary>
	/// Number of pips added to the breakout range.
	/// </summary>
	public int PipsForEntry
	{
		get => _pipsForEntry.Value;
		set => _pipsForEntry.Value = value;
	}

	/// <summary>
	/// Number of closed candles used as signal offset.
	/// </summary>
	public int SignalBar
	{
		get => _signalBar.Value;
		set => _signalBar.Value = value;
	}

	/// <summary>
	/// Broker/server time zone in hours.
	/// </summary>
	public int LocalTimeZone
	{
		get => _localTimeZone.Value;
		set => _localTimeZone.Value = value;
	}

	/// <summary>
	/// Destination time zone defining the Hans breakout sessions.
	/// </summary>
	public int DestinationTimeZone
	{
		get => _destinationTimeZone.Value;
		set => _destinationTimeZone.Value = value;
	}

	/// <summary>
	/// Bars to wait after each entry before accepting a new one.
	/// </summary>
	public int EntryCooldownBars
	{
		get => _entryCooldownBars.Value;
		set => _entryCooldownBars.Value = value;
	}

	/// <summary>
	/// Candle series used for indicator calculations.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Maximum number of Hans color samples preserved for decision making.
	/// </summary>
	public int MaxHistory
	{
		get => _maxHistory.Value;
		set => _maxHistory.Value = value;
	}

	/// <summary>
	/// Initialize the strategy parameters with defaults matching the MQL5 inputs.
	/// </summary>
	public ExpHansIndicatorCloudSystemTmPlusStrategy()
	{
		_maxHistory = Param(nameof(MaxHistory), 1024)
			.SetGreaterThanZero()
			.SetDisplay("Max History", "Maximum number of Hans color entries stored", "Indicator");

		_moneyManagement = Param(nameof(MoneyManagement), 0.1m)
		.SetDisplay("Money Management", "Portion of the base volume traded per entry", "Risk");

		_moneyMode = Param(nameof(MoneyMode), MoneyManagementModes.Lot)
		.SetDisplay("Money Mode", "Interpretation of the money management value", "Risk");

		_stopLossPoints = Param(nameof(StopLossPoints), 1000)
		.SetDisplay("Stop Loss (points)", "Distance to the protective stop in points", "Risk")
		.SetNotNegative();

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 2000)
		.SetDisplay("Take Profit (points)", "Distance to the profit target in points", "Risk")
		.SetNotNegative();

		_deviationPoints = Param(nameof(DeviationPoints), 10)
		.SetDisplay("Execution Deviation", "Maximum acceptable slippage in points", "Orders")
		.SetNotNegative();

		_allowBuyEntries = Param(nameof(AllowBuyEntries), true)
		.SetDisplay("Enable Long Entries", "Allow opening long positions", "Signals");

		_allowSellEntries = Param(nameof(AllowSellEntries), true)
		.SetDisplay("Enable Short Entries", "Allow opening short positions", "Signals");

		_allowBuyExits = Param(nameof(AllowBuyExits), true)
		.SetDisplay("Enable Long Exits", "Allow automated long exits", "Signals");

		_allowSellExits = Param(nameof(AllowSellExits), true)
		.SetDisplay("Enable Short Exits", "Allow automated short exits", "Signals");

		_useTimeExit = Param(nameof(UseTimeExit), true)
		.SetDisplay("Use Time Exit", "Close positions after the holding period", "Risk");

		_holdingMinutes = Param(nameof(HoldingMinutes), 1500)
		.SetDisplay("Holding Minutes", "Maximum position lifetime in minutes", "Risk")
		.SetNotNegative();

		_pipsForEntry = Param(nameof(PipsForEntry), 5)
		.SetDisplay("Pips For Entry", "Offset added above/below the breakout range", "Indicator")
		.SetNotNegative();

		_signalBar = Param(nameof(SignalBar), 1)
		.SetDisplay("Signal Bar", "Closed candle offset used for signals", "Indicator")
		.SetNotNegative();

		_localTimeZone = Param(nameof(LocalTimeZone), 0)
		.SetDisplay("Local Time Zone", "Broker/server time zone", "Indicator");

		_destinationTimeZone = Param(nameof(DestinationTimeZone), 0)
		.SetDisplay("Destination Time Zone", "Target time zone for sessions", "Indicator");

		_entryCooldownBars = Param(nameof(EntryCooldownBars), 10)
			.SetDisplay("Entry Cooldown", "Bars to wait after an entry signal", "Risk")
			.SetGreaterThanZero();

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(30).TimeFrame())
		.SetDisplay("Candle Type", "Time frame used for Hans calculations", "Data");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();

		_colorHistory.Clear();
		_dayState = null;
		_prevClosePrice = 0m;
		_cooldownRemaining = 0;
		_hasPrevClose = false;
		ResetPositionState();
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		var subscription = SubscribeCandles(CandleType);
		subscription.Bind(ProcessCandle).Start();
	}

	private void ProcessCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
		return;

		if (Position == 0 && (_entryTime.HasValue || _stopPrice.HasValue || _takePrice.HasValue))
		ResetPositionState();

		if (_cooldownRemaining > 0)
			_cooldownRemaining--;

		UpdateDailyState(candle);

		var color = CalculateColor(candle);
		_colorHistory.Add(color);
		TrimHistory();

		var offset = Math.Max(1, SignalBar);
		if (_colorHistory.Count <= offset)
		return;

		var currentIndex = _colorHistory.Count - offset;
		if (currentIndex >= _colorHistory.Count)
		return;

		var currentColor = _colorHistory[currentIndex];
		var hasBands = TryGetActiveBands(out var upper, out var lower);
		var buyEntrySignal = false;
		var sellEntrySignal = false;

		if (hasBands && _hasPrevClose)
		{
			buyEntrySignal = AllowBuyEntries && _prevClosePrice <= upper && candle.ClosePrice > upper;
			sellEntrySignal = AllowSellEntries && _prevClosePrice >= lower && candle.ClosePrice < lower;
		}

		var buyExitSignal = AllowBuyExits && (IsLowerBreakout(currentColor) || (hasBands && candle.ClosePrice < lower));
		var sellExitSignal = AllowSellExits && (IsUpperBreakout(currentColor) || (hasBands && candle.ClosePrice > upper));

		if (Position > 0)
		{
			var exitByTime = UseTimeExit && HoldingMinutes > 0 && _entryTime.HasValue && candle.CloseTime - _entryTime.Value >= TimeSpan.FromMinutes(HoldingMinutes);
			var exitByStop = _stopPrice.HasValue && candle.LowPrice <= _stopPrice.Value;
			var exitByTarget = _takePrice.HasValue && candle.HighPrice >= _takePrice.Value;
			if (exitByTime || buyExitSignal || exitByStop || exitByTarget)
			{
				CloseLong();
			}
		}
		else if (Position < 0)
		{
			var exitByTime = UseTimeExit && HoldingMinutes > 0 && _entryTime.HasValue && candle.CloseTime - _entryTime.Value >= TimeSpan.FromMinutes(HoldingMinutes);
			var exitByStop = _stopPrice.HasValue && candle.HighPrice >= _stopPrice.Value;
			var exitByTarget = _takePrice.HasValue && candle.LowPrice <= _takePrice.Value;
			if (exitByTime || sellExitSignal || exitByStop || exitByTarget)
			{
				CloseShort();
			}
		}

		_prevClosePrice = candle.ClosePrice;
		_hasPrevClose = true;

		if (_cooldownRemaining > 0)
			return;

		if (_dayState != null && _dayState.EntryTaken)
			return;

		if (buyEntrySignal && Position <= 0)
		{
			EnterLong(candle);
		}
		else if (sellEntrySignal && Position >= 0)
		{
			EnterShort(candle);
		}
	}

	private void EnterLong(ICandleMessage candle)
	{
		var volume = GetOrderVolume();
		if (volume <= 0)
		return;

		var existingShort = Position < 0 ? Math.Abs(Position) : 0m;
		var totalVolume = volume + existingShort;
		if (totalVolume <= 0)
		return;

		BuyMarket(totalVolume);
		_cooldownRemaining = EntryCooldownBars;
		_dayState!.EntryTaken = true;

		_entryTime = candle.CloseTime != default ? candle.CloseTime : candle.OpenTime;

		var pipSize = GetPipSize();
		if (pipSize <= 0)
		{
			_stopPrice = null;
			_takePrice = null;
			return;
		}

		_stopPrice = StopLossPoints > 0 ? candle.ClosePrice - pipSize * StopLossPoints : null;
		_takePrice = TakeProfitPoints > 0 ? candle.ClosePrice + pipSize * TakeProfitPoints : null;
	}

	private void EnterShort(ICandleMessage candle)
	{
		var volume = GetOrderVolume();
		if (volume <= 0)
		return;

		var existingLong = Position > 0 ? Math.Abs(Position) : 0m;
		var totalVolume = volume + existingLong;
		if (totalVolume <= 0)
		return;

		SellMarket(totalVolume);
		_cooldownRemaining = EntryCooldownBars;
		_dayState!.EntryTaken = true;

		_entryTime = candle.CloseTime != default ? candle.CloseTime : candle.OpenTime;

		var pipSize = GetPipSize();
		if (pipSize <= 0)
		{
			_stopPrice = null;
			_takePrice = null;
			return;
		}

		_stopPrice = StopLossPoints > 0 ? candle.ClosePrice + pipSize * StopLossPoints : null;
		_takePrice = TakeProfitPoints > 0 ? candle.ClosePrice - pipSize * TakeProfitPoints : null;
	}

	private void CloseLong()
	{
		var volume = Math.Abs(Position);
		if (volume <= 0)
		return;

		SellMarket(volume);
		ResetPositionState();
	}

	private void CloseShort()
	{
		var volume = Math.Abs(Position);
		if (volume <= 0)
		return;

		BuyMarket(volume);
		ResetPositionState();
	}

	private void UpdateDailyState(ICandleMessage candle)
	{
		var destOpen = ToDestinationTime(candle.OpenTime);
		var date = destOpen.Date;

		if (_dayState == null || _dayState.Date != date)
		{
			_dayState = new DailySessionState { Date = date };
		}

		var state = _dayState;
		var timeOfDay = destOpen.TimeOfDay;

		if (timeOfDay >= Session1Start && timeOfDay < Session1End)
		{
			UpdateSessionRange(state, candle.HighPrice, candle.LowPrice, true);
			state.Session1Completed = false;
		}
		else if (timeOfDay >= Session1End && timeOfDay < Session2End)
		{
			if (!state.Session1Completed && state.Session1High.HasValue && state.Session1Low.HasValue)
			state.Session1Completed = true;

			UpdateSessionRange(state, candle.HighPrice, candle.LowPrice, false);
			state.Session2Completed = false;
		}
		else
		{
			if (!state.Session1Completed && state.Session1High.HasValue && state.Session1Low.HasValue)
			state.Session1Completed = true;

			if (!state.Session2Completed && state.Session2High.HasValue && state.Session2Low.HasValue)
			state.Session2Completed = true;
		}
	}

	private int CalculateColor(ICandleMessage candle)
	{
		if (!TryGetActiveBands(out var upper, out var lower))
		return 2;

		if (candle.ClosePrice > upper)
		return candle.ClosePrice >= candle.OpenPrice ? 0 : 1;

		if (candle.ClosePrice < lower)
		return candle.ClosePrice <= candle.OpenPrice ? 4 : 3;

		return 2;
	}

	private bool TryGetActiveBands(out decimal upper, out decimal lower)
	{
		upper = 0m;
		lower = 0m;

		var pipSize = GetPipSize();
		if (pipSize <= 0)
		return false;

		if (_dayState == null)
		return false;

		if (_dayState.Session2Completed && _dayState.Session2High.HasValue && _dayState.Session2Low.HasValue)
		{
			upper = _dayState.Session2High.Value + pipSize * PipsForEntry;
			lower = _dayState.Session2Low.Value - pipSize * PipsForEntry;
			return true;
		}

		if (_dayState.Session1Completed && _dayState.Session1High.HasValue && _dayState.Session1Low.HasValue)
		{
			upper = _dayState.Session1High.Value + pipSize * PipsForEntry;
			lower = _dayState.Session1Low.Value - pipSize * PipsForEntry;
			return true;
		}

		return false;
	}

	private void UpdateSessionRange(DailySessionState state, decimal high, decimal low, bool isFirstSession)
	{
		if (isFirstSession)
		{
			state.Session1High = state.Session1High.HasValue ? Math.Max(state.Session1High.Value, high) : high;
			state.Session1Low = state.Session1Low.HasValue ? Math.Min(state.Session1Low.Value, low) : low;
		}
		else
		{
			state.Session2High = state.Session2High.HasValue ? Math.Max(state.Session2High.Value, high) : high;
			state.Session2Low = state.Session2Low.HasValue ? Math.Min(state.Session2Low.Value, low) : low;
		}
	}

	private decimal GetOrderVolume()
	{
		var step = Security?.VolumeStep ?? 1m;
		if (step <= 0)
		step = 1m;

		var baseVolume = Volume * MoneyManagement;
		if (baseVolume <= 0)
		baseVolume = Volume;

		var normalized = Math.Round(baseVolume / step) * step;
		if (normalized <= 0)
		normalized = step;

		return normalized;
	}

	private decimal GetPipSize()
	{
		if (Security?.PriceStep is decimal step && step > 0m)
		{
			var decimals = Security.Decimals;
			if (decimals == 3 || decimals == 5)
			return step * 10m;

			return step;
		}

		return 0.01m;
	}

	private void ResetPositionState()
	{
		_entryTime = null;
		_stopPrice = null;
		_takePrice = null;
	}

	private void TrimHistory()
	{
		if (_colorHistory.Count <= MaxHistory)
		return;

		var excess = _colorHistory.Count - MaxHistory;
		_colorHistory.RemoveRange(0, excess);
	}

	private DateTimeOffset ToDestinationTime(DateTimeOffset time)
	{
		var shift = TimeSpan.FromHours(LocalTimeZone - DestinationTimeZone);
		return time - shift;
	}

	private static bool IsUpperBreakout(int? color) => color is 0 or 1;

	private static bool IsLowerBreakout(int? color) => color is 3 or 4;

	private sealed class DailySessionState
	{
		public DateTime Date { get; set; }
		public decimal? Session1High { get; set; }
		public decimal? Session1Low { get; set; }
		public decimal? Session2High { get; set; }
		public decimal? Session2Low { get; set; }
		public bool Session1Completed { get; set; }
		public bool Session2Completed { get; set; }
		public bool EntryTaken { get; set; }
	}
}