GitHub で見る

リッチ コホーネン マップ戦略

概要

リッチ コホーネン マップ ストラテジーは、MetaTrader 4 エキスパート アドバイザー「Rich.mq4」を変換したものです。オリジナルのシステムは、Tom DeMark ピボット計算から導出された特徴ベクトル上に自己組織化マップ (Kohonen ネットワーク) を構築し、次の足を買い、売り、またはホールドの機会として分類します。 StockSharp ポートは学習アプローチを維持しながら、高レベルの戦略 API と統合し、完了したローソク足と成行注文のみで動作します。

市場データ

  • 機器 – ホスト アプリケーション内のリンクされた Security を通じて設定されます。
  • ローソクのタイプ – パラメータ CandleType (デフォルト: 1 時間枠)。この戦略では、現在と以前の両方の特徴ベクトルを組み立てることができるように、信号を生成する前に少なくとも 7 つの完成したキャンドルが必要です。

取引ロジック

  1. 最新の 7 つの完成したキャンドルのローリング ウィンドウを維持します。
  2. 完成したすべてのキャンドルに 2 つの 7 要素ベクトルを構築します。
    • 現在のベクトルは、最新の始値と、前の 5 つのローソク足から計算された Tom DeMark ピボット投影を使用します。
    • 前のベクトルはウィンドウを 1 バー分シフトし、閉じたばかりのバーを表します。このベクトルはトレーニングに使用されます。
  3. 現在のベクトルを 3 つのコホーネン マップ (買い、売り、ホールド) と比較し、最もよく一致する各単位までのユークリッド距離を記録します。
  4. 距離が最も短いアクションを選択し、ターゲット位置を設定します。
    • 購入 → 計算された量に等しい長時間露光。
    • 売り→同じ規模のショートエクスポージャ。
    • ホールド→ポジションなし。 この戦略は、最終的なエクスポージャーが決定と一致するように、現在のポジションと目標ポジションの差について成行注文を送信します。
  5. 最新の 2 つのローソク足間のオープンからオープンへの動き (pips 単位) を計算し、マップをトレーニングします。
    • [MinPips, MaxPips] 内のポジティブな動き → 前のベクトルを購入マップに追加します。
    • [-MaxPips, -MinPips] 内のマイナスの動き → 前のベクトルを売りマップに追加します。
    • それ以外の場合 → ベクトルをホールド マップに保存します。
  6. ポジション サイズはポートフォリオ残高から動的に決定されます: floor(balance / 50) / 10。これによりゼロが生成された場合、代わりに Lots フォールバック パラメータが使用されます。

パラメーター

  • MinPips – 買いトレーニングの例としてオープンからオープンへのポジティブな動きを考慮するための下限(ピップ単位)。
  • MaxPips – 売買トレーニング サンプルの上限 (ピップ単位)。
  • TakeProfit、StopLoss – 文書化目的で MQL の専門家から保存されています。高レベルの実装では、ストップを付けるのではなく、成行注文を通じてポジションをクローズまたは反転します。
  • Lots – 残高ベースの計算式の結果がゼロの場合に適用されるフォールバックボリューム。
  • Slippage – 手動による注文調整用に予約されています(高レベルの API ヘルパーでは直接使用されません)。
  • MapPath – 実行間で 3 つの Kohonen マップを保持するために使用されるバイナリ ファイル パス。
  • EAName – 参照用に保存されるオプションのコメント。
  • CandleType – 特徴抽出に使用されるキャンドル サブスクリプション。

永続的なマップストレージ

この戦略は、トレーニングされたマップを MapPath で定義されたバイナリ ファイルに保存します (デフォルトは作業ディレクトリ内の rl.bin)。ファイルには、買い、売り、ホールドのマトリックスが順番に含まれています。起動時に行列がロードされ、ストラテジは空でない行をカウントして前の状態からトレーニングを再開します。欠落しているファイルは無視されるため、マップはゼロで埋められたメモリから開始されます。

