Frank Ud Minimal Strategy
This sample ports the classic Frank Ud MetaTrader expert advisor into StockSharp using the high-level strategy API. The original MQL script runs a hedged martingale grid that keeps adding positions every time price moves against the latest entry. Profits are locked once the most recent (and therefore largest) order earns a fixed number of pips, after which all trades on that side are closed simultaneously.
Core logic
- Symmetric hedging. The strategy maintains two independent ladders of market positions: a long ladder and a short ladder. It is therefore possible to hold longs and shorts at the same time, as in MetaTrader's hedging mode.
- Martingale progression. The first order on any side uses
InitialVolume(default 0.1 lots). Each subsequent entry on the same side doubles the largest currently open volume. Volume adjustments respect the instrument'sMinVolume,MaxVolume, andVolumeStepconstraints. - Entry spacing. A new position is added only when price has moved by at least
ReEntryPips(default 41 pips) beyond the best entry price of the existing ladder. The long ladder waits for ask prices to drop belowlowest_buy - ReEntryPips, while the short ladder waits for bid prices to rise abovehighest_sell + ReEntryPips. - Profit harvesting. For each ladder the trade with the largest volume acts as the "trigger" order. When its profit exceeds
TakeProfitPips(default 65 pips), or when price touches the implicit take-profit level(TakeProfitPips + 25)used by the MQL version, every position on that side is flattened with a single market order. - Margin protection. Before submitting any new entry the strategy verifies that the free margin reported by the portfolio (
CurrentValue - BlockedValue) stays aboveBalance × MinimumFreeMarginRatio(default 0.5). If the broker does not report portfolio statistics the check falls back to the fixed-volume behaviour of the original expert.
Parameters
| Parameter | Description |
|---|---|
TakeProfitPips |
Pip profit threshold measured on the most recent, largest order. Once exceeded, all positions on that side are closed. |
ReEntryPips |
Minimum pip distance between the best existing entry and the current bid/ask before a new martingale order is added. |
InitialVolume |
Base lot size for the first order of each ladder. Subsequent orders double the largest active volume. |
MinimumFreeMarginRatio |
Required ratio of free margin to balance before new entries are allowed. Set to 0 to disable the check. |
Implementation notes
- The strategy relies solely on level-1 quotes: bid updates drive the short ladder logic and ask updates drive the long ladder logic.
- Order intents are tracked in an internal dictionary so that
OnNewMyTradeknows whether a fill opened or closed a ladder. This mimics the explicit ticket bookkeeping in the MQL source. - Position bookkeeping stores every fill (price and volume) in lists instead of querying cumulative statistics, preserving the behaviour of the MQL arrays that were used to locate the largest lot and its entry price.
- The extra 25 pip buffer that the original expert placed on each take-profit order is retained as an additional exit condition.
Note: The Python port is intentionally omitted for now, as requested. The folder contains only the C# implementation and the multilingual documentation.
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>
/// Minimal port of the Frank Ud averaging expert from MetaTrader.
/// The strategy opens hedged martingale grids and liquidates both sides
/// once the newest position reaches the configured profit in pips.
/// </summary>
public class FrankUdMinimalStrategy : Strategy
{
private readonly StrategyParam<decimal> _takeProfitPips;
private readonly StrategyParam<decimal> _reEntryPips;
private readonly StrategyParam<decimal> _initialVolume;
private readonly StrategyParam<decimal> _minimumFreeMarginRatio;
private readonly StrategyParam<decimal> _extraTakeProfitPips;
private readonly List<PositionEntry> _longEntries = new();
private readonly List<PositionEntry> _shortEntries = new();
private readonly Dictionary<long, OrderActions> _orderActions = new();
private decimal _pointValue;
private decimal _takeProfitThreshold;
private decimal _takeProfitDistance;
private decimal _reEntryDistance;
private decimal _baseVolume;
private decimal _lastBid;
private decimal _lastAsk;
/// <summary>
/// Creates a new instance of <see cref="FrankUdMinimalStrategy"/> with default parameters.
/// </summary>
public FrankUdMinimalStrategy()
{
_takeProfitPips = Param(nameof(TakeProfitPips), 65m)
.SetDisplay("Profit trigger (pips)", "Pip profit that forces an exit of all positions.", "Risk")
.SetGreaterThanZero();
_reEntryPips = Param(nameof(ReEntryPips), 41m)
.SetDisplay("Re-entry distance (pips)", "Pip distance required before adding the next grid order.", "Grid")
.SetGreaterThanZero();
_initialVolume = Param(nameof(InitialVolume), 0.1m)
.SetDisplay("Initial volume", "Base lot used for the very first order.", "Risk")
.SetGreaterThanZero();
_minimumFreeMarginRatio = Param(nameof(MinimumFreeMarginRatio), 0.5m)
.SetDisplay("Free margin ratio", "Free margin must stay above Balance × Ratio before adding orders.", "Risk")
.SetGreaterThanZero();
_extraTakeProfitPips = Param(nameof(ExtraTakeProfitPips), 25m)
.SetDisplay("Buffer profit (pips)", "Additional pip distance applied when calculating buffered targets.", "Risk")
.SetNotNegative();
}
/// <summary>
/// Profit threshold expressed in pips.
/// </summary>
public decimal TakeProfitPips
{
get => _takeProfitPips.Value;
set => _takeProfitPips.Value = value;
}
/// <summary>
/// Distance in pips between consecutive martingale entries.
/// </summary>
public decimal ReEntryPips
{
get => _reEntryPips.Value;
set => _reEntryPips.Value = value;
}
/// <summary>
/// Base lot volume for the very first order.
/// </summary>
public decimal InitialVolume
{
get => _initialVolume.Value;
set => _initialVolume.Value = value;
}
/// <summary>
/// Minimal free margin ratio required to send new orders.
/// </summary>
public decimal MinimumFreeMarginRatio
{
get => _minimumFreeMarginRatio.Value;
set => _minimumFreeMarginRatio.Value = value;
}
/// <summary>
/// Additional pip buffer added to the take-profit distance.
/// </summary>
public decimal ExtraTakeProfitPips
{
get => _extraTakeProfitPips.Value;
set => _extraTakeProfitPips.Value = value;
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_longEntries.Clear();
_shortEntries.Clear();
_orderActions.Clear();
_pointValue = 0m;
_takeProfitThreshold = 0m;
_takeProfitDistance = 0m;
_reEntryDistance = 0m;
_baseVolume = 0m;
_lastBid = 0m;
_lastAsk = 0m;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
var security = Security ?? throw new InvalidOperationException("Security is not assigned.");
var priceStep = security.PriceStep ?? 0.01m;
_pointValue = priceStep;
_takeProfitThreshold = TakeProfitPips;
_takeProfitDistance = (TakeProfitPips + ExtraTakeProfitPips) * _pointValue;
_reEntryDistance = ReEntryPips * _pointValue;
_baseVolume = AdjustVolume(InitialVolume);
var l1sub = new Subscription(DataType.Level1, Security);
l1sub.MarketData.BuildField = Level1Fields.BestBidPrice;
SubscribeLevel1(l1sub)
.Bind(ProcessLevel1)
.Start();
}
private void ProcessLevel1(Level1ChangeMessage message)
{
if (message.Changes.TryGetValue(Level1Fields.BestBidPrice, out var bidPrice))
_lastBid = (decimal)bidPrice;
if (message.Changes.TryGetValue(Level1Fields.BestAskPrice, out var askPrice))
_lastAsk = (decimal)askPrice;
if (_lastBid <= 0m || _lastAsk <= 0m)
return;
if (ShouldCloseLong())
CloseLongPositions();
if (ShouldCloseShort())
CloseShortPositions();
if (ShouldOpenLong())
OpenLongPosition();
if (ShouldOpenShort())
OpenShortPosition();
}
private bool ShouldCloseLong()
{
if (_longEntries.Count == 0)
return false;
var entry = GetMaxVolumeEntry(_longEntries);
if (entry == null)
return false;
var profitPips = (_lastBid - entry.Price) / _pointValue;
var bufferedTarget = entry.Price + _takeProfitDistance;
var reachedBufferedTarget = _takeProfitDistance > 0m && _lastBid >= bufferedTarget;
return profitPips > _takeProfitThreshold || reachedBufferedTarget;
}
private bool ShouldCloseShort()
{
if (_shortEntries.Count == 0)
return false;
var entry = GetMaxVolumeEntry(_shortEntries);
if (entry == null)
return false;
var profitPips = (entry.Price - _lastAsk) / _pointValue;
var bufferedTarget = entry.Price - _takeProfitDistance;
var reachedBufferedTarget = _takeProfitDistance > 0m && _lastAsk <= bufferedTarget;
return profitPips > _takeProfitThreshold || reachedBufferedTarget;
}
private bool ShouldOpenLong()
{
if (_baseVolume <= 0m)
return false;
if (!HasEnoughMargin())
return false;
if (_longEntries.Count == 0)
return true;
var lowestPrice = GetExtremePrice(_longEntries, true);
return lowestPrice - _reEntryDistance > _lastAsk;
}
private bool ShouldOpenShort()
{
if (_baseVolume <= 0m)
return false;
if (!HasEnoughMargin())
return false;
if (_shortEntries.Count == 0)
return true;
var highestPrice = GetExtremePrice(_shortEntries, false);
return highestPrice + _reEntryDistance < _lastBid;
}
private void OpenLongPosition()
{
var volume = DetermineNextVolume(_longEntries);
if (volume <= 0m)
return;
var order = BuyMarket(volume);
RegisterOrder(order, OrderActions.OpenLong);
}
private void OpenShortPosition()
{
var volume = DetermineNextVolume(_shortEntries);
if (volume <= 0m)
return;
var order = SellMarket(volume);
RegisterOrder(order, OrderActions.OpenShort);
}
private void CloseLongPositions()
{
var volume = GetTotalVolume(_longEntries);
if (volume <= 0m)
return;
var order = SellMarket(volume);
RegisterOrder(order, OrderActions.CloseLong);
}
private void CloseShortPositions()
{
var volume = GetTotalVolume(_shortEntries);
if (volume <= 0m)
return;
var order = BuyMarket(volume);
RegisterOrder(order, OrderActions.CloseShort);
}
private void RegisterOrder(Order order, OrderActions action)
{
if (order == null)
return;
if (order.Id is long id)
_orderActions[id] = action;
}
/// <inheritdoc />
protected override void OnOwnTradeReceived(MyTrade trade)
{
base.OnOwnTradeReceived(trade);
if (trade.Order.Id is not long tradeOrderId || !_orderActions.TryGetValue(tradeOrderId, out var action))
return;
var price = trade.Trade.Price;
var volume = trade.Trade.Volume;
switch (action)
{
case OrderActions.OpenLong:
AddEntry(_longEntries, price, volume);
break;
case OrderActions.OpenShort:
AddEntry(_shortEntries, price, volume);
break;
case OrderActions.CloseLong:
RemoveVolume(_longEntries, volume);
break;
case OrderActions.CloseShort:
RemoveVolume(_shortEntries, volume);
break;
}
}
/// <inheritdoc />
protected override void OnOrderReceived(Order order)
{
base.OnOrderReceived(order);
if (order.Id is long oid && order.State is OrderStates.Done or OrderStates.Failed)
_orderActions.Remove(oid);
}
/// <inheritdoc />
protected override void OnOrderRegisterFailed(OrderFail fail, bool calcRisk)
{
base.OnOrderRegisterFailed(fail, calcRisk);
if (fail.Order.Id is long foid)
_orderActions.Remove(foid);
}
private decimal DetermineNextVolume(List<PositionEntry> entries)
{
if (_baseVolume <= 0m)
return 0m;
var volume = entries.Count == 0
? _baseVolume
: GetMaxVolume(entries) * 2m;
return AdjustVolume(volume);
}
private decimal AdjustVolume(decimal volume)
{
if (volume <= 0m)
return 0m;
var security = Security;
if (security?.VolumeStep is decimal step && step > 0m)
{
var steps = Math.Floor(volume / step);
volume = steps * step;
}
if (security?.MinVolume is decimal min && min > 0m && volume < min)
volume = min;
if (security?.MaxVolume is decimal max && max > 0m && volume > max)
volume = max;
return volume;
}
private bool HasEnoughMargin()
{
if (MinimumFreeMarginRatio <= 0m)
return true;
var portfolio = Portfolio;
if (portfolio == null)
return true;
var balance = portfolio.CurrentValue ?? portfolio.BeginValue ?? 0m;
if (balance <= 0m)
return true;
var blocked = portfolio.Commission ?? 0m;
var baseValue = portfolio.CurrentValue ?? portfolio.BeginValue;
if (baseValue == null)
return true;
var freeMargin = baseValue.Value - blocked;
return freeMargin > balance * MinimumFreeMarginRatio;
}
private static void AddEntry(List<PositionEntry> entries, decimal price, decimal volume)
{
if (volume <= 0m)
return;
entries.Add(new PositionEntry(price, volume));
}
private static void RemoveVolume(List<PositionEntry> entries, decimal volume)
{
var remaining = volume;
for (var i = entries.Count - 1; i >= 0 && remaining > 0m; i--)
{
var entry = entries[i];
if (entry.Volume <= remaining)
{
remaining -= entry.Volume;
entries.RemoveAt(i);
}
else
{
entries[i] = entry.WithVolume(entry.Volume - remaining);
remaining = 0m;
}
}
}
private static decimal GetTotalVolume(List<PositionEntry> entries)
{
decimal total = 0m;
foreach (var entry in entries)
total += entry.Volume;
return total;
}
private static PositionEntry GetMaxVolumeEntry(List<PositionEntry> entries)
{
PositionEntry result = null;
decimal maxVolume = 0m;
foreach (var entry in entries)
{
if (entry.Volume > maxVolume)
{
maxVolume = entry.Volume;
result = entry;
}
}
return result;
}
private static decimal GetMaxVolume(List<PositionEntry> entries)
{
decimal maxVolume = 0m;
foreach (var entry in entries)
if (entry.Volume > maxVolume)
maxVolume = entry.Volume;
return maxVolume;
}
private static decimal GetExtremePrice(List<PositionEntry> entries, bool isLong)
{
var hasValue = false;
decimal result = 0m;
foreach (var entry in entries)
{
var price = entry.Price;
if (!hasValue)
{
result = price;
hasValue = true;
continue;
}
if (isLong)
{
if (price < result)
result = price;
}
else if (price > result)
{
result = price;
}
}
return result;
}
private sealed class PositionEntry
{
public PositionEntry(decimal price, decimal volume)
{
Price = price;
Volume = volume;
}
public decimal Price { get; }
public decimal Volume { get; }
public PositionEntry WithVolume(decimal volume)
{
return new PositionEntry(Price, volume);
}
}
private enum OrderActions
{
OpenLong,
CloseLong,
OpenShort,
CloseShort
}
}
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
class frank_ud_minimal_strategy(Strategy):
"""Hedged martingale grid strategy that liquidates both sides once the newest
position reaches the configured profit in pips."""
def __init__(self):
super(frank_ud_minimal_strategy, self).__init__()
self._take_profit_pips = self.Param("TakeProfitPips", 65.0) \
.SetGreaterThanZero() \
.SetDisplay("Profit trigger (pips)", "Pip profit that forces an exit of all positions", "Risk")
self._re_entry_pips = self.Param("ReEntryPips", 41.0) \
.SetGreaterThanZero() \
.SetDisplay("Re-entry distance (pips)", "Pip distance required before adding the next grid order", "Grid")
self._initial_volume = self.Param("InitialVolume", 0.1) \
.SetGreaterThanZero() \
.SetDisplay("Initial volume", "Base lot used for the very first order", "Risk")
self._extra_take_profit_pips = self.Param("ExtraTakeProfitPips", 25.0) \
.SetDisplay("Buffer profit (pips)", "Additional pip distance applied when calculating buffered targets", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1))) \
.SetDisplay("Candle Type", "Candle series used for price tracking", "General")
self._long_entries = []
self._short_entries = []
self._point_value = 0.0
self._take_profit_threshold = 0.0
self._take_profit_distance = 0.0
self._re_entry_distance = 0.0
self._base_volume = 0.0
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def TakeProfitPips(self):
return self._take_profit_pips.Value
@property
def ReEntryPips(self):
return self._re_entry_pips.Value
@property
def InitialVolume(self):
return self._initial_volume.Value
@property
def ExtraTakeProfitPips(self):
return self._extra_take_profit_pips.Value
def OnReseted(self):
super(frank_ud_minimal_strategy, self).OnReseted()
self._long_entries = []
self._short_entries = []
self._point_value = 0.0
self._take_profit_threshold = 0.0
self._take_profit_distance = 0.0
self._re_entry_distance = 0.0
self._base_volume = 0.0
def OnStarted2(self, time):
super(frank_ud_minimal_strategy, self).OnStarted2(time)
self._point_value = 1.0
if self.Security is not None and self.Security.PriceStep is not None:
ps = float(self.Security.PriceStep)
if ps > 0:
self._point_value = ps
self._take_profit_threshold = float(self.TakeProfitPips)
self._take_profit_distance = (float(self.TakeProfitPips) + float(self.ExtraTakeProfitPips)) * self._point_value
self._re_entry_distance = float(self.ReEntryPips) * self._point_value
self._base_volume = float(self.InitialVolume)
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self._process_candle).Start()
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
bid = float(candle.ClosePrice)
ask = float(candle.ClosePrice)
if bid <= 0 or ask <= 0:
return
if self._should_close_long(bid):
self._close_long_positions()
if self._should_close_short(ask):
self._close_short_positions()
if self._should_open_long(ask):
self._open_long_position(ask)
if self._should_open_short(bid):
self._open_short_position(bid)
def _should_close_long(self, bid):
if len(self._long_entries) == 0:
return False
entry = self._get_max_volume_entry(self._long_entries)
if entry is None:
return False
profit_pips = (bid - entry[0]) / self._point_value
buffered_target = entry[0] + self._take_profit_distance
reached_buffered = self._take_profit_distance > 0 and bid >= buffered_target
return profit_pips > self._take_profit_threshold or reached_buffered
def _should_close_short(self, ask):
if len(self._short_entries) == 0:
return False
entry = self._get_max_volume_entry(self._short_entries)
if entry is None:
return False
profit_pips = (entry[0] - ask) / self._point_value
buffered_target = entry[0] - self._take_profit_distance
reached_buffered = self._take_profit_distance > 0 and ask <= buffered_target
return profit_pips > self._take_profit_threshold or reached_buffered
def _should_open_long(self, ask):
if self._base_volume <= 0:
return False
if len(self._long_entries) == 0:
return True
lowest_price = self._get_extreme_price(self._long_entries, True)
return lowest_price - self._re_entry_distance > ask
def _should_open_short(self, bid):
if self._base_volume <= 0:
return False
if len(self._short_entries) == 0:
return True
highest_price = self._get_extreme_price(self._short_entries, False)
return highest_price + self._re_entry_distance < bid
def _open_long_position(self, price):
volume = self._determine_next_volume(self._long_entries)
if volume <= 0:
return
self.BuyMarket(volume)
self._long_entries.append([price, volume])
def _open_short_position(self, price):
volume = self._determine_next_volume(self._short_entries)
if volume <= 0:
return
self.SellMarket(volume)
self._short_entries.append([price, volume])
def _close_long_positions(self):
volume = self._get_total_volume(self._long_entries)
if volume <= 0:
return
self.SellMarket(volume)
self._long_entries = []
def _close_short_positions(self):
volume = self._get_total_volume(self._short_entries)
if volume <= 0:
return
self.BuyMarket(volume)
self._short_entries = []
def _determine_next_volume(self, entries):
if self._base_volume <= 0:
return 0.0
if len(entries) == 0:
return self._base_volume
max_vol = self._get_max_volume(entries)
return max_vol * 2.0
def _get_max_volume_entry(self, entries):
result = None
max_volume = 0.0
for entry in entries:
if entry[1] > max_volume:
max_volume = entry[1]
result = entry
return result
def _get_max_volume(self, entries):
max_volume = 0.0
for entry in entries:
if entry[1] > max_volume:
max_volume = entry[1]
return max_volume
def _get_total_volume(self, entries):
total = 0.0
for entry in entries:
total += entry[1]
return total
def _get_extreme_price(self, entries, is_long):
has_value = False
result = 0.0
for entry in entries:
price = entry[0]
if not has_value:
result = price
has_value = True
continue
if is_long:
if price < result:
result = price
else:
if price > result:
result = price
return result
def CreateClone(self):
return frank_ud_minimal_strategy()