TwentyPipsOnceADayStrategy
该策略基于 MetaTrader 专家顾问 20pipsOnceADayOppositeLastNHourTrend,并使用 StockSharp 高阶 API 重新实现。策略每天在指定的小时执行一次交易,通过比较最近一小时与 N 小时前的收盘价,逆势开仓。仓位规模采用阶梯式马丁格尔,只在最近的交易出现亏损时才放大手数;同时还增加了交易时段过滤、可选的移动止损以及最大持仓时间限制。
交易逻辑
- 订阅指定周期的蜡烛(默认 1 小时,可通过
CandleType修改)。 - 当一根蜡烛收盘且下一根蜡烛的小时数等于
TradingHour时执行信号判断:- 取最近一根完整蜡烛的收盘价,与
HoursToCheckTrend小时前的收盘价比较。 - 如果价格下跌,则在新小时开多仓;如果价格上涨,则开空仓。
- 取最近一根完整蜡烛的收盘价,与
- 同一时间仅允许存在一笔仓位(由
MaxOrders控制,默认 1)。 - 每笔交易都会附带固定止盈、可选止损与移动止损,距离均以点数(pip)定义,并自动转换为品种价格单位。
- 若持仓时间超过
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 |
持仓时间上限(秒)。 |
FirstMultiplier … FifthMultiplier |
针对最近五笔亏损交易的马丁格尔倍率。 |
StopLossPips |
初始止损距离(pip),设为 0 关闭。 |
TrailingStopPips |
移动止损距离(pip),设为 0 关闭。 |
TakeProfitPips |
止盈距离(pip)。 |
CandleType |
信号所用蜡烛类型,默认一小时。 |
风险控制与离场
- 止盈/止损:使用
TakeProfitPips、StopLossPips配置,自动转换为价格单位。 - 移动止损:当浮盈超过设定点数时,将止损向有利方向移动。
- 超时平仓:仓位持有时间超过
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);
}
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
from StockSharp.Algo.Indicators import SimpleMovingAverage
class twenty_pips_once_a_day_strategy(Strategy):
def __init__(self):
super(twenty_pips_once_a_day_strategy, self).__init__()
self._fixed_volume = self.Param("FixedVolume", 0.1) \
.SetDisplay("Fixed Volume", "Fixed trading volume (set to 0 to use risk based sizing)", "Risk")
self._min_volume = self.Param("MinVolume", 0.1) \
.SetDisplay("Min Volume", "Lower volume bound applied after sizing", "Risk")
self._max_volume = self.Param("MaxVolume", 5.0) \
.SetDisplay("Max Volume", "Upper volume bound applied after sizing", "Risk")
self._risk_percent = self.Param("RiskPercent", 5.0) \
.SetDisplay("Risk Percent", "Percentage of portfolio value converted into volume", "Risk")
self._max_orders = self.Param("MaxOrders", 1) \
.SetDisplay("Max Orders", "Maximum number of simultaneously open positions", "Trading")
self._trading_hour = self.Param("TradingHour", 7) \
.SetDisplay("Trading Hour", "Hour of day when the strategy evaluates signals", "Schedule")
self._trading_day_hours = self.Param("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 of allowed session hours", "Schedule")
self._hours_to_check_trend = self.Param("HoursToCheckTrend", 30) \
.SetDisplay("Hours To Check", "Number of historical hourly closes used for the contrarian check", "Signals")
self._order_max_age_seconds = self.Param("OrderMaxAgeSeconds", 75600) \
.SetDisplay("Max Position Age (s)", "Maximum holding time in seconds before forcing an exit", "Risk")
self._first_multiplier = self.Param("FirstMultiplier", 4) \
.SetDisplay("First Multiplier", "Multiplier applied after the most recent loss", "Money Management")
self._second_multiplier = self.Param("SecondMultiplier", 2) \
.SetDisplay("Second Multiplier", "Multiplier applied when the last win was preceded by a loss", "Money Management")
self._third_multiplier = self.Param("ThirdMultiplier", 5) \
.SetDisplay("Third Multiplier", "Multiplier applied when the third latest trade was a loss", "Money Management")
self._fourth_multiplier = self.Param("FourthMultiplier", 5) \
.SetDisplay("Fourth Multiplier", "Multiplier applied when the fourth latest trade was a loss", "Money Management")
self._fifth_multiplier = self.Param("FifthMultiplier", 1) \
.SetDisplay("Fifth Multiplier", "Multiplier applied when the fifth latest trade was a loss", "Money Management")
self._stop_loss_pips = self.Param("StopLossPips", 50.0) \
.SetDisplay("Stop Loss (pips)", "Stop loss distance expressed in pips", "Risk")
self._trailing_stop_pips = self.Param("TrailingStopPips", 0.0) \
.SetDisplay("Trailing Stop (pips)", "Trailing stop distance expressed in pips", "Risk")
self._take_profit_pips = self.Param("TakeProfitPips", 10.0) \
.SetDisplay("Take Profit (pips)", "Take profit distance expressed in pips", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(1))) \
.SetDisplay("Candle Type", "Timeframe used for signal calculations", "Market Data")
self._close_history = []
self._recent_losses = []
self._allowed_hours = set()
self._sma = None
self._last_trade_bar_time = None
self._entry_time = None
self._entry_price = None
self._stop_price = None
self._take_profit_price = None
self._entry_volume = 0.0
self._position_direction = 0
self._pip_size = 0.0
@property
def FixedVolume(self):
return self._fixed_volume.Value
@property
def MinVolume(self):
return self._min_volume.Value
@property
def MaxVolume(self):
return self._max_volume.Value
@property
def RiskPercent(self):
return self._risk_percent.Value
@property
def MaxOrders(self):
return self._max_orders.Value
@property
def TradingHour(self):
return self._trading_hour.Value
@property
def TradingDayHours(self):
return self._trading_day_hours.Value
@property
def HoursToCheckTrend(self):
return self._hours_to_check_trend.Value
@property
def OrderMaxAgeSeconds(self):
return self._order_max_age_seconds.Value
@property
def FirstMultiplier(self):
return self._first_multiplier.Value
@property
def SecondMultiplier(self):
return self._second_multiplier.Value
@property
def ThirdMultiplier(self):
return self._third_multiplier.Value
@property
def FourthMultiplier(self):
return self._fourth_multiplier.Value
@property
def FifthMultiplier(self):
return self._fifth_multiplier.Value
@property
def StopLossPips(self):
return self._stop_loss_pips.Value
@property
def TrailingStopPips(self):
return self._trailing_stop_pips.Value
@property
def TakeProfitPips(self):
return self._take_profit_pips.Value
@property
def CandleType(self):
return self._candle_type.Value
def _calculate_pip_size(self):
if self.Security is None:
return 0.0001
ps = self.Security.PriceStep
step = float(ps) if ps is not None else 0.0001
decimals = self.Security.Decimals if self.Security.Decimals is not None else 0
if (decimals == 3 or decimals == 5) and step > 0:
return step * 10.0
return step if step > 0 else 0.0001
def _update_trading_hours(self):
self._allowed_hours = set()
raw = str(self.TradingDayHours) if self.TradingDayHours is not None else ""
if raw.strip() == "":
for h in range(24):
self._allowed_hours.add(h)
return
parts = raw.split(",")
for part in parts:
trimmed = part.strip()
if len(trimmed) == 0:
continue
if "-" in trimmed:
range_parts = trimmed.split("-")
if len(range_parts) != 2:
continue
try:
start = int(range_parts[0].strip())
end = int(range_parts[1].strip())
except ValueError:
continue
if start < 0 or start > 23 or end < 0 or end > 23:
continue
if end < start:
start, end = end, start
for h in range(start, end + 1):
self._allowed_hours.add(h)
else:
try:
val = int(trimmed)
except ValueError:
continue
if 0 <= val <= 23:
self._allowed_hours.add(val)
if len(self._allowed_hours) == 0:
for h in range(24):
self._allowed_hours.add(h)
def _is_hour_allowed(self, hour):
if len(self._allowed_hours) == 0:
return True
return hour in self._allowed_hours
def OnStarted2(self, time):
super(twenty_pips_once_a_day_strategy, self).OnStarted2(time)
self._pip_size = self._calculate_pip_size()
self._update_trading_hours()
self._sma = SimpleMovingAverage()
self._sma.Length = 2
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self._sma, self.ProcessCandle).Start()
def ProcessCandle(self, candle, sma_value):
if candle.State != CandleStates.Finished:
return
self._add_close_to_history(float(candle.ClosePrice))
if self._position_direction != 0:
self._manage_open_position(candle)
if self._position_direction != 0:
self._enforce_session_limits(candle)
self._try_open_position(candle)
def _add_close_to_history(self, close_price):
hours = self.HoursToCheckTrend
if hours <= 0:
return
self._close_history.insert(0, close_price)
required = max(hours, 5)
while len(self._close_history) > required:
self._close_history.pop()
def _manage_open_position(self, candle):
if self._position_direction == 0 or self._entry_price is None:
return
direction = self._position_direction
close_price = float(candle.ClosePrice)
entry_price = self._entry_price
pip = self._pip_size
stop_distance = float(self.StopLossPips) * pip
if self._stop_price is None and stop_distance > 0:
if direction > 0:
self._stop_price = entry_price - stop_distance
else:
self._stop_price = entry_price + stop_distance
trailing_distance = float(self.TrailingStopPips) * pip
if trailing_distance > 0:
if direction > 0:
profit = close_price - entry_price
if profit > trailing_distance:
candidate = close_price - trailing_distance
if self._stop_price is None or candidate > self._stop_price:
self._stop_price = candidate
else:
profit = entry_price - close_price
if profit > trailing_distance:
candidate = close_price + trailing_distance
if self._stop_price is None or candidate < self._stop_price:
self._stop_price = candidate
if self._take_profit_price is not None:
if direction > 0:
hit_target = float(candle.HighPrice) >= self._take_profit_price
else:
hit_target = float(candle.LowPrice) <= self._take_profit_price
if hit_target:
self._exit_position(self._take_profit_price)
return
if self._stop_price is not None:
if direction > 0:
hit_stop = float(candle.LowPrice) <= self._stop_price
else:
hit_stop = float(candle.HighPrice) >= self._stop_price
if hit_stop:
self._exit_position(self._stop_price)
return
max_age = self.OrderMaxAgeSeconds
if max_age > 0 and self._entry_time is not None:
age = candle.CloseTime - self._entry_time
if age.TotalSeconds >= max_age:
self._exit_position(close_price)
def _enforce_session_limits(self, candle):
if self._position_direction == 0:
return
next_hour = candle.CloseTime.Hour
if not self._is_hour_allowed(next_hour):
self._exit_position(float(candle.ClosePrice))
def _try_open_position(self, candle):
if self.MaxOrders <= 0 or self._position_direction != 0:
return
next_hour = candle.CloseTime.Hour
if next_hour != self.TradingHour or not self._is_hour_allowed(next_hour):
return
if self._last_trade_bar_time is not None and self._last_trade_bar_time == candle.CloseTime:
return
hours_check = self.HoursToCheckTrend
if len(self._close_history) < hours_check:
return
last_close = self._close_history[0]
index = hours_check - 1
if index < 0 or index >= len(self._close_history):
return
reference_close = self._close_history[index]
if last_close == reference_close:
return
go_long = reference_close > last_close
volume = self._calculate_order_volume()
if volume <= 0:
return
entry_price = float(candle.ClosePrice)
pip = self._pip_size
if go_long:
self.BuyMarket(volume)
self._position_direction = 1
else:
self.SellMarket(volume)
self._position_direction = -1
self._entry_price = entry_price
self._entry_time = candle.CloseTime
self._entry_volume = volume
self._last_trade_bar_time = candle.CloseTime
stop_distance = float(self.StopLossPips) * pip
if stop_distance > 0:
if self._position_direction > 0:
self._stop_price = entry_price - stop_distance
else:
self._stop_price = entry_price + stop_distance
else:
self._stop_price = None
take_distance = float(self.TakeProfitPips) * pip
if take_distance > 0:
if self._position_direction > 0:
self._take_profit_price = entry_price + take_distance
else:
self._take_profit_price = entry_price - take_distance
else:
self._take_profit_price = None
def _exit_position(self, exit_price):
direction = self._position_direction
if direction == 0:
return
volume = Math.Abs(self.Position)
if volume <= 0:
volume = Math.Abs(self._entry_volume)
if volume <= 0:
self._reset_position_state()
return
if direction > 0:
self.SellMarket(volume)
else:
self.BuyMarket(volume)
if self._entry_price is not None:
if direction > 0:
is_loss = exit_price < self._entry_price
else:
is_loss = exit_price > self._entry_price
self._register_trade_result(is_loss)
else:
self._reset_position_state()
def _register_trade_result(self, is_loss):
self._recent_losses.insert(0, is_loss)
while len(self._recent_losses) > 5:
self._recent_losses.pop()
self._reset_position_state()
def _reset_position_state(self):
self._position_direction = 0
self._entry_price = None
self._entry_time = None
self._entry_volume = 0.0
self._stop_price = None
self._take_profit_price = None
def _calculate_order_volume(self):
base_volume = float(self.FixedVolume)
if base_volume <= 0:
base_volume = self._calculate_risk_volume()
if base_volume <= 0:
return 0.0
multiplier = self._get_multiplier_from_history()
desired = self._align_volume(base_volume * multiplier)
return desired
def _calculate_risk_volume(self):
risk_pct = float(self.RiskPercent)
min_vol = float(self.MinVolume)
if risk_pct <= 0:
return min_vol if min_vol > 0 else 0.0
balance = 0.0
if self.Portfolio is not None:
cv = self.Portfolio.CurrentValue
if cv is not None and float(cv) > 0:
balance = float(cv)
elif self.Portfolio.BeginValue is not None:
balance = float(self.Portfolio.BeginValue)
if balance <= 0:
return min_vol if min_vol > 0 else 0.0
raw = balance * risk_pct / 1000.0
return raw
def _get_multiplier_from_history(self):
multipliers = [
float(self.FirstMultiplier),
float(self.SecondMultiplier),
float(self.ThirdMultiplier),
float(self.FourthMultiplier),
float(self.FifthMultiplier),
]
for index in range(min(len(self._recent_losses), 5)):
if not self._recent_losses[index]:
continue
if index < len(multipliers):
return multipliers[index]
return 1.0
return 1.0
def _align_volume(self, volume):
if self.Security is not None:
min_sec = self.Security.MinVolume
max_sec = self.Security.MaxVolume
step_sec = self.Security.VolumeStep
min_val = float(min_sec) if min_sec is not None else 0.0
max_val = float(max_sec) if max_sec is not None else 0.0
step = float(step_sec) if step_sec is not None else 0.0
if step > 0:
volume = round(volume / step) * step
if min_val > 0 and volume < min_val:
volume = min_val
if max_val > 0 and volume > max_val:
volume = max_val
min_vol = float(self.MinVolume)
max_vol = float(self.MaxVolume)
if min_vol > 0 and volume < min_vol:
volume = min_vol
if max_vol > 0 and volume > max_vol:
volume = max_vol
return volume
def OnReseted(self):
super(twenty_pips_once_a_day_strategy, self).OnReseted()
self._close_history = []
self._recent_losses = []
self._allowed_hours = set()
self._sma = None
self._last_trade_bar_time = None
self._entry_time = None
self._entry_price = None
self._stop_price = None
self._take_profit_price = None
self._entry_volume = 0.0
self._position_direction = 0
self._pip_size = 0.0
self._update_trading_hours()
def CreateClone(self):
return twenty_pips_once_a_day_strategy()