Стратегия Frank Ud (минимальная версия)
Пример демонстрирует портирование классического советника Frank Ud из MetaTrader в StockSharp на основе высокоуровневого API. Оригинальный MQL-скрипт ведёт две хеджирующие сетки (лонгов и шортов), наращивая позицию при движении цены против последнего входа. Как только самый свежий (и самый крупный) ордер приносит фиксированное число пунктов, все сделки данного направления закрываются единовременно.
Основные идеи
- Симметричное хеджирование. Стратегия параллельно ведёт «лестницы» длинных и коротких позиций, поэтому может держать лонги и шорты одновременно, как и в режиме хеджирования MetaTrader.
- Мартингейл. Первый ордер на каждой стороне использует
InitialVolume(по умолчанию 0.1 лота). Каждый следующий вход удваивает наибольший уже открытый объём. При расчёте учитываются биржевые ограниченияMinVolume,MaxVolume,VolumeStep. - Шаг сетки. Добавление нового ордера допускается только после смещения цены минимум на
ReEntryPips(41 пункт) относительно лучшей цены текущей лестницы. Для лонгов требуется падение ask нижеминимальная_цена_покупки - ReEntryPips, для шортов — рост bid вышемаксимальная_цена_продажи + ReEntryPips. - Фиксация прибыли. Ордер с наибольшим объёмом играет роль «триггера». Когда его прибыль превышает
TakeProfitPips(65 пунктов) либо цена достигает скрытого уровня(TakeProfitPips + 25)из оригинала, весь соответствующий блок позиций закрывается одной рыночной заявкой. - Контроль маржи. Перед отправкой нового ордера проверяется, что свободная маржа (
CurrentValue - BlockedValue) выше порогаBalance × MinimumFreeMarginRatio(по умолчанию 0.5). Если коннектор не предоставляет эти значения, стратегия работает с фиксированными объёмами, как и исходный советник.
Параметры
| Параметр | Описание |
|---|---|
TakeProfitPips |
Порог прибыли в пунктах для самого крупного ордера. При превышении закрываются все позиции данного направления. |
ReEntryPips |
Минимальное расстояние в пунктах между лучшей ценой входа и текущей ценой, необходимое для следующего усреднения. |
InitialVolume |
Базовый объём первой заявки в каждой сетке; последующие ордера удваивают максимальный активный объём. |
MinimumFreeMarginRatio |
Минимально допустимое отношение свободной маржи к балансу. При значении 0 проверка отключается. |
Особенности реализации
- Стратегия использует только Level1-котировки: обновления bid управляют шортами, ask — лонгами.
- Для отслеживания назначения ордеров используется словарь, чтобы
OnNewMyTradeпонимал, было ли исполнение открытием или закрытием. Это аналог массивов ордеров в MQL. - Каждое исполнение сохраняется в списке (цена + объём), что позволяет точно вычислять максимальный лот и его цену входа, как в оригинальном коде.
- Дополнительный буфер в 25 пунктов к уровню тейк-профита, присутствующий в MQL-версии, сохранён как альтернативное условие выхода.
Важно: по требованию Python-версия не создавалась — каталог содержит только C#-код и документацию на трёх языках.
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()