在 GitHub 上查看

Money Rain 策略

概述

  • 将原始的 MoneyRain (barabashkakvn 版本) MQL5 专家顾问迁移到 StockSharp 高级 API。
  • 通过 DeMarker 指标判断方向:数值大于 0.5 时建立多头,数值小于或等于 0.5 时建立空头。
  • 任意时刻只允许持有一个仓位,止盈止损距离以“点”形式固定。

数据与指标

  • 订阅可配置的 CandleType(默认 30 分钟 K 线)。
  • 计算一个 DeMarker 指标,周期参数 DeMarkerPeriod 默认 31。
  • 额外订阅 Level 1 报价,用于估算当前点差,以驱动动态仓位管理。

交易流程

  1. 仅处理已经完成的 K 线,对应原脚本中的 iTime(0) 新柱判定。
  2. 在持仓期间监控 K 线的最高/最低价与预先计算的止损、止盈价格,当任一价格被触发时使用市价单平仓,并记录结果是盈利还是亏损。
  3. 当没有持仓且未达到连续亏损上限时,计算下一笔订单的数量。
  4. DeMarker > 0.5 则买入,否则卖出。提交市价单前会取消所有挂单。

资金管理

  • 复刻 MQL 中 getLots() 的逻辑,维护以下状态:
    • _lossesVolume:最近亏损交易的累计数量,相对于基础手数进行归一化。
    • _consecutiveLosses_consecutiveProfits:连亏/连盈计数器,用于决定何时重置亏损累积。
  • 在连亏后出现第一笔盈利时(_consecutiveProfits == 0),下一单的手数按照原公式增加: [ \text = \text \times \frac{_lossesVolume \times (\text + \text)}{\text - \text} ]
  • 点差通过最优买/卖价估算(以点为单位)。若 Level 1 尚未到达,则点差视为 0。
  • FastOptimize 设置为 true 可关闭自适应手数,始终使用基础手数。

风险控制

  • StopLossPointsTakeProfitPoints 通过证券的最小价差转换成绝对价格;对于 3 或 5 位小数的品种按照原策略增加 10 倍系数(对应 digits_adjust)。
  • LossLimit 限制连续亏损次数,超过后停止开仓(默认值 1,000,000,相当于不限制)。

参数

参数 说明 默认值
DeMarkerPeriod DeMarker 指标周期。 31
TakeProfitPoints 止盈距离(点)。 5
StopLossPoints 止损距离(点)。 20
BaseVolume 基础下单手数。 0.01
LossLimit 允许的最大连续亏损次数。 1,000,000
FastOptimize true 时禁用自适应加仓。 false
CandleType 用于计算的 K 线类型。 30 分钟 K 线

实现说明

  • 止损/止盈通过比较当前 K 线的最高价和最低价模拟触发,若同一根 K 线同时触及两个目标,策略保守地认为先触发止损。
  • 使用 OnOwnTradeReceived 监控平仓成交,以便更新连盈连亏计数和亏损手数累积。
  • 源代码使用制表符缩进并保持英文注释,符合仓库约定。

目录结构

  • CS/MoneyRainStrategy.cs:策略实现。
  • README.md / README_ru.md / README_zh.md:多语言文档。

与 MQL 版本的差异

  • 原先在服务器端挂出的保护性订单改为根据 K 线区间触发的市价平仓。
  • 点差来自 Level 1 报价而非 MetaTrader 符号属性。
  • 删除了邮件通知和 IsTradeAllowed 检查,相关责任由 StockSharp 运行环境承担。
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;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Money Rain strategy converted from the original MQL5 expert advisor.
/// </summary>
public class MoneyRainStrategy : Strategy
{
	private enum ExitReasons
	{
		None,
		StopLoss,
		TakeProfit
	}

	private readonly StrategyParam<int> _deMarkerPeriod;
	private readonly StrategyParam<decimal> _takeProfitPoints;
	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _baseVolume;
	private readonly StrategyParam<int> _lossLimit;
	private readonly StrategyParam<bool> _fastOptimize;
	private readonly StrategyParam<DataType> _candleType;

	private DeMarker _deMarker;
	private decimal _adjustedPoint;
	private decimal _takeProfitOffset;
	private decimal _stopLossOffset;
	private decimal _lastSpreadPoints;
	private decimal _entryPrice;
	private decimal _stopPrice;
	private decimal _takePrice;
	private decimal _activeVolume;
	private int _consecutiveLosses;
	private int _consecutiveProfits;
	private decimal _lossesVolume;
	private bool _exitOrderActive;
	private ExitReasons _pendingExitReason;
	private Sides? _currentSide;

