在 GitHub 上查看

TwentyPipsOnceADayStrategy

该策略基于 MetaTrader 专家顾问 20pipsOnceADayOppositeLastNHourTrend,并使用 StockSharp 高阶 API 重新实现。策略每天在指定的小时执行一次交易,通过比较最近一小时与 N 小时前的收盘价,逆势开仓。仓位规模采用阶梯式马丁格尔,只在最近的交易出现亏损时才放大手数;同时还增加了交易时段过滤、可选的移动止损以及最大持仓时间限制。

交易逻辑

  1. 订阅指定周期的蜡烛(默认 1 小时,可通过 CandleType 修改)。
  2. 当一根蜡烛收盘且下一根蜡烛的小时数等于 TradingHour 时执行信号判断:
    • 取最近一根完整蜡烛的收盘价,与 HoursToCheckTrend 小时前的收盘价比较。
    • 如果价格下跌,则在新小时开多仓;如果价格上涨,则开空仓。
  3. 同一时间仅允许存在一笔仓位(由 MaxOrders 控制,默认 1)。
  4. 每笔交易都会附带固定止盈、可选止损与移动止损,距离均以点数(pip)定义,并自动转换为品种价格单位。
  5. 若持仓时间超过 OrderMaxAgeSeconds,或下一小时不在 TradingDayHours 指定的交易时段内,则立即平仓。

资金管理

  • FixedVolume 为基准手数。设置为 0 时启用风险百分比计算,按照 (账户价值 * RiskPercent) / 1000 计算手数,复刻原版 EA 的做法。
  • 计算得到的手数会同时受到交易品种的 VolumeMin/VolumeMax/VolumeStep 以及参数 MinVolume / MaxVolume 的限制。
  • 马丁格尔阶梯仅在相应历史交易为亏损时生效:
    • 最近一笔亏损时使用 FirstMultiplier
    • 最近一笔盈利但倒数第二笔亏损时使用 SecondMultiplier
    • 依次类推直到 FifthMultiplier,与原始 EA 的五级扩仓一致。

参数说明

参数 说明
FixedVolume 固定手数;设为 0 启用风险百分比计算。
MinVolume / MaxVolume 计算后手数的最小值与最大值限制。
RiskPercent FixedVolume = 0 时,根据账户价值换算手数的百分比。
MaxOrders 同时允许的最大持仓数量(默认 1)。
TradingHour 允许开仓的小时(0-23)。
TradingDayHours 允许持仓的小时集合,可写成逗号分隔或区间(例如 0-7,13-22)。下一小时不在集合内时强制平仓。
HoursToCheckTrend 反向交易所使用的小时回溯长度。
OrderMaxAgeSeconds 持仓时间上限(秒)。
FirstMultiplierFifthMultiplier 针对最近五笔亏损交易的马丁格尔倍率。
StopLossPips 初始止损距离(pip),设为 0 关闭。
TrailingStopPips 移动止损距离(pip),设为 0 关闭。
TakeProfitPips 止盈距离(pip)。
CandleType 信号所用蜡烛类型,默认一小时。

风险控制与离场

  • 止盈/止损:使用 TakeProfitPipsStopLossPips 配置,自动转换为价格单位。
  • 移动止损:当浮盈超过设定点数时,将止损向有利方向移动。
  • 超时平仓:仓位持有时间超过 OrderMaxAgeSeconds 时按当前蜡烛收盘价离场。
  • 时段过滤:下一小时不在 TradingDayHours 内时立即平仓。

使用建议

  • 适用于任意提供小时蜡烛数据且定义了 PriceStep 的标的;若标的报价带有 3 或 5 位小数,策略会自动换算点值。
  • 若希望贴近原版 EA,请保持 CandleType 为 1 小时并将 TradingDayHours 设为完整的 0-23。策略会在指定小时的开盘价附近成交。
  • 马丁格尔阶梯最多参考最近五笔历史结果,重置策略会清空该记录。
  • 本项目仅提供 C# 版本,暂未实现 Python 版本。

