Frank Ud 极简策略
本示例将经典的 Frank Ud MetaTrader 专家顾问移植到 StockSharp,并使用高阶策略 API 复刻其逻辑。原始 MQL 程序通过对冲模式维护一组做多网格和一组做空网格,当价格向最新仓位不利移动时不断加仓;一旦最新(也是手数最大的)订单获得固定点数利润,就立即同时平掉该方向的所有仓位。
核心思路
- 双向对冲。 策略分别维护多头和空头两个独立梯队,因此可以像 MT4 对冲账户一样同时持有多、空仓位。
- 马丁加仓。 任一方向的首单采用
InitialVolume(默认 0.1 手),此后每次加仓都会把当前最大手数翻倍。策略发出的每一笔手数(首单也包括在内)都会被收敛到品种实际接受的范围:先向下取整为VolumeStep的整数倍,低于MinVolume时提升至MinVolume,超过MaxVolume时以其封顶。品种未提供的约束则跳过。 - 间距控制。 只有当价格相对已有最佳入场价至少反向移动
ReEntryPips(默认 41 点)时才允许再加一单。做多梯队等待卖价跌破最低买价 - ReEntryPips,做空梯队等待买价突破最高卖价 + ReEntryPips。买价和卖价都取自同一根 K 线的收盘价,因此在本移植版本中这两项比较使用的是同一个价格。 - 收益锁定。 每个梯队都以手数最大的订单作为“触发器”。当其浮动盈利超过
TakeProfitPips(默认 65 点),或价格触及距该入场价TakeProfitPips + ExtraTakeProfitPips点的缓冲目标位时,该方向的所有仓位都会通过一笔市价单一次性平仓,同时清空该梯队。 - 保证金保护。 在尝试加仓之前,策略会检查投资组合的可用保证金(当前市值减去其报告的手续费)是否仍高于
Balance × MinimumFreeMarginRatio(默认 0.5)。该防护覆盖两个梯队上的每一笔加仓,首单也不例外。把比例设为 0 即可关闭它;若投资组合未返回任何数值,检查同样直接通过,策略即退回到原始 EA 的固定手数模式。
参数说明
| 参数 | 作用 |
|---|---|
TakeProfitPips |
以手数最大订单为基准的点数盈利阈值,超过后立即平掉该方向全部仓位。 |
ReEntryPips |
价格相对最佳入场价必须达到的最小点数差,满足后才会继续加仓。 |
InitialVolume |
每个梯队首单的基础手数,后续加仓会按马丁逻辑翻倍。 |
MinimumFreeMarginRatio |
可用保证金占余额的最小比例,低于该值时禁止继续加仓;设为 0 可关闭此检查。默认 0.5。 |
ExtraTakeProfitPips |
计算缓冲离场目标位时在 TakeProfitPips 之上额外增加的点数。默认 25。 |
CandleType |
策略订阅的 K 线序列。默认:1 分钟周期。 |
实现细节
- 一个“点”并不等于品种的原始最小价格变动单位。策略在处理第一根收盘完成的 K 线时,把一个点定为该报价价格的万分之一,并以品种的最小价格变动单位作为下限(保证点值不会细于品种实际可交易的精度),此后在整个运行过程中保持该数值不变,以免网格自己发生漂移。这样既复现了该 EA 所依据的外汇惯例(EURUSD 在 1.10 时为 0.0001,USDJPY 在 150 时为 0.01),也让各项距离在五位数报价的品种上依然有意义——若直接使用 0.01 的原始价格步长,65 点的止盈目标几乎每根 K 线都会被触发。若品种未提供最小价格变动单位,则仅按该比例确定点值。
- 策略由收盘完成的 K 线驱动,而不是盘口报价。它订阅
CandleType序列(默认 1 分钟周期),并忽略所有尚未收盘的 K 线。随附的历史数据不含订单簿,因此已收盘 K 线的收盘价同时充当买价和卖价。C# 与 Python 两个实现的订阅方式完全一致。 - 梯队记录是在订单发出的那一刻写入的,而不是在成交时:开仓时把 K 线收盘价和请求手数追加到列表,平仓时针对该梯队的全部手数发出一笔市价单并清空列表。策略不维护“订单 → 意图”的字典,也不使用成交回调——在本模拟器中,成交是在订单注册过程内同步返回的,早于订单能被写入这类字典的时刻。
- 持仓记录以普通列表保存每条梯队记录的价格和手数,而非依赖聚合统计,从而保持原脚本查找最大手数及其入场价的方式。
- 原 EA 在每单止盈价上额外附加的点数缓冲已提取为
ExtraTakeProfitPips参数(默认 25 点),并作为补充退出条件保留下来。
同时提供 C# 和 Python 实现。
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
{
// Forex convention this expert came from: one pip is roughly a ten-thousandth of the quoted
// price (0.0001 on EURUSD at 1.10, 0.01 on USDJPY at 150). Expressing it as a fraction of the
// price keeps the same grid spacing on instruments quoted in five figures.
private const decimal _pipFraction = 0.0001m;
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 StrategyParam<DataType> _candleType;
private readonly List<PositionEntry> _longEntries = new();
private readonly List<PositionEntry> _shortEntries = 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")
.SetNotNegative();
_extraTakeProfitPips = Param(nameof(ExtraTakeProfitPips), 25m)
.SetDisplay("Buffer profit (pips)", "Additional pip distance applied when calculating buffered targets.", "Risk")
.SetNotNegative();
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
.SetDisplay("Candle type", "Candle series the grid reacts to.", "General");
}
/// <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;
}
/// <summary>
/// Candle series the grid reacts to.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_longEntries.Clear();
_shortEntries.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);
// The pip and the distances derived from it need a quote, so they are set up on the first one.
_takeProfitThreshold = TakeProfitPips;
_baseVolume = AdjustVolume(InitialVolume);
SubscribeCandles(CandleType)
.Bind(ProcessCandle)
.Start();
}
private void ProcessCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
// The bundled history carries no book, so the close stands for both sides of the quote.
_lastBid = candle.ClosePrice;
_lastAsk = candle.ClosePrice;
if (_lastBid <= 0m || _lastAsk <= 0m)
return;
if (!TryInitializePip())
return;
if (ShouldCloseLong())
CloseLongPositions();
if (ShouldCloseShort())
CloseShortPositions();
if (ShouldOpenLong())
OpenLongPosition();
if (ShouldOpenShort())
OpenShortPosition();
}
private bool TryInitializePip()
{
if (_pointValue > 0m)
return true;
var reference = (_lastBid + _lastAsk) / 2m;
if (reference <= 0m)
return false;
// A missing or zero price step simply leaves the pip unfloored; the fraction alone already
// keeps it positive.
var floor = Security?.PriceStep is decimal step && step > 0m ? step : 0m;
// Frozen for the rest of the run: a pip that followed the price would move the grid under itself.
_pointValue = Math.Max(reference * _pipFraction, floor);
_takeProfitDistance = (TakeProfitPips + ExtraTakeProfitPips) * _pointValue;
_reEntryDistance = ReEntryPips * _pointValue;
return true;
}
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;
BuyMarket(volume);
AddEntry(_longEntries, _lastAsk, volume);
}
private void OpenShortPosition()
{
var volume = DetermineNextVolume(_shortEntries);
if (volume <= 0m)
return;
SellMarket(volume);
AddEntry(_shortEntries, _lastBid, volume);
}
private void CloseLongPositions()
{
var volume = GetTotalVolume(_longEntries);
if (volume <= 0m)
return;
SellMarket(volume);
_longEntries.Clear();
}
private void CloseShortPositions()
{
var volume = GetTotalVolume(_shortEntries);
if (volume <= 0m)
return;
BuyMarket(volume);
_shortEntries.Clear();
}
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 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; }
}
}
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
# Forex convention this expert came from: one pip is roughly a ten-thousandth of the quoted
# price (0.0001 on EURUSD at 1.10, 0.01 on USDJPY at 150). Expressing it as a fraction of the
# price keeps the same grid spacing on instruments quoted in five figures.
PIP_FRACTION = 0.0001
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._minimum_free_margin_ratio = self.Param("MinimumFreeMarginRatio", 0.5) \
.SetNotNegative() \
.SetDisplay("Free margin ratio", "Free margin must stay above Balance x Ratio before adding orders", "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 MinimumFreeMarginRatio(self):
return self._minimum_free_margin_ratio.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)
# The pip and the distances derived from it need a quote, so they are set up on the first one.
self._take_profit_threshold = float(self.TakeProfitPips)
self._base_volume = self._adjust_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 not self._try_initialize_pip((bid + ask) / 2.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 _try_initialize_pip(self, reference):
if self._point_value > 0:
return True
if reference <= 0:
return False
# A missing or zero price step simply leaves the pip unfloored; the fraction alone already
# keeps it positive.
floor = 0.0
if self.Security is not None and self.Security.PriceStep is not None:
step = float(self.Security.PriceStep)
if step > 0:
floor = step
# Frozen for the rest of the run: a pip that followed the price would move the grid under itself.
self._point_value = max(reference * PIP_FRACTION, floor)
self._take_profit_distance = (float(self.TakeProfitPips) + float(self.ExtraTakeProfitPips)) * self._point_value
self._re_entry_distance = float(self.ReEntryPips) * self._point_value
return True
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 not self._has_enough_margin():
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 not self._has_enough_margin():
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:
volume = self._base_volume
else:
volume = self._get_max_volume(entries) * 2.0
return self._adjust_volume(volume)
def _adjust_volume(self, volume):
if volume <= 0:
return 0.0
security = self.Security
if security is not None and security.VolumeStep is not None:
step = float(security.VolumeStep)
if step > 0:
steps = Math.Floor(volume / step)
volume = steps * step
if security is not None and security.MinVolume is not None:
min_volume = float(security.MinVolume)
if min_volume > 0 and volume < min_volume:
volume = min_volume
if security is not None and security.MaxVolume is not None:
max_volume = float(security.MaxVolume)
if max_volume > 0 and volume > max_volume:
volume = max_volume
return volume
def _has_enough_margin(self):
ratio = float(self.MinimumFreeMarginRatio)
if ratio <= 0:
return True
portfolio = self.Portfolio
if portfolio is None:
return True
current_value = portfolio.CurrentValue
base_value = current_value if current_value is not None else portfolio.BeginValue
balance = float(base_value) if base_value is not None else 0.0
if balance <= 0:
return True
commission = portfolio.Commission
blocked = float(commission) if commission is not None else 0.0
free_margin = float(base_value) - blocked
return free_margin > balance * ratio
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()