元の MQL 専門家との違い

  • 注文は StockSharp ヘルパー (BuyMarket / SellMarket) を通じて発行され、各バーで強制的に完全クローズして再度開くのではなく、最終的に希望するエクスポージャーをターゲットにします。これにより、管理環境での重複トランザクションを削減しながら、効果的な動作が維持されます。
  • ストップロスとテイクプロフィットのレベルは文書化のパラメータとして残りますが、個別の注文としては登録されません。ポジションの終了は、分類子が反対側またはホールド アクションを選択したときに発生します。
  • ファイル処理には .NET I/O ヘルパーが使用されます。マップ形式は互換性を維持します (倍精度値は同じ順序で並べられます)。

使用上の注意

  • pip の差が正しく計算されるように、選択したセキュリティが有効な PriceStep を公開していることを確認してください。ステップが欠落しているかゼロの場合、戦略は単位ステップに戻ります。
  • コホーネン マップは大きくなる可能性があります (最大 10,000 の買い/売りエントリーと 25,000 のホールド エントリー)。十分な容量 (フルの場合は約 2.5 MB) を持つストレージ デバイス上のデフォルト パスを保持します。
  • アルゴリズムは継続的にトレーニングされるため、ライブ デプロイの前に履歴データに対して戦略を実行すると、マップに代表的なサンプルを追加するのに役立ちます。
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

using Ecng.Common;

using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Self-organizing-map style classifier trained on DeMark-derived candle feature vectors.
/// </summary>
public class RichKohonenMapStrategy : Strategy
{
	private const int _vectorSize = 7;
	private const int _buyCapacity = 10000;
	private const int _sellCapacity = 10000;
	private const int _holdCapacity = 25000;
	private const int _fileSize = (_buyCapacity + _sellCapacity + _holdCapacity) * _vectorSize * sizeof(double);

	private readonly StrategyParam<decimal> _minPips;
	private readonly StrategyParam<decimal> _maxPips;
	private readonly StrategyParam<decimal> _takeProfit;
	private readonly StrategyParam<decimal> _stopLoss;
	private readonly StrategyParam<decimal> _lots;
	private readonly StrategyParam<int> _slippage;
	private readonly StrategyParam<string> _mapPath;
	private readonly StrategyParam<string> _eaName;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<ICandleMessage> _candles = [];
	private readonly List<double[]> _buyMap = [];
	private readonly List<double[]> _sellMap = [];
	private readonly List<double[]> _holdMap = [];