文件结构

  • CS/TwentyPipsOnceADayStrategy.cs:C# 策略源码。
  • README.md:英文说明。
  • README_zh.md:中文说明(当前文件)。
  • README_ru.md:俄文说明。
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;

using System.Globalization;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Port of the MetaTrader expert "20pipsOnceADayOppositeLastNHourTrend".
/// Trades once per configured hour against the drift of the last N hourly candles and applies martingale style sizing.
/// Includes daily session control, optional trailing protection, and automatic position aging.
/// </summary>
public class TwentyPipsOnceADayStrategy : Strategy
{
	private readonly StrategyParam<decimal> _fixedVolume;
	private readonly StrategyParam<decimal> _minVolume;
	private readonly StrategyParam<decimal> _maxVolume;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<int> _maxOrders;
	private readonly StrategyParam<int> _tradingHour;
	private readonly StrategyParam<string> _tradingDayHours;
	private readonly StrategyParam<int> _hoursToCheckTrend;
	private readonly StrategyParam<int> _orderMaxAgeSeconds;
	private readonly StrategyParam<int> _firstMultiplier;
	private readonly StrategyParam<int> _secondMultiplier;
	private readonly StrategyParam<int> _thirdMultiplier;
	private readonly StrategyParam<int> _fourthMultiplier;
	private readonly StrategyParam<int> _fifthMultiplier;
	private readonly StrategyParam<decimal> _stopLossPips;
	private readonly StrategyParam<decimal> _trailingStopPips;
	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<decimal> _closeHistory = new();
	private readonly List<bool> _recentLosses = new(5);
	private readonly HashSet<int> _allowedHours = new();

	private SimpleMovingAverage _sma;

	private DateTime? _lastTradeBarTime;
	private DateTime? _entryTime;
	private decimal? _entryPrice;
	private decimal? _stopPrice;
	private decimal? _takeProfitPrice;
	private decimal _entryVolume;
	private int _positionDirection;
	private decimal _pipSize;

	/// <summary>
	/// Initializes a new instance of <see cref="TwentyPipsOnceADayStrategy"/>.
	/// </summary>
	public TwentyPipsOnceADayStrategy()
	{
		_fixedVolume = Param(nameof(FixedVolume), 0.1m)
		.SetDisplay("Fixed Volume", "Fixed trading volume (set to 0 to use risk based sizing)", "Risk");

		_minVolume = Param(nameof(MinVolume), 0.1m)
		.SetDisplay("Min Volume", "Lower volume bound applied after sizing", "Risk");

		_maxVolume = Param(nameof(MaxVolume), 5m)
		.SetDisplay("Max Volume", "Upper volume bound applied after sizing", "Risk");

		_riskPercent = Param(nameof(RiskPercent), 5m)
		.SetDisplay("Risk Percent", "Percentage of portfolio value converted into volume when fixed size is disabled", "Risk");

		_maxOrders = Param(nameof(MaxOrders), 1)
		.SetGreaterThanZero()
		.SetDisplay("Max Orders", "Maximum number of simultaneously open positions", "Trading");

		_tradingHour = Param(nameof(TradingHour), 7)
		.SetRange(0, 23)
		.SetDisplay("Trading Hour", "Hour of day (0-23) when the strategy evaluates signals", "Schedule");

		_tradingDayHours = Param(nameof(TradingDayHours), "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23")
		.SetDisplay("Trading Day Hours", "Comma separated list or ranges of allowed session hours", "Schedule");

		_hoursToCheckTrend = Param(nameof(HoursToCheckTrend), 30)
		.SetGreaterThanZero()
		.SetDisplay("Hours To Check", "Number of historical hourly closes used for the contrarian check", "Signals");

		_orderMaxAgeSeconds = Param(nameof(OrderMaxAgeSeconds), 75600)
		.SetGreaterThanZero()
		.SetDisplay("Max Position Age (s)", "Maximum holding time in seconds before forcing an exit", "Risk");

		_firstMultiplier = Param(nameof(FirstMultiplier), 4)
		.SetGreaterThanZero()
		.SetDisplay("First Multiplier", "Multiplier applied after the most recent loss", "Money Management");

		_secondMultiplier = Param(nameof(SecondMultiplier), 2)
		.SetGreaterThanZero()
		.SetDisplay("Second Multiplier", "Multiplier applied when the last win was preceded by a loss", "Money Management");

		_thirdMultiplier = Param(nameof(ThirdMultiplier), 5)
		.SetGreaterThanZero()
		.SetDisplay("Third Multiplier", "Multiplier applied when the third latest trade was a loss", "Money Management");

		_fourthMultiplier = Param(nameof(FourthMultiplier), 5)
		.SetGreaterThanZero()
		.SetDisplay("Fourth Multiplier", "Multiplier applied when the fourth latest trade was a loss", "Money Management");

		_fifthMultiplier = Param(nameof(FifthMultiplier), 1)
		.SetGreaterThanZero()
		.SetDisplay("Fifth Multiplier", "Multiplier applied when the fifth latest trade was a loss", "Money Management");

		_stopLossPips = Param(nameof(StopLossPips), 50m)
		.SetDisplay("Stop Loss (pips)", "Stop loss distance expressed in pips", "Risk");

		_trailingStopPips = Param(nameof(TrailingStopPips), 0m)
		.SetDisplay("Trailing Stop (pips)", "Trailing stop distance expressed in pips", "Risk");

		_takeProfitPips = Param(nameof(TakeProfitPips), 10m)
		.SetDisplay("Take Profit (pips)", "Take profit distance expressed in pips", "Risk");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
		.SetDisplay("Candle Type", "Timeframe used for signal calculations", "Market Data");
	}