	/// <summary>
	/// DeMarker indicator period.
	/// </summary>
	public int DeMarkerPeriod
	{
		get => _deMarkerPeriod.Value;
		set => _deMarkerPeriod.Value = value;
	}

	/// <summary>
	/// Take-profit distance measured in DeMarker-style points.
	/// </summary>
	public decimal TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Stop-loss distance measured in DeMarker-style points.
	/// </summary>
	public decimal StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Base trading volume.
	/// </summary>
	public decimal BaseVolume
	{
		get => _baseVolume.Value;
		set => _baseVolume.Value = value;
	}

	/// <summary>
	/// Maximum allowed consecutive losses.
	/// </summary>
	public int LossLimit
	{
		get => _lossLimit.Value;
		set => _lossLimit.Value = value;
	}

	/// <summary>
	/// Enables lightweight optimisation mode that disables money management.
	/// </summary>
	public bool FastOptimize
	{
		get => _fastOptimize.Value;
		set => _fastOptimize.Value = value;
	}

	/// <summary>
	/// Candles used by the strategy.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes strategy parameters with defaults close to the MQL version.
	/// </summary>
	public MoneyRainStrategy()
	{
		_deMarkerPeriod = Param(nameof(DeMarkerPeriod), 31)
		.SetGreaterThanZero()
		.SetDisplay("DeMarker Period", "DeMarker indicator averaging period", "Indicators")
		
		.SetOptimize(5, 60, 5);

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 5m)
		.SetGreaterThanZero()
		.SetDisplay("Take Profit (points)", "Take-profit distance expressed in points", "Risk")
		
		.SetOptimize(2m, 15m, 1m);

		_stopLossPoints = Param(nameof(StopLossPoints), 20m)
		.SetGreaterThanZero()
		.SetDisplay("Stop Loss (points)", "Stop-loss distance expressed in points", "Risk")
		
		.SetOptimize(10m, 60m, 5m);

		_baseVolume = Param(nameof(BaseVolume), 0.01m)
		.SetGreaterThanZero()
		.SetDisplay("Base Volume", "Lot size used when no recovery is required", "Trading")
		
		.SetOptimize(0.01m, 1m, 0.01m);

		_lossLimit = Param(nameof(LossLimit), 1000000)
		.SetGreaterThanZero()
		.SetDisplay("Loss Limit", "Maximum consecutive losses before trading is paused", "Risk");

		_fastOptimize = Param(nameof(FastOptimize), false)
		.SetDisplay("Fast Optimisation", "Disable adaptive position sizing during rough optimisation", "Trading");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
		.SetDisplay("Candle Type", "Candles used for indicator calculations", "Data");
	}

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

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

		_deMarker = null;
		_adjustedPoint = 0m;
		_takeProfitOffset = 0m;
		_stopLossOffset = 0m;
		_lastSpreadPoints = 0m;
		_entryPrice = 0m;
		_stopPrice = 0m;
		_takePrice = 0m;
		_activeVolume = 0m;
		_consecutiveLosses = 0;
		_consecutiveProfits = 0;
		_lossesVolume = 0m;
		_exitOrderActive = false;
		_pendingExitReason = ExitReasons.None;
		_currentSide = null;
	}

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

		UpdateOffsets();

		_deMarker = new DeMarker
		{
			Length = DeMarkerPeriod
		};

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

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

	private void ProcessLevel1(Level1ChangeMessage level1)
	{
		if (_adjustedPoint <= 0m)
		return;

		if (level1.Changes.TryGetValue(Level1Fields.BestBidPrice, out var bidObj) &&
		level1.Changes.TryGetValue(Level1Fields.BestAskPrice, out var askObj) &&
		bidObj is decimal bid &&
		askObj is decimal ask &&
		ask > bid &&
		bid > 0m)
		{
			_lastSpreadPoints = (ask - bid) / _adjustedPoint;
		}
	}

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

		ManageOpenPosition(candle);

		if (_deMarker == null || !_deMarker.IsFormed)
		return;

		if (Position != 0 || _exitOrderActive)
		return;

		if (LossLimit > 0 && _consecutiveLosses >= LossLimit)
		{
			this.LogInfo($"Trading paused after reaching loss limit of {LossLimit} consecutive losses.");
			return;
		}

		if (_adjustedPoint <= 0m)
		UpdateOffsets();

		var volume = GetTradeVolume();
		if (volume <= 0m)
		return;

		if (deMarkerValue > 0.5m)
		{
			EnterPosition(Sides.Buy, volume, candle.ClosePrice, deMarkerValue);
		}
		else
		{
			EnterPosition(Sides.Sell, volume, candle.ClosePrice, deMarkerValue);
		}
	}

	private void ManageOpenPosition(ICandleMessage candle)
	{
		if (_currentSide == null || Position == 0 || _exitOrderActive)
		return;

		var hasStop = _stopLossOffset > 0m;
		var hasTake = _takeProfitOffset > 0m;

		var hitStop = false;
		var hitTake = false;

		switch (_currentSide)
		{
		case Sides.Buy:
			hitStop = hasStop && candle.LowPrice <= _stopPrice;
			hitTake = hasTake && candle.HighPrice >= _takePrice;
			break;
		case Sides.Sell:
			hitStop = hasStop && candle.HighPrice >= _stopPrice;
			hitTake = hasTake && candle.LowPrice <= _takePrice;
			break;
	}

	if (!hitStop && !hitTake)
	return;

	_exitOrderActive = true;
	_pendingExitReason = hitStop ? ExitReasons.StopLoss : ExitReasons.TakeProfit;

	if (Position > 0)
		SellMarket();
	else if (Position < 0)
		BuyMarket();

	var exitPrice = hitStop ? _stopPrice : _takePrice;
	this.LogInfo(hitStop
	? $"Stop-loss triggered near {exitPrice} (range {candle.LowPrice} - {candle.HighPrice})."
	: $"Take-profit triggered near {exitPrice} (range {candle.LowPrice} - {candle.HighPrice}).");
}