	public decimal MinPips { get => _minPips.Value; set => _minPips.Value = value; }
	public decimal MaxPips { get => _maxPips.Value; set => _maxPips.Value = value; }
	public decimal TakeProfit { get => _takeProfit.Value; set => _takeProfit.Value = value; }
	public decimal StopLoss { get => _stopLoss.Value; set => _stopLoss.Value = value; }
	public decimal Lots { get => _lots.Value; set => _lots.Value = value; }
	public int Slippage { get => _slippage.Value; set => _slippage.Value = value; }
	public string MapPath { get => _mapPath.Value; set => _mapPath.Value = value; }
	public string EAName { get => _eaName.Value; set => _eaName.Value = value; }
	public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }

	public RichKohonenMapStrategy()
	{
		// Pips are price steps. For the sample BTCUSDT (step 0.01) at 1h the band is 100-1000 USDT, the middle of the hourly
		// open-to-open moves: the quietest quarter of the hours and the rare spikes train the hold map.
		_minPips = Param(nameof(MinPips), 10000m).SetNotNegative().SetDisplay("Min Pips", "Minimum move classified as directional.", "Learning");
		_maxPips = Param(nameof(MaxPips), 100000m).SetGreaterThanZero().SetDisplay("Max Pips", "Maximum move accepted as directional training sample.", "Learning");
		_takeProfit = Param(nameof(TakeProfit), 0m).SetNotNegative().SetDisplay("Take Profit", "Compatibility parameter from the original EA.", "Trading");
		_stopLoss = Param(nameof(StopLoss), 0m).SetNotNegative().SetDisplay("Stop Loss", "Compatibility parameter from the original EA.", "Trading");
		_lots = Param(nameof(Lots), 0.1m).SetGreaterThanZero().SetDisplay("Lots", "Fallback volume when balance-based sizing returns zero.", "Trading");
		_slippage = Param(nameof(Slippage), 3).SetNotNegative().SetDisplay("Slippage", "Compatibility slippage parameter.", "Trading");
		_mapPath = Param(nameof(MapPath), "rl.bin").SetDisplay("Map Path", "Binary persistence path for Kohonen maps.", "Learning");
		_eaName = Param(nameof(EAName), "Rich").SetDisplay("EA Name", "Informational EA name.", "General");
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame()).SetDisplay("Candle Type", "Feature extraction timeframe.", "General");
	}

	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
		=> [(Security, CandleType)];

	protected override void OnReseted()
	{
		base.OnReseted();
		_candles.Clear();
		_buyMap.Clear();
		_sellMap.Clear();
		_holdMap.Clear();
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);
		LoadMaps();

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

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}

	protected override void OnStopped()
	{
		SaveMaps();

		base.OnStopped();
	}

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

		_candles.Add(candle);
		if (_candles.Count > 8)
			_candles.RemoveAt(0);

		if (_candles.Count < 7)
			return;

		var current = BuildVector(0);
		var previous = BuildVector(1);
		var decision = Classify(current);
		SetTarget(decision);

		var latest = _candles[^1];
		var prior = _candles[^2];
		var step = Security?.PriceStep ?? 1m;
		if (step <= 0m)
			step = 1m;

		var movePips = (latest.OpenPrice - prior.OpenPrice) / step;
		if (movePips >= MinPips && movePips <= MaxPips)
			AddPrototype(_buyMap, previous, _buyCapacity);
		else if (movePips <= -MinPips && movePips >= -MaxPips)
			AddPrototype(_sellMap, previous, _sellCapacity);
		else
			AddPrototype(_holdMap, previous, _holdCapacity);
	}

	private int Classify(double[] vector)
	{
		var buyDistance = BestDistance(_buyMap, vector);
		var sellDistance = BestDistance(_sellMap, vector);
		var holdDistance = BestDistance(_holdMap, vector);

		if (double.IsPositiveInfinity(buyDistance) &&
			double.IsPositiveInfinity(sellDistance) &&
			double.IsPositiveInfinity(holdDistance))
			return 0;

		if (buyDistance <= sellDistance && buyDistance <= holdDistance)
			return 1;
		if (sellDistance <= buyDistance && sellDistance <= holdDistance)
			return -1;
		return 0;
	}

	private void SetTarget(int decision)
	{
		var volume = CalculateVolume();
		var target = decision * volume;
		var difference = target - Position;

		if (difference > 0m)
			BuyMarket(difference);
		else if (difference < 0m)
			SellMarket(Math.Abs(difference));
	}

	private decimal CalculateVolume()
	{
		var balance = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;
		var volume = balance > 0m ? Math.Floor(balance / 50m) / 10m : 0m;
		if (volume <= 0m)
			volume = Lots;

		if (Security?.MaxVolume is decimal max && max > 0m)
			volume = Math.Min(volume, max);
		if (Security?.MinVolume is decimal min && min > 0m)
			volume = Math.Max(volume, min);
		if (Security?.VolumeStep is decimal step && step > 0m)
			volume = Math.Floor(volume / step) * step;

		return volume > 0m ? volume : Lots;
	}

	private double[] BuildVector(int offset)
	{
		var lastIndex = _candles.Count - 1 - offset;
		var firstIndex = lastIndex - 5;
		var current = _candles[lastIndex];

		double pivotSum = 0, r1Sum = 0, s1Sum = 0;
		var high = decimal.MinValue;
		var low = decimal.MaxValue;

		for (var i = firstIndex; i < lastIndex; i++)
		{
			var c = _candles[i];
			var (pivot, r1, s1) = DeMark(c.OpenPrice, c.HighPrice, c.LowPrice, c.ClosePrice);

			pivotSum += (double)pivot;
			r1Sum += (double)r1;
			s1Sum += (double)s1;
			high = Math.Max(high, c.HighPrice);
			low = Math.Min(low, c.LowPrice);
		}

		// The five candles taken as one bar: first open, extreme high and low, last close.
		var (barPivot, barR1, barS1) = DeMark(_candles[firstIndex].OpenPrice, high, low, _candles[lastIndex - 1].ClosePrice);

		return
		[
			(double)current.OpenPrice,
			pivotSum / 5d,
			r1Sum / 5d,
			s1Sum / 5d,
			(double)barPivot,
			(double)barR1,
			(double)barS1,
		];
	}

	private static (decimal pivot, decimal r1, decimal s1) DeMark(decimal open, decimal high, decimal low, decimal close)
	{
		var x = close < open
			? high + 2m * low + close
			: close > open
				? 2m * high + low + close
				: high + low + 2m * close;

		return (x / 4m, x / 2m - low, x / 2m - high);
	}

	private static double BestDistance(List<double[]> map, double[] vector)
	{
		var best = double.PositiveInfinity;
		foreach (var prototype in map)
		{
			var sum = 0d;
			for (var i = 0; i < _vectorSize; i++)
			{
				var d = vector[i] - prototype[i];
				sum += d * d;
			}
			best = Math.Min(best, Math.Sqrt(sum));
		}
		return best;
	}

	private static void AddPrototype(List<double[]> map, double[] vector, int capacity)
	{
		if (map.Count < capacity)
			map.Add((double[])vector.Clone());
	}

	private void LoadMaps()
	{
		if (MapPath.IsEmpty() || !File.Exists(MapPath))
			return;

		try
		{
			using var reader = new BinaryReader(File.OpenRead(MapPath));

			if (reader.BaseStream.Length < _fileSize)
				throw new InvalidDataException($"the file holds {reader.BaseStream.Length} bytes, the three matrices need {_fileSize}");

			ReadMap(reader, _buyMap, _buyCapacity);
			ReadMap(reader, _sellMap, _sellCapacity);
			ReadMap(reader, _holdMap, _holdCapacity);
		}
		catch (Exception ex)
		{
			_buyMap.Clear();
			_sellMap.Clear();
			_holdMap.Clear();

			LogWarning($"Unable to load Kohonen map '{MapPath}': {ex.Message}");
		}
	}

	private void SaveMaps()
	{
		if (MapPath.IsEmpty())
			return;

		try
		{
			using var writer = new BinaryWriter(File.Create(MapPath));
			WriteMap(writer, _buyMap, _buyCapacity);
			WriteMap(writer, _sellMap, _sellCapacity);
			WriteMap(writer, _holdMap, _holdCapacity);
		}
		catch (Exception ex)
		{
			LogWarning($"Unable to save Kohonen map '{MapPath}': {ex.Message}");
		}
	}

	/// <summary>
	/// Reads a zero-padded matrix of <paramref name="capacity"/> rows and keeps its non-empty rows.
	/// </summary>
	private static void ReadMap(BinaryReader reader, List<double[]> map, int capacity)
	{
		map.Clear();

		for (var i = 0; i < capacity; i++)
		{
			var vector = new double[_vectorSize];

			for (var j = 0; j < _vectorSize; j++)
				vector[j] = reader.ReadDouble();

			if (vector.Any(value => value != 0d))
				map.Add(vector);
		}
	}

	/// <summary>
	/// Writes the map as a matrix of <paramref name="capacity"/> rows, padding the unused rows with zeros.
	/// </summary>
	private static void WriteMap(BinaryWriter writer, List<double[]> map, int capacity)
	{
		for (var i = 0; i < capacity; i++)
		{
			for (var j = 0; j < _vectorSize; j++)
				writer.Write(i < map.Count ? map[i][j] : 0d);
		}
	}
}