OCO Pending Orders Strategy
Overview
The OCO Pending Orders Strategy replicates the behaviour of the MetaTrader4 expert advisor OCO_EA.mq4 inside the StockSharp high-level API. The algorithm allows a trader to arm up to four independent price triggers (buy limit, buy stop, sell limit, sell stop). Whenever the live best bid or best ask touches the configured price level the strategy sends an immediate market order, optionally cancelling all other pending triggers in a classic "one-cancels-the-others" (OCO) fashion.
The strategy relies purely on level-1 market data – no historical indicators are required. It is intended for discretionary or semi-automated trading workflows where traders manually define price levels and want the platform to execute as soon as the level is hit, while also attaching protective exit orders.
Trading logic
- The trader sets any combination of the four trigger prices and toggles the Armed parameter to
true.
- The strategy subscribes to level-1 updates and keeps the latest best bid and best ask in memory.
- On every update it compares the stored prices with the configured thresholds:
- If the best ask is less than or equal to the Buy limit price, a market buy order with the configured volume is submitted.
- If the best ask is greater than or equal to the Buy stop price, a market buy order is submitted.
- If the best bid is greater than or equal to the Sell limit price, a market sell order is submitted.
- If the best bid is less than or equal to the Sell stop price, a market sell order is submitted.
- After every executed trigger the corresponding level is cleared (set back to zero). When Use OCO link is enabled all other levels are cleared immediately, mirroring the original MT4 behaviour. When the OCO link is disabled other levels remain active until they trigger or are manually cleared.
- If all trigger prices are zero the strategy automatically disarms itself by switching Armed back to
false.
All executions are performed with BuyMarket and SellMarket calls to ensure immediate fills that respect the exchange routing configured in the StockSharp environment. Informative log entries are produced for every trigger to simplify monitoring.
Parameters
- Order volume – volume sent with each market order. The value must be positive.
- Buy limit price – ask price threshold that activates a limit-style long entry. Set to
0 to disable.
- Buy stop price – ask price threshold that activates a stop-style long entry. Set to
0 to disable.
- Sell limit price – bid price threshold that activates a limit-style short entry. Set to
0 to disable.
- Sell stop price – bid price threshold that activates a stop-style short entry. Set to
0 to disable.
- Stop loss (pips) – distance in instrument points for the protective stop. Converted to price by multiplying with
Security.PriceStep (fallback 1 when the instrument does not report a tick size).
- Take profit (pips) – distance in instrument points for the profit target. The same conversion logic as for the stop loss is used.
- Use OCO link – if
true, the first filled order clears the remaining price levels and disarms the strategy. If false, remaining levels stay active until triggered individually.
- Armed – safety switch that enables or disables trading logic. The strategy automatically resets it to
false whenever no active trigger levels remain.
Risk management
StartProtection is enabled during OnStarted, attaching absolute-price stop-loss and take-profit offsets to every open position. The offsets are derived from the Stop loss (pips) and Take profit (pips) parameters using the instrument tick size. Protective orders are always sent as market orders to guarantee exit execution even when the underlying instrument is illiquid.
Because the strategy is purely event-driven it does not maintain pending limit orders on the exchange; it reacts to market data and sends market orders, just like the original MQL version that issued immediate orders and then modified them to apply stop-loss and take-profit distances.
Usage tips
- Configure the security, portfolio, and connection inside StockSharp as usual.
- Set Order volume to match the desired lot size.
- Enter any subset of trigger prices and flip Armed to
true. Values left at 0 are ignored.
- Optionally disable Use OCO link to keep remaining triggers active after the first fill.
- Monitor the log for messages confirming each trigger and the automatic reset state.
Remember that the strategy uses the broker-provided price step. If the trading instrument quotes in fractional pips or uses unconventional tick sizes, adjust the pip-based distances accordingly before arming the strategy.
Differences from the original MQL script
- The strategy uses StockSharp's
StartProtection helper instead of manually modifying orders to apply stop-loss and take-profit levels.
- Level-1 data subscriptions are handled through high-level bindings instead of manual polling of
Bid, Ask, High, and Low values.
- Parameters are exposed through
StrategyParam<T> so they can be adjusted and optimised directly in the StockSharp UI.
- Logging replaces the MT4
Comment and PlaySound notifications, providing execution transparency within StockSharp logs.
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Manually armed OCO price triggers driven by Level1 bid/ask updates.
/// </summary>
public class OcoPendingOrdersStrategy : Strategy
{
private readonly StrategyParam<decimal> _orderVolume;
private readonly StrategyParam<decimal> _buyLimitPrice;
private readonly StrategyParam<decimal> _buyStopPrice;
private readonly StrategyParam<decimal> _sellLimitPrice;
private readonly StrategyParam<decimal> _sellStopPrice;
private readonly StrategyParam<int> _stopLossPips;
private readonly StrategyParam<int> _takeProfitPips;
private readonly StrategyParam<bool> _useOcoLink;
private readonly StrategyParam<bool> _armed;
private decimal[] _initialLevels;
private bool _initialArmed;
private decimal? _bestBid;
private decimal? _bestAsk;
public decimal OrderVolume { get => _orderVolume.Value; set => _orderVolume.Value = value; }
public decimal BuyLimitPrice { get => _buyLimitPrice.Value; set => _buyLimitPrice.Value = value; }
public decimal BuyStopPrice { get => _buyStopPrice.Value; set => _buyStopPrice.Value = value; }
public decimal SellLimitPrice { get => _sellLimitPrice.Value; set => _sellLimitPrice.Value = value; }
public decimal SellStopPrice { get => _sellStopPrice.Value; set => _sellStopPrice.Value = value; }
public int StopLossPips { get => _stopLossPips.Value; set => _stopLossPips.Value = value; }
public int TakeProfitPips { get => _takeProfitPips.Value; set => _takeProfitPips.Value = value; }
public bool UseOcoLink { get => _useOcoLink.Value; set => _useOcoLink.Value = value; }
public bool Armed { get => _armed.Value; set => _armed.Value = value; }
public OcoPendingOrdersStrategy()
{
_orderVolume = Param(nameof(OrderVolume), 1m)
.SetGreaterThanZero()
.SetDisplay("Order volume", "Volume sent with each market order", "Trading");
_buyLimitPrice = Param(nameof(BuyLimitPrice), 0m)
.SetNotNegative()
.SetDisplay("Buy limit price", "Ask price threshold that activates a limit-style long entry, 0 disables", "Levels");
_buyStopPrice = Param(nameof(BuyStopPrice), 0m)
.SetNotNegative()
.SetDisplay("Buy stop price", "Ask price threshold that activates a stop-style long entry, 0 disables", "Levels");
_sellLimitPrice = Param(nameof(SellLimitPrice), 0m)
.SetNotNegative()
.SetDisplay("Sell limit price", "Bid price threshold that activates a limit-style short entry, 0 disables", "Levels");
_sellStopPrice = Param(nameof(SellStopPrice), 0m)
.SetNotNegative()
.SetDisplay("Sell stop price", "Bid price threshold that activates a stop-style short entry, 0 disables", "Levels");
_stopLossPips = Param(nameof(StopLossPips), 0)
.SetNotNegative()
.SetDisplay("Stop loss (pips)", "Protective stop distance in instrument points, multiplied by the price step", "Risk");
_takeProfitPips = Param(nameof(TakeProfitPips), 0)
.SetNotNegative()
.SetDisplay("Take profit (pips)", "Profit target distance in instrument points, multiplied by the price step", "Risk");
_useOcoLink = Param(nameof(UseOcoLink), true)
.SetDisplay("Use OCO link", "The first filled order clears the remaining price levels and disarms the strategy", "Control");
_armed = Param(nameof(Armed), false)
.SetDisplay("Armed", "Safety switch for the triggers, reset to false when no active level remains", "Control");
}
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> [(Security, DataType.Level1)];
protected override void OnReseted()
{
base.OnReseted();
if (_initialLevels != null)
{
BuyLimitPrice = _initialLevels[0];
BuyStopPrice = _initialLevels[1];
SellLimitPrice = _initialLevels[2];
SellStopPrice = _initialLevels[3];
Armed = _initialArmed;
}
_initialLevels = null;
_initialArmed = false;
_bestBid = null;
_bestAsk = null;
}
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_initialLevels = [BuyLimitPrice, BuyStopPrice, SellLimitPrice, SellStopPrice];
_initialArmed = Armed;
var step = Security?.PriceStep ?? 1m;
if (step <= 0m)
step = 1m;
var take = TakeProfitPips > 0 ? new Unit(TakeProfitPips * step, UnitTypes.Absolute) : null;
var stop = StopLossPips > 0 ? new Unit(StopLossPips * step, UnitTypes.Absolute) : null;
if (take is not null || stop is not null)
StartProtection(takeProfit: take, stopLoss: stop, useMarketOrders: true);
// A Level1 binding only sees updates carrying its build field, so bid-only and ask-only updates need one binding each.
foreach (var field in new[] { Level1Fields.BestBidPrice, Level1Fields.BestAskPrice })
{
var quotes = new Subscription(DataType.Level1, Security);
quotes.MarketData.BuildField = field;
SubscribeLevel1(quotes).Bind(ProcessLevel1).Start();
}
}
private void ProcessLevel1(Level1ChangeMessage message)
{
if (message.TryGetDecimal(Level1Fields.BestBidPrice) is decimal bid)
_bestBid = bid;
if (message.TryGetDecimal(Level1Fields.BestAskPrice) is decimal ask)
_bestAsk = ask;
if (!Armed)
return;
if (_bestAsk is decimal bestAsk)
{
if (BuyLimitPrice > 0m && bestAsk <= BuyLimitPrice)
{
var level = BuyLimitPrice;
BuyLimitPrice = 0m;
Execute(Sides.Buy, $"Buy limit {level} hit by ask {bestAsk}");
if (!Armed)
return;
}
if (BuyStopPrice > 0m && bestAsk >= BuyStopPrice)
{
var level = BuyStopPrice;
BuyStopPrice = 0m;
Execute(Sides.Buy, $"Buy stop {level} hit by ask {bestAsk}");
if (!Armed)
return;
}
}
if (_bestBid is decimal bestBid)
{
if (SellLimitPrice > 0m && bestBid >= SellLimitPrice)
{
var level = SellLimitPrice;
SellLimitPrice = 0m;
Execute(Sides.Sell, $"Sell limit {level} hit by bid {bestBid}");
if (!Armed)
return;
}
if (SellStopPrice > 0m && bestBid <= SellStopPrice)
{
var level = SellStopPrice;
SellStopPrice = 0m;
Execute(Sides.Sell, $"Sell stop {level} hit by bid {bestBid}");
if (!Armed)
return;
}
}
DisarmIfEmpty();
}
private void Execute(Sides side, string trigger)
{
LogInfo($"{trigger}: {side} {OrderVolume} at market.");
if (side == Sides.Buy)
BuyMarket(OrderVolume);
else
SellMarket(OrderVolume);
if (!UseOcoLink)
return;
ClearLevels();
Armed = false;
LogInfo("OCO link cleared all trigger levels; strategy disarmed.");
}
private void ClearLevels()
{
BuyLimitPrice = 0m;
BuyStopPrice = 0m;
SellLimitPrice = 0m;
SellStopPrice = 0m;
}
private void DisarmIfEmpty()
{
if (BuyLimitPrice > 0m || BuyStopPrice > 0m || SellLimitPrice > 0m || SellStopPrice > 0m)
return;
Armed = false;
LogInfo("No trigger levels remain; strategy disarmed.");
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
from System import Decimal
from StockSharp.Messages import DataType, Level1Fields, Sides, Unit, UnitTypes
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Subscription
class oco_pending_orders_strategy(Strategy):
def __init__(self):
super(oco_pending_orders_strategy, self).__init__()
self._order_volume = self.Param("OrderVolume", 1.0) \
.SetGreaterThanZero() \
.SetDisplay("Order volume", "Volume sent with each market order", "Trading")
self._buy_limit = self.Param("BuyLimitPrice", 0.0) \
.SetNotNegative() \
.SetDisplay("Buy limit price", "Ask price threshold that activates a limit-style long entry, 0 disables", "Levels")
self._buy_stop = self.Param("BuyStopPrice", 0.0) \
.SetNotNegative() \
.SetDisplay("Buy stop price", "Ask price threshold that activates a stop-style long entry, 0 disables", "Levels")
self._sell_limit = self.Param("SellLimitPrice", 0.0) \
.SetNotNegative() \
.SetDisplay("Sell limit price", "Bid price threshold that activates a limit-style short entry, 0 disables", "Levels")
self._sell_stop = self.Param("SellStopPrice", 0.0) \
.SetNotNegative() \
.SetDisplay("Sell stop price", "Bid price threshold that activates a stop-style short entry, 0 disables", "Levels")
self._stop_loss = self.Param("StopLossPips", 0) \
.SetNotNegative() \
.SetDisplay("Stop loss (pips)", "Protective stop distance in instrument points, multiplied by the price step", "Risk")
self._take_profit = self.Param("TakeProfitPips", 0) \
.SetNotNegative() \
.SetDisplay("Take profit (pips)", "Profit target distance in instrument points, multiplied by the price step", "Risk")
self._use_oco = self.Param("UseOcoLink", True) \
.SetDisplay("Use OCO link", "The first filled order clears the remaining price levels and disarms the strategy", "Control")
self._armed = self.Param("Armed", False) \
.SetDisplay("Armed", "Safety switch for the triggers, reset to false when no active level remains", "Control")
self._initial_levels = None
self._initial_armed = False
self._best_bid = None
self._best_ask = None
def GetWorkingSecurities(self):
return [(self.Security, DataType.Level1)]
def OnStarted2(self, time):
super(oco_pending_orders_strategy, self).OnStarted2(time)
self._initial_levels = [self._buy_limit.Value, self._buy_stop.Value, self._sell_limit.Value, self._sell_stop.Value]
self._initial_armed = self._armed.Value
step = float(self.Security.PriceStep) if self.Security is not None and self.Security.PriceStep is not None else 1.0
if step <= 0:
step = 1.0
tp = int(self._take_profit.Value)
sl = int(self._stop_loss.Value)
take = Unit(tp * step, UnitTypes.Absolute) if tp > 0 else None
stop = Unit(sl * step, UnitTypes.Absolute) if sl > 0 else None
if take is not None or stop is not None:
self.StartProtection(takeProfit=take, stopLoss=stop, useMarketOrders=True)
# A Level1 binding only sees updates carrying its build field, so bid-only and ask-only updates need one binding each.
for field in (Level1Fields.BestBidPrice, Level1Fields.BestAskPrice):
quotes = Subscription(DataType.Level1, self.Security)
quotes.MarketData.BuildField = field
self.SubscribeLevel1(quotes).Bind(self._process_level1).Start()
def _process_level1(self, message):
if message.Changes.ContainsKey(Level1Fields.BestBidPrice):
self._best_bid = message.Changes[Level1Fields.BestBidPrice]
if message.Changes.ContainsKey(Level1Fields.BestAskPrice):
self._best_ask = message.Changes[Level1Fields.BestAskPrice]
if not bool(self._armed.Value):
return
if self._best_ask is not None:
best_ask = self._best_ask
level = Decimal(float(self._buy_limit.Value))
if level > 0 and best_ask <= level:
self._buy_limit.Value = 0.0
self._execute(Sides.Buy, "Buy limit {0} hit by ask {1}".format(level, best_ask))
if not bool(self._armed.Value):
return
level = Decimal(float(self._buy_stop.Value))
if level > 0 and best_ask >= level:
self._buy_stop.Value = 0.0
self._execute(Sides.Buy, "Buy stop {0} hit by ask {1}".format(level, best_ask))
if not bool(self._armed.Value):
return
if self._best_bid is not None:
best_bid = self._best_bid
level = Decimal(float(self._sell_limit.Value))
if level > 0 and best_bid >= level:
self._sell_limit.Value = 0.0
self._execute(Sides.Sell, "Sell limit {0} hit by bid {1}".format(level, best_bid))
if not bool(self._armed.Value):
return
level = Decimal(float(self._sell_stop.Value))
if level > 0 and best_bid <= level:
self._sell_stop.Value = 0.0
self._execute(Sides.Sell, "Sell stop {0} hit by bid {1}".format(level, best_bid))
if not bool(self._armed.Value):
return
self._disarm_if_empty()
def _execute(self, side, trigger):
volume = float(self._order_volume.Value)
self.LogInfo("{0}: {1} {2} at market.".format(trigger, "Buy" if side == Sides.Buy else "Sell", volume))
if side == Sides.Buy:
self.BuyMarket(volume)
else:
self.SellMarket(volume)
if not bool(self._use_oco.Value):
return
self._clear_levels()
self._armed.Value = False
self.LogInfo("OCO link cleared all trigger levels; strategy disarmed.")
def _clear_levels(self):
self._buy_limit.Value = 0.0
self._buy_stop.Value = 0.0
self._sell_limit.Value = 0.0
self._sell_stop.Value = 0.0
def _disarm_if_empty(self):
if (float(self._buy_limit.Value) > 0 or float(self._buy_stop.Value) > 0 or
float(self._sell_limit.Value) > 0 or float(self._sell_stop.Value) > 0):
return
self._armed.Value = False
self.LogInfo("No trigger levels remain; strategy disarmed.")
def OnReseted(self):
super(oco_pending_orders_strategy, self).OnReseted()
if self._initial_levels is not None:
self._buy_limit.Value, self._buy_stop.Value, self._sell_limit.Value, self._sell_stop.Value = self._initial_levels
self._armed.Value = self._initial_armed
self._initial_levels = None
self._initial_armed = False
self._best_bid = None
self._best_ask = None
def CreateClone(self):
return oco_pending_orders_strategy()