Ilan 1.4 篮式网格策略
概述
Ilan 1.4 是典型的网格加仓系统。本策略订阅单一周期的 K 线序列,根据最近两根已完成 K 线的收盘价决定首单方向:如果最新收盘价低于更早的收盘价,则以卖单开启篮子,否则开多。价格按照配置的 Pip Step 逆向移动时,策略可选择性地在同一方向加仓,并重新计算加权平均建仓价。
所有交易均以市价成交。当价格回到平均建仓价并达到 Take Profit 距离时,整篮订单一起平仓。策略同时还实现了移动止损、固定止损、基于权益的紧急止损以及最长持仓时间限制,与原 MetaTrader 智能交易系统的保护模块保持一致。
交易规则
- 等待新的 K 线收盘,读取最近两根收盘价。
- 当没有持仓时,如果最新收盘价高于前一收盘价则开多,否则开空。
- 维护最近一次成交价与篮子的加权平均建仓价。
- 当启用 Use Add 且价格逆向运行超过 Pip Step 点时,计算下一手数并在同方向加仓;若启用 Close Before Adding,则先平掉当前篮子,再按照放大后的手数重新开仓。
- 每次成交后重新计算平均价,一旦价格触及平均价加上(或减去)盈利距离或任一风险控制触发,立即平掉整个篮子。
- 篮子平仓后立即根据最近两根收盘价准备新的入场信号。
资金管理模式
Money Management 参数对应原脚本中的 MMType:
- Fixed:每次下单都使用 Initial Volume 指定的基础手数。
- Geometric:后续订单的手数按照
LotExponent^n放大,n为当前已开订单数量。 - RecoverLastLoss:若上一次篮子亏损,则下一次使用上一笔成交手数乘以 Lot Exponent;如果上一次获利,则恢复到基础手数。
手数会根据 Volume Digits 以及交易品种的最小成交步长进行四舍五入;若四舍五入后为零,则退回未取整的输入值。
风险控制
- Take Profit:价格到达平均建仓价 ± 设定点数时整体平仓。
- Stop Loss:价格相对平均建仓价逆向超出设定点数时平仓。
- Use Trailing Stop 配合 Trail Start 与 Trail Stop:在篮子盈利达到阈值后启动移动止损,跟随价格保护利润。
- Use Equity Stop 配合 Equity Risk %:监控投资组合权益,当浮亏超过历史权益峰值的一定比例时强制平仓。
- Use Timeout 配合 Max Open Hours:当篮子持仓时间超过设定小时数时强制平仓。
参数说明
- Candle Type:用于生成信号的 K 线周期。
- Initial Volume:新篮子初始手数。
- Volume Digits:手数保留的小数位数。
- Money Management:手数计算模式(
Fixed、Geometric、RecoverLastLoss)。 - Lot Exponent:几何放大及亏损恢复模式使用的乘数。
- Close Before Adding:在加仓前先平掉当前篮子。
- Use Add:是否允许加仓。
- Pip Step:触发加仓所需的逆向点数。
- Take Profit:平均价附近的获利目标。
- Stop Loss:平均价允许的最大逆向距离。
- Use Trailing Stop / Trail Start / Trail Stop:移动止损设置。
- Max Trades:单个篮子允许的最大加仓次数。
- Use Equity Stop / Equity Risk %:浮亏保护参数。
- Use Timeout / Max Open Hours:篮子最长持仓时间设置。
转换说明
- 原 EA 中的挂单函数被直接替换为市价交易,因为原逻辑始终立即执行。
- 移动止损针对整个篮子计算,而非逐单修改,触发阈值保持与脚本一致。
- 通过 StockSharp 的投资组合对象跟踪权益,以复现脚本中的权益止损机制。
- 策略内部通过累计变量计算平均价和篮子数据,遵循高层 API 要求,不额外存储逐笔集合。
namespace StockSharp.Samples.Strategies;
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;
/// <summary>
/// Grid and martingale strategy converted from the MetaTrader Ilan 1.4 expert advisor.
/// The strategy opens an initial trade based on the last two candle closes and adds
/// averaging positions whenever price moves against the basket by the configured step.
/// </summary>
public class Ilan14GridStrategy : Strategy
{
private readonly StrategyParam<decimal> _initialVolume;
private readonly StrategyParam<int> _volumeDigits;
private readonly StrategyParam<MoneyManagementModes> _moneyManagementMode;
private readonly StrategyParam<bool> _useCloseBeforeAdding;
private readonly StrategyParam<bool> _useAdd;
private readonly StrategyParam<decimal> _lotExponent;
private readonly StrategyParam<decimal> _pipStep;
private readonly StrategyParam<decimal> _takeProfit;
private readonly StrategyParam<decimal> _stopLoss;
private readonly StrategyParam<bool> _useTrailingStop;
private readonly StrategyParam<decimal> _trailStart;
private readonly StrategyParam<decimal> _trailStop;
private readonly StrategyParam<int> _maxTrades;
private readonly StrategyParam<bool> _useEquityStop;
private readonly StrategyParam<decimal> _equityRiskPercent;
private readonly StrategyParam<bool> _useTimeOut;
private readonly StrategyParam<decimal> _maxTradeOpenHours;
private readonly StrategyParam<DataType> _candleType;
private int _tradeCount;
private decimal _averagePrice;
private decimal _totalVolume;
private decimal _lastEntryPrice;
private decimal _lastEntryVolume;
private decimal _equityPeak;
private decimal _lastClosedOrderVolume;
private bool _lastClosedWasLoss;
private decimal? _previousClose;
private decimal? _trailingStopLevel;
private DateTimeOffset? _basketExpiration;
private Sides? _basketDirection;
public enum MoneyManagementModes
{
Fixed,
Geometric,
RecoverLastLoss,
}
public Ilan14GridStrategy()
{
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(8).TimeFrame())
.SetDisplay("Candle type", "Timeframe that feeds the strategy logic.", "General");
_initialVolume = Param(nameof(InitialVolume), 0.1m)
.SetGreaterThanZero()
.SetDisplay("Initial volume", "Base volume used for the first trade in a basket.", "Trading")
.SetOptimize(0.01m, 1m, 0.01m);
_volumeDigits = Param(nameof(VolumeDigits), 2)
.SetNotNegative()
.SetDisplay("Volume digits", "Number of decimal places used to round trade volume.", "Trading");
_moneyManagementMode = Param(nameof(MoneyManagementModes), MoneyManagementModes.Geometric)
.SetDisplay("Money management", "Volume calculation mode: fixed, geometric martingale or recover last loss.", "Trading");
_lotExponent = Param(nameof(LotExponent), 1.667m)
.SetGreaterThanZero()
.SetDisplay("Lot exponent", "Multiplier applied when calculating the next position size.", "Trading")
.SetOptimize(1.1m, 2m, 0.1m);
_useCloseBeforeAdding = Param(nameof(UseCloseBeforeAdding), false)
.SetDisplay("Close before adding", "Close the current basket before opening the next averaging order.", "Trading");
_useAdd = Param(nameof(UseAdd), true)
.SetDisplay("Allow averaging", "Enable opening of additional orders when price moves against the basket.", "Trading");
_pipStep = Param(nameof(PipStep), 30m)
.SetGreaterThanZero()
.SetDisplay("Pip step", "Distance in price steps that triggers a new averaging trade.", "Trading")
.SetOptimize(10m, 100m, 5m);
_takeProfit = Param(nameof(TakeProfit), 10m)
.SetGreaterThanZero()
.SetDisplay("Take profit", "Distance from the average price where the basket is closed in profit.", "Trading")
.SetOptimize(5m, 50m, 5m);
_stopLoss = Param(nameof(StopLoss), 500m)
.SetNotNegative()
.SetDisplay("Stop loss", "Maximum adverse distance from the average price before the basket is closed.", "Risk");
_useTrailingStop = Param(nameof(UseTrailingStop), false)
.SetDisplay("Use trailing stop", "Enable dynamic trailing of profits once the basket gains enough points.", "Risk");
_trailStart = Param(nameof(TrailStart), 10m)
.SetNotNegative()
.SetDisplay("Trail start", "Profit distance in points required before the trailing stop activates.", "Risk");
_trailStop = Param(nameof(TrailStop), 10m)
.SetNotNegative()
.SetDisplay("Trail distance", "Gap between current price and trailing stop when it is active.", "Risk");
_maxTrades = Param(nameof(MaxTrades), 10)
.SetGreaterThanZero()
.SetDisplay("Max trades", "Maximum number of averaging orders allowed in one basket.", "Trading")
.SetOptimize(1, 15, 1);
_useEquityStop = Param(nameof(UseEquityStop), false)
.SetDisplay("Use equity stop", "Close the basket if floating loss exceeds a share of the equity peak.", "Risk");
_equityRiskPercent = Param(nameof(EquityRiskPercent), 20m)
.SetNotNegative()
.SetDisplay("Equity risk %", "Percentage of the recorded equity peak tolerated as floating loss.", "Risk");
_useTimeOut = Param(nameof(UseTimeOut), false)
.SetDisplay("Use timeout", "Close all trades once the basket has been open for too long.", "Risk");
_maxTradeOpenHours = Param(nameof(MaxTradeOpenHours), 48m)
.SetNotNegative()
.SetDisplay("Max open hours", "Maximum lifetime of the basket before it is forcefully closed.", "Risk");
}
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
public decimal InitialVolume
{
get => _initialVolume.Value;
set => _initialVolume.Value = value;
}
public int VolumeDigits
{
get => _volumeDigits.Value;
set => _volumeDigits.Value = value;
}
public MoneyManagementModes MoneyManagementMode
{
get => _moneyManagementMode.Value;
set => _moneyManagementMode.Value = value;
}
public bool UseCloseBeforeAdding
{
get => _useCloseBeforeAdding.Value;
set => _useCloseBeforeAdding.Value = value;
}
public bool UseAdd
{
get => _useAdd.Value;
set => _useAdd.Value = value;
}
public decimal LotExponent
{
get => _lotExponent.Value;
set => _lotExponent.Value = value;
}
public decimal PipStep
{
get => _pipStep.Value;
set => _pipStep.Value = value;
}
public decimal TakeProfit
{
get => _takeProfit.Value;
set => _takeProfit.Value = value;
}
public decimal StopLoss
{
get => _stopLoss.Value;
set => _stopLoss.Value = value;
}
public bool UseTrailingStop
{
get => _useTrailingStop.Value;
set => _useTrailingStop.Value = value;
}
public decimal TrailStart
{
get => _trailStart.Value;
set => _trailStart.Value = value;
}
public decimal TrailStop
{
get => _trailStop.Value;
set => _trailStop.Value = value;
}
public int MaxTrades
{
get => _maxTrades.Value;
set => _maxTrades.Value = value;
}
public bool UseEquityStop
{
get => _useEquityStop.Value;
set => _useEquityStop.Value = value;
}
public decimal EquityRiskPercent
{
get => _equityRiskPercent.Value;
set => _equityRiskPercent.Value = value;
}
public bool UseTimeOut
{
get => _useTimeOut.Value;
set => _useTimeOut.Value = value;
}
public decimal MaxTradeOpenHours
{
get => _maxTradeOpenHours.Value;
set => _maxTradeOpenHours.Value = value;
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> [(Security, CandleType)];
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
ResetState();
_previousClose = null;
_lastClosedWasLoss = false;
_lastClosedOrderVolume = 0m;
_equityPeak = 0m;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
StartProtection(null, null);
ResetState();
_previousClose = null;
_lastClosedWasLoss = false;
var baseVolume = RoundVolume(InitialVolume);
if (baseVolume <= 0m)
baseVolume = InitialVolume;
_lastClosedOrderVolume = baseVolume;
Volume = baseVolume;
_equityPeak = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;
var subscription = SubscribeCandles(CandleType);
subscription.Bind(ProcessCandle).Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
var price = candle.ClosePrice;
var step = Security?.PriceStep ?? 1m;
if (step <= 0m)
step = 1m;
UpdateEquityPeak();
if (!IsFormedAndOnlineAndAllowTrading())
{
_previousClose = candle.ClosePrice;
return;
}
if (_tradeCount > 0)
{
if (UseTimeOut && _basketExpiration != null && candle.CloseTime >= _basketExpiration)
CloseBasket(price);
if (_tradeCount > 0 && UseEquityStop)
{
var floatingLoss = CalculateFloatingProfit(price);
var threshold = (EquityRiskPercent / 100m) * _equityPeak;
if (floatingLoss < 0m && Math.Abs(floatingLoss) > threshold && threshold > 0m)
CloseBasket(price);
}
if (_tradeCount > 0 && UseTrailingStop && TrailStop > 0m)
{
if (UpdateTrailingStop(price, step))
CloseBasket(price);
}
if (_tradeCount > 0 && StopLoss > 0m && _basketDirection != null)
{
var stopDistance = StopLoss * step;
if (_basketDirection == Sides.Buy && price <= _averagePrice - stopDistance)
CloseBasket(price);
else if (_basketDirection == Sides.Sell && price >= _averagePrice + stopDistance)
CloseBasket(price);
}
if (_tradeCount > 0 && TakeProfit > 0m && _basketDirection != null)
{
var targetDistance = TakeProfit * step;
if (_basketDirection == Sides.Buy && price >= _averagePrice + targetDistance)
CloseBasket(price);
else if (_basketDirection == Sides.Sell && price <= _averagePrice - targetDistance)
CloseBasket(price);
}
if (_tradeCount > 0 && UseAdd && _tradeCount <= MaxTrades && _basketDirection != null)
{
var trigger = PipStep * step;
if (trigger > 0m)
{
if (_basketDirection == Sides.Buy && _lastEntryPrice - price >= trigger)
TryAddPosition(price, candle.CloseTime, true);
else if (_basketDirection == Sides.Sell && price - _lastEntryPrice >= trigger)
TryAddPosition(price, candle.CloseTime, false);
}
}
}
if (_tradeCount == 0)
{
if (_previousClose is null)
{
_previousClose = candle.ClosePrice;
return;
}
var directionIsLong = _previousClose <= candle.ClosePrice;
var volume = CalculateTradeVolume();
if (volume > 0m)
OpenPosition(directionIsLong ? Sides.Buy : Sides.Sell, price, volume, candle.CloseTime);
}
_previousClose = candle.ClosePrice;
}
private void TryAddPosition(decimal price, DateTimeOffset time, bool isLong)
{
if (!UseAdd)
return;
if (UseCloseBeforeAdding)
{
var referenceVolume = _lastEntryVolume;
if (referenceVolume <= 0m)
referenceVolume = CalculateTradeVolume();
var nextVolume = RoundVolume(referenceVolume * LotExponent);
if (nextVolume <= 0m)
return;
CloseBasket(price);
OpenPosition(isLong ? Sides.Buy : Sides.Sell, price, nextVolume, time);
}
else
{
var volume = CalculateTradeVolume();
if (volume <= 0m)
return;
OpenPosition(isLong ? Sides.Buy : Sides.Sell, price, volume, time);
}
}
private void OpenPosition(Sides direction, decimal price, decimal volume, DateTimeOffset time)
{
var roundedVolume = RoundVolume(volume);
if (roundedVolume <= 0m)
return;
var isFirstTrade = _tradeCount == 0;
if (direction == Sides.Buy)
BuyMarket(roundedVolume);
else
SellMarket(roundedVolume);
if (isFirstTrade)
{
_basketDirection = direction;
_averagePrice = price;
_totalVolume = roundedVolume;
_tradeCount = 1;
}
else
{
var previousVolume = _totalVolume;
_totalVolume += roundedVolume;
_averagePrice = ((_averagePrice * previousVolume) + price * roundedVolume) / _totalVolume;
_tradeCount++;
}
_lastEntryPrice = price;
_lastEntryVolume = roundedVolume;
_trailingStopLevel = null;
if (isFirstTrade)
{
if (UseTimeOut && MaxTradeOpenHours > 0m)
_basketExpiration = time + TimeSpan.FromHours((double)MaxTradeOpenHours);
else
_basketExpiration = null;
}
}
private void CloseBasket(decimal price)
{
if (_tradeCount == 0)
return;
var volume = Math.Abs(Position);
if (volume > 0m)
{
if (_basketDirection == Sides.Buy)
{
if (Position > 0m)
SellMarket(Position);
}
else if (_basketDirection == Sides.Sell)
{
if (Position < 0m)
BuyMarket(-Position);
}
else
{
if (Position > 0m)
SellMarket(Position);
else if (Position < 0m)
BuyMarket(-Position);
}
}
if (_basketDirection != null && _totalVolume > 0m)
{
var diff = _basketDirection == Sides.Buy
? price - _averagePrice
: _averagePrice - price;
var profit = diff * _totalVolume;
_lastClosedWasLoss = profit < 0m;
_lastClosedOrderVolume = _lastEntryVolume > 0m ? _lastEntryVolume : InitialVolume;
}
ResetState();
}
private void ResetState()
{
_tradeCount = 0;
_averagePrice = 0m;
_totalVolume = 0m;
_lastEntryPrice = 0m;
_lastEntryVolume = 0m;
_trailingStopLevel = null;
_basketExpiration = null;
_basketDirection = null;
}
private void UpdateEquityPeak()
{
if (Portfolio == null)
return;
var current = Portfolio.CurrentValue ?? Portfolio.BeginValue ?? 0m;
if (_tradeCount == 0 || _equityPeak <= 0m)
_equityPeak = current;
else if (current > _equityPeak)
_equityPeak = current;
}
private decimal CalculateFloatingProfit(decimal price)
{
if (_tradeCount == 0 || _totalVolume <= 0m || _basketDirection == null)
return 0m;
return _basketDirection == Sides.Buy
? (price - _averagePrice) * _totalVolume
: (_averagePrice - price) * _totalVolume;
}
private bool UpdateTrailingStop(decimal price, decimal step)
{
if (_basketDirection == null || step <= 0m)
return false;
if (_basketDirection == Sides.Buy)
{
var profit = price - _averagePrice;
if (profit < TrailStart * step)
return false;
var candidate = price - TrailStop * step;
if (_trailingStopLevel is null || candidate > _trailingStopLevel)
_trailingStopLevel = candidate;
return _trailingStopLevel is not null && price <= _trailingStopLevel;
}
var shortProfit = _averagePrice - price;
if (shortProfit < TrailStart * step)
return false;
var shortCandidate = price + TrailStop * step;
if (_trailingStopLevel is null || shortCandidate < _trailingStopLevel)
_trailingStopLevel = shortCandidate;
return _trailingStopLevel is not null && price >= _trailingStopLevel;
}
private decimal CalculateTradeVolume()
{
decimal volume;
switch (MoneyManagementMode)
{
case MoneyManagementModes.Fixed:
volume = InitialVolume;
break;
case MoneyManagementModes.Geometric:
var power = _tradeCount;
volume = InitialVolume * (decimal)Math.Pow((double)LotExponent, power);
break;
case MoneyManagementModes.RecoverLastLoss:
volume = _lastClosedWasLoss
? (_lastClosedOrderVolume > 0m ? _lastClosedOrderVolume : InitialVolume) * LotExponent
: InitialVolume;
break;
default:
volume = InitialVolume;
break;
}
return RoundVolume(volume);
}
private decimal RoundVolume(decimal volume)
{
var abs = Math.Abs(volume);
if (abs <= 0m)
return 0m;
var rounded = VolumeDigits > 0
? Math.Round(abs, VolumeDigits, MidpointRounding.AwayFromZero)
: Math.Round(abs, MidpointRounding.AwayFromZero);
if (rounded <= 0m)
return 0m;
if (Security?.VolumeStep is decimal step && step > 0m)
{
var steps = Math.Round(rounded / step, MidpointRounding.AwayFromZero);
if (steps <= 0m)
steps = 1m;
rounded = steps * step;
}
return rounded;
}
}
import clr
import math
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
class ilan14_grid_strategy(Strategy):
"""
Grid martingale strategy. Opens on last two candle closes direction,
adds averaging positions when price moves against by pip step.
"""
def __init__(self):
super(ilan14_grid_strategy, self).__init__()
self._lot_exponent = self.Param("LotExponent", 1.667).SetDisplay("Lot Exponent", "Multiplier for averaging", "Trading")
self._pip_step = self.Param("PipStep", 30.0).SetDisplay("Pip Step", "Grid step in price steps", "Trading")
self._take_profit = self.Param("TakeProfit", 10.0).SetDisplay("Take Profit", "TP in price steps", "Trading")
self._max_trades = self.Param("MaxTrades", 10).SetDisplay("Max Trades", "Max averaging orders", "Trading")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(8))).SetDisplay("Candle Type", "Timeframe", "General")
self._trade_count = 0
self._avg_price = 0.0
self._total_vol = 0.0
self._last_entry = 0.0
self._direction = 0
self._prev_close = 0.0
@property
def candle_type(self):
return self._candle_type.Value
def OnReseted(self):
super(ilan14_grid_strategy, self).OnReseted()
self._trade_count = 0
self._avg_price = 0.0
self._total_vol = 0.0
self._last_entry = 0.0
self._direction = 0
self._prev_close = 0.0
def OnStarted2(self, time):
super(ilan14_grid_strategy, self).OnStarted2(time)
subscription = self.SubscribeCandles(self.candle_type)
subscription.Bind(self._process_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawOwnTrades(area)
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
price = float(candle.ClosePrice)
sec = self.Security
step = float(sec.PriceStep) if sec is not None and sec.PriceStep is not None else 1.0
if step <= 0:
step = 1.0
if not self.IsFormedAndOnlineAndAllowTrading():
self._prev_close = price
return
if self._trade_count > 0:
tp_dist = self._take_profit.Value * step
if self._direction == 1 and price >= self._avg_price + tp_dist:
self.SellMarket()
self._reset_basket()
elif self._direction == -1 and price <= self._avg_price - tp_dist:
self.BuyMarket()
self._reset_basket()
if self._trade_count > 0 and self._trade_count < self._max_trades.Value:
trigger = self._pip_step.Value * step
if trigger > 0:
if self._direction == 1 and self._last_entry - price >= trigger:
self.BuyMarket()
prev_vol = self._total_vol
self._total_vol += 1
self._avg_price = (self._avg_price * prev_vol + price) / self._total_vol
self._last_entry = price
self._trade_count += 1
elif self._direction == -1 and price - self._last_entry >= trigger:
self.SellMarket()
prev_vol = self._total_vol
self._total_vol += 1
self._avg_price = (self._avg_price * prev_vol + price) / self._total_vol
self._last_entry = price
self._trade_count += 1
if self._trade_count == 0:
if self._prev_close == 0:
self._prev_close = price
return
is_long = self._prev_close <= price
if is_long:
self.BuyMarket()
self._direction = 1
else:
self.SellMarket()
self._direction = -1
self._avg_price = price
self._total_vol = 1
self._last_entry = price
self._trade_count = 1
self._prev_close = price
def _reset_basket(self):
self._trade_count = 0
self._avg_price = 0.0
self._total_vol = 0.0
self._last_entry = 0.0
self._direction = 0
def CreateClone(self):
return ilan14_grid_strategy()