From 361100649ff1d4b6e4af4384e58895b6e3fee335 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 9 Oct 2026 15:19:11 +0300 Subject: [PATCH 01/25] feature: add etoro brokerage name and order properties - BrokerageName.Etoro as the last enum member - EtoroOrderProperties with StopLossRate, the absolute price at which eToro closes a short position --- Common/Brokerages/BrokerageName.cs | 7 ++++- Common/Orders/EtoroOrderProperties.cs | 41 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Common/Orders/EtoroOrderProperties.cs diff --git a/Common/Brokerages/BrokerageName.cs b/Common/Brokerages/BrokerageName.cs index be7cbcb8425f..ec051846c329 100644 --- a/Common/Brokerages/BrokerageName.cs +++ b/Common/Brokerages/BrokerageName.cs @@ -229,6 +229,11 @@ public enum BrokerageName /// /// Transaction and submit/execution rules will use Quant Bridge models /// - QuantBridge + QuantBridge, + + /// + /// Transaction and submit/execution rules will use eToro models + /// + Etoro } } diff --git a/Common/Orders/EtoroOrderProperties.cs b/Common/Orders/EtoroOrderProperties.cs new file mode 100644 index 000000000000..9e583b566933 --- /dev/null +++ b/Common/Orders/EtoroOrderProperties.cs @@ -0,0 +1,41 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +using QuantConnect.Interfaces; + +namespace QuantConnect.Orders +{ + /// + /// Represents the properties of an order in eToro. + /// + public class EtoroOrderProperties : OrderProperties + { + /// + /// The absolute price at which eToro closes the position when the market moves against it. + /// eToro requires it for a short sale and for any position with leverage above 1; + /// Lean sends every order at leverage 1, so it is needed for short sales only. + /// + public decimal? StopLossRate { get; set; } + + /// + /// Returns a new instance clone of this object + /// + public override IOrderProperties Clone() + { + return (EtoroOrderProperties)MemberwiseClone(); + } + } +} From 3a973e5da3959c0516bfb950b218875df1fe6304 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 9 Oct 2026 15:19:11 +0300 Subject: [PATCH 02/25] feature: add etoro fee model - crypto pays 1% of the trade value on every buy and sell, from the eToro US fees page - stocks and ETFs are commission free on the eToro US fees page, so the equity fee is zero --- Common/Orders/Fees/EtoroFeeModel.cs | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Common/Orders/Fees/EtoroFeeModel.cs diff --git a/Common/Orders/Fees/EtoroFeeModel.cs b/Common/Orders/Fees/EtoroFeeModel.cs new file mode 100644 index 000000000000..a5d81bea0ee5 --- /dev/null +++ b/Common/Orders/Fees/EtoroFeeModel.cs @@ -0,0 +1,59 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +using QuantConnect.Securities; + +namespace QuantConnect.Orders.Fees +{ + /// + /// Represents a fee model specific to eToro. + /// + /// + /// + /// Stock and ETF trades are commission free. Crypto trades pay 1% of the trade value on every buy and sell. + /// The stock commission on the global fees page (https://www.etoro.com/trading/fees/) depends on the country + /// of residence and the exchange; the model uses the US page, which charges nothing. + /// + public class EtoroFeeModel : FeeModel + { + /// + /// Crypto fee rate on the trade value, charged on every buy and sell. + /// From https://www.etoro.com/en-us/trading/fees/: + /// "A standardized 1% commission is charged each time you buy or sell a cryptoasset on etoro." + /// + private const decimal _cryptoFeeRate = 0.01m; + + /// + /// Gets the order fee for a given security and order. + /// + /// The parameters including the security and order details. + /// A in USD for the order. + public override OrderFee GetOrderFee(OrderFeeParameters parameters) + { + var order = parameters.Order; + var security = parameters.Security; + + // From https://www.etoro.com/en-us/trading/fees/: "We charge zero commissions when you buy or sell a stock." + if (security.Type != SecurityType.Crypto) + { + return OrderFee.Zero; + } + + var tradeValue = security.Price * security.SymbolProperties.ContractMultiplier * order.AbsoluteQuantity; + return new OrderFee(new CashAmount(tradeValue * _cryptoFeeRate, Currencies.USD)); + } + } +} From d2fc41797e74a112c2314c77349795e586944494 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 9 Oct 2026 15:19:11 +0300 Subject: [PATCH 03/25] feature: add etoro brokerage model - Equity and Crypto with Market orders only, no contingent orders - a short sale needs a positive StopLossRate in EtoroOrderProperties - a market sell beyond the long holdings is refused as a cross-zero order - CanUpdateOrder returns false, eToro has no order update - Messages.EtoroBrokerageModel for the two model texts - BrokerageName.Etoro wired in the brokerage model factory --- Common/Brokerages/EtoroBrokerageModel.cs | 136 +++++++++++++++++++++++ Common/Brokerages/IBrokerageModel.cs | 6 + Common/Messages/Messages.Brokerages.cs | 24 ++++ 3 files changed, 166 insertions(+) create mode 100644 Common/Brokerages/EtoroBrokerageModel.cs diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs new file mode 100644 index 000000000000..e14e46648ab6 --- /dev/null +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -0,0 +1,136 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +using QuantConnect.Orders; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; +using System.Collections.Generic; + +namespace QuantConnect.Brokerages +{ + /// + /// Represents a brokerage model specific to eToro. + /// + public class EtoroBrokerageModel : DefaultBrokerageModel + { + /// + /// The security types supported by eToro: US stocks and ETFs, and crypto. + /// + private readonly HashSet _supportSecurityTypes = new( + new[] + { + SecurityType.Equity, + SecurityType.Crypto + }); + + /// + /// The order types supported by the operation in eToro. + /// + private readonly HashSet _supportOrderTypes = new( + new[] + { + OrderType.Market + }); + + /// + /// The order types that cannot cross zero holdings. + /// eToro closes a position by units, so a sell beyond the long holdings is refused. + /// + private static readonly IReadOnlySet NotSupportedCrossZeroOrderTypes = new HashSet + { + OrderType.Market + }; + + /// + /// Constructor for eToro brokerage model + /// + /// Cash or Margin + public EtoroBrokerageModel(AccountType accountType = AccountType.Margin) + : base(accountType) + { + } + + /// + /// Provides the eToro fee model + /// + /// Security + /// eToro fee model + public override IFeeModel GetFeeModel(Security security) + { + return new EtoroFeeModel(); + } + + /// + /// Returns true if the brokerage could accept this order. This takes into account order type, security type. + /// + /// The security of the order + /// The order to be processed + /// If this function returns false, a brokerage message detailing why the order may not be submitted + /// True if the brokerage could process the order, false otherwise + public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) + { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + + if (!_supportSecurityTypes.Contains(security.Type)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedSecurityType(this, security)); + return false; + } + + if (!_supportOrderTypes.Contains(order.Type)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedOrderType(this, order, _supportOrderTypes)); + return false; + } + + // eToro refuses a short sale without a stop loss rate, so the order must carry a positive one in its properties. + if (BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity) == OrderPosition.SellToOpen + && order.Properties is not EtoroOrderProperties { StopLossRate: > 0 }) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.ShortSaleRequiresStopLossRate(order)); + return false; + } + + if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) + { + return false; + } + + return base.CanSubmitOrder(security, order, out message); + } + + /// + /// Returns false: eToro has no order update, an order is cancelled and placed again instead. + /// + /// The security of the order + /// The order to be updated + /// The requested update to be made to the order + /// A brokerage message detailing why the order may not be updated + /// Always false + public override bool CanUpdateOrder(Security security, Order order, UpdateOrderRequest request, out BrokerageMessageEvent message) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.UnsupportedOrderUpdate(order)); + return false; + } + } +} diff --git a/Common/Brokerages/IBrokerageModel.cs b/Common/Brokerages/IBrokerageModel.cs index 737effbfefe3..2dbf1a4daed0 100644 --- a/Common/Brokerages/IBrokerageModel.cs +++ b/Common/Brokerages/IBrokerageModel.cs @@ -306,6 +306,9 @@ public static IBrokerageModel Create(IOrderProvider orderProvider, BrokerageName case BrokerageName.ClearStreet: return new ClearStreetBrokerageModel(accountType); + case BrokerageName.Etoro: + return new EtoroBrokerageModel(accountType); + default: throw new ArgumentOutOfRangeException(nameof(brokerage), brokerage, null); } @@ -424,6 +427,9 @@ public static BrokerageName GetBrokerageName(IBrokerageModel brokerageModel) case ClearStreetBrokerageModel: return BrokerageName.ClearStreet; + case EtoroBrokerageModel: + return BrokerageName.Etoro; + case DefaultBrokerageModel _: return BrokerageName.Default; diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index ed7d03d735c1..081775259401 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -691,6 +691,30 @@ public static string ExtendedMarketOrderMustBeLimit(Orders.Order order) } } + /// + /// Provides user-facing messages for the class and its consumers or related classes + /// + public static class EtoroBrokerageModel + { + /// + /// Returns a message explaining that a short sale needs the stop loss rate in the eToro order properties. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ShortSaleRequiresStopLossRate(Orders.Order order) + { + return Invariant($"A short sale of {order.Symbol.Value} at eToro needs the stop loss rate. Set {nameof(EtoroOrderProperties.StopLossRate)} in {nameof(EtoroOrderProperties)} to the absolute price at which eToro closes the short position."); + } + + /// + /// Returns a message explaining that eToro has no order update. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedOrderUpdate(Orders.Order order) + { + return Invariant($"eToro does not support updating orders. Cancel order {order.Id} and place a new one."); + } + } + /// /// Provides user-facing messages for the class and its consumers or related classes /// From d8ffe40c520a46d3dfa5006da1097f349b985b05 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 9 Oct 2026 15:19:11 +0300 Subject: [PATCH 04/25] feature: add etoro keys and live environment to config - etoro-api-url, etoro-api-key, etoro-user-key and etoro-demo-environment - live-etoro environment with EtoroBrokerage as brokerage and data queue handler --- Launcher/config.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Launcher/config.json b/Launcher/config.json index a864ae0b56d9..e9febcbe0b2c 100644 --- a/Launcher/config.json +++ b/Launcher/config.json @@ -295,6 +295,13 @@ // Users can have more than one account "clearstreet-account-id": "", + // eToro configuration + "etoro-api-url": "https://public-api.etoro.com", + "etoro-api-key": "", + "etoro-user-key": "", + // true trades the eToro demo account, false the real one + "etoro-demo-environment": true, + // Exante trading configuration // client-id, application-id, shared-key are required to access Exante REST API // Exante generates them at https://exante.eu/clientsarea/dashboard/ after adding an application @@ -851,6 +858,21 @@ "history-provider": [ "BrokerageHistoryProvider", "SubscriptionDataReaderHistoryProvider" ] }, + // defines the 'live-etoro' environment + "live-etoro": { + "live-mode": true, + + // real brokerage implementations require the BrokerageTransactionHandler + "live-mode-brokerage": "EtoroBrokerage", + "data-queue-handler": [ "EtoroBrokerage" ], + "setup-handler": "QuantConnect.Lean.Engine.Setup.BrokerageSetupHandler", + "result-handler": "QuantConnect.Lean.Engine.Results.LiveTradingResultHandler", + "data-feed-handler": "QuantConnect.Lean.Engine.DataFeeds.LiveTradingDataFeed", + "real-time-handler": "QuantConnect.Lean.Engine.RealTime.LiveTradingRealTimeHandler", + "transaction-handler": "QuantConnect.Lean.Engine.TransactionHandlers.BrokerageTransactionHandler", + "history-provider": [ "BrokerageHistoryProvider", "SubscriptionDataReaderHistoryProvider" ] + }, + // defines the 'live-dydx' environment "live-dydx": { "live-mode": true, From 5038d5715c123af4dc8e522c500e3225ed8078c7 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 9 Oct 2026 15:19:11 +0300 Subject: [PATCH 05/25] test: add etoro brokerage model and fee model tests - CanSubmitOrder: supported and unsupported security and order types, short sale stop loss rate, cross-zero sell - CanUpdateOrder returns false and GetFeeModel returns EtoroFeeModel - EtoroFeeModel: zero for equity, 1% of the trade value for crypto including the live 85.75 USD BTC buy --- .../Brokerages/EtoroBrokerageModelTests.cs | 182 ++++++++++++++++++ .../Common/Orders/Fees/EtoroFeeModelTests.cs | 79 ++++++++ 2 files changed, 261 insertions(+) create mode 100644 Tests/Common/Brokerages/EtoroBrokerageModelTests.cs create mode 100644 Tests/Common/Orders/Fees/EtoroFeeModelTests.cs diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs new file mode 100644 index 000000000000..027d9f97fa53 --- /dev/null +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -0,0 +1,182 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using NUnit.Framework; +using QuantConnect.Orders; +using QuantConnect.Brokerages; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; +using QuantConnect.Tests.Brokerages; + +namespace QuantConnect.Tests.Common.Brokerages +{ + [TestFixture] + public class EtoroBrokerageModelTests + { + private readonly EtoroBrokerageModel _brokerageModel = new(); + + [TestCase(SecurityType.Equity)] + [TestCase(SecurityType.Crypto)] + public void CanSubmitOrderMarketOrderReturnsTrue(SecurityType securityType) + { + var security = GetSecurityForType(securityType); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + Assert.That(canSubmit, Is.True); + Assert.That(message, Is.Null); + } + + [TestCase(SecurityType.Forex)] + [TestCase(SecurityType.Cfd)] + [TestCase(SecurityType.Option)] + [TestCase(SecurityType.Future)] + [TestCase(SecurityType.Index)] + public void CanSubmitOrderUnsupportedSecurityTypeReturnsFalse(SecurityType securityType) + { + var security = GetSecurityForType(securityType); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + } + + [TestCase(OrderType.Limit)] + [TestCase(OrderType.StopMarket)] + [TestCase(OrderType.StopLimit)] + [TestCase(OrderType.MarketOnOpen)] + [TestCase(OrderType.MarketOnClose)] + [TestCase(OrderType.TrailingStop)] + public void CanSubmitOrderUnsupportedOrderTypeReturnsFalse(OrderType orderType) + { + var security = GetSecurityForType(SecurityType.Equity); + var order = CreateOrder(orderType, security.Symbol); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + } + + // A sell with no holdings opens a short position, which eToro accepts only with a positive stop loss rate. + [TestCase(false, null, false)] + [TestCase(true, null, false)] + [TestCase(true, 0, false)] + [TestCase(true, 150, true)] + public void CanSubmitOrderShortSaleRequiresStopLossRate(bool hasEtoroProperties, decimal? stopLossRate, bool expectedCanSubmit) + { + var security = GetSecurityForType(SecurityType.Equity); + var properties = hasEtoroProperties ? new EtoroOrderProperties { StopLossRate = stopLossRate } : null; + var order = new MarketOrder(security.Symbol, -1m, DateTime.UtcNow, properties: properties); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit)); + if (expectedCanSubmit) + { + Assert.That(message, Is.Null); + } + else + { + Assert.That(message.Message, Does.Contain(nameof(EtoroOrderProperties.StopLossRate))); + } + } + + // A sell within the long holdings closes units and needs no stop loss rate; a sell beyond them crosses zero and is refused. + [TestCase(2, -1, true)] + [TestCase(2, -2, true)] + [TestCase(1, -2, false)] + public void CanSubmitOrderSellAgainstLongHoldingsRefusesCrossZero(decimal holdingQuantity, decimal orderQuantity, bool expectedCanSubmit) + { + var equity = Symbols.AAPL; + var security = TestsHelpers.InitializeSecurity(SecurityType.Equity, (equity, 209m, holdingQuantity))[equity]; + var order = new MarketOrder(equity, orderQuantity, DateTime.UtcNow); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit)); + Assert.That(message, expectedCanSubmit ? Is.Null : Is.Not.Null); + } + + [Test] + public void CanUpdateOrderReturnsFalse() + { + var security = GetSecurityForType(SecurityType.Equity); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + var request = new UpdateOrderRequest(DateTime.UtcNow, order.Id, new UpdateOrderFields { Quantity = 2m }); + + var canUpdate = _brokerageModel.CanUpdateOrder(security, order, request, out var message); + + Assert.That(canUpdate, Is.False); + Assert.That(message, Is.Not.Null); + } + + [Test] + public void GetFeeModelReturnsEtoroFeeModel() + { + var security = GetSecurityForType(SecurityType.Equity); + + Assert.That(_brokerageModel.GetFeeModel(security), Is.InstanceOf()); + } + + private static Security GetSecurityForType(SecurityType securityType) + { + switch (securityType) + { + case SecurityType.Future: + return TestsHelpers.GetSecurity(securityType: SecurityType.Future, + symbol: Futures.Indices.SP500EMini, market: Market.CME); + case SecurityType.Crypto: + return TestsHelpers.GetSecurity(securityType: SecurityType.Crypto, + symbol: "BTCUSD", market: Market.Coinbase); + case SecurityType.Forex: + case SecurityType.Cfd: + return TestsHelpers.GetSecurity(securityType: securityType, + symbol: "EURUSD", market: Market.Oanda); + case SecurityType.Index: + return TestsHelpers.GetSecurity(securityType: SecurityType.Index, + symbol: "SPX", market: Market.USA); + default: + return TestsHelpers.GetSecurity(securityType: securityType, + symbol: "AAPL", market: Market.USA); + } + } + + private static Order CreateOrder(OrderType orderType, Symbol symbol) + { + switch (orderType) + { + case OrderType.Limit: + return new LimitOrder(symbol, 1m, 100m, DateTime.UtcNow); + case OrderType.StopMarket: + return new StopMarketOrder(symbol, 1m, 100m, DateTime.UtcNow); + case OrderType.StopLimit: + return new StopLimitOrder(symbol, 1m, 105m, 100m, DateTime.UtcNow); + case OrderType.MarketOnClose: + return new MarketOnCloseOrder(symbol, 1m, DateTime.UtcNow); + case OrderType.MarketOnOpen: + return new MarketOnOpenOrder(symbol, 1m, DateTime.UtcNow); + case OrderType.TrailingStop: + return new TrailingStopOrder(symbol, 1m, 100m, 1m, false, DateTime.UtcNow); + default: + throw new ArgumentOutOfRangeException(nameof(orderType), orderType, null); + } + } + } +} diff --git a/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs b/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs new file mode 100644 index 000000000000..d116e598571b --- /dev/null +++ b/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs @@ -0,0 +1,79 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using NUnit.Framework; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Orders; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Common.Orders.Fees +{ + [TestFixture] + public class EtoroFeeModelTests + { + private readonly EtoroFeeModel _feeModel = new(); + + // Stocks and ETFs are commission free: "We charge zero commissions when you buy or sell a stock." + [TestCase(1)] + [TestCase(10)] + [TestCase(-5)] + public void GetOrderFeeEquityReturnsZero(decimal quantity) + { + var security = CreateSecurity(Symbol.Create("AAPL", SecurityType.Equity, Market.USA), 200m); + var order = new MarketOrder(security.Symbol, quantity, DateTime.UtcNow); + + var fee = _feeModel.GetOrderFee(new OrderFeeParameters(security, order)); + + Assert.That(fee.Value.Amount, Is.EqualTo(0m)); + } + + // Crypto pays 1% of the trade value (price * quantity) on every buy and sell. + // The live BTC market buy of 85.75 USD was booked at 0.86 USD: 0.8575 rounded to the cent. + [TestCase(85.75, 1, 0.8575)] + [TestCase(100, 2, 2)] + [TestCase(50, -3, 1.5)] + public void GetOrderFeeCryptoReturnsPercentOfTradeValue(decimal price, decimal quantity, decimal expectedFee) + { + var security = CreateSecurity(Symbol.Create("BTCUSD", SecurityType.Crypto, Market.Coinbase), price); + var order = new MarketOrder(security.Symbol, quantity, DateTime.UtcNow); + + var fee = _feeModel.GetOrderFee(new OrderFeeParameters(security, order)); + + Assert.That(fee.Value.Amount, Is.EqualTo(expectedFee)); + Assert.That(fee.Value.Currency, Is.EqualTo(Currencies.USD)); + } + + private static Security CreateSecurity(Symbol symbol, decimal price) + { + var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, + TimeZones.Utc, TimeZones.Utc, false, true, false); + + var security = new Security( + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache()); + + security.SetMarketPrice(new Tick(DateTime.UtcNow, symbol, price, price)); + return security; + } + } +} From 8b1c91dfdbe68ca1b628a6ae29eb98e6ac0e9717 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 9 Oct 2026 16:02:58 +0300 Subject: [PATCH 06/25] refactor: use the lean update message in the etoro brokerage model - CanUpdateOrder answers Messages.DefaultBrokerageModel.OrderUpdateNotSupported - the custom update message and the short-sale comment are removed --- Common/Brokerages/EtoroBrokerageModel.cs | 3 +-- Common/Messages/Messages.Brokerages.cs | 9 --------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index e14e46648ab6..6f3c0a04a281 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -101,7 +101,6 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - // eToro refuses a short sale without a stop loss rate, so the order must carry a positive one in its properties. if (BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity) == OrderPosition.SellToOpen && order.Properties is not EtoroOrderProperties { StopLossRate: > 0 }) { @@ -129,7 +128,7 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag public override bool CanUpdateOrder(Security security, Order order, UpdateOrderRequest request, out BrokerageMessageEvent message) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.EtoroBrokerageModel.UnsupportedOrderUpdate(order)); + Messages.DefaultBrokerageModel.OrderUpdateNotSupported); return false; } } diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index 081775259401..793dd8f385fc 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -704,15 +704,6 @@ public static string ShortSaleRequiresStopLossRate(Orders.Order order) { return Invariant($"A short sale of {order.Symbol.Value} at eToro needs the stop loss rate. Set {nameof(EtoroOrderProperties.StopLossRate)} in {nameof(EtoroOrderProperties)} to the absolute price at which eToro closes the short position."); } - - /// - /// Returns a message explaining that eToro has no order update. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string UnsupportedOrderUpdate(Orders.Order order) - { - return Invariant($"eToro does not support updating orders. Cancel order {order.Id} and place a new one."); - } } /// From e9a9a490315b0aa438cd2262c1938bb8b32c612a Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 12 Oct 2026 20:59:36 +0300 Subject: [PATCH 07/25] refactor: drop the etoro demo environment key from the launcher config --- Launcher/config.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/Launcher/config.json b/Launcher/config.json index e9febcbe0b2c..1ed615a4018b 100644 --- a/Launcher/config.json +++ b/Launcher/config.json @@ -299,8 +299,6 @@ "etoro-api-url": "https://public-api.etoro.com", "etoro-api-key": "", "etoro-user-key": "", - // true trades the eToro demo account, false the real one - "etoro-demo-environment": true, // Exante trading configuration // client-id, application-id, shared-key are required to access Exante REST API From 1b0e0f2315b348be5fe92d8c8a899e14f902bcd9 Mon Sep 17 00:00:00 2001 From: Romazes Date: Tue, 13 Oct 2026 14:11:51 +0300 Subject: [PATCH 08/25] feature: bring back the etoro demo environment key in the launcher config --- Launcher/config.json | 1 + 1 file changed, 1 insertion(+) diff --git a/Launcher/config.json b/Launcher/config.json index 1ed615a4018b..ab440f5daeb3 100644 --- a/Launcher/config.json +++ b/Launcher/config.json @@ -299,6 +299,7 @@ "etoro-api-url": "https://public-api.etoro.com", "etoro-api-key": "", "etoro-user-key": "", + "etoro-demo-environment": true, // Exante trading configuration // client-id, application-id, shared-key are required to access Exante REST API From 4fb0c34550ade25cb798e168559c8d12ed1ea64f Mon Sep 17 00:00:00 2001 From: Romazes Date: Wed, 14 Oct 2026 19:21:59 +0300 Subject: [PATCH 09/25] fix: brokerage order tests name the invalid reason on a status timeout PlaceOrderWaitForStatus keeps the Invalid order events and, when the expected status does not come within the timeout, fails with each order's expected and actual status plus its Invalid message. The status handler is unhooked in a finally block, so every failure path removes it. --- Tests/Brokerages/BrokerageTests.cs | 72 ++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index 2aeee2965a59..d994f66cc573 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -851,12 +852,17 @@ protected IReadOnlyCollection PlaceOrderWaitForStatus(IReadOnlyCollection { using var requiredStatusEvent = new ManualResetEvent(false); using var desiredStatusEvent = new ManualResetEvent(false); + var invalidOrderEvents = new ConcurrentQueue(); EventHandler> brokerageOnOrdersStatusChanged = (sender, orderEvents) => { foreach (var orderEvent in orderEvents) { var order = _orderProvider.GetOrderById(orderEvent.OrderId); order.Status = orderEvent.Status; + if (orderEvent.Status == OrderStatus.Invalid) + { + invalidOrderEvents.Enqueue(orderEvent); + } // no matter what, every order should fire at least one of these if (orders.All(o => o.Status is OrderStatus.Submitted or OrderStatus.Invalid)) { @@ -890,41 +896,59 @@ protected IReadOnlyCollection PlaceOrderWaitForStatus(IReadOnlyCollection Brokerage.OrdersStatusChanged += brokerageOnOrdersStatusChanged; - OrderFillEvent.Reset(); - - foreach (var order in orders) + try { - OrderProvider.Add(order); - if (!Brokerage.PlaceOrder(order) && !allowFailedSubmission) + OrderFillEvent.Reset(); + + foreach (var order in orders) { - Assert.Fail($"Brokerage failed to place the order: {order}"); + OrderProvider.Add(order); + if (!Brokerage.PlaceOrder(order) && !allowFailedSubmission) + { + Assert.Fail($"Brokerage failed to place the order: {order}"); + } } - } - // This is due to IB simulating stop orders https://www.interactivebrokers.com/en/trading/orders/stop.php - // which causes the Status.Submitted order event to never be set - var assertOrderEventStatus = true; - if (Brokerage.Name == "Interactive Brokers Brokerage" && orders.Any(o => o.Type is OrderType.StopMarket or OrderType.StopLimit)) - { - assertOrderEventStatus = false; - } + // This is due to IB simulating stop orders https://www.interactivebrokers.com/en/trading/orders/stop.php + // which causes the Status.Submitted order event to never be set + var assertOrderEventStatus = true; + if (Brokerage.Name == "Interactive Brokers Brokerage" && orders.Any(o => o.Type is OrderType.StopMarket or OrderType.StopLimit)) + { + assertOrderEventStatus = false; + } - var delayMilliseconds = Convert.ToInt32(1000 * secondsTimeout); - if (assertOrderEventStatus) - { - if (requiredStatusEvent.WaitOneAssertFail(delayMilliseconds, "Expected every order to fire a submitted or invalid status event")) + var delayMilliseconds = Convert.ToInt32(1000 * secondsTimeout); + if (assertOrderEventStatus) { - desiredStatusEvent.WaitOneAssertFail(delayMilliseconds, - "OrderStatus " + expectedStatus + " was not encountered within the timeout." + string.Join("", orders.Select(x => " Order Id:" + x.Id))); + if (requiredStatusEvent.WaitOneAssertFail(delayMilliseconds, "Expected every order to fire a submitted or invalid status event")) + { + if (!desiredStatusEvent.WaitOne(delayMilliseconds)) + { + var details = new List(); + foreach (var order in orders) + { + var detail = $"OrderID: {order.Id} expected OrderStatus {expectedStatus} actual {order.Status}"; + var invalidOrderEvent = invalidOrderEvents.FirstOrDefault(orderEvent => orderEvent.OrderId == order.Id); + if (invalidOrderEvent != null) + { + detail += $" Message: {invalidOrderEvent.Message}"; + } + details.Add(detail); + } + Assert.Fail("Timeout: " + string.Join("; ", details)); + } + } + } + else + { + requiredStatusEvent.WaitOne(delayMilliseconds); } } - else + finally { - requiredStatusEvent.WaitOne(delayMilliseconds); + Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; } - Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; - return orders; } From 7fbad03502cd9b427efced12fd9456e5b36b6d43 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 19 Oct 2026 17:42:12 +0300 Subject: [PATCH 10/25] fix: the etoro brokerage model accepts currency pairs - why: the etoro plugin advertises Forex (etoro.json assets, symbol mapper on Market.Oanda) and streams and downloads it, but this model refused every Forex order as an unsupported security type, so a Forex algorithm could read prices and never trade - Forex joins Equity and Crypto in the supported security types; the market order, short sale stop loss and cross-zero rules apply the same - the fee stays zero for Forex: eToro charges currencies the spread only, no commission - the Forex test case moves from the unsupported list to the market order accepted list --- Common/Brokerages/EtoroBrokerageModel.cs | 5 +++-- Tests/Common/Brokerages/EtoroBrokerageModelTests.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 6f3c0a04a281..8749eae71e51 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -27,13 +27,14 @@ namespace QuantConnect.Brokerages public class EtoroBrokerageModel : DefaultBrokerageModel { /// - /// The security types supported by eToro: US stocks and ETFs, and crypto. + /// The security types supported by eToro: US stocks and ETFs, crypto and currency pairs. /// private readonly HashSet _supportSecurityTypes = new( new[] { SecurityType.Equity, - SecurityType.Crypto + SecurityType.Crypto, + SecurityType.Forex }); /// diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 027d9f97fa53..d4e216c1d9f8 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -30,6 +30,7 @@ public class EtoroBrokerageModelTests [TestCase(SecurityType.Equity)] [TestCase(SecurityType.Crypto)] + [TestCase(SecurityType.Forex)] public void CanSubmitOrderMarketOrderReturnsTrue(SecurityType securityType) { var security = GetSecurityForType(securityType); @@ -41,7 +42,6 @@ public void CanSubmitOrderMarketOrderReturnsTrue(SecurityType securityType) Assert.That(message, Is.Null); } - [TestCase(SecurityType.Forex)] [TestCase(SecurityType.Cfd)] [TestCase(SecurityType.Option)] [TestCase(SecurityType.Future)] From 73e1416cdf1eae51e486295b6eaa9656b4ca4d52 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 23 Oct 2026 21:29:20 +0300 Subject: [PATCH 11/25] fix: etoro brokerage model gives every security leverage 1 - why: eToro opens every position at leverage 1, since no leverage is sent with an order and the account eligibility lists only "real long [1]" for every instrument; the model inherited the margin defaults (equity 2, forex 50), so Lean counted buying power eToro does not give - GetLeverage returns 1, as the Coinbase model does - a test checks equity, crypto and forex --- Common/Brokerages/EtoroBrokerageModel.cs | 11 +++++++++++ Tests/Common/Brokerages/EtoroBrokerageModelTests.cs | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 8749eae71e51..9e8a51bd2aeb 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -74,6 +74,17 @@ public override IFeeModel GetFeeModel(Security security) return new EtoroFeeModel(); } + /// + /// eToro global leverage rule + /// + /// Security + /// 1, the leverage eToro opens every position at + public override decimal GetLeverage(Security security) + { + // no leverage is sent with an order, so eToro opens every position at 1 + return 1m; + } + /// /// Returns true if the brokerage could accept this order. This takes into account order type, security type. /// diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index d4e216c1d9f8..3f674a7651b5 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -135,6 +135,16 @@ public void GetFeeModelReturnsEtoroFeeModel() Assert.That(_brokerageModel.GetFeeModel(security), Is.InstanceOf()); } + [TestCase(SecurityType.Equity)] + [TestCase(SecurityType.Crypto)] + [TestCase(SecurityType.Forex)] + public void GetLeverageReturnsOne(SecurityType securityType) + { + var security = GetSecurityForType(securityType); + + Assert.That(_brokerageModel.GetLeverage(security), Is.EqualTo(1m)); + } + private static Security GetSecurityForType(SecurityType securityType) { switch (securityType) From 2e3430be380ecb3d2a8ccc2385583aa40913cd35 Mon Sep 17 00:00:00 2001 From: Romazes Date: Tue, 3 Nov 2026 23:02:33 +0200 Subject: [PATCH 12/25] fix: etoro model defaults to a cash account, leverage needs a stop - why: the eToro order now carries the leverage of the security; a margin account gives equities 2 and forex 50 by default, so a plain order would open a leveraged position, and eToro refuses a leverage above 1 without a stop loss - the model defaults to AccountType.Cash and uses the base GetLeverage, 1 for a cash account; an algorithm sets more with SetLeverage - CanSubmitOrder refuses an opening order at a leverage above 1 without a StopLossRate, for a buy and for a short sale - tests: the leverage by account type and security type, the cash default, and the stop loss rule --- Common/Brokerages/EtoroBrokerageModel.cs | 27 ++++++------ Common/Messages/Messages.Brokerages.cs | 9 ++++ Common/Orders/EtoroOrderProperties.cs | 3 +- .../Brokerages/EtoroBrokerageModelTests.cs | 43 ++++++++++++++++--- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 9e8a51bd2aeb..69777f3860ec 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -58,8 +58,8 @@ public class EtoroBrokerageModel : DefaultBrokerageModel /// /// Constructor for eToro brokerage model /// - /// Cash or Margin - public EtoroBrokerageModel(AccountType accountType = AccountType.Margin) + /// Cash or Margin; a cash account gives every security leverage 1 + public EtoroBrokerageModel(AccountType accountType = AccountType.Cash) : base(accountType) { } @@ -74,17 +74,6 @@ public override IFeeModel GetFeeModel(Security security) return new EtoroFeeModel(); } - /// - /// eToro global leverage rule - /// - /// Security - /// 1, the leverage eToro opens every position at - public override decimal GetLeverage(Security security) - { - // no leverage is sent with an order, so eToro opens every position at 1 - return 1m; - } - /// /// Returns true if the brokerage could accept this order. This takes into account order type, security type. /// @@ -113,14 +102,22 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - if (BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity) == OrderPosition.SellToOpen - && order.Properties is not EtoroOrderProperties { StopLossRate: > 0 }) + var orderPosition = BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity); + var hasStopLossRate = order.Properties is EtoroOrderProperties { StopLossRate: > 0 }; + if (orderPosition == OrderPosition.SellToOpen && !hasStopLossRate) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", Messages.EtoroBrokerageModel.ShortSaleRequiresStopLossRate(order)); return false; } + if ((orderPosition is OrderPosition.BuyToOpen or OrderPosition.SellToOpen) && security.Leverage > 1 && !hasStopLossRate) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.LeverageRequiresStopLossRate(order, security.Leverage)); + return false; + } + if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) { return false; diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index 793dd8f385fc..648e423c73da 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -704,6 +704,15 @@ public static string ShortSaleRequiresStopLossRate(Orders.Order order) { return Invariant($"A short sale of {order.Symbol.Value} at eToro needs the stop loss rate. Set {nameof(EtoroOrderProperties.StopLossRate)} in {nameof(EtoroOrderProperties)} to the absolute price at which eToro closes the short position."); } + + /// + /// Returns a message explaining that a position with leverage above 1 needs the stop loss rate in the eToro order properties. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string LeverageRequiresStopLossRate(Orders.Order order, decimal leverage) + { + return Invariant($"A position in {order.Symbol.Value} at leverage {leverage} at eToro needs the stop loss rate. Set {nameof(EtoroOrderProperties.StopLossRate)} in {nameof(EtoroOrderProperties)} to the absolute price at which eToro closes the position."); + } } /// diff --git a/Common/Orders/EtoroOrderProperties.cs b/Common/Orders/EtoroOrderProperties.cs index 9e583b566933..e5bbe051a3a1 100644 --- a/Common/Orders/EtoroOrderProperties.cs +++ b/Common/Orders/EtoroOrderProperties.cs @@ -25,8 +25,7 @@ public class EtoroOrderProperties : OrderProperties { /// /// The absolute price at which eToro closes the position when the market moves against it. - /// eToro requires it for a short sale and for any position with leverage above 1; - /// Lean sends every order at leverage 1, so it is needed for short sales only. + /// eToro requires it for a short sale and for a position with leverage above 1, the leverage of the security. /// public decimal? StopLossRate { get; set; } diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 3f674a7651b5..0c69e5bfe02f 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -135,14 +135,47 @@ public void GetFeeModelReturnsEtoroFeeModel() Assert.That(_brokerageModel.GetFeeModel(security), Is.InstanceOf()); } - [TestCase(SecurityType.Equity)] - [TestCase(SecurityType.Crypto)] - [TestCase(SecurityType.Forex)] - public void GetLeverageReturnsOne(SecurityType securityType) + [TestCase(1, null, true)] + [TestCase(2, null, false)] + [TestCase(2, 0, false)] + [TestCase(2, 150, true)] + public void CanSubmitOrderLeverageAboveOneRequiresStopLossRate(decimal leverage, decimal? stopLossRate, bool expectedCanSubmit) { + var security = GetSecurityForType(SecurityType.Equity); + security.SetLeverage(leverage); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow, properties: new EtoroOrderProperties { StopLossRate = stopLossRate }); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit)); + if (expectedCanSubmit) + { + Assert.That(message, Is.Null); + } + else + { + Assert.That(message.Message, Does.Contain(nameof(EtoroOrderProperties.StopLossRate))); + } + } + + [TestCase(AccountType.Cash, SecurityType.Equity, 1)] + [TestCase(AccountType.Cash, SecurityType.Crypto, 1)] + [TestCase(AccountType.Cash, SecurityType.Forex, 1)] + [TestCase(AccountType.Margin, SecurityType.Equity, 2)] + [TestCase(AccountType.Margin, SecurityType.Crypto, 1)] + [TestCase(AccountType.Margin, SecurityType.Forex, 50)] + public void GetLeverageReturnsTheDefaultOfTheAccountType(AccountType accountType, SecurityType securityType, decimal expectedLeverage) + { + var brokerageModel = new EtoroBrokerageModel(accountType); var security = GetSecurityForType(securityType); - Assert.That(_brokerageModel.GetLeverage(security), Is.EqualTo(1m)); + Assert.That(brokerageModel.GetLeverage(security), Is.EqualTo(expectedLeverage)); + } + + [Test] + public void ConstructorWithoutAccountTypeUsesCash() + { + Assert.That(new EtoroBrokerageModel().AccountType, Is.EqualTo(AccountType.Cash)); } private static Security GetSecurityForType(SecurityType securityType) From 38d8a7410e4575061ab675e35b0a4a173f1111f2 Mon Sep 17 00:00:00 2001 From: Romazes Date: Tue, 3 Nov 2026 23:17:42 +0200 Subject: [PATCH 13/25] fix: etoro crypto fee in the quote currency of the pair - why: the fee was 1% of the trade value but always labelled USD, while the trade value of a pair is in its quote currency, so a BTCEUR trade got its EUR fee booked as USD - the fee uses security.QuoteCurrency.Symbol, as the Coinbase, Binance, Bitfinex and Bybit fee models do; Lean converts it to the account currency - a test checks BTCUSD (fee in USD) and BTCEUR (fee in EUR) --- Common/Orders/Fees/EtoroFeeModel.cs | 4 ++-- .../Common/Orders/Fees/EtoroFeeModelTests.cs | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Common/Orders/Fees/EtoroFeeModel.cs b/Common/Orders/Fees/EtoroFeeModel.cs index a5d81bea0ee5..5c1ebb188e62 100644 --- a/Common/Orders/Fees/EtoroFeeModel.cs +++ b/Common/Orders/Fees/EtoroFeeModel.cs @@ -40,7 +40,7 @@ public class EtoroFeeModel : FeeModel /// Gets the order fee for a given security and order. /// /// The parameters including the security and order details. - /// A in USD for the order. + /// A in the quote currency of the security. public override OrderFee GetOrderFee(OrderFeeParameters parameters) { var order = parameters.Order; @@ -53,7 +53,7 @@ public override OrderFee GetOrderFee(OrderFeeParameters parameters) } var tradeValue = security.Price * security.SymbolProperties.ContractMultiplier * order.AbsoluteQuantity; - return new OrderFee(new CashAmount(tradeValue * _cryptoFeeRate, Currencies.USD)); + return new OrderFee(new CashAmount(tradeValue * _cryptoFeeRate, security.QuoteCurrency.Symbol)); } } } diff --git a/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs b/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs index d116e598571b..e89401c12069 100644 --- a/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs +++ b/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs @@ -58,7 +58,21 @@ public void GetOrderFeeCryptoReturnsPercentOfTradeValue(decimal price, decimal q Assert.That(fee.Value.Currency, Is.EqualTo(Currencies.USD)); } - private static Security CreateSecurity(Symbol symbol, decimal price) + // The trade value of a pair is in its quote currency, so the fee is too. + [TestCase("BTCUSD", Currencies.USD)] + [TestCase("BTCEUR", Currencies.EUR)] + public void GetOrderFeeCryptoReturnsTheFeeInTheQuoteCurrency(string ticker, string quoteCurrency) + { + var security = CreateSecurity(Symbol.Create(ticker, SecurityType.Crypto, Market.Coinbase), 100m, quoteCurrency); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + + var fee = _feeModel.GetOrderFee(new OrderFeeParameters(security, order)); + + Assert.That(fee.Value.Amount, Is.EqualTo(1m)); + Assert.That(fee.Value.Currency, Is.EqualTo(quoteCurrency)); + } + + private static Security CreateSecurity(Symbol symbol, decimal price, string quoteCurrency = Currencies.USD) { var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, TimeZones.Utc, TimeZones.Utc, false, true, false); @@ -66,8 +80,8 @@ private static Security CreateSecurity(Symbol symbol, decimal price) var security = new Security( SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), config, - new Cash(Currencies.USD, 0, 1m), - SymbolProperties.GetDefault(Currencies.USD), + new Cash(quoteCurrency, 0, 1m), + SymbolProperties.GetDefault(quoteCurrency), ErrorCurrencyConverter.Instance, RegisteredSecurityDataTypesProvider.Null, new SecurityCache()); From a7160e33b2cd1cb1d52702c29e6a86dd1fc6b88a Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 6 Nov 2026 01:27:59 +0200 Subject: [PATCH 14/25] feature: a contingent order set exposes its absolute quantities - a brokerage model validates one order at a time and the set only told it the symbols, directions and order types of the other members, so a model could not refuse an exit order sized differently from its entry, which a broker that closes the whole position at the stop loss or the take profit cannot take - the set of distinct absolute quantities is built lazily like the other views, so a set is valid for such a broker when it has one element --- Common/Orders/OrderContingency.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Common/Orders/OrderContingency.cs b/Common/Orders/OrderContingency.cs index 46d303161836..ddf9d4858978 100644 --- a/Common/Orders/OrderContingency.cs +++ b/Common/Orders/OrderContingency.cs @@ -76,6 +76,13 @@ public class OrderContingency [JsonIgnore] public IReadOnlySet OrderTypes => _set.OrderTypes ??= _set.BuildSet(member => member.OrderType); + /// + /// The different absolute quantities of the orders in the set. Allows a brokerage model to validate a single + /// order knowing about the rest of the set. Only available at submission time + /// + [JsonIgnore] + public IReadOnlySet AbsoluteQuantities => _set.AbsoluteQuantities ??= _set.BuildSet(member => Math.Abs(member.Quantity)); + /// /// The links of this order to the rest of the set: the role it plays in each contingency /// @@ -513,6 +520,7 @@ private class SharedState public HashSet Symbols { get; set; } public HashSet Directions { get; set; } public HashSet OrderTypes { get; set; } + public HashSet AbsoluteQuantities { get; set; } public List Requests { get; set; } public SharedState(int id, int count) From 64c0614f1ada501615ec86c9310b7b00fc5b63ad Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 6 Nov 2026 01:27:59 +0200 Subject: [PATCH 15/25] feature: etoro stop loss and take profit as contingent exit orders - the stop loss eToro requires on a short sale or a leveraged position came as a price in the eToro order properties, so Lean never knew it as an order and could not see the close eToro places when it is hit; the stop loss is now a stop market order the entry triggers, and a take profit a limit order, a bracket when one cancels the other - eToro keeps both on the position and closes it whole, so the set has to be a market entry that opens a position, with exits on the opposite side for the entry quantity, and three orders only as a bracket; the exits skip the cross zero check since they close the position the entry opens, whatever is held now - an exit order can change its price, eToro moves the stop loss or the take profit of the position; a quantity change or any other update stays refused --- Common/Brokerages/EtoroBrokerageModel.cs | 135 +++++++++++++++--- Common/Messages/Messages.Brokerages.cs | 12 +- Common/Orders/EtoroOrderProperties.cs | 6 - .../Brokerages/EtoroBrokerageModelTests.cs | 131 ++++++++++++++--- 4 files changed, 236 insertions(+), 48 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 69777f3860ec..694e524245ae 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -46,6 +46,27 @@ public class EtoroBrokerageModel : DefaultBrokerageModel OrderType.Market }); + /// + /// The order types of an exit order an entry triggers: a stop market order is the stop loss of the position the + /// entry opens, a limit order its take profit. + /// + private readonly HashSet _supportExitOrderTypes = new( + new[] + { + OrderType.StopMarket, + OrderType.Limit + }); + + /// + /// The contingency types supported: an entry which triggers its exits, and the two exits of a bracket where one + /// cancels the other. + /// + private readonly HashSet _supportedContingencyTypes = new() + { + ContingencyType.OneTriggersOther, + ContingencyType.OneCancelsOther + }; + /// /// The order types that cannot cross zero holdings. /// eToro closes a position by units, so a sell beyond the long holdings is refused. @@ -83,7 +104,7 @@ public override IFeeModel GetFeeModel(Security security) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { - if (!this.ValidateContingentOrdersNotSupported(order, out message)) + if (!CanSubmitContingentOrder(security, order, out message)) { return false; } @@ -95,50 +116,124 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - if (!_supportOrderTypes.Contains(order.Type)) + var isExitOrder = order.GetContingencyLink(ContingencyRole.Child) != null; + var supportedOrderTypes = isExitOrder ? _supportExitOrderTypes : _supportOrderTypes; + if (!supportedOrderTypes.Contains(order.Type)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.DefaultBrokerageModel.UnsupportedOrderType(this, order, _supportOrderTypes)); + Messages.DefaultBrokerageModel.UnsupportedOrderType(this, order, supportedOrderTypes)); return false; } - var orderPosition = BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity); - var hasStopLossRate = order.Properties is EtoroOrderProperties { StopLossRate: > 0 }; - if (orderPosition == OrderPosition.SellToOpen && !hasStopLossRate) + if (!isExitOrder) { - message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.EtoroBrokerageModel.ShortSaleRequiresStopLossRate(order)); - return false; + var orderPosition = BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity); + var hasStopLoss = order.Contingency?.OrderTypes.Contains(OrderType.StopMarket) == true; + if (orderPosition == OrderPosition.SellToOpen && !hasStopLoss) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.ShortSaleRequiresStopLoss(order)); + return false; + } + + if ((orderPosition is OrderPosition.BuyToOpen or OrderPosition.SellToOpen) && security.Leverage > 1 && !hasStopLoss) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.LeverageRequiresStopLoss(order, security.Leverage)); + return false; + } } - if ((orderPosition is OrderPosition.BuyToOpen or OrderPosition.SellToOpen) && security.Leverage > 1 && !hasStopLossRate) + if (!isExitOrder && !BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) { - message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.EtoroBrokerageModel.LeverageRequiresStopLossRate(order, security.Leverage)); return false; } - if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) + return base.CanSubmitOrder(security, order, out message); + } + + /// + /// Validates a set of contingent orders: a market entry which once filled triggers a stop market order (the stop loss), + /// a limit order (the take profit), or both where one cancels the other (a bracket). eToro sets them on the position + /// the entry opens, so the exits are on the opposite side of the entry and close the position whole. + /// + /// The security of the order + /// The order to be processed + /// If this function returns false, a brokerage message detailing why the order may not be submitted + /// True if the order is not a contingent order or its set is supported + private bool CanSubmitContingentOrder(Security security, Order order, out BrokerageMessageEvent message) + { + if (!this.ValidateContingentOrder(order, _supportedContingencyTypes, out message, supportsComboOrders: false, + supportsMultipleSymbols: false, supportsNesting: false, maximumOrderCount: 3)) { return false; } - return base.CanSubmitOrder(security, order, out message); + var contingency = order.Contingency; + if (contingency == null) + { + return true; + } + + var isEntry = order.GetContingencyLink(ContingencyRole.Parent) != null; + var isExit = order.GetContingencyLink(ContingencyRole.Child) != null; + var isMember = order.GetSiblingLink() != null; + if (!isEntry && !isExit) + { + message = this.UnsupportedContingentOrdersShape("the exit orders have to be triggered by an entry order."); + } + else if (isEntry && isMember) + { + message = this.UnsupportedContingentOrdersShape("the entry order can not be part of a one cancels other contingency."); + } + else if (isEntry && BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity) is not (OrderPosition.BuyToOpen or OrderPosition.SellToOpen)) + { + message = this.UnsupportedContingentOrdersShape("the entry order has to open a position."); + } + else if (contingency.Directions.Count != 2) + { + message = this.UnsupportedContingentOrdersShape("the exit orders have to be on the opposite side of the entry order."); + } + else if (contingency.AbsoluteQuantities.Count != 1) + { + message = this.UnsupportedContingentOrdersShape("the exit orders have to be for the quantity of the entry order: eToro closes the whole position at the stop loss or the take profit."); + } + else if (contingency.Count == 3 && (isExit && !isMember + || !contingency.OrderTypes.Contains(OrderType.Limit) || !contingency.OrderTypes.Contains(OrderType.StopMarket))) + { + message = this.UnsupportedContingentOrdersShape("3 orders are only supported as a bracket: an entry order which triggers a take profit limit order and a stop loss stop market order where one cancels the other."); + } + return message == null; } /// - /// Returns false: eToro has no order update, an order is cancelled and placed again instead. + /// Returns true for a price change of an exit order: eToro moves the stop loss or the take profit of the position. + /// Any other update is refused, eToro has no order update; an order is cancelled and placed again instead. /// /// The security of the order /// The order to be updated /// The requested update to be made to the order - /// A brokerage message detailing why the order may not be updated - /// Always false + /// If this function returns false, a brokerage message detailing why the order may not be updated + /// True for a price change of an exit order, false otherwise public override bool CanUpdateOrder(Security security, Order order, UpdateOrderRequest request, out BrokerageMessageEvent message) { - message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.DefaultBrokerageModel.OrderUpdateNotSupported); - return false; + message = null; + if (order.GetContingencyLink(ContingencyRole.Child) == null) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.OrderUpdateNotSupported); + return false; + } + + if (request.Quantity.HasValue && request.Quantity.Value != order.Quantity) + { + // the whole position closes at the stop loss or the take profit + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedContingentOrdersQuantityUpdate(this)); + return false; + } + + return true; } } } diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index 648e423c73da..6c7fedcb0432 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -697,21 +697,21 @@ public static string ExtendedMarketOrderMustBeLimit(Orders.Order order) public static class EtoroBrokerageModel { /// - /// Returns a message explaining that a short sale needs the stop loss rate in the eToro order properties. + /// Returns a message explaining that a short sale needs a stop loss: a stop market order it triggers. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string ShortSaleRequiresStopLossRate(Orders.Order order) + public static string ShortSaleRequiresStopLoss(Orders.Order order) { - return Invariant($"A short sale of {order.Symbol.Value} at eToro needs the stop loss rate. Set {nameof(EtoroOrderProperties.StopLossRate)} in {nameof(EtoroOrderProperties)} to the absolute price at which eToro closes the short position."); + return Invariant($"A short sale of {order.Symbol.Value} at eToro needs a stop loss: a stop market order the short sale triggers, as a one triggers other or a bracket order. eToro closes the short position at its stop price."); } /// - /// Returns a message explaining that a position with leverage above 1 needs the stop loss rate in the eToro order properties. + /// Returns a message explaining that a position with leverage above 1 needs a stop loss: a stop market order the entry triggers. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string LeverageRequiresStopLossRate(Orders.Order order, decimal leverage) + public static string LeverageRequiresStopLoss(Orders.Order order, decimal leverage) { - return Invariant($"A position in {order.Symbol.Value} at leverage {leverage} at eToro needs the stop loss rate. Set {nameof(EtoroOrderProperties.StopLossRate)} in {nameof(EtoroOrderProperties)} to the absolute price at which eToro closes the position."); + return Invariant($"A position in {order.Symbol.Value} at leverage {leverage} at eToro needs a stop loss: a stop market order the entry triggers, as a one triggers other or a bracket order. eToro closes the position at its stop price."); } } diff --git a/Common/Orders/EtoroOrderProperties.cs b/Common/Orders/EtoroOrderProperties.cs index e5bbe051a3a1..3f34325c3c87 100644 --- a/Common/Orders/EtoroOrderProperties.cs +++ b/Common/Orders/EtoroOrderProperties.cs @@ -23,12 +23,6 @@ namespace QuantConnect.Orders /// public class EtoroOrderProperties : OrderProperties { - /// - /// The absolute price at which eToro closes the position when the market moves against it. - /// eToro requires it for a short sale and for a position with leverage above 1, the leverage of the security. - /// - public decimal? StopLossRate { get; set; } - /// /// Returns a new instance clone of this object /// diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 0c69e5bfe02f..bdeb2955cb13 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -14,6 +14,8 @@ */ using System; +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using QuantConnect.Orders; using QuantConnect.Brokerages; @@ -74,16 +76,17 @@ public void CanSubmitOrderUnsupportedOrderTypeReturnsFalse(OrderType orderType) Assert.That(message, Is.Not.Null); } - // A sell with no holdings opens a short position, which eToro accepts only with a positive stop loss rate. - [TestCase(false, null, false)] - [TestCase(true, null, false)] - [TestCase(true, 0, false)] - [TestCase(true, 150, true)] - public void CanSubmitOrderShortSaleRequiresStopLossRate(bool hasEtoroProperties, decimal? stopLossRate, bool expectedCanSubmit) + // A sell with no holdings opens a short position, which eToro accepts only with a stop loss: a stop market order it triggers. + [TestCase(false, false)] + [TestCase(true, true)] + public void CanSubmitOrderShortSaleRequiresStopLoss(bool triggersStopLoss, bool expectedCanSubmit) { var security = GetSecurityForType(SecurityType.Equity); - var properties = hasEtoroProperties ? new EtoroOrderProperties { StopLossRate = stopLossRate } : null; - var order = new MarketOrder(security.Symbol, -1m, DateTime.UtcNow, properties: properties); + var order = new MarketOrder(security.Symbol, -1m, DateTime.UtcNow); + if (triggersStopLoss) + { + OrderContingency.Trigger([order], [new StopMarketOrder(security.Symbol, 1m, 110m, DateTime.UtcNow)]); + } var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); @@ -94,7 +97,7 @@ public void CanSubmitOrderShortSaleRequiresStopLossRate(bool hasEtoroProperties, } else { - Assert.That(message.Message, Does.Contain(nameof(EtoroOrderProperties.StopLossRate))); + Assert.That(message.Message, Does.Contain("stop market order")); } } @@ -127,6 +130,24 @@ public void CanUpdateOrderReturnsFalse() Assert.That(message, Is.Not.Null); } + // An exit order moves the stop loss or the take profit of the position; its quantity is the position's + [TestCase(false, true, Description = "A new stop price")] + [TestCase(true, false, Description = "A new quantity")] + public void CanUpdateOrderExitOrderAllowsThePriceOnly(bool changesQuantity, bool expectedCanUpdate) + { + var security = GetSecurityForType(SecurityType.Equity); + var entry = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + var stopLoss = new StopMarketOrder(security.Symbol, -1m, 90m, DateTime.UtcNow); + OrderContingency.Trigger([entry], [stopLoss]); + var fields = changesQuantity ? new UpdateOrderFields { Quantity = -2m } : new UpdateOrderFields { StopPrice = 95m }; + var request = new UpdateOrderRequest(DateTime.UtcNow, stopLoss.Id, fields); + + var canUpdate = _brokerageModel.CanUpdateOrder(security, stopLoss, request, out var message); + + Assert.That(canUpdate, Is.EqualTo(expectedCanUpdate)); + Assert.That(message, expectedCanUpdate ? Is.Null : Is.Not.Null); + } + [Test] public void GetFeeModelReturnsEtoroFeeModel() { @@ -135,15 +156,18 @@ public void GetFeeModelReturnsEtoroFeeModel() Assert.That(_brokerageModel.GetFeeModel(security), Is.InstanceOf()); } - [TestCase(1, null, true)] - [TestCase(2, null, false)] - [TestCase(2, 0, false)] - [TestCase(2, 150, true)] - public void CanSubmitOrderLeverageAboveOneRequiresStopLossRate(decimal leverage, decimal? stopLossRate, bool expectedCanSubmit) + [TestCase(1, false, true)] + [TestCase(2, false, false)] + [TestCase(2, true, true)] + public void CanSubmitOrderLeverageAboveOneRequiresStopLoss(decimal leverage, bool triggersStopLoss, bool expectedCanSubmit) { var security = GetSecurityForType(SecurityType.Equity); security.SetLeverage(leverage); - var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow, properties: new EtoroOrderProperties { StopLossRate = stopLossRate }); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + if (triggersStopLoss) + { + OrderContingency.Trigger([order], [new StopMarketOrder(security.Symbol, -1m, 90m, DateTime.UtcNow)]); + } var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); @@ -154,7 +178,7 @@ public void CanSubmitOrderLeverageAboveOneRequiresStopLossRate(decimal leverage, } else { - Assert.That(message.Message, Does.Contain(nameof(EtoroOrderProperties.StopLossRate))); + Assert.That(message.Message, Does.Contain("stop market order")); } } @@ -178,6 +202,81 @@ public void ConstructorWithoutAccountTypeUsesCash() Assert.That(new EtoroBrokerageModel().AccountType, Is.EqualTo(AccountType.Cash)); } + [TestCase("stop loss", 1, true, Description = "A market entry which triggers a stop market stop loss")] + [TestCase("take profit", 1, true, Description = "A market entry which triggers a limit take profit")] + [TestCase("bracket", 1, true, Description = "A market entry which triggers a take profit and a stop loss, one cancels the other")] + [TestCase("short stop loss", 1, true, Description = "A short sale which triggers a stop loss")] + [TestCase("short take profit", 1, false, Description = "A short sale needs a stop loss")] + [TestCase("stop loss", 2, true, Description = "A leveraged entry which triggers a stop loss")] + [TestCase("take profit", 2, false, Description = "A leveraged entry needs a stop loss")] + [TestCase("exits only", 1, false, Description = "The exits have to be triggered by an entry")] + [TestCase("same side exit", 1, false, Description = "The exits are on the opposite side of the entry")] + [TestCase("half exit", 1, false, Description = "The exits are for the quantity of the entry, the whole position closes at one of them")] + [TestCase("limit entry", 1, false, Description = "The entry has to be a market order")] + [TestCase("market exit", 1, false, Description = "An exit has to be a stop market or a limit order")] + [TestCase("two stop losses", 1, false, Description = "3 orders are only supported as a bracket")] + public void CanSubmitOrderContingentOrders(string shape, decimal leverage, bool expectedCanSubmit) + { + var security = GetSecurityForType(SecurityType.Equity); + security.SetLeverage(leverage); + var orders = CreateContingentOrders(shape, security.Symbol); + + // the transaction handler submits the set only when every order of it can be submitted + var canSubmit = orders.All(order => _brokerageModel.CanSubmitOrder(security, order, out _)); + + Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit)); + } + + // The exits close the position the entry opens, so the 2 shares held now do not make a 5 share exit cross zero + [Test] + public void CanSubmitOrderContingentOrdersExitsBeyondTheHoldings() + { + var equity = Symbols.AAPL; + var security = TestsHelpers.InitializeSecurity(SecurityType.Equity, (equity, 209m, 2m))[equity]; + var entry = new MarketOrder(equity, 5m, DateTime.UtcNow); + var exits = new List { new LimitOrder(equity, -5m, 230m, DateTime.UtcNow), new StopMarketOrder(equity, -5m, 190m, DateTime.UtcNow) }; + OrderContingency.Trigger([entry], exits); + OrderContingency.Relate(ContingencyType.OneCancelsOther, exits); + + var canSubmit = exits.Prepend(entry).All(order => _brokerageModel.CanSubmitOrder(security, order, out _)); + + Assert.That(canSubmit, Is.True); + } + + private static List CreateContingentOrders(string shape, Symbol symbol) + { + var time = DateTime.UtcNow; + var entryQuantity = shape.StartsWith("short") ? -2m : 2m; + var exitQuantity = shape switch + { + "same side exit" => entryQuantity, + "half exit" => -entryQuantity / 2, + _ => -entryQuantity + }; + Order entry = shape == "limit entry" ? new LimitOrder(symbol, entryQuantity, 100m, time) : new MarketOrder(symbol, entryQuantity, time); + var stopLoss = new StopMarketOrder(symbol, exitQuantity, 90m, time); + var takeProfit = new LimitOrder(symbol, exitQuantity, 110m, time); + + List exits = shape switch + { + "take profit" or "short take profit" => [takeProfit], + "bracket" or "exits only" => [takeProfit, stopLoss], + "market exit" => [new MarketOrder(symbol, exitQuantity, time)], + "two stop losses" => [stopLoss, new StopMarketOrder(symbol, exitQuantity, 80m, time)], + _ => [stopLoss] + }; + + if (shape != "exits only") + { + OrderContingency.Trigger([entry], exits); + } + if (exits.Count == 2) + { + OrderContingency.Relate(ContingencyType.OneCancelsOther, exits); + } + return shape == "exits only" ? exits : [entry, .. exits]; + } + private static Security GetSecurityForType(SecurityType securityType) { switch (securityType) From fc768d79e35a3517120de7502311840459c0918b Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 6 Nov 2026 18:58:22 +0200 Subject: [PATCH 16/25] feature: etoro model keeps a crypto stop loss at least 10% from the price - eToro keeps the stop loss of every crypto position at least 10% of the margin away and moves a closer one there without an error, so an algorithm that set a tighter stop ran with twice the risk it thought it had and only learned it from the fill at the stop - the model refuses a crypto stop market exit, or a move of one, closer than 10% to the security price, so the set is refused before it leaves the algorithm with a message that names the rule; stocks, ETFs and forex have no such minimum but a few instruments, which the model cannot know without the eligibility of each --- Common/Brokerages/EtoroBrokerageModel.cs | 34 +++++++++++++++++ Common/Messages/Messages.Brokerages.cs | 9 +++++ .../Brokerages/EtoroBrokerageModelTests.cs | 38 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 694e524245ae..2701d41ef9e9 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -17,6 +17,7 @@ using QuantConnect.Orders; using QuantConnect.Orders.Fees; using QuantConnect.Securities; +using System; using System.Collections.Generic; namespace QuantConnect.Brokerages @@ -26,6 +27,12 @@ namespace QuantConnect.Brokerages /// public class EtoroBrokerageModel : DefaultBrokerageModel { + /// + /// The least distance of a crypto stop loss from the price, in percent of the margin, which is the whole + /// position at leverage 1, the one leverage of crypto at eToro. eToro moves a closer stop loss there without an error. + /// + private const int CryptoMinimumStopLossPercent = 10; + /// /// The security types supported by eToro: US stocks and ETFs, crypto and currency pairs. /// @@ -125,6 +132,13 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } + if (isExitOrder && order is StopMarketOrder stopLoss && IsCryptoStopLossTooClose(security, stopLoss.StopPrice)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.CryptoStopLossTooClose(order, security.Price, CryptoMinimumStopLossPercent)); + return false; + } + if (!isExitOrder) { var orderPosition = BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity); @@ -233,7 +247,27 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR return false; } + if (order.Type == OrderType.StopMarket && request.StopPrice.HasValue && IsCryptoStopLossTooClose(security, request.StopPrice.Value)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.CryptoStopLossTooClose(order, security.Price, CryptoMinimumStopLossPercent)); + return false; + } + return true; } + + /// + /// True for a stop loss on a crypto security closer to its price than eToro keeps one; a security without a + /// price yet is not checked. + /// + /// The security of the order + /// The stop price asked + /// True when eToro would move the stop loss away from the price + private static bool IsCryptoStopLossTooClose(Security security, decimal stopPrice) + { + return security.Type == SecurityType.Crypto && security.Price != 0 + && Math.Abs(security.Price - stopPrice) < security.Price * CryptoMinimumStopLossPercent / 100; + } } } diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index 6c7fedcb0432..6ebe46c8220f 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -713,6 +713,15 @@ public static string LeverageRequiresStopLoss(Orders.Order order, decimal levera { return Invariant($"A position in {order.Symbol.Value} at leverage {leverage} at eToro needs a stop loss: a stop market order the entry triggers, as a one triggers other or a bracket order. eToro closes the position at its stop price."); } + + /// + /// Returns a message explaining that a crypto stop loss has to be at least a given percent away from the price. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string CryptoStopLossTooClose(Orders.Order order, decimal price, int minimumPercent) + { + return Invariant($"The stop loss of {order.Symbol.Value} at eToro has to be at least {minimumPercent}% away from the price {price}: eToro keeps a crypto stop loss that far from the position and moves a closer one there."); + } } /// diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index bdeb2955cb13..226cb01d44e0 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -17,6 +17,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using QuantConnect.Data.Market; using QuantConnect.Orders; using QuantConnect.Brokerages; using QuantConnect.Orders.Fees; @@ -148,6 +149,43 @@ public void CanUpdateOrderExitOrderAllowsThePriceOnly(bool changesQuantity, bool Assert.That(message, expectedCanUpdate ? Is.Null : Is.Not.Null); } + [TestCase(0.05, false, Description = "A crypto stop loss 5% under the price is inside the 10% eToro keeps")] + [TestCase(0.1, true, Description = "A crypto stop loss 10% under the price")] + [TestCase(0.2, true, Description = "A crypto stop loss 20% under the price")] + public void CanSubmitOrderCryptoStopLossKeepsTheMinimumDistance(decimal distance, bool expectedCanSubmit) + { + var security = GetSecurityForType(SecurityType.Crypto); + security.SetMarketPrice(new Tick { Symbol = security.Symbol, Value = 100m }); + var entry = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + var stopLoss = new StopMarketOrder(security.Symbol, -1m, 100m * (1 - distance), DateTime.UtcNow); + OrderContingency.Trigger([entry], [stopLoss]); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, stopLoss, out var message); + + Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit)); + if (!expectedCanSubmit) + { + Assert.That(message.Message, Does.Contain("10%")); + } + } + + [TestCase(0.05, false, Description = "A crypto stop loss moved to 5% under the price is inside the 10% eToro keeps")] + [TestCase(0.2, true, Description = "A crypto stop loss moved to 20% under the price")] + public void CanUpdateOrderCryptoStopLossKeepsTheMinimumDistance(decimal distance, bool expectedCanUpdate) + { + var security = GetSecurityForType(SecurityType.Crypto); + security.SetMarketPrice(new Tick { Symbol = security.Symbol, Value = 100m }); + var entry = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + var stopLoss = new StopMarketOrder(security.Symbol, -1m, 70m, DateTime.UtcNow); + OrderContingency.Trigger([entry], [stopLoss]); + var request = new UpdateOrderRequest(DateTime.UtcNow, stopLoss.Id, new UpdateOrderFields { StopPrice = 100m * (1 - distance) }); + + var canUpdate = _brokerageModel.CanUpdateOrder(security, stopLoss, request, out var message); + + Assert.That(canUpdate, Is.EqualTo(expectedCanUpdate)); + Assert.That(message, expectedCanUpdate ? Is.Null : Is.Not.Null); + } + [Test] public void GetFeeModelReturnsEtoroFeeModel() { From cc2987cfcf57d38d8218f0a1ddf36dfa74d92e22 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 6 Nov 2026 20:16:05 +0200 Subject: [PATCH 17/25] test: etoro model counts as supporting contingent orders - BrokerageModelsReject walks every model outside the supported set and expects the "does not support contingent orders" refusal; the eToro model accepts a bracket now, so CI failed on it with the order type message instead - the eToro shapes stay covered by EtoroBrokerageModelTests, so the shared file only lists the model --- Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs index c56bf5f75c36..fe17c0449df9 100644 --- a/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs @@ -57,6 +57,7 @@ private static IEnumerable NotSupportedBrokerageModels() typeof(CharlesSchwabBrokerageModel), typeof(TradeStationBrokerageModel), typeof(AlpacaBrokerageModel), + typeof(EtoroBrokerageModel), typeof(BinanceBrokerageModel), typeof(BinanceFuturesBrokerageModel), typeof(BinanceCoinFuturesBrokerageModel) From 89218068897245db62c7262008421148f8d9a949 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 6 Nov 2026 21:40:30 +0200 Subject: [PATCH 18/25] feature: etoro model updates a stop market or a limit order by type - an exit order read from the position after a restart has no entry order link, so the child link check refused to move it; eToro can change only the stop loss and the take profit of a position, and a stop market or a limit order is one of them in this model, with or without its entry --- Common/Brokerages/EtoroBrokerageModel.cs | 8 +++++--- Tests/Common/Brokerages/EtoroBrokerageModelTests.cs | 13 ++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 2701d41ef9e9..4177d1960d01 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -221,8 +221,10 @@ private bool CanSubmitContingentOrder(Security security, Order order, out Broker } /// - /// Returns true for a price change of an exit order: eToro moves the stop loss or the take profit of the position. - /// Any other update is refused, eToro has no order update; an order is cancelled and placed again instead. + /// Returns true for a price change of a stop market or a limit order, the stop loss or the take profit of a + /// position, which eToro moves on the position; the order may come without its entry, read from the position + /// after a restart. Any other update is refused, eToro has no order update; an order is cancelled and placed + /// again instead. /// /// The security of the order /// The order to be updated @@ -232,7 +234,7 @@ private bool CanSubmitContingentOrder(Security security, Order order, out Broker public override bool CanUpdateOrder(Security security, Order order, UpdateOrderRequest request, out BrokerageMessageEvent message) { message = null; - if (order.GetContingencyLink(ContingencyRole.Child) == null) + if (order.Type is not (OrderType.StopMarket or OrderType.Limit)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", Messages.DefaultBrokerageModel.OrderUpdateNotSupported); diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 226cb01d44e0..5dd06ea1d8db 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -132,14 +132,17 @@ public void CanUpdateOrderReturnsFalse() } // An exit order moves the stop loss or the take profit of the position; its quantity is the position's - [TestCase(false, true, Description = "A new stop price")] - [TestCase(true, false, Description = "A new quantity")] - public void CanUpdateOrderExitOrderAllowsThePriceOnly(bool changesQuantity, bool expectedCanUpdate) + [TestCase(true, false, true, Description = "A new stop price")] + [TestCase(true, true, false, Description = "A new quantity")] + [TestCase(false, false, true, Description = "A new stop price on an exit read from the position after a restart, without its entry")] + public void CanUpdateOrderExitOrderAllowsThePriceOnly(bool hasEntry, bool changesQuantity, bool expectedCanUpdate) { var security = GetSecurityForType(SecurityType.Equity); - var entry = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); var stopLoss = new StopMarketOrder(security.Symbol, -1m, 90m, DateTime.UtcNow); - OrderContingency.Trigger([entry], [stopLoss]); + if (hasEntry) + { + OrderContingency.Trigger([new MarketOrder(security.Symbol, 1m, DateTime.UtcNow)], [stopLoss]); + } var fields = changesQuantity ? new UpdateOrderFields { Quantity = -2m } : new UpdateOrderFields { StopPrice = 95m }; var request = new UpdateOrderRequest(DateTime.UtcNow, stopLoss.Id, fields); From ada43a3c91ddddadc18176e1d58f8af91c59bfe6 Mon Sep 17 00:00:00 2001 From: Romazes Date: Fri, 6 Nov 2026 23:05:34 +0200 Subject: [PATCH 19/25] test: brokerage test harness applies a quantity update - a brokerage that resizes a contingent order raises OnOrderUpdated with the quantity, which the transaction handler applies; the harness only released held children, so a plugin test could not see the resize --- Tests/Brokerages/BrokerageTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index d994f66cc573..b8630b60542d 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -139,14 +139,19 @@ private IBrokerage InitializeBrokerage() /// /// Applies the brokerage updates of the orders the way the transaction handler does: contingent orders held waiting - /// for their parent are released + /// for their parent are released, and an order the brokerage resized takes the new quantity /// private void HandleOrderUpdatedEvents(object _, OrderUpdateEvent orderUpdateEvent) { - if (orderUpdateEvent.ContingencyTriggered && OrderProvider.GetOrderById(orderUpdateEvent.OrderId)?.GetContingencyLink(ContingencyRole.Child) is { } child) + var order = OrderProvider.GetOrderById(orderUpdateEvent.OrderId); + if (orderUpdateEvent.ContingencyTriggered && order?.GetContingencyLink(ContingencyRole.Child) is { } child) { child.Triggered = true; } + if (orderUpdateEvent.Quantity.HasValue && order != null) + { + order.Quantity = orderUpdateEvent.Quantity.Value; + } } /// From 46f6c5e4650cef090ca1ef0828f687d7f236bc95 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 9 Nov 2026 17:05:12 +0200 Subject: [PATCH 20/25] fix: etoro model refuses an exit order on the side of its entry - the directions of a contingent set are a set, so a bracket with one exit on the entry's side passed: Market +2, Limit -2, StopMarket +2; eToro closes the position at the stop and the Lean order that fills is a buy, so Lean holds +4 where the account is flat - OrderContingency.Quantities lists the signed quantity of every order of the set, and the model refuses a set from its entry when another order shares the entry's side; the exits keep the two-sides check and Lean refuses the whole set when one order fails - OnMembersChanged did not reset AbsoluteQuantities; it resets every view now --- Common/Brokerages/EtoroBrokerageModel.cs | 3 ++- Common/Orders/OrderContingency.cs | 20 +++++++++++++++++++ .../Brokerages/EtoroBrokerageModelTests.cs | 18 ++++++++--------- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 4177d1960d01..ee666a56f065 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -19,6 +19,7 @@ using QuantConnect.Securities; using System; using System.Collections.Generic; +using System.Linq; namespace QuantConnect.Brokerages { @@ -204,7 +205,7 @@ private bool CanSubmitContingentOrder(Security security, Order order, out Broker { message = this.UnsupportedContingentOrdersShape("the entry order has to open a position."); } - else if (contingency.Directions.Count != 2) + else if (contingency.Directions.Count != 2 || isEntry && contingency.Quantities.Count(quantity => Math.Sign(quantity) == Math.Sign(order.Quantity)) != 1) { message = this.UnsupportedContingentOrdersShape("the exit orders have to be on the opposite side of the entry order."); } diff --git a/Common/Orders/OrderContingency.cs b/Common/Orders/OrderContingency.cs index ddf9d4858978..8a48d57bd18b 100644 --- a/Common/Orders/OrderContingency.cs +++ b/Common/Orders/OrderContingency.cs @@ -83,6 +83,13 @@ public class OrderContingency [JsonIgnore] public IReadOnlySet AbsoluteQuantities => _set.AbsoluteQuantities ??= _set.BuildSet(member => Math.Abs(member.Quantity)); + /// + /// The quantities of the orders in the set, one per order, signed. Allows a brokerage model to validate a single + /// order against the side of each other order of the set. Only available at submission time + /// + [JsonIgnore] + public IReadOnlyList Quantities => _set.Quantities ??= _set.BuildList(member => member.Quantity); + /// /// The links of this order to the rest of the set: the role it plays in each contingency /// @@ -521,6 +528,7 @@ private class SharedState public HashSet Directions { get; set; } public HashSet OrderTypes { get; set; } public HashSet AbsoluteQuantities { get; set; } + public List Quantities { get; set; } public List Requests { get; set; } public SharedState(int id, int count) @@ -536,6 +544,8 @@ public void OnMembersChanged() Symbols = null; Directions = null; OrderTypes = null; + AbsoluteQuantities = null; + Quantities = null; Requests = null; } @@ -549,6 +559,16 @@ public HashSet BuildSet(Func selector) return result; } + public List BuildList(Func selector) + { + var result = new List(Members.Count); + foreach (var member in Members) + { + result.Add(selector(member)); + } + return result; + } + public List BuildRequests() { var result = new List(Members.Count); diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 5dd06ea1d8db..36a1886c552a 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -252,6 +252,9 @@ public void ConstructorWithoutAccountTypeUsesCash() [TestCase("take profit", 2, false, Description = "A leveraged entry needs a stop loss")] [TestCase("exits only", 1, false, Description = "The exits have to be triggered by an entry")] [TestCase("same side exit", 1, false, Description = "The exits are on the opposite side of the entry")] + [TestCase("stop loss on the entry side", 1, false, Description = "A bracket whose stop loss buys with the entry: eToro's close at the stop would fill a buy")] + [TestCase("take profit on the entry side", 1, false, Description = "A bracket whose take profit buys with the entry")] + [TestCase("short stop loss on the entry side", 1, false, Description = "A short bracket whose stop loss sells with the entry")] [TestCase("half exit", 1, false, Description = "The exits are for the quantity of the entry, the whole position closes at one of them")] [TestCase("limit entry", 1, false, Description = "The entry has to be a market order")] [TestCase("market exit", 1, false, Description = "An exit has to be a stop market or a limit order")] @@ -288,20 +291,17 @@ private static List CreateContingentOrders(string shape, Symbol symbol) { var time = DateTime.UtcNow; var entryQuantity = shape.StartsWith("short") ? -2m : 2m; - var exitQuantity = shape switch - { - "same side exit" => entryQuantity, - "half exit" => -entryQuantity / 2, - _ => -entryQuantity - }; + var exitQuantity = shape == "half exit" ? -entryQuantity / 2 : -entryQuantity; + var stopLossQuantity = shape is "same side exit" or "stop loss on the entry side" or "short stop loss on the entry side" ? entryQuantity : exitQuantity; + var takeProfitQuantity = shape is "same side exit" or "take profit on the entry side" ? entryQuantity : exitQuantity; Order entry = shape == "limit entry" ? new LimitOrder(symbol, entryQuantity, 100m, time) : new MarketOrder(symbol, entryQuantity, time); - var stopLoss = new StopMarketOrder(symbol, exitQuantity, 90m, time); - var takeProfit = new LimitOrder(symbol, exitQuantity, 110m, time); + var stopLoss = new StopMarketOrder(symbol, stopLossQuantity, 90m, time); + var takeProfit = new LimitOrder(symbol, takeProfitQuantity, 110m, time); List exits = shape switch { "take profit" or "short take profit" => [takeProfit], - "bracket" or "exits only" => [takeProfit, stopLoss], + "bracket" or "exits only" or "stop loss on the entry side" or "take profit on the entry side" or "short stop loss on the entry side" => [takeProfit, stopLoss], "market exit" => [new MarketOrder(symbol, exitQuantity, time)], "two stop losses" => [stopLoss, new StopMarketOrder(symbol, exitQuantity, 80m, time)], _ => [stopLoss] From 74e49773acd145f47b779065ac4837880f19eddf Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 9 Nov 2026 17:10:40 +0200 Subject: [PATCH 21/25] refactor: etoro order properties type removed until it has a field - the type lost its only field when the stop loss became an exit order; nothing in Lean or the plugin uses it, and an empty public type is one a user sees and can do nothing with --- Common/Orders/EtoroOrderProperties.cs | 34 --------------------------- 1 file changed, 34 deletions(-) delete mode 100644 Common/Orders/EtoroOrderProperties.cs diff --git a/Common/Orders/EtoroOrderProperties.cs b/Common/Orders/EtoroOrderProperties.cs deleted file mode 100644 index 3f34325c3c87..000000000000 --- a/Common/Orders/EtoroOrderProperties.cs +++ /dev/null @@ -1,34 +0,0 @@ -/* - * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. - * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * -*/ - -using QuantConnect.Interfaces; - -namespace QuantConnect.Orders -{ - /// - /// Represents the properties of an order in eToro. - /// - public class EtoroOrderProperties : OrderProperties - { - /// - /// Returns a new instance clone of this object - /// - public override IOrderProperties Clone() - { - return (EtoroOrderProperties)MemberwiseClone(); - } - } -} From 5334ab37f0e296e8d401e0bc10db3a27f61f62ff Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 9 Nov 2026 17:19:13 +0200 Subject: [PATCH 22/25] fix: etoro model starts a currency pair at 30 on a margin account - Lean's margin default of 50 is above the highest leverage eToro offers on any pair, so a plain forex order on a margin account went out at a leverage eToro does not have; 30 is the most it offers, a pair that takes 10 or 20 is refused at the order and the algorithm sets it lower - a cash account gives every security 1 and its buying power model takes no other leverage on crypto and forex, so leverage needs the margin account; the constructor doc says so --- Common/Brokerages/EtoroBrokerageModel.cs | 20 ++++++++++++++++++- .../Brokerages/EtoroBrokerageModelTests.cs | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index ee666a56f065..521292232358 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -87,12 +87,30 @@ public class EtoroBrokerageModel : DefaultBrokerageModel /// /// Constructor for eToro brokerage model /// - /// Cash or Margin; a cash account gives every security leverage 1 + /// Cash or Margin; a cash account gives every security leverage 1 and takes no other, + /// a margin account starts at the leverage of and takes another with SetLeverage public EtoroBrokerageModel(AccountType accountType = AccountType.Cash) : base(accountType) { } + /// + /// Gets the leverage a security starts at: 1 on a cash account; on a margin account 30 on a currency pair, the + /// highest eToro offers, and Lean's default elsewhere, 2 on equities and 1 on crypto, inside what eToro offers. + /// + /// The security + /// The leverage the security starts at + public override decimal GetLeverage(Security security) + { + if (AccountType == AccountType.Margin && security.Type == SecurityType.Forex) + { + // the highest eToro offers on a currency pair; a pair that takes 10 or 20 is refused at the order + return 30m; + } + + return base.GetLeverage(security); + } + /// /// Provides the eToro fee model /// diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 36a1886c552a..83af6127179f 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -228,7 +228,7 @@ public void CanSubmitOrderLeverageAboveOneRequiresStopLoss(decimal leverage, boo [TestCase(AccountType.Cash, SecurityType.Forex, 1)] [TestCase(AccountType.Margin, SecurityType.Equity, 2)] [TestCase(AccountType.Margin, SecurityType.Crypto, 1)] - [TestCase(AccountType.Margin, SecurityType.Forex, 50)] + [TestCase(AccountType.Margin, SecurityType.Forex, 30, Description = "The highest leverage eToro offers on a currency pair, under Lean's default of 50")] public void GetLeverageReturnsTheDefaultOfTheAccountType(AccountType accountType, SecurityType securityType, decimal expectedLeverage) { var brokerageModel = new EtoroBrokerageModel(accountType); From 468d705a2d6a3020cce0fdfb1467a202693e0f31 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 9 Nov 2026 17:30:10 +0200 Subject: [PATCH 23/25] test: brokerage test harness guards a quantity update like the transaction handler - the harness applied any quantity a brokerage sent, so a plugin test could pass on a resize live Lean drops: a zero quantity, a change of side, a quantity under what already filled - the timeout diagnostics of PlaceOrderWaitForStatus go to their own pull request: a change to a harness every plugin shares has nothing to do with registering eToro --- Tests/Brokerages/BrokerageTests.cs | 80 ++++++++++++------------------ 1 file changed, 31 insertions(+), 49 deletions(-) diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index b8630b60542d..cf51c07e056d 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -14,7 +14,6 @@ */ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -150,7 +149,13 @@ private void HandleOrderUpdatedEvents(object _, OrderUpdateEvent orderUpdateEven } if (orderUpdateEvent.Quantity.HasValue && order != null) { - order.Quantity = orderUpdateEvent.Quantity.Value; + // the transaction handler drops a zero quantity, a change of side and a quantity under what already filled + var quantity = orderUpdateEvent.Quantity.Value; + var filledQuantity = OrderProvider.GetOrderTicket(order.Id)?.QuantityFilled ?? 0; + if (quantity != 0 && quantity != order.Quantity && Math.Sign(quantity) == Math.Sign(order.Quantity) && Math.Abs(quantity) >= Math.Abs(filledQuantity)) + { + order.Quantity = quantity; + } } } @@ -857,17 +862,12 @@ protected IReadOnlyCollection PlaceOrderWaitForStatus(IReadOnlyCollection { using var requiredStatusEvent = new ManualResetEvent(false); using var desiredStatusEvent = new ManualResetEvent(false); - var invalidOrderEvents = new ConcurrentQueue(); EventHandler> brokerageOnOrdersStatusChanged = (sender, orderEvents) => { foreach (var orderEvent in orderEvents) { var order = _orderProvider.GetOrderById(orderEvent.OrderId); order.Status = orderEvent.Status; - if (orderEvent.Status == OrderStatus.Invalid) - { - invalidOrderEvents.Enqueue(orderEvent); - } // no matter what, every order should fire at least one of these if (orders.All(o => o.Status is OrderStatus.Submitted or OrderStatus.Invalid)) { @@ -901,59 +901,41 @@ protected IReadOnlyCollection PlaceOrderWaitForStatus(IReadOnlyCollection Brokerage.OrdersStatusChanged += brokerageOnOrdersStatusChanged; - try - { - OrderFillEvent.Reset(); + OrderFillEvent.Reset(); - foreach (var order in orders) + foreach (var order in orders) + { + OrderProvider.Add(order); + if (!Brokerage.PlaceOrder(order) && !allowFailedSubmission) { - OrderProvider.Add(order); - if (!Brokerage.PlaceOrder(order) && !allowFailedSubmission) - { - Assert.Fail($"Brokerage failed to place the order: {order}"); - } + Assert.Fail($"Brokerage failed to place the order: {order}"); } + } - // This is due to IB simulating stop orders https://www.interactivebrokers.com/en/trading/orders/stop.php - // which causes the Status.Submitted order event to never be set - var assertOrderEventStatus = true; - if (Brokerage.Name == "Interactive Brokers Brokerage" && orders.Any(o => o.Type is OrderType.StopMarket or OrderType.StopLimit)) - { - assertOrderEventStatus = false; - } + // This is due to IB simulating stop orders https://www.interactivebrokers.com/en/trading/orders/stop.php + // which causes the Status.Submitted order event to never be set + var assertOrderEventStatus = true; + if (Brokerage.Name == "Interactive Brokers Brokerage" && orders.Any(o => o.Type is OrderType.StopMarket or OrderType.StopLimit)) + { + assertOrderEventStatus = false; + } - var delayMilliseconds = Convert.ToInt32(1000 * secondsTimeout); - if (assertOrderEventStatus) - { - if (requiredStatusEvent.WaitOneAssertFail(delayMilliseconds, "Expected every order to fire a submitted or invalid status event")) - { - if (!desiredStatusEvent.WaitOne(delayMilliseconds)) - { - var details = new List(); - foreach (var order in orders) - { - var detail = $"OrderID: {order.Id} expected OrderStatus {expectedStatus} actual {order.Status}"; - var invalidOrderEvent = invalidOrderEvents.FirstOrDefault(orderEvent => orderEvent.OrderId == order.Id); - if (invalidOrderEvent != null) - { - detail += $" Message: {invalidOrderEvent.Message}"; - } - details.Add(detail); - } - Assert.Fail("Timeout: " + string.Join("; ", details)); - } - } - } - else + var delayMilliseconds = Convert.ToInt32(1000 * secondsTimeout); + if (assertOrderEventStatus) + { + if (requiredStatusEvent.WaitOneAssertFail(delayMilliseconds, "Expected every order to fire a submitted or invalid status event")) { - requiredStatusEvent.WaitOne(delayMilliseconds); + desiredStatusEvent.WaitOneAssertFail(delayMilliseconds, + "OrderStatus " + expectedStatus + " was not encountered within the timeout." + string.Join("", orders.Select(x => " Order Id:" + x.Id))); } } - finally + else { - Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; + requiredStatusEvent.WaitOne(delayMilliseconds); } + Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; + return orders; } From e3c8ccc9c5d1153d2b79204d52293df05360837d Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 9 Nov 2026 18:23:57 +0200 Subject: [PATCH 24/25] refactor: contingent set keeps one quantities view - AbsoluteQuantities said nothing Quantities does not: the eToro model, its only user, takes the distinct absolute values of the signed list instead, and the core type grows by one public property rather than two --- Common/Brokerages/EtoroBrokerageModel.cs | 2 +- Common/Orders/OrderContingency.cs | 11 +---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 521292232358..37f49bdf00e2 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -227,7 +227,7 @@ private bool CanSubmitContingentOrder(Security security, Order order, out Broker { message = this.UnsupportedContingentOrdersShape("the exit orders have to be on the opposite side of the entry order."); } - else if (contingency.AbsoluteQuantities.Count != 1) + else if (contingency.Quantities.Select(Math.Abs).Distinct().Count() != 1) { message = this.UnsupportedContingentOrdersShape("the exit orders have to be for the quantity of the entry order: eToro closes the whole position at the stop loss or the take profit."); } diff --git a/Common/Orders/OrderContingency.cs b/Common/Orders/OrderContingency.cs index 8a48d57bd18b..261c3286a1b7 100644 --- a/Common/Orders/OrderContingency.cs +++ b/Common/Orders/OrderContingency.cs @@ -76,16 +76,9 @@ public class OrderContingency [JsonIgnore] public IReadOnlySet OrderTypes => _set.OrderTypes ??= _set.BuildSet(member => member.OrderType); - /// - /// The different absolute quantities of the orders in the set. Allows a brokerage model to validate a single - /// order knowing about the rest of the set. Only available at submission time - /// - [JsonIgnore] - public IReadOnlySet AbsoluteQuantities => _set.AbsoluteQuantities ??= _set.BuildSet(member => Math.Abs(member.Quantity)); - /// /// The quantities of the orders in the set, one per order, signed. Allows a brokerage model to validate a single - /// order against the side of each other order of the set. Only available at submission time + /// order against the side and the size of each other order of the set. Only available at submission time /// [JsonIgnore] public IReadOnlyList Quantities => _set.Quantities ??= _set.BuildList(member => member.Quantity); @@ -527,7 +520,6 @@ private class SharedState public HashSet Symbols { get; set; } public HashSet Directions { get; set; } public HashSet OrderTypes { get; set; } - public HashSet AbsoluteQuantities { get; set; } public List Quantities { get; set; } public List Requests { get; set; } @@ -544,7 +536,6 @@ public void OnMembersChanged() Symbols = null; Directions = null; OrderTypes = null; - AbsoluteQuantities = null; Quantities = null; Requests = null; } From 6f696b72ba242119e577165829ba93ab94569571 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 9 Nov 2026 19:54:01 +0200 Subject: [PATCH 25/25] fix: etoro model keeps an exit price on its side of the market A stop loss above the price of a long position, or below the price of a short one, passed the model: the crypto distance rule took the absolute distance. Lean's fill model fills such a stop at once while eToro refuses the rate and the position keeps its old one, so a backtest closed where a live run stayed open. A take profit on the wrong side had the same gap. The side comes from the exit itself, a sell closes a long position and a buy a short one, so an exit read from the position after a restart is checked the same, at submission and at update. The crypto 10% rule stays behind the side check. 16 cases cover the four wrong-side stops and the four wrong-side take profits at submission and at update. The sets are collection expressions, the exit identifiers carry the contingent name, and the crypto constant's doc names the eligibility field it comes from. --- Common/Brokerages/EtoroBrokerageModel.cs | 117 ++++++++++++------ Common/Messages/Messages.Brokerages.cs | 18 +++ .../Brokerages/EtoroBrokerageModelTests.cs | 64 ++++++++++ 3 files changed, 159 insertions(+), 40 deletions(-) diff --git a/Common/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs index 37f49bdf00e2..b68c6a240b5c 100644 --- a/Common/Brokerages/EtoroBrokerageModel.cs +++ b/Common/Brokerages/EtoroBrokerageModel.cs @@ -31,49 +31,37 @@ public class EtoroBrokerageModel : DefaultBrokerageModel /// /// The least distance of a crypto stop loss from the price, in percent of the margin, which is the whole /// position at leverage 1, the one leverage of crypto at eToro. eToro moves a closer stop loss there without an error. + /// It is the minStopLossPercentage in the leverageConfigs of every crypto instrument in the eligibility + /// endpoint, https://api-portal.etoro.com/api-reference/trading--real/check-instrument-trading-eligibility.md; + /// the other security types carry it per instrument, 0 on most. /// private const int CryptoMinimumStopLossPercent = 10; /// /// The security types supported by eToro: US stocks and ETFs, crypto and currency pairs. /// - private readonly HashSet _supportSecurityTypes = new( - new[] - { - SecurityType.Equity, - SecurityType.Crypto, - SecurityType.Forex - }); + private readonly HashSet _supportSecurityTypes = [SecurityType.Equity, SecurityType.Crypto, SecurityType.Forex]; /// /// The order types supported by the operation in eToro. /// - private readonly HashSet _supportOrderTypes = new( - new[] - { - OrderType.Market - }); + private readonly HashSet _supportOrderTypes = [OrderType.Market]; /// /// The order types of an exit order an entry triggers: a stop market order is the stop loss of the position the /// entry opens, a limit order its take profit. /// - private readonly HashSet _supportExitOrderTypes = new( - new[] - { - OrderType.StopMarket, - OrderType.Limit - }); + private readonly HashSet _supportContingentExitOrderTypes = [OrderType.StopMarket, OrderType.Limit]; /// /// The contingency types supported: an entry which triggers its exits, and the two exits of a bracket where one /// cancels the other. /// - private readonly HashSet _supportedContingencyTypes = new() - { + private readonly HashSet _supportedContingencyTypes = + [ ContingencyType.OneTriggersOther, ContingencyType.OneCancelsOther - }; + ]; /// /// The order types that cannot cross zero holdings. @@ -142,8 +130,8 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - var isExitOrder = order.GetContingencyLink(ContingencyRole.Child) != null; - var supportedOrderTypes = isExitOrder ? _supportExitOrderTypes : _supportOrderTypes; + var isContingentExitOrder = order.GetContingencyLink(ContingencyRole.Child) != null; + var supportedOrderTypes = isContingentExitOrder ? _supportContingentExitOrderTypes : _supportOrderTypes; if (!supportedOrderTypes.Contains(order.Type)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", @@ -151,14 +139,12 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - if (isExitOrder && order is StopMarketOrder stopLoss && IsCryptoStopLossTooClose(security, stopLoss.StopPrice)) + if (isContingentExitOrder && !IsExitPriceSupported(security, order, GetContingentExitOrderPrice(order), out message)) { - message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.EtoroBrokerageModel.CryptoStopLossTooClose(order, security.Price, CryptoMinimumStopLossPercent)); return false; } - if (!isExitOrder) + if (!isContingentExitOrder) { var orderPosition = BrokerageExtensions.GetOrderPosition(order.Direction, security.Holdings.Quantity); var hasStopLoss = order.Contingency?.OrderTypes.Contains(OrderType.StopMarket) == true; @@ -177,7 +163,7 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag } } - if (!isExitOrder && !BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) + if (!isContingentExitOrder && !BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) { return false; } @@ -242,8 +228,8 @@ private bool CanSubmitContingentOrder(Security security, Order order, out Broker /// /// Returns true for a price change of a stop market or a limit order, the stop loss or the take profit of a /// position, which eToro moves on the position; the order may come without its entry, read from the position - /// after a restart. Any other update is refused, eToro has no order update; an order is cancelled and placed - /// again instead. + /// after a restart. The new price is checked as at submission, see . Any + /// other update is refused, eToro has no order update; an order is cancelled and placed again instead. /// /// The security of the order /// The order to be updated @@ -268,10 +254,9 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR return false; } - if (order.Type == OrderType.StopMarket && request.StopPrice.HasValue && IsCryptoStopLossTooClose(security, request.StopPrice.Value)) + var exitPrice = order.Type == OrderType.StopMarket ? request.StopPrice : request.LimitPrice; + if (exitPrice.HasValue && !IsExitPriceSupported(security, order, exitPrice.Value, out message)) { - message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", - Messages.EtoroBrokerageModel.CryptoStopLossTooClose(order, security.Price, CryptoMinimumStopLossPercent)); return false; } @@ -279,16 +264,68 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR } /// - /// True for a stop loss on a crypto security closer to its price than eToro keeps one; a security without a - /// price yet is not checked. + /// Checks the price of an exit order as eToro takes a rate of the position: the stop loss of a long position + /// below the price and of a short position above it, the take profit the other side, and a crypto stop loss + /// at least 10% away from the price. eToro refuses a rate on the wrong side of the price, where the fill model + /// would fill the exit at once. A security without a price yet is not checked. /// /// The security of the order - /// The stop price asked - /// True when eToro would move the stop loss away from the price - private static bool IsCryptoStopLossTooClose(Security security, decimal stopPrice) + /// The exit order: a sell closes a long position, a buy a short one + /// The stop price of a stop loss or the limit price of a take profit, asked or new + /// The refusal, null when eToro takes the price + /// True when eToro takes the price + private static bool IsExitPriceSupported(Security security, Order order, decimal exitPrice, out BrokerageMessageEvent message) { - return security.Type == SecurityType.Crypto && security.Price != 0 - && Math.Abs(security.Price - stopPrice) < security.Price * CryptoMinimumStopLossPercent / 100; + message = null; + if (security.Price == 0) + { + return true; + } + + var closesLong = order.Direction == OrderDirection.Sell; + switch (order.Type) + { + case OrderType.StopMarket: + if (closesLong ? exitPrice >= security.Price : exitPrice <= security.Price) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.StopLossOnTheWrongSide(order, exitPrice, security.Price)); + } + else if (security.Type == SecurityType.Crypto && Math.Abs(security.Price - exitPrice) < security.Price * CryptoMinimumStopLossPercent / 100) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.CryptoStopLossTooClose(order, security.Price, CryptoMinimumStopLossPercent)); + } + break; + case OrderType.Limit: + if (closesLong ? exitPrice <= security.Price : exitPrice >= security.Price) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.EtoroBrokerageModel.TakeProfitOnTheWrongSide(order, exitPrice, security.Price)); + } + break; + } + + return message == null; + } + + /// + /// Gets the price of a contingent exit order, the rate eToro keeps on the position: the stop price of a stop + /// loss, the limit price of a take profit. + /// + /// The exit order, a stop market or a limit order + /// The stop price or the limit price + private static decimal GetContingentExitOrderPrice(Order order) + { + switch (order) + { + case StopMarketOrder stopLoss: + return stopLoss.StopPrice; + case LimitOrder takeProfit: + return takeProfit.LimitPrice; + default: + throw new NotSupportedException($"A {order.Type} order is not supported as an exit order: a stop market order is the stop loss, a limit order the take profit."); + } } } } diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index 6ebe46c8220f..58f5ea46f783 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -722,6 +722,24 @@ public static string CryptoStopLossTooClose(Orders.Order order, decimal price, i { return Invariant($"The stop loss of {order.Symbol.Value} at eToro has to be at least {minimumPercent}% away from the price {price}: eToro keeps a crypto stop loss that far from the position and moves a closer one there."); } + + /// + /// Returns a message explaining that a stop loss has to be below the price of a long position and above the price of a short one. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string StopLossOnTheWrongSide(Orders.Order order, decimal stopPrice, decimal price) + { + return Invariant($"The stop loss of {order.Symbol.Value} at {stopPrice} is on the wrong side of the price {price}: eToro keeps the stop loss of a long position below the price and that of a short position above it."); + } + + /// + /// Returns a message explaining that a take profit has to be above the price of a long position and below the price of a short one. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string TakeProfitOnTheWrongSide(Orders.Order order, decimal limitPrice, decimal price) + { + return Invariant($"The take profit of {order.Symbol.Value} at {limitPrice} is on the wrong side of the price {price}: eToro keeps the take profit of a long position above the price and that of a short position below it."); + } } /// diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs index 83af6127179f..02ccc6827ec4 100644 --- a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs @@ -189,6 +189,57 @@ public void CanUpdateOrderCryptoStopLossKeepsTheMinimumDistance(decimal distance Assert.That(message, expectedCanUpdate ? Is.Null : Is.Not.Null); } + // eToro refuses a rate on the wrong side of the price, where Lean's fill model would fill the exit at once + [TestCase(OrderType.StopMarket, -1, 90, true, Description = "The stop loss of a long position below the price")] + [TestCase(OrderType.StopMarket, -1, 120, false, Description = "The stop loss of a long position above the price")] + [TestCase(OrderType.StopMarket, 1, 110, true, Description = "The stop loss of a short position above the price")] + [TestCase(OrderType.StopMarket, 1, 80, false, Description = "The stop loss of a short position below the price")] + [TestCase(OrderType.Limit, -1, 110, true, Description = "The take profit of a long position above the price")] + [TestCase(OrderType.Limit, -1, 90, false, Description = "The take profit of a long position below the price")] + [TestCase(OrderType.Limit, 1, 90, true, Description = "The take profit of a short position below the price")] + [TestCase(OrderType.Limit, 1, 110, false, Description = "The take profit of a short position above the price")] + public void CanSubmitOrderExitPriceHasToBeOnItsSideOfThePrice(OrderType exitType, decimal exitQuantity, decimal exitPrice, bool expectedCanSubmit) + { + var security = GetSecurityForType(SecurityType.Equity); + security.SetMarketPrice(new Tick { Symbol = security.Symbol, Value = 100m }); + var exit = CreateExitOrder(exitType, security.Symbol, exitQuantity, exitPrice); + OrderContingency.Trigger([new MarketOrder(security.Symbol, -exitQuantity, DateTime.UtcNow)], [exit]); + + var canSubmit = _brokerageModel.CanSubmitOrder(security, exit, out var message); + + Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit)); + if (!expectedCanSubmit) + { + Assert.That(message.Message, Does.Contain("wrong side")); + } + } + + // The exit comes without its entry, as read from the position after a restart; the side of its new price is checked all the same + [TestCase(OrderType.StopMarket, -1, 90, true, Description = "The stop loss of a long position moved below the price")] + [TestCase(OrderType.StopMarket, -1, 120, false, Description = "The stop loss of a long position moved above the price")] + [TestCase(OrderType.StopMarket, 1, 110, true, Description = "The stop loss of a short position moved above the price")] + [TestCase(OrderType.StopMarket, 1, 80, false, Description = "The stop loss of a short position moved below the price")] + [TestCase(OrderType.Limit, -1, 110, true, Description = "The take profit of a long position moved above the price")] + [TestCase(OrderType.Limit, -1, 90, false, Description = "The take profit of a long position moved below the price")] + [TestCase(OrderType.Limit, 1, 90, true, Description = "The take profit of a short position moved below the price")] + [TestCase(OrderType.Limit, 1, 110, false, Description = "The take profit of a short position moved above the price")] + public void CanUpdateOrderExitPriceHasToBeOnItsSideOfThePrice(OrderType exitType, decimal exitQuantity, decimal newPrice, bool expectedCanUpdate) + { + var security = GetSecurityForType(SecurityType.Equity); + security.SetMarketPrice(new Tick { Symbol = security.Symbol, Value = 100m }); + var exit = CreateExitOrder(exitType, security.Symbol, exitQuantity, 100m); + var fields = exitType == OrderType.StopMarket ? new UpdateOrderFields { StopPrice = newPrice } : new UpdateOrderFields { LimitPrice = newPrice }; + var request = new UpdateOrderRequest(DateTime.UtcNow, exit.Id, fields); + + var canUpdate = _brokerageModel.CanUpdateOrder(security, exit, request, out var message); + + Assert.That(canUpdate, Is.EqualTo(expectedCanUpdate)); + if (!expectedCanUpdate) + { + Assert.That(message.Message, Does.Contain("wrong side")); + } + } + [Test] public void GetFeeModelReturnsEtoroFeeModel() { @@ -318,6 +369,19 @@ private static List CreateContingentOrders(string shape, Symbol symbol) return shape == "exits only" ? exits : [entry, .. exits]; } + private static Order CreateExitOrder(OrderType exitType, Symbol symbol, decimal quantity, decimal price) + { + switch (exitType) + { + case OrderType.StopMarket: + return new StopMarketOrder(symbol, quantity, price, DateTime.UtcNow); + case OrderType.Limit: + return new LimitOrder(symbol, quantity, price, DateTime.UtcNow); + default: + throw new ArgumentOutOfRangeException(nameof(exitType), exitType, null); + } + } + private static Security GetSecurityForType(SecurityType securityType) { switch (securityType)