Rich Kohonen Map 策略
概述
Rich Kohonen Map 策略来源于 MetaTrader 4 专家顾问“Rich.mq4”的移植版。原始系统通过 Tom DeMark 枢轴所构建的特征向量训练自组织映射(Kohonen 网络),并将下一根 K 线分类为买入、卖出或观望。StockSharp 版本保持了该学习方法,同时与高层策略 API 集成,仅依赖已经完成的 K 线与市价单进行交易。
市场数据
- 交易品种:由外部应用程序中绑定的
Security决定。 - K 线类型:参数
CandleType(默认:1 小时时间框架)。策略在开始发出信号前需要至少七根已完成的 K 线,以便构建当前和上一根的特征向量。
交易逻辑
- 维护最近七根完成 K 线的滚动窗口。
- 在每根完成的 K 线上构建两个包含七个元素的向量:
- 当前向量:使用最新开盘价以及基于前五根 K 线计算的 Tom DeMark 枢轴预测值。
- 上一向量:整体向后移动一根,代表刚刚收盘的那根 K 线,该向量用于训练。
- 将当前向量与三个 Kohonen 映射(买入、卖出、观望)进行比较,记录与各自最佳匹配单元的欧氏距离。
- 选择距离最小的动作并设定目标持仓:
- 买入 → 建立等于计算手数的多头仓位。
- 卖出 → 建立相同手数的空头仓位。
- 观望 → 保持空仓。 策略会根据当前持仓与目标持仓的差值发送市价单,使最终仓位与决策保持一致。
- 计算最近两根 K 线开盘价之间的点差(以点数计),并据此训练映射:
- 正向点差落在
[MinPips, MaxPips]内 → 将上一向量加入买入映射。 - 负向点差落在
[-MaxPips, -MinPips]内 → 将上一向量加入卖出映射。 - 其它情况 → 将上一向量加入观望映射。
- 正向点差落在
- 仓位规模由账户余额动态决定:
floor(balance / 50) / 10。若结果为零,则改用备用参数Lots。
参数说明
MinPips– 将正向开盘差值视为买入样本时的下限(点)。MaxPips– 买入/卖出训练样本的上限(点)。TakeProfit,StopLoss– 保留自原始 EA,主要用于文档说明。高层实现通过市价单平仓或反手,而不是挂出止盈/止损单。Lots– 当余额公式得到零时的备用手数。Slippage– 预留给手动下单控制(高层辅助方法未直接使用)。MapPath– 用于跨次运行保存三个 Kohonen 映射的二进制文件路径。EAName– 可选的订单备注。CandleType– 构建特征向量时订阅的 K 线类型。
映射持久化
策略会将训练后的映射写入 MapPath 指定的二进制文件(默认位于工作目录下的 rl.bin)。文件按照买入、卖出、观望矩阵的顺序存储双精度值。启动时读取文件,并统计每个矩阵的非空行数,以便在上次训练的基础上继续学习。如果文件不存在,则从全零的内存开始。
与原始 MQL 专家顾问的差异
- 订单通过 StockSharp 的
BuyMarket/SellMarket辅助方法发送,并直接调整到目标持仓,而不是在每根 K 线上先完全平仓再重新建仓。这样在托管环境中避免了重复交易,同时保持原有效果。 - 止盈、止损参数仍然保留用于说明,但不会单独注册委托。当分类器给出相反方向或观望信号时,仓位会被市价单关闭。
- 文件操作使用 .NET I/O 工具,但映射的格式保持一致(双精度值顺序相同)。
使用建议
- 请确保所选
Security提供有效的PriceStep,以正确计算点差;若缺失或为零,则自动退回到单位步长。 - Kohonen 映射容量较大(买入/卖出各最多 10000 行,观望最多 25000 行),默认文件写满约占 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);
}
}
}
import clr
import os
import struct
import math
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math, Decimal
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
class rich_kohonen_map_strategy(Strategy):
VECTOR_SIZE = 7
BUY_CAPACITY = 10000
SELL_CAPACITY = 10000
HOLD_CAPACITY = 25000
FILE_SIZE = (BUY_CAPACITY + SELL_CAPACITY + HOLD_CAPACITY) * VECTOR_SIZE * 8
def __init__(self):
super(rich_kohonen_map_strategy, self).__init__()
# 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.
self._min_pips = self.Param("MinPips", 10000.0).SetNotNegative()
self._max_pips = self.Param("MaxPips", 100000.0).SetGreaterThanZero()
self._take_profit = self.Param("TakeProfit", 0.0).SetNotNegative()
self._stop_loss = self.Param("StopLoss", 0.0).SetNotNegative()
self._lots = self.Param("Lots", 0.1).SetGreaterThanZero()
self._slippage = self.Param("Slippage", 3).SetNotNegative()
self._map_path = self.Param("MapPath", "rl.bin")
self._ea_name = self.Param("EAName", "Rich")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(1)))
self._candles = []
self._buy_map = []
self._sell_map = []
self._hold_map = []
@property
def CandleType(self):
return self._candle_type.Value
def GetWorkingSecurities(self):
return [(self.Security, self.CandleType)]
def OnReseted(self):
super(rich_kohonen_map_strategy, self).OnReseted()
self._candles = []
self._buy_map = []
self._sell_map = []
self._hold_map = []
def OnStarted2(self, time):
super(rich_kohonen_map_strategy, self).OnStarted2(time)
self._load_maps()
self.SubscribeCandles(self.CandleType).Bind(self._process_candle).Start()
def OnStopped(self):
self._save_maps()
super(rich_kohonen_map_strategy, self).OnStopped()
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
self._candles.append(candle)
if len(self._candles) > 8:
del self._candles[0]
if len(self._candles) < 7:
return
current = self._build_vector(0)
previous = self._build_vector(1)
decision = self._classify(current)
self._set_target(decision)
latest = self._candles[-1]
prior = self._candles[-2]
step = self.Security.PriceStep if self.Security is not None and self.Security.PriceStep is not None else Decimal.One
if step <= 0:
step = Decimal.One
# Exact decimal steps: in binary floating point a move of exactly MinPips or MaxPips can fall outside the band.
move_pips = (latest.OpenPrice - prior.OpenPrice) / step
min_pips = Decimal(self._min_pips.Value)
max_pips = Decimal(self._max_pips.Value)
if min_pips <= move_pips <= max_pips:
self._add_prototype(self._buy_map, previous, self.BUY_CAPACITY)
elif -max_pips <= move_pips <= -min_pips:
self._add_prototype(self._sell_map, previous, self.SELL_CAPACITY)
else:
self._add_prototype(self._hold_map, previous, self.HOLD_CAPACITY)
def _classify(self, vector):
distances = [
(self._best_distance(self._buy_map, vector), 1),
(self._best_distance(self._sell_map, vector), -1),
(self._best_distance(self._hold_map, vector), 0),
]
finite = [item for item in distances if not math.isinf(item[0])]
if not finite:
return 0
return min(finite, key=lambda item: item[0])[1]
def _set_target(self, decision):
volume = self._calculate_volume()
target = decision * volume
difference = target - float(self.Position)
if difference > 0:
self.BuyMarket(difference)
elif difference < 0:
self.SellMarket(abs(difference))
def _calculate_volume(self):
balance = 0.0
if self.Portfolio is not None:
if self.Portfolio.CurrentValue is not None:
balance = float(self.Portfolio.CurrentValue)
elif self.Portfolio.BeginValue is not None:
balance = float(self.Portfolio.BeginValue)
volume = math.floor(balance / 50.0) / 10.0 if balance > 0 else 0.0
if volume <= 0:
volume = float(self._lots.Value)
if self.Security is not None:
if self.Security.MaxVolume is not None and float(self.Security.MaxVolume) > 0:
volume = min(volume, float(self.Security.MaxVolume))
if self.Security.MinVolume is not None and float(self.Security.MinVolume) > 0:
volume = max(volume, float(self.Security.MinVolume))
if self.Security.VolumeStep is not None and float(self.Security.VolumeStep) > 0:
step = float(self.Security.VolumeStep)
volume = math.floor(volume / step) * step
return volume if volume > 0 else float(self._lots.Value)
def _build_vector(self, offset):
last_index = len(self._candles) - 1 - offset
first_index = last_index - 5
current = self._candles[last_index]
window = self._candles[first_index:last_index]
pivot_sum = r1_sum = s1_sum = 0.0
for c in window:
pivot, r1, s1 = self._demark(float(c.OpenPrice), float(c.HighPrice), float(c.LowPrice), float(c.ClosePrice))
pivot_sum += pivot
r1_sum += r1
s1_sum += s1
# The five candles taken as one bar: first open, extreme high and low, last close.
bar_pivot, bar_r1, bar_s1 = self._demark(
float(window[0].OpenPrice),
max(float(c.HighPrice) for c in window),
min(float(c.LowPrice) for c in window),
float(window[-1].ClosePrice))
return [
float(current.OpenPrice),
pivot_sum / 5.0,
r1_sum / 5.0,
s1_sum / 5.0,
bar_pivot,
bar_r1,
bar_s1,
]
@staticmethod
def _demark(o, h, l, close):
if close < o:
x = h + 2.0 * l + close
elif close > o:
x = 2.0 * h + l + close
else:
x = h + l + 2.0 * close
return x / 4.0, x / 2.0 - l, x / 2.0 - h
@staticmethod
def _best_distance(map_values, vector):
if not map_values:
return float("inf")
return min(math.sqrt(sum((vector[i] - prototype[i]) ** 2 for i in range(7))) for prototype in map_values)
def _add_prototype(self, map_values, vector, capacity):
if len(map_values) < capacity:
map_values.append(list(vector))
def _load_maps(self):
path = str(self._map_path.Value)
if not path or not os.path.isfile(path):
return
try:
with open(path, "rb") as f:
data = f.read(self.FILE_SIZE)
if len(data) < self.FILE_SIZE:
raise IOError("the file holds {0} bytes, the three matrices need {1}".format(len(data), self.FILE_SIZE))
buy_map, offset = self._read_map(data, 0, self.BUY_CAPACITY)
sell_map, offset = self._read_map(data, offset, self.SELL_CAPACITY)
hold_map, offset = self._read_map(data, offset, self.HOLD_CAPACITY)
self._buy_map = buy_map
self._sell_map = sell_map
self._hold_map = hold_map
except Exception as ex:
self._buy_map = []
self._sell_map = []
self._hold_map = []
self.LogWarning("Unable to load Kohonen map '{0}': {1}".format(path, ex))
def _save_maps(self):
path = str(self._map_path.Value)
if not path:
return
try:
with open(path, "wb") as f:
self._write_map(f, self._buy_map, self.BUY_CAPACITY)
self._write_map(f, self._sell_map, self.SELL_CAPACITY)
self._write_map(f, self._hold_map, self.HOLD_CAPACITY)
except Exception as ex:
self.LogWarning("Unable to save Kohonen map '{0}': {1}".format(path, ex))
@classmethod
def _read_map(cls, data, offset, capacity):
# A zero-padded matrix of capacity rows; only its non-empty rows are kept.
count = capacity * cls.VECTOR_SIZE
end = offset + count * 8
values = struct.unpack("<%dd" % count, data[offset:end])
empty = (0.0,) * cls.VECTOR_SIZE
rows = []
for i in range(0, count, cls.VECTOR_SIZE):
row = values[i:i + cls.VECTOR_SIZE]
if row != empty:
rows.append(list(row))
return rows, end
@classmethod
def _write_map(cls, f, values, capacity):
# A matrix of capacity rows whose unused rows are padded with zeros.
row_format = "<%dd" % cls.VECTOR_SIZE
for vector in values:
f.write(struct.pack(row_format, *vector))
f.write(bytes(8 * cls.VECTOR_SIZE * (capacity - len(values))))
def CreateClone(self):
return rich_kohonen_map_strategy()