挂单稳定策略
概述
挂单稳定策略 是对 MetaTrader 专家顾问 hjueiisyx8lp2o379e_www_forex-instruments_info.mq4 的移植。原策略会在现价上下放置一对止损挂单,并等待价格突破。一旦触发开仓,系统会监控最近几根蜡烛的实体大小,用来判断行情是否进入横盘,并在动能减弱或利润达到预设目标时退出仓位。
该 C# 版本使用 StockSharp 的高级 API,只在蜡烛收盘后做出决策,从而保证回测与实盘表现的一致性。
交易规则
- 当没有持仓或挂单时,策略会在收盘价上方放置 buy stop,下方放置 sell stop,间距为
OrderDistancePoints个 MetaTrader 点。 - 任一止损挂单被触发时:
- 以
OrderVolume手的固定仓位开仓; - 另一侧的止损挂单保留,用于捕捉反向突破。
- 以
- 持仓期间持续检查最近两根已完成蜡烛的实体:
- 如果最新蜡烛实体小于
StabilizationPoints,且浮动利润大于ProfitThreshold,则平仓并取消对侧挂单; - 如果连续两根蜡烛都小于
StabilizationPoints,无论盈亏都立即平仓; - 当利润达到
AbsoluteFixation时立刻平仓。
- 如果最新蜡烛实体小于
- 若
ExpirationMinutes大于零,则挂单在到期后取消并重新创建。
参数
| 名称 | 说明 | 默认值 |
|---|---|---|
OrderVolume |
每次交易的手数。 | 0.1 |
OrderDistancePoints |
与当前价格的距离(MetaTrader 点)。 | 20 |
ProfitThreshold |
允许根据“稳定”信号离场所需的最低浮动利润(账户货币)。 | -2 |
AbsoluteFixation |
达到该利润(账户货币)时无条件平仓。 | 30 |
StabilizationPoints |
判断市场进入盘整的最大蜡烛实体(点)。 | 25 |
ExpirationMinutes |
挂单有效期(分钟),0 表示无限期。 |
20 |
CandleType |
用于判断稳定性的蜡烛类型(默认 5 分钟)。 | TimeFrame(5m) |
转换说明
- 原始程序基于逐笔行情;移植版本只处理已完成蜡烛,使结果更易复现。
- MetaTrader 的“点”映射到 StockSharp 的
PriceStep,若无定义则使用1。 - 利润通过
PriceStep与StepPrice换算为账户货币的估算值。 - 代码中的注释与参数描述全部改写为英文,以符合仓库要求。
使用方法
- 将策略添加到 StockSharp 项目中,并指定交易品种与账户。
- 根据交易品种调整参数,尤其是蜡烛周期与挂单间距。
- 启动策略,系统会自动下达成对的止损挂单,并按上述规则管理仓位。
可扩展方向
- 根据市场波动性调整蜡烛周期,以在敏感度与噪音过滤之间取得平衡。
- 引入 ATR、布林带等波动性过滤器,避免在极端平静的时段交易。
- 在接近目标利润时加入移动止损或分批减仓机制。
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.MatchingEngine;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Paired breakout stop orders with stabilization-based exits.
/// </summary>
public class OrderStabilizationStrategy : Strategy
{
private readonly StrategyParam<decimal> _orderVolume;
private readonly StrategyParam<decimal> _orderDistancePoints;
private readonly StrategyParam<decimal> _profitThreshold;
private readonly StrategyParam<decimal> _absoluteFixation;
private readonly StrategyParam<decimal> _stabilizationPoints;
private readonly StrategyParam<int> _expirationMinutes;
private readonly StrategyParam<DataType> _candleType;
private Order _buyStop;
private Order _sellStop;
private DateTime? _createdAt;
private decimal _filledPosition;
private decimal _entryValue;
private decimal _previousBody;
private bool _hasPreviousBody;
public decimal OrderVolume { get => _orderVolume.Value; set => _orderVolume.Value = value; }
public decimal OrderDistancePoints { get => _orderDistancePoints.Value; set => _orderDistancePoints.Value = value; }
public decimal ProfitThreshold { get => _profitThreshold.Value; set => _profitThreshold.Value = value; }
public decimal AbsoluteFixation { get => _absoluteFixation.Value; set => _absoluteFixation.Value = value; }
public decimal StabilizationPoints { get => _stabilizationPoints.Value; set => _stabilizationPoints.Value = value; }
public int ExpirationMinutes { get => _expirationMinutes.Value; set => _expirationMinutes.Value = value; }
public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
public OrderStabilizationStrategy()
{
_orderVolume = Param(nameof(OrderVolume), 0.1m).SetGreaterThanZero()
.SetDisplay("Order Volume", "Trade volume in lots used for both stop entries", "Trading");
_orderDistancePoints = Param(nameof(OrderDistancePoints), 20m).SetGreaterThanZero()
.SetDisplay("Order Distance", "Distance between the current close price and each stop order, in points", "Trading");
_profitThreshold = Param(nameof(ProfitThreshold), -2m)
.SetDisplay("Profit Threshold", "Minimum floating profit (account currency) required before an exit triggered by stabilization is allowed", "Exit");
_absoluteFixation = Param(nameof(AbsoluteFixation), 30m).SetNotNegative()
.SetDisplay("Absolute Fixation", "Profit level (account currency) that forces an immediate exit", "Exit");
_stabilizationPoints = Param(nameof(StabilizationPoints), 25m).SetGreaterThanZero()
.SetDisplay("Stabilization", "Maximum candle body size (points) that signals a flat market", "Exit");
_expirationMinutes = Param(nameof(ExpirationMinutes), 20).SetNotNegative()
.SetDisplay("Expiration", "Lifetime of pending stop orders in minutes, 0 disables expiration", "Trading");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Candle type used to evaluate stabilization", "General");
}
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> [(Security, CandleType), (Security, DataType.Ticks)];
protected override void OnReseted()
{
base.OnReseted();
_buyStop = null;
_sellStop = null;
_createdAt = null;
_filledPosition = 0m;
_entryValue = 0m;
_previousBody = 0m;
_hasPreviousBody = false;
}
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
// Stop orders are matched against trade prints, so they fire between candle closes.
Subscribe(new Subscription(DataType.Ticks, Security));
SubscribeCandles(CandleType).Bind(ProcessCandle).Start();
}
private void ProcessCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
var point = Security?.PriceStep ?? 1m;
if (point <= 0m)
point = 1m;
var body = Math.Abs(candle.ClosePrice - candle.OpenPrice);
if (Position != 0m && IsExitDue(candle.ClosePrice, body, point))
{
CancelIfActive(_buyStop);
CancelIfActive(_sellStop);
Flatten();
}
else if ((Position == 0m && !IsWorking(_buyStop) && !IsWorking(_sellStop)) || IsExpired(candle.OpenTime))
{
ArmStops(candle.ClosePrice, candle.OpenTime, point);
}
_previousBody = body;
_hasPreviousBody = true;
}
protected override void OnOwnTradeReceived(MyTrade trade)
{
base.OnOwnTradeReceived(trade);
var price = trade.Trade.Price;
var volume = trade.Trade.Volume;
var signed = trade.Order.Side == Sides.Buy ? volume : -volume;
var previous = _filledPosition;
_filledPosition += signed;
if (_filledPosition == 0m)
_entryValue = 0m;
else if (previous == 0m || Math.Sign(previous) == Math.Sign(signed))
_entryValue += price * volume;
else if (Math.Sign(_filledPosition) != Math.Sign(previous))
_entryValue = price * Math.Abs(_filledPosition);
else
_entryValue -= _entryValue / Math.Abs(previous) * volume;
}
private bool IsExitDue(decimal price, decimal body, decimal point)
{
var bodyLimit = StabilizationPoints * point;
var pnl = FloatingPnL(price, point);
var oneSmall = body <= bodyLimit;
var twoSmall = oneSmall && _hasPreviousBody && _previousBody <= bodyLimit;
return (oneSmall && pnl > ProfitThreshold) ||
twoSmall ||
(AbsoluteFixation > 0m && pnl >= AbsoluteFixation);
}
private bool IsExpired(DateTime time)
=> ExpirationMinutes > 0 &&
_createdAt is DateTime created &&
time - created >= TimeSpan.FromMinutes(ExpirationMinutes);
// Flat: both stops are placed around the close. With a position open only the pending
// opposite stop is moved; the side that opened the position is never placed again.
private void ArmStops(decimal center, DateTime time, decimal point)
{
var distance = OrderDistancePoints * point;
if (Position == 0m || (Position < 0m && IsWorking(_buyStop)))
_buyStop = RenewStop(_buyStop, Sides.Buy, center + distance);
if (Position == 0m || (Position > 0m && IsWorking(_sellStop)))
_sellStop = RenewStop(_sellStop, Sides.Sell, center - distance);
_createdAt = IsWorking(_buyStop) || IsWorking(_sellStop) ? time : null;
}
private Order RenewStop(Order order, Sides side, decimal activationPrice)
{
if (order is { State: OrderStates.Active })
CancelOrder(order);
else if (IsWorking(order))
return order;
var stop = new Order
{
Security = Security,
Portfolio = Portfolio,
Side = side,
Volume = OrderVolume,
Type = OrderTypes.Conditional,
Condition = new StopOrderCondition { ActivationPrice = activationPrice },
};
RegisterOrder(stop);
return stop;
}
private static bool IsWorking(Order order)
=> order is not null && order.State is not (OrderStates.Done or OrderStates.Failed);
private void CancelIfActive(Order order)
{
if (order is { State: OrderStates.Active })
CancelOrder(order);
}
private decimal FloatingPnL(decimal price, decimal point)
{
if (Position == 0m || _filledPosition == 0m)
return 0m;
var entryPrice = _entryValue / Math.Abs(_filledPosition);
var direction = Position > 0m ? 1m : -1m;
var move = (price - entryPrice) * direction;
var stepPrice = Security?.StepPrice ?? 0m;
return stepPrice > 0m
? move / point * stepPrice * Math.Abs(Position)
: move * (Security?.Multiplier ?? 1m) * Math.Abs(Position);
}
private void Flatten()
{
if (Position > 0m)
SellMarket(Math.Abs(Position));
else if (Position < 0m)
BuyMarket(Math.Abs(Position));
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.MatchingEngine")
from System import TimeSpan, Math, Decimal
from StockSharp.Messages import DataType, CandleStates, Sides, OrderTypes, OrderStates, ITickTradeMessage
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Order, Subscription
from StockSharp.MatchingEngine import StopOrderCondition
# ExecutionMessage implements tick Price/Volume explicitly; access the declared interface.
_tick_price = clr.GetClrType(ITickTradeMessage).GetProperty("Price")
_tick_volume = clr.GetClrType(ITickTradeMessage).GetProperty("Volume")
class order_stabilization_strategy(Strategy):
def __init__(self):
super(order_stabilization_strategy, self).__init__()
self._order_volume = self.Param("OrderVolume", 0.1).SetGreaterThanZero() \
.SetDisplay("Order Volume", "Trade volume in lots used for both stop entries", "Trading")
self._order_distance = self.Param("OrderDistancePoints", 20.0).SetGreaterThanZero() \
.SetDisplay("Order Distance", "Distance between the current close price and each stop order, in points", "Trading")
self._profit_threshold = self.Param("ProfitThreshold", -2.0) \
.SetDisplay("Profit Threshold", "Minimum floating profit (account currency) required before an exit triggered by stabilization is allowed", "Exit")
self._absolute_fixation = self.Param("AbsoluteFixation", 30.0).SetNotNegative() \
.SetDisplay("Absolute Fixation", "Profit level (account currency) that forces an immediate exit", "Exit")
self._stabilization_points = self.Param("StabilizationPoints", 25.0).SetGreaterThanZero() \
.SetDisplay("Stabilization", "Maximum candle body size (points) that signals a flat market", "Exit")
self._expiration_minutes = self.Param("ExpirationMinutes", 20).SetNotNegative() \
.SetDisplay("Expiration", "Lifetime of pending stop orders in minutes, 0 disables expiration", "Trading")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))) \
.SetDisplay("Candle Type", "Candle type used to evaluate stabilization", "General")
self._buy_stop = None
self._sell_stop = None
self._created_at = None
self._filled_position = Decimal.Zero
self._entry_value = Decimal.Zero
self._previous_body = Decimal.Zero
self._has_previous_body = False
def GetWorkingSecurities(self):
return [(self.Security, self._candle_type.Value), (self.Security, DataType.Ticks)]
def OnReseted(self):
super(order_stabilization_strategy, self).OnReseted()
self._buy_stop = None
self._sell_stop = None
self._created_at = None
self._filled_position = Decimal.Zero
self._entry_value = Decimal.Zero
self._previous_body = Decimal.Zero
self._has_previous_body = False
def OnStarted2(self, time):
super(order_stabilization_strategy, self).OnStarted2(time)
# Stop orders are matched against trade prints, so they fire between candle closes.
self.Subscribe(Subscription(DataType.Ticks, self.Security))
self.SubscribeCandles(self._candle_type.Value).Bind(self._process_candle).Start()
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
point = self.Security.PriceStep if self.Security is not None and self.Security.PriceStep is not None else Decimal.One
if point <= 0:
point = Decimal.One
body = Math.Abs(candle.ClosePrice - candle.OpenPrice)
if self.Position != 0 and self._is_exit_due(candle.ClosePrice, body, point):
self._cancel_if_active(self._buy_stop)
self._cancel_if_active(self._sell_stop)
self._flatten()
elif (self.Position == 0 and not self._is_working(self._buy_stop) and not self._is_working(self._sell_stop)) or \
self._is_expired(candle.OpenTime):
self._arm_stops(candle.ClosePrice, candle.OpenTime, point)
self._previous_body = body
self._has_previous_body = True
def OnOwnTradeReceived(self, trade):
super(order_stabilization_strategy, self).OnOwnTradeReceived(trade)
price = _tick_price.GetValue(trade.Trade)
volume = _tick_volume.GetValue(trade.Trade)
signed = volume if trade.Order.Side == Sides.Buy else -volume
previous = self._filled_position
self._filled_position += signed
if self._filled_position == 0:
self._entry_value = Decimal.Zero
elif previous == 0 or Math.Sign(previous) == Math.Sign(signed):
self._entry_value += price * volume
elif Math.Sign(self._filled_position) != Math.Sign(previous):
self._entry_value = price * Math.Abs(self._filled_position)
else:
self._entry_value -= self._entry_value / Math.Abs(previous) * volume
def _is_exit_due(self, price, body, point):
body_limit = Decimal(self._stabilization_points.Value) * point
pnl = self._floating_pnl(price, point)
one_small = body <= body_limit
two_small = one_small and self._has_previous_body and self._previous_body <= body_limit
absolute_fixation = Decimal(self._absolute_fixation.Value)
return (one_small and pnl > Decimal(self._profit_threshold.Value)) or \
two_small or \
(absolute_fixation > 0 and pnl >= absolute_fixation)
def _is_expired(self, time):
minutes = int(self._expiration_minutes.Value)
return minutes > 0 and \
self._created_at is not None and \
time - self._created_at >= TimeSpan.FromMinutes(minutes)
# Flat: both stops are placed around the close. With a position open only the pending
# opposite stop is moved; the side that opened the position is never placed again.
def _arm_stops(self, center, time, point):
distance = Decimal(self._order_distance.Value) * point
if self.Position == 0 or (self.Position < 0 and self._is_working(self._buy_stop)):
self._buy_stop = self._renew_stop(self._buy_stop, Sides.Buy, center + distance)
if self.Position == 0 or (self.Position > 0 and self._is_working(self._sell_stop)):
self._sell_stop = self._renew_stop(self._sell_stop, Sides.Sell, center - distance)
self._created_at = time if self._is_working(self._buy_stop) or self._is_working(self._sell_stop) else None
def _renew_stop(self, order, side, activation_price):
if order is not None and order.State == OrderStates.Active:
self.CancelOrder(order)
elif self._is_working(order):
return order
stop = Order()
stop.Security = self.Security
stop.Portfolio = self.Portfolio
stop.Side = side
stop.Volume = Decimal(self._order_volume.Value)
stop.Type = OrderTypes.Conditional
condition = StopOrderCondition()
condition.ActivationPrice = activation_price
stop.Condition = condition
self.RegisterOrder(stop)
return stop
def _is_working(self, order):
return order is not None and order.State != OrderStates.Done and order.State != OrderStates.Failed
def _cancel_if_active(self, order):
if order is not None and order.State == OrderStates.Active:
self.CancelOrder(order)
def _floating_pnl(self, price, point):
if self.Position == 0 or self._filled_position == 0:
return Decimal.Zero
entry_price = self._entry_value / Math.Abs(self._filled_position)
direction = Decimal.One if self.Position > 0 else Decimal.MinusOne
move = (price - entry_price) * direction
step_price = self.Security.StepPrice if self.Security is not None and self.Security.StepPrice is not None else Decimal.Zero
if step_price > 0:
return move / point * step_price * Math.Abs(self.Position)
multiplier = self.Security.Multiplier if self.Security is not None and self.Security.Multiplier is not None else Decimal.One
return move * multiplier * Math.Abs(self.Position)
def _flatten(self):
if self.Position > 0:
self.SellMarket(Math.Abs(self.Position))
elif self.Position < 0:
self.BuyMarket(Math.Abs(self.Position))
def CreateClone(self):
return order_stabilization_strategy()