Pendulum 摆动策略
概述
Pendulum 策略 是 MetaTrader 智能交易系统 Pendulum 1_01 的 StockSharp 版本。原始 EA 会在现价上下保持两个挂单,并在每次成交后成倍增加同向挂单的手数。本 C# 版本使用 StockSharp 的高级 API 复现了这种“钟摆式”运行方式。
核心思想:
- 在最近完成的蜡烛收盘价附近维持对称的买入止损和卖出止损挂单。
- 每次挂单成交后,同方向的下一张挂单按设定倍率放大手数,形成类似马丁格尔的仓位进阶。
- 当达到短期点数目标或账户权益触发全局止盈 / 止损阈值时平仓。
工作流程
- 策略启动时会订阅用户指定的主时间框蜡烛(默认 15 分钟),并在需要时额外订阅日线。最新日线波动区间用于计算挂单距离。
- 每根完成的交易蜡烛都会执行以下步骤:
- 更新全局权益限制。
- 检查当前持仓是否触及本地点数止盈。
- 根据日线区间或手动输入的点距计算挂单价格,并创建/更新买入止损与卖出止损。
- 挂单成交后,对应方向的层级递增,下一张挂单采用放大后的手数;达到
MaxLevels后在该方向暂停新挂单,直至仓位归零。 - 全局止盈 / 止损在每根蜡烛结束后检查,一旦权益超过设定百分比即清空持仓。
参数
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
BaseVolume |
decimal |
0.1 |
第一张挂单的手数。 |
VolumeMultiplier |
decimal |
2 |
同向每次成交后的手数倍数。 |
MaxLevels |
int |
8 |
单个方向允许的最大成交次数。 |
ManualStepPips |
int |
50 |
在没有日线数据时使用的点距。 |
UseDynamicRange |
bool |
true |
是否使用最新日线区间动态计算挂单距离。 |
RangeFraction |
decimal |
0.2 |
取日线波动区间的哪一部分作为基础距离。 |
TakeProfitPips |
int |
10 |
本地点数止盈,设为 0 表示关闭。 |
SlippagePips |
int |
3 |
额外增加的安全点差,用于模拟 MetaTrader 中的滑点设置。 |
UseGlobalTargets |
bool |
true |
是否开启基于权益的全局止盈 / 止损。 |
GlobalTakePercent |
decimal |
1 |
达到该百分比的权益增长时平掉所有仓位。 |
GlobalStopPercent |
decimal |
2 |
达到该百分比的权益回撤时强制止损。 |
CandleType |
DataType |
15m 蜡烛 |
主要交易逻辑使用的时间框。 |
说明
- 下单手数会自动符合交易品种的最小/最大手数以及步长限制。
- 挂单价格会按品种的最小报价步长对齐,并设置容差避免频繁撤单重下。
- 全局止盈 / 止损依赖
Portfolio.CurrentValue(若不可用则回退到BeginValue),因此所选组合需提供该数据。 - 启动时调用
StartProtection()以启用 StockSharp 自带的风控保护。
转换差异
- 原始 EA 中的标签显示和账户余额表格被移除。
- 全局止盈改为按账户权益百分比计算,以便在不同券商之间保持一致性。
- MetaTrader 的
OrderModify等函数以撤单 + 重新下单方式实现。
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Port of the "Pendulum 1_01" MetaTrader strategy.
/// Replicates the idea of symmetric stop entries that scale the volume after each fill.
/// </summary>
public class PendulumSwingStrategy : Strategy
{
private readonly StrategyParam<decimal> _baseVolume;
private readonly StrategyParam<decimal> _volumeMultiplier;
private readonly StrategyParam<int> _maxLevels;
private readonly StrategyParam<int> _manualStepPips;
private readonly StrategyParam<bool> _useDynamicRange;
private readonly StrategyParam<decimal> _rangeFraction;
private readonly StrategyParam<int> _takeProfitPips;
private readonly StrategyParam<int> _slippagePips;
private readonly StrategyParam<bool> _useGlobalTargets;
private readonly StrategyParam<decimal> _globalTakePercent;
private readonly StrategyParam<decimal> _globalStopPercent;
private readonly StrategyParam<DataType> _candleType;
private decimal _pipSize;
private decimal _currentStep;
private decimal _pendingBuyPrice;
private decimal _pendingSellPrice;
private decimal _pendingBuyVolume;
private decimal _pendingSellVolume;
private int _longLevel;
private int _shortLevel;
private decimal _initialEquity;
private decimal _takeProfitMoney;
private decimal _stopLossMoney;
private decimal _entryPrice;
/// <summary>
/// Initializes a new instance of the strategy.
/// </summary>
public PendulumSwingStrategy()
{
_baseVolume = Param(nameof(BaseVolume), 0.1m)
.SetGreaterThanZero()
.SetDisplay("Base volume", "Initial lot used for the very first pending stop.", "Risk")
.SetOptimize(0.1m, 1m, 0.1m);
_volumeMultiplier = Param(nameof(VolumeMultiplier), 2m)
.SetGreaterThanZero()
.SetDisplay("Volume multiplier", "Progression factor applied after each filled level.", "Risk")
.SetOptimize(1.2m, 3m, 0.2m);
_maxLevels = Param(nameof(MaxLevels), 8)
.SetGreaterThanZero()
.SetDisplay("Maximum levels", "How many successive fills are allowed per direction before pausing new stops.", "Risk");
_manualStepPips = Param(nameof(ManualStepPips), 50)
.SetGreaterThanZero()
.SetDisplay("Manual step (pips)", "Fallback distance between price and stop entries when daily range is not available.", "Entry")
.SetOptimize(20, 120, 10);
_useDynamicRange = Param(nameof(UseDynamicRange), true)
.SetDisplay("Use daily range", "Derive the pending distance from the previous daily candle range.", "Entry");
_rangeFraction = Param(nameof(RangeFraction), 0.2m)
.SetGreaterThanZero()
.SetDisplay("Range fraction", "Portion of the last finished daily range that becomes the base step.", "Entry")
.SetOptimize(0.1m, 0.5m, 0.05m);
_takeProfitPips = Param(nameof(TakeProfitPips), 10)
.SetDisplay("Take profit (pips)", "Local profit target for the active position. Zero disables local exits.", "Exit")
.SetOptimize(5, 40, 5);
_slippagePips = Param(nameof(SlippagePips), 3)
.SetDisplay("Safety buffer (pips)", "Extra distance added to the computed step to mimic the MetaTrader slippage allowance.", "Entry");
_useGlobalTargets = Param(nameof(UseGlobalTargets), true)
.SetDisplay("Use global targets", "Close all positions once account equity reaches configured percentages.", "Exit");
_globalTakePercent = Param(nameof(GlobalTakePercent), 1m)
.SetGreaterThanZero()
.SetDisplay("Global take-profit %", "Equity growth that triggers closing of every position.", "Exit")
.SetOptimize(0.5m, 3m, 0.5m);
_globalStopPercent = Param(nameof(GlobalStopPercent), 2m)
.SetGreaterThanZero()
.SetDisplay("Global stop-loss %", "Drawdown that forces a full liquidation.", "Exit")
.SetOptimize(1m, 5m, 1m);
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(6).TimeFrame())
.SetDisplay("Trading candle", "Primary timeframe used to manage pending stops.", "Data");
}
/// <summary>
/// Base pending order volume.
/// </summary>
public decimal BaseVolume
{
get => _baseVolume.Value;
set => _baseVolume.Value = value;
}
/// <summary>
/// Multiplier applied after each fill.
/// </summary>
public decimal VolumeMultiplier
{
get => _volumeMultiplier.Value;
set => _volumeMultiplier.Value = value;
}
/// <summary>
/// Maximum number of fills per direction.
/// </summary>
public int MaxLevels
{
get => _maxLevels.Value;
set => _maxLevels.Value = value;
}
/// <summary>
/// Manual step used when no daily range is available.
/// </summary>
public int ManualStepPips
{
get => _manualStepPips.Value;
set => _manualStepPips.Value = value;
}
/// <summary>
/// Whether to derive the step from the daily candle.
/// </summary>
public bool UseDynamicRange
{
get => _useDynamicRange.Value;
set => _useDynamicRange.Value = value;
}
/// <summary>
/// Fraction of the daily range used as the base step.
/// </summary>
public decimal RangeFraction
{
get => _rangeFraction.Value;
set => _rangeFraction.Value = value;
}
/// <summary>
/// Local take-profit measured in pips.
/// </summary>
public int TakeProfitPips
{
get => _takeProfitPips.Value;
set => _takeProfitPips.Value = value;
}
/// <summary>
/// Slippage buffer measured in pips.
/// </summary>
public int SlippagePips
{
get => _slippagePips.Value;
set => _slippagePips.Value = value;
}
/// <summary>
/// Whether global take-profit and stop-loss are enabled.
/// </summary>
public bool UseGlobalTargets
{
get => _useGlobalTargets.Value;
set => _useGlobalTargets.Value = value;
}
/// <summary>
/// Equity gain that triggers a full liquidation.
/// </summary>
public decimal GlobalTakePercent
{
get => _globalTakePercent.Value;
set => _globalTakePercent.Value = value;
}
/// <summary>
/// Equity drawdown that forces a liquidation.
/// </summary>
public decimal GlobalStopPercent
{
get => _globalStopPercent.Value;
set => _globalStopPercent.Value = value;
}
/// <summary>
/// Trading candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
if (Security == null)
yield break;
yield return (Security, CandleType);
if (UseDynamicRange)
{
yield return (Security, TimeSpan.FromHours(6).TimeFrame());
}
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_pipSize = 0m;
_currentStep = 0m;
_pendingBuyPrice = 0m;
_pendingSellPrice = 0m;
_pendingBuyVolume = 0m;
_pendingSellVolume = 0m;
_longLevel = 0;
_shortLevel = 0;
_initialEquity = 0m;
_takeProfitMoney = 0m;
_stopLossMoney = 0m;
_entryPrice = 0m;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
Volume = BaseVolume;
InitializePipSize();
InitializeEquityTargets();
var subscription = SubscribeCandles(CandleType);
subscription
.Bind(ProcessTradingCandle)
.Start();
if (UseDynamicRange)
{
var dailySub = SubscribeCandles(TimeSpan.FromHours(6).TimeFrame());
dailySub
.Bind(ProcessDailyCandle)
.Start();
}
}
private void ProcessTradingCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
if (UseGlobalTargets)
CheckGlobalTargets();
ManageLocalTakeProfit(candle);
EnsurePendulumOrders(candle);
}
private void ProcessDailyCandle(ICandleMessage candle)
{
if (!UseDynamicRange || candle.State != CandleStates.Finished)
return;
var range = candle.HighPrice - candle.LowPrice;
if (range <= 0m)
return;
var dynamicStep = range * RangeFraction;
if (dynamicStep <= 0m)
return;
_currentStep = dynamicStep;
}
private void EnsurePendulumOrders(ICandleMessage candle)
{
if (!IsFormedAndOnlineAndAllowTrading())
return;
var step = GetEffectiveStep();
if (step <= 0m)
return;
var buffer = SlippagePips > 0 ? SlippagePips * _pipSize : 0m;
// Check if pending buy level was breached
if (_pendingBuyPrice > 0m && _pendingBuyVolume > 0m && candle.HighPrice >= _pendingBuyPrice)
{
BuyMarket(_pendingBuyVolume);
_entryPrice = candle.ClosePrice;
_longLevel = Math.Min(_longLevel + 1, MaxLevels);
_pendingBuyPrice = 0m;
_pendingBuyVolume = 0m;
}
// Check if pending sell level was breached
if (_pendingSellPrice > 0m && _pendingSellVolume > 0m && candle.LowPrice <= _pendingSellPrice)
{
SellMarket(_pendingSellVolume);
_entryPrice = candle.ClosePrice;
_shortLevel = Math.Min(_shortLevel + 1, MaxLevels);
_pendingSellPrice = 0m;
_pendingSellVolume = 0m;
}
if (Position == 0m)
{
_longLevel = 0;
_shortLevel = 0;
}
// Set new pending levels
_pendingBuyPrice = candle.ClosePrice + step + buffer;
_pendingSellPrice = candle.ClosePrice - step - buffer;
_pendingBuyVolume = GetNextVolume(Sides.Buy);
_pendingSellVolume = GetNextVolume(Sides.Sell);
}
private decimal GetNextVolume(Sides side)
{
var baseVolume = BaseVolume;
if (baseVolume <= 0m)
return 0m;
var level = side == Sides.Buy ? _longLevel : _shortLevel;
if (level >= MaxLevels)
return 0m;
var multiplier = VolumeMultiplier <= 0m ? 1m : VolumeMultiplier;
var scaled = baseVolume * (decimal)Math.Pow((double)multiplier, level);
return AdjustVolume(scaled);
}
private void ManageLocalTakeProfit(ICandleMessage candle)
{
if (TakeProfitPips <= 0 || Position == 0m)
return;
if (_pipSize <= 0m || _entryPrice <= 0m)
return;
var diff = candle.ClosePrice - _entryPrice;
var threshold = TakeProfitPips * _pipSize;
if (Position > 0 && diff >= threshold)
{
SellMarket(Position);
_longLevel = 0;
}
else if (Position < 0 && -diff >= threshold)
{
BuyMarket(-Position);
_shortLevel = 0;
}
}
private void CheckGlobalTargets()
{
if (_initialEquity <= 0m)
return;
var currentValue = Portfolio?.CurrentValue ?? _initialEquity + PnL;
var profit = currentValue - _initialEquity;
if (_takeProfitMoney > 0m && profit >= _takeProfitMoney)
{
CloseAllPositions();
}
else if (_stopLossMoney > 0m && -profit >= _stopLossMoney)
{
CloseAllPositions();
}
}
private void CloseAllPositions()
{
if (Position > 0m)
{
SellMarket(Position);
}
else if (Position < 0m)
{
BuyMarket(-Position);
}
_pendingBuyPrice = 0m;
_pendingSellPrice = 0m;
_pendingBuyVolume = 0m;
_pendingSellVolume = 0m;
}
private decimal GetEffectiveStep()
{
var manualStep = ManualStepPips > 0 && _pipSize > 0m
? ManualStepPips * _pipSize
: 0m;
var step = _currentStep > 0m ? _currentStep : manualStep;
if (step <= 0m)
step = manualStep;
return step;
}
private decimal AdjustVolume(decimal volume)
{
var security = Security;
if (security == null)
return volume;
var step = security.VolumeStep;
if (step is > 0m)
{
var steps = Math.Max(1m, Math.Round(volume / step.Value, MidpointRounding.AwayFromZero));
volume = steps * step.Value;
}
var minVol = security.MinVolume;
if (minVol is > 0m && volume < minVol.Value)
volume = minVol.Value;
var maxVol = security.MaxVolume;
if (maxVol is > 0m && volume > maxVol.Value)
volume = maxVol.Value;
return volume;
}
private void InitializePipSize()
{
var security = Security;
if (security == null)
{
_pipSize = 0.01m;
return;
}
var step = security.PriceStep ?? 0m;
if (step <= 0m)
step = 0.01m;
_pipSize = step;
}
private void InitializeEquityTargets()
{
var portfolio = Portfolio;
if (portfolio == null)
{
_initialEquity = 0m;
_takeProfitMoney = 0m;
_stopLossMoney = 0m;
return;
}
var currentValue = portfolio.CurrentValue ?? 0m;
if (currentValue <= 0m)
currentValue = portfolio.BeginValue ?? 0m;
_initialEquity = currentValue;
_takeProfitMoney = _initialEquity * GlobalTakePercent / 100m;
_stopLossMoney = _initialEquity * GlobalStopPercent / 100m;
}
}
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
from datatype_extensions import *
class pendulum_swing_strategy(Strategy):
def __init__(self):
super(pendulum_swing_strategy, self).__init__()
self._base_volume = self.Param("BaseVolume", 0.1).SetGreaterThanZero().SetDisplay("Base volume", "Initial lot", "Risk")
self._volume_multiplier = self.Param("VolumeMultiplier", 2.0).SetGreaterThanZero().SetDisplay("Volume multiplier", "Progression factor", "Risk")
self._max_levels = self.Param("MaxLevels", 8).SetGreaterThanZero().SetDisplay("Maximum levels", "Max fills per direction", "Risk")
self._manual_step_pips = self.Param("ManualStepPips", 50).SetGreaterThanZero().SetDisplay("Manual step (pips)", "Fallback distance", "Entry")
self._tp_pips = self.Param("TakeProfitPips", 10).SetDisplay("Take profit (pips)", "Local profit target", "Exit")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(6))).SetDisplay("Trading candle", "Primary timeframe", "Data")
@property
def CandleType(self): return self._candle_type.Value
@CandleType.setter
def CandleType(self, value): self._candle_type.Value = value
def OnReseted(self):
super(pendulum_swing_strategy, self).OnReseted()
self._pip_size = 0
self._pending_buy_price = 0
self._pending_sell_price = 0
self._pending_buy_vol = 0
self._pending_sell_vol = 0
self._long_level = 0
self._short_level = 0
self._entry_price = 0
def OnStarted2(self, time):
super(pendulum_swing_strategy, self).OnStarted2(time)
self._pip_size = self._get_pip_size()
self._pending_buy_price = 0
self._pending_sell_price = 0
self._pending_buy_vol = 0
self._pending_sell_vol = 0
self._long_level = 0
self._short_level = 0
self._entry_price = 0
self.Volume = self._base_volume.Value
sub = self.SubscribeCandles(self.CandleType)
sub.Bind(self.OnProcess).Start()
def _get_pip_size(self):
step = 0.01
if self.Security is not None and self.Security.PriceStep is not None and self.Security.PriceStep > 0:
step = float(self.Security.PriceStep)
return step
def _get_next_volume(self, level):
base = self._base_volume.Value
if base <= 0:
return 0
if level >= self._max_levels.Value:
return 0
mult = self._volume_multiplier.Value if self._volume_multiplier.Value > 0 else 1.0
return base * (mult ** level)
def OnProcess(self, candle):
if candle.State != CandleStates.Finished:
return
self._manage_tp(candle)
self._ensure_pendulum(candle)
def _manage_tp(self, candle):
if self._tp_pips.Value <= 0 or self.Position == 0 or self._pip_size <= 0 or self._entry_price <= 0:
return
diff = float(candle.ClosePrice) - self._entry_price
threshold = self._tp_pips.Value * self._pip_size
if self.Position > 0 and diff >= threshold:
self.SellMarket()
self._long_level = 0
elif self.Position < 0 and -diff >= threshold:
self.BuyMarket()
self._short_level = 0
def _ensure_pendulum(self, candle):
step = self._manual_step_pips.Value * self._pip_size if self._manual_step_pips.Value > 0 and self._pip_size > 0 else 0
if step <= 0:
return
if self._pending_buy_price > 0 and self._pending_buy_vol > 0 and candle.HighPrice >= self._pending_buy_price:
self.BuyMarket(self._pending_buy_vol)
self._entry_price = float(candle.ClosePrice)
self._long_level = min(self._long_level + 1, self._max_levels.Value)
self._pending_buy_price = 0
self._pending_buy_vol = 0
if self._pending_sell_price > 0 and self._pending_sell_vol > 0 and candle.LowPrice <= self._pending_sell_price:
self.SellMarket(self._pending_sell_vol)
self._entry_price = float(candle.ClosePrice)
self._short_level = min(self._short_level + 1, self._max_levels.Value)
self._pending_sell_price = 0
self._pending_sell_vol = 0
if self.Position == 0:
self._long_level = 0
self._short_level = 0
self._pending_buy_price = float(candle.ClosePrice) + step
self._pending_sell_price = float(candle.ClosePrice) - step
self._pending_buy_vol = self._get_next_volume(self._long_level)
self._pending_sell_vol = self._get_next_volume(self._short_level)
def CreateClone(self):
return pendulum_swing_strategy()