private void EnterPosition(Sides side, decimal volume, decimal referencePrice, decimal deMarkerValue)
{
	CancelActiveOrders();

	_currentSide = side;
	_exitOrderActive = false;
	_pendingExitReason = ExitReasons.None;
	_entryPrice = referencePrice;
	_activeVolume = volume;

	if (side == Sides.Buy)
	{
		_stopPrice = referencePrice - _stopLossOffset;
		_takePrice = referencePrice + _takeProfitOffset;
		BuyMarket();
		this.LogInfo($"Entered long at {referencePrice} (DeMarker={deMarkerValue:F4}) with volume {volume}.");
	}
	else
	{
		_stopPrice = referencePrice + _stopLossOffset;
		_takePrice = referencePrice - _takeProfitOffset;
		SellMarket();
		this.LogInfo($"Entered short at {referencePrice} (DeMarker={deMarkerValue:F4}) with volume {volume}.");
	}
}

private decimal GetTradeVolume()
{
	var volume = BaseVolume;
	if (volume <= 0m)
	return 0m;

	if (FastOptimize)
	return volume;

	if (_lossesVolume <= 0.5m || _consecutiveProfits > 0)
	return volume;

	var spread = Math.Max(0m, _lastSpreadPoints);
	var denominator = TakeProfitPoints - spread;
	if (denominator <= 0m)
	return volume;

	var multiplier = _lossesVolume * (StopLossPoints + spread) / denominator;
	if (multiplier <= 0m)
	return volume;

	return volume * multiplier;
}

private void UpdateTradeStats(bool isProfit)
{
	if (isProfit)
	{
		_consecutiveLosses = 0;

		if (_consecutiveProfits > 1)
		_lossesVolume = 0m;

		_consecutiveProfits++;

		this.LogInfo($"Take-profit confirmed. Profit streak = {_consecutiveProfits}.");
	}
	else
	{
		_consecutiveLosses++;
		_consecutiveProfits = 0;

		if (BaseVolume > 0m)
		_lossesVolume += _activeVolume / BaseVolume;

		this.LogInfo($"Stop-loss confirmed. Loss streak = {_consecutiveLosses}, accumulated loss volume = {_lossesVolume:F2}.");
	}
}

protected override void OnOwnTradeReceived(MyTrade trade)
{
	base.OnOwnTradeReceived(trade);

	if (_exitOrderActive)
	{
		if (Position != 0)
		return;

		UpdateTradeStats(_pendingExitReason == ExitReasons.TakeProfit);

		_exitOrderActive = false;
		_pendingExitReason = ExitReasons.None;
		_currentSide = null;
		_entryPrice = 0m;
		_stopPrice = 0m;
		_takePrice = 0m;
		_activeVolume = 0m;
		return;
	}

	if (_currentSide != null && Position != 0)
	{
		_entryPrice = trade.Trade.Price;
		_activeVolume = trade.Trade.Volume;
	}
}

private void UpdateOffsets()
{
	var priceStep = Security?.PriceStep ?? 0m;
	if (priceStep <= 0m)
	priceStep = 0.0001m;

	var decimals = Security?.Decimals ?? 0;
	var digitsAdjust = (decimals == 3 || decimals == 5) ? 10m : 1m;

	_adjustedPoint = priceStep * digitsAdjust;
	_takeProfitOffset = TakeProfitPoints * _adjustedPoint;
	_stopLossOffset = StopLossPoints * _adjustedPoint;
}
}