	/// <summary>
	/// Fixed trading volume. Set to zero to enable risk based sizing.
	/// </summary>
	public decimal FixedVolume
	{
		get => _fixedVolume.Value;
		set => _fixedVolume.Value = value;
	}

	/// <summary>
	/// Minimum allowed trading volume.
	/// </summary>
	public decimal MinVolume
	{
		get => _minVolume.Value;
		set => _minVolume.Value = value;
	}

	/// <summary>
	/// Maximum allowed trading volume.
	/// </summary>
	public decimal MaxVolume
	{
		get => _maxVolume.Value;
		set => _maxVolume.Value = value;
	}

	/// <summary>
	/// Portfolio percentage converted into volume when <see cref="FixedVolume"/> equals zero.
	/// </summary>
	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}

	/// <summary>
	/// Maximum number of simultaneously open positions.
	/// </summary>
	public int MaxOrders
	{
		get => _maxOrders.Value;
		set => _maxOrders.Value = value;
	}

	/// <summary>
	/// Hour of day when new positions may be opened.
	/// </summary>
	public int TradingHour
	{
		get => _tradingHour.Value;
		set => _tradingHour.Value = value;
	}

	/// <summary>
	/// Comma separated list or ranges of allowed trading hours.
	/// </summary>
	public string TradingDayHours
	{
		get => _tradingDayHours.Value;
		set
		{
			_tradingDayHours.Value = value;
			UpdateTradingHours();
		}
	}

	/// <summary>
	/// Lookback depth measured in hourly candles.
	/// </summary>
	public int HoursToCheckTrend
	{
		get => _hoursToCheckTrend.Value;
		set => _hoursToCheckTrend.Value = value;
	}

	/// <summary>
	/// Maximum holding time before a position is forcefully closed.
	/// </summary>
	public int OrderMaxAgeSeconds
	{
		get => _orderMaxAgeSeconds.Value;
		set => _orderMaxAgeSeconds.Value = value;
	}

	/// <summary>
	/// Multiplier used after the latest loss.
	/// </summary>
	public int FirstMultiplier
	{
		get => _firstMultiplier.Value;
		set => _firstMultiplier.Value = value;
	}

	/// <summary>
	/// Multiplier used when only the second latest trade was a loss.
	/// </summary>
	public int SecondMultiplier
	{
		get => _secondMultiplier.Value;
		set => _secondMultiplier.Value = value;
	}

	/// <summary>
	/// Multiplier used when the third latest trade was a loss.
	/// </summary>
	public int ThirdMultiplier
	{
		get => _thirdMultiplier.Value;
		set => _thirdMultiplier.Value = value;
	}

	/// <summary>
	/// Multiplier used when the fourth latest trade was a loss.
	/// </summary>
	public int FourthMultiplier
	{
		get => _fourthMultiplier.Value;
		set => _fourthMultiplier.Value = value;
	}

	/// <summary>
	/// Multiplier used when the fifth latest trade was a loss.
	/// </summary>
	public int FifthMultiplier
	{
		get => _fifthMultiplier.Value;
		set => _fifthMultiplier.Value = value;
	}

	/// <summary>
	/// Stop loss distance in pips.
	/// </summary>
	public decimal StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Trailing stop distance in pips.
	/// </summary>
	public decimal TrailingStopPips
	{
		get => _trailingStopPips.Value;
		set => _trailingStopPips.Value = value;
	}

	/// <summary>
	/// Take profit distance in pips.
	/// </summary>
	public decimal TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Candle type used to process signals.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

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

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

		_closeHistory.Clear();
		_recentLosses.Clear();
		_lastTradeBarTime = null;
		_entryTime = null;
		_entryPrice = null;
		_stopPrice = null;
		_takeProfitPrice = null;
		_entryVolume = 0m;
		_positionDirection = 0;
		_pipSize = 0m;
		UpdateTradingHours();
	}

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

		_pipSize = CalculatePipSize();
		UpdateTradingHours();

		_sma = new SimpleMovingAverage { Length = 2 };

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

		StartProtection(null, null);
	}

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

		AddCloseToHistory(candle.ClosePrice);

		if (_positionDirection != 0)
		{
			ManageOpenPosition(candle);
			if (_positionDirection != 0)
			{
				EnforceSessionLimits(candle);
			}
		}

		TryOpenPosition(candle);
	}

	private void AddCloseToHistory(decimal closePrice)
	{
		if (HoursToCheckTrend <= 0)
		return;

		_closeHistory.Insert(0, closePrice);

		var required = Math.Max(HoursToCheckTrend, 5);
		if (_closeHistory.Count > required)
		{
			_closeHistory.RemoveRange(required, _closeHistory.Count - required);
		}
	}

	private void ManageOpenPosition(ICandleMessage candle)
	{
		if (_positionDirection == 0 || _entryPrice is not decimal entryPrice)
		return;

		var direction = _positionDirection;
		var closePrice = candle.ClosePrice;

		var stopDistance = StopLossPips * _pipSize;
		if (_stopPrice is null && stopDistance > 0m)
		{
			_stopPrice = direction > 0
			? entryPrice - stopDistance
			: entryPrice + stopDistance;
		}

		var trailingDistance = TrailingStopPips * _pipSize;
		if (trailingDistance > 0m)
		{
			if (direction > 0)
			{
				var profit = closePrice - entryPrice;
				if (profit > trailingDistance)
				{
					var candidate = closePrice - trailingDistance;
					if (_stopPrice is null || candidate > _stopPrice.Value)
					{
						_stopPrice = candidate;
					}
				}
			}
			else
			{
				var profit = entryPrice - closePrice;
				if (profit > trailingDistance)
				{
					var candidate = closePrice + trailingDistance;
					if (_stopPrice is null || candidate < _stopPrice.Value)
					{
						_stopPrice = candidate;
					}
				}
			}
		}

		if (_takeProfitPrice is decimal target)
		{
			var hitTarget = direction > 0
			? candle.HighPrice >= target
			: candle.LowPrice <= target;

			if (hitTarget)
			{
				ExitPosition(target);
				return;
			}
		}

		if (_stopPrice is decimal stopLevel)
		{
			var hitStop = direction > 0
			? candle.LowPrice <= stopLevel
			: candle.HighPrice >= stopLevel;

			if (hitStop)
			{
				ExitPosition(stopLevel);
				return;
			}
		}

		if (OrderMaxAgeSeconds > 0 && _entryTime is DateTime entryTime)
		{
			var age = candle.CloseTime - entryTime;
			if (age.TotalSeconds >= OrderMaxAgeSeconds)
			{
				ExitPosition(candle.ClosePrice);
			}
		}
	}

	private void EnforceSessionLimits(ICandleMessage candle)
	{
		if (_positionDirection == 0)
		return;

		var nextHour = candle.CloseTime.Hour;
		if (!IsHourAllowed(nextHour))
		{
			ExitPosition(candle.ClosePrice);
		}
	}

	private void TryOpenPosition(ICandleMessage candle)
	{
		if (MaxOrders <= 0 || _positionDirection != 0)
		return;

		var nextHour = candle.CloseTime.Hour;
		if (nextHour != TradingHour || !IsHourAllowed(nextHour))
		return;

		if (_lastTradeBarTime.HasValue && _lastTradeBarTime.Value == candle.CloseTime)
		return;

		if (_closeHistory.Count < HoursToCheckTrend)
		return;

		var lastClose = _closeHistory[0];
		var index = HoursToCheckTrend - 1;
		if (index < 0 || index >= _closeHistory.Count)
		return;

		var referenceClose = _closeHistory[index];
		if (lastClose == referenceClose)
		return;

		var goLong = referenceClose > lastClose;

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

		var entryPrice = candle.ClosePrice;

		if (goLong)
		{
			BuyMarket(volume);
			_positionDirection = 1;
		}
		else
		{
			SellMarket(volume);
			_positionDirection = -1;
		}

		_entryPrice = entryPrice;
		_entryTime = candle.CloseTime;
		_entryVolume = volume;
		_lastTradeBarTime = candle.CloseTime;

		var stopDistance = StopLossPips * _pipSize;
		_stopPrice = stopDistance > 0m
		? _positionDirection > 0
		? entryPrice - stopDistance
		: entryPrice + stopDistance
		: null;

		var takeDistance = TakeProfitPips * _pipSize;
		_takeProfitPrice = takeDistance > 0m
		? _positionDirection > 0
		? entryPrice + takeDistance
		: entryPrice - takeDistance
		: null;
	}

	private void ExitPosition(decimal exitPrice)
	{
		var direction = _positionDirection;
		if (direction == 0)
		return;

		var volume = Math.Abs(Position);
		if (volume <= 0m)
		{
			volume = Math.Abs(_entryVolume);
		}

		if (volume <= 0m)
		{
			ResetPositionState();
			return;
		}

		if (direction > 0)
		{
			SellMarket(volume);
		}
		else
		{
			BuyMarket(volume);
		}

		if (_entryPrice is decimal entryPrice)
		{
			var isLoss = direction > 0
			? exitPrice < entryPrice
			: exitPrice > entryPrice;

			RegisterTradeResult(isLoss);
		}
		else
		{
			ResetPositionState();
		}
	}

	private void RegisterTradeResult(bool isLoss)
	{
		_recentLosses.Insert(0, isLoss);
		if (_recentLosses.Count > 5)
		{
			_recentLosses.RemoveRange(5, _recentLosses.Count - 5);
		}

		ResetPositionState();
	}

	private void ResetPositionState()
	{
		_positionDirection = 0;
		_entryPrice = null;
		_entryTime = null;
		_entryVolume = 0m;
		_stopPrice = null;
		_takeProfitPrice = null;
	}

	private decimal CalculateOrderVolume()
	{
		var baseVolume = FixedVolume;

		if (baseVolume <= 0m)
		{
			baseVolume = CalculateRiskVolume();
		}

		if (baseVolume <= 0m)
		return 0m;

		var multiplier = GetMultiplierFromHistory();
		var desired = AlignVolume(baseVolume * multiplier);

		return desired;
	}

	private decimal CalculateRiskVolume()
	{
		if (RiskPercent <= 0m)
		return MinVolume > 0m ? MinVolume : 0m;

		var portfolio = Portfolio;
		var balance = portfolio?.CurrentValue ?? portfolio?.BeginValue ?? 0m;
		if (balance <= 0m)
		return MinVolume > 0m ? MinVolume : 0m;

		var raw = balance * RiskPercent / 1000m;
		return raw;
	}

	private decimal GetMultiplierFromHistory()
	{
		for (var index = 0; index < _recentLosses.Count && index < 5; index++)
		{
			if (!_recentLosses[index])
			continue;

			return index switch
			{
				0 => FirstMultiplier,
				1 => SecondMultiplier,
				2 => ThirdMultiplier,
				3 => FourthMultiplier,
				4 => FifthMultiplier,
				_ => 1m,
			};
		}

		return 1m;
	}

	private decimal AlignVolume(decimal volume)
	{
		var security = Security;
		if (security != null)
		{
			var min = security.MinVolume ?? 0m;
			var max = security.MaxVolume ?? decimal.MaxValue;
			var step = security.VolumeStep ?? 0m;

			if (step > 0m)
			{
				volume = Math.Round(volume / step) * step;
			}

			if (min > 0m && volume < min)
			volume = min;

			if (max > 0m && volume > max)
			volume = max;
		}

		if (MinVolume > 0m && volume < MinVolume)
		volume = MinVolume;

		if (MaxVolume > 0m && volume > MaxVolume)
		volume = MaxVolume;

		return volume;
	}

	private decimal CalculatePipSize()
	{
		var security = Security;
		if (security == null)
		return 0.0001m;

		var step = security.PriceStep ?? 0.0001m;
		var decimals = security.Decimals;

		if ((decimals == 3 || decimals == 5) && step > 0m)
		{
			return step * 10m;
		}

		return step > 0m ? step : 0.0001m;
	}

	private bool IsHourAllowed(int hour)
	{
		if (_allowedHours.Count == 0)
		return true;

		return _allowedHours.Contains(hour);
	}

	private void UpdateTradingHours()
	{
		_allowedHours.Clear();

		var raw = _tradingDayHours.Value;
		if (raw.IsEmptyOrWhiteSpace())
		{
			FillFullDay();
			return;
		}

		var parts = raw.Split(',', StringSplitOptions.RemoveEmptyEntries);
		foreach (var part in parts)
		{
			var trimmed = part.Trim();
			if (trimmed.Length == 0)
			continue;

			if (trimmed.Contains('-', StringComparison.Ordinal))
			{
				var rangeParts = trimmed.Split('-', StringSplitOptions.RemoveEmptyEntries);
				if (rangeParts.Length != 2)
				continue;

				if (TryParseHour(rangeParts[0], out var start) && TryParseHour(rangeParts[1], out var end))
				{
					if (end < start)
					{
						(end, start) = (start, end);
					}

					for (var hour = start; hour <= end; hour++)
					{
						_allowedHours.Add(hour);
					}
				}
			}
			else if (TryParseHour(trimmed, out var value))
			{
				_allowedHours.Add(value);
			}
		}

		if (_allowedHours.Count == 0)
		{
			FillFullDay();
		}
	}

	private static bool TryParseHour(string text, out int hour)
	{
		if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out hour))
		{
			if (hour >= 0 && hour <= 23)
			return true;
		}

		hour = 0;
		return false;
	}

	private void FillFullDay()
	{
		_allowedHours.Clear();
		for (var hour = 0; hour < 24; hour++)
		{
			_allowedHours.Add(hour);
		}
	}
}