Frank Ud 极简策略
本示例将经典的 Frank Ud MetaTrader 专家顾问移植到 StockSharp,并使用高阶策略 API 复刻其逻辑。原始 MQL 程序通过对冲模式维护一组做多网格和一组做空网格,当价格向最新仓位不利移动时不断加仓;一旦最新(也是手数最大的)订单获得固定点数利润,就立即同时平掉该方向的所有仓位。
核心思路
- 双向对冲。 策略分别维护多头和空头两个独立梯队,因此可以像 MT4 对冲账户一样同时持有多、空仓位。
- 马丁加仓。 任一方向的首单采用
InitialVolume(默认 0.1 手),此后每次加仓都会把当前最大手数翻倍。下单前会根据品种的MinVolume、MaxVolume、VolumeStep自动调整最终手数。 - 间距控制。 只有当价格相对已有最佳入场价至少反向移动
ReEntryPips(默认 41 点)时才允许再加一单。做多梯队等待卖价跌破最低买价 - ReEntryPips,做空梯队等待买价突破最高卖价 + ReEntryPips。 - 收益锁定。 每个梯队都以手数最大的订单作为“触发器”。当其浮动盈利超过
TakeProfitPips(默认 65 点),或价格触及原脚本设置的(TakeProfitPips + 25)点止盈位置时,该方向的所有仓位都会通过一笔市价单一次性平仓。 - 保证金保护。 在尝试加仓之前,策略会检查投资组合的可用保证金
CurrentValue - BlockedValue是否仍高于Balance × MinimumFreeMarginRatio(默认 0.5)。如果连接器无法提供这些指标,则退回到原始 EA 的固定手数模式。
参数说明
| 参数 | 作用 |
|---|---|
TakeProfitPips |
以手数最大订单为基准的点数盈利阈值,超过后立即平掉该方向全部仓位。 |
ReEntryPips |
价格相对最佳入场价必须达到的最小点数差,满足后才会继续加仓。 |
InitialVolume |
每个梯队首单的基础手数,后续加仓会按马丁逻辑翻倍。 |
MinimumFreeMarginRatio |
可用保证金占余额的最小比例,低于该值时禁止继续加仓;设为 0 可关闭此检查。 |
实现细节
- 策略仅依赖盘口报价:买价更新驱动空头逻辑,卖价更新驱动多头逻辑。
- 通过内部字典记录每笔订单的意图,以便在
OnNewMyTrade中区分是开仓还是平仓,等价于 MQL 中对订单票号的数组管理。 - 持仓记录以列表保存每次成交的价格和手数,而非依赖聚合统计,从而保持原脚本查找最大手数及其入场价的方式。
- 原 EA 在每单止盈价上额外加的 25 点缓冲也作为补充退出条件保留下来。
提示: 按需求暂不提供 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()