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/Brokerages/EtoroBrokerageModel.cs b/Common/Brokerages/EtoroBrokerageModel.cs
new file mode 100644
index 000000000000..b68c6a240b5c
--- /dev/null
+++ b/Common/Brokerages/EtoroBrokerageModel.cs
@@ -0,0 +1,331 @@
+/*
+ * 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;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace QuantConnect.Brokerages
+{
+ ///
+ /// Represents a brokerage model specific to eToro.
+ ///
+ 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 = [SecurityType.Equity, SecurityType.Crypto, SecurityType.Forex];
+
+ ///
+ /// The order types supported by the operation in eToro.
+ ///
+ 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 _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 =
+ [
+ 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.
+ ///
+ private static readonly IReadOnlySet NotSupportedCrossZeroOrderTypes = new HashSet
+ {
+ OrderType.Market
+ };
+
+ ///
+ /// Constructor for eToro brokerage model
+ ///
+ /// 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
+ ///
+ /// 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 (!CanSubmitContingentOrder(security, order, out message))
+ {
+ return false;
+ }
+
+ if (!_supportSecurityTypes.Contains(security.Type))
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.DefaultBrokerageModel.UnsupportedSecurityType(this, security));
+ return false;
+ }
+
+ var isContingentExitOrder = order.GetContingencyLink(ContingencyRole.Child) != null;
+ var supportedOrderTypes = isContingentExitOrder ? _supportContingentExitOrderTypes : _supportOrderTypes;
+ if (!supportedOrderTypes.Contains(order.Type))
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.DefaultBrokerageModel.UnsupportedOrderType(this, order, supportedOrderTypes));
+ return false;
+ }
+
+ if (isContingentExitOrder && !IsExitPriceSupported(security, order, GetContingentExitOrderPrice(order), out message))
+ {
+ return false;
+ }
+
+ if (!isContingentExitOrder)
+ {
+ 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 (!isContingentExitOrder && !BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes))
+ {
+ return false;
+ }
+
+ 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;
+ }
+
+ 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 || 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.");
+ }
+ 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.");
+ }
+ 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 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. 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
+ /// The requested update to be made to the order
+ /// 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 = null;
+ if (order.Type is not (OrderType.StopMarket or OrderType.Limit))
+ {
+ 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;
+ }
+
+ var exitPrice = order.Type == OrderType.StopMarket ? request.StopPrice : request.LimitPrice;
+ if (exitPrice.HasValue && !IsExitPriceSupported(security, order, exitPrice.Value, out message))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// 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 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)
+ {
+ 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/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..58f5ea46f783 100644
--- a/Common/Messages/Messages.Brokerages.cs
+++ b/Common/Messages/Messages.Brokerages.cs
@@ -691,6 +691,57 @@ 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 a stop loss: a stop market order it triggers.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string ShortSaleRequiresStopLoss(Orders.Order order)
+ {
+ 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 a stop loss: a stop market order the entry triggers.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string LeverageRequiresStopLoss(Orders.Order order, decimal leverage)
+ {
+ 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.");
+ }
+
+ ///
+ /// 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.");
+ }
+ }
+
///
/// Provides user-facing messages for the class and its consumers or related classes
///
diff --git a/Common/Orders/Fees/EtoroFeeModel.cs b/Common/Orders/Fees/EtoroFeeModel.cs
new file mode 100644
index 000000000000..5c1ebb188e62
--- /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 the quote currency of the security.
+ 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, security.QuoteCurrency.Symbol));
+ }
+ }
+}
diff --git a/Common/Orders/OrderContingency.cs b/Common/Orders/OrderContingency.cs
index 46d303161836..261c3286a1b7 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 quantities of the orders in the set, one per order, signed. Allows a brokerage model to validate a single
+ /// 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);
+
///
/// 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 List Quantities { get; set; }
public List Requests { get; set; }
public SharedState(int id, int count)
@@ -528,6 +536,7 @@ public void OnMembersChanged()
Symbols = null;
Directions = null;
OrderTypes = null;
+ Quantities = null;
Requests = null;
}
@@ -541,6 +550,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/Launcher/config.json b/Launcher/config.json
index a864ae0b56d9..ab440f5daeb3 100644
--- a/Launcher/config.json
+++ b/Launcher/config.json
@@ -295,6 +295,12 @@
// 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": "",
+ "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 +857,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,
diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs
index 2aeee2965a59..cf51c07e056d 100644
--- a/Tests/Brokerages/BrokerageTests.cs
+++ b/Tests/Brokerages/BrokerageTests.cs
@@ -138,14 +138,25 @@ 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)
+ {
+ // 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;
+ }
+ }
}
///
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)
diff --git a/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs
new file mode 100644
index 000000000000..02ccc6827ec4
--- /dev/null
+++ b/Tests/Common/Brokerages/EtoroBrokerageModelTests.cs
@@ -0,0 +1,429 @@
+/*
+ * 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 System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using QuantConnect.Data.Market;
+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)]
+ [TestCase(SecurityType.Forex)]
+ 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.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 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 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);
+
+ Assert.That(canSubmit, Is.EqualTo(expectedCanSubmit));
+ if (expectedCanSubmit)
+ {
+ Assert.That(message, Is.Null);
+ }
+ else
+ {
+ Assert.That(message.Message, Does.Contain("stop market order"));
+ }
+ }
+
+ // 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);
+ }
+
+ // An exit order moves the stop loss or the take profit of the position; its quantity is the position's
+ [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 stopLoss = new StopMarketOrder(security.Symbol, -1m, 90m, DateTime.UtcNow);
+ 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);
+
+ 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);
+ }
+
+ [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);
+ }
+
+ // 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()
+ {
+ var security = GetSecurityForType(SecurityType.Equity);
+
+ Assert.That(_brokerageModel.GetFeeModel(security), Is.InstanceOf());
+ }
+
+ [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);
+ if (triggersStopLoss)
+ {
+ OrderContingency.Trigger([order], [new StopMarketOrder(security.Symbol, -1m, 90m, DateTime.UtcNow)]);
+ }
+
+ 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("stop market order"));
+ }
+ }
+
+ [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, 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);
+ var security = GetSecurityForType(securityType);
+
+ Assert.That(brokerageModel.GetLeverage(security), Is.EqualTo(expectedLeverage));
+ }
+
+ [Test]
+ 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("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")]
+ [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 == "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, 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" 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]
+ };
+
+ 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 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)
+ {
+ 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..e89401c12069
--- /dev/null
+++ b/Tests/Common/Orders/Fees/EtoroFeeModelTests.cs
@@ -0,0 +1,93 @@
+/*
+ * 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));
+ }
+
+ // 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);
+
+ var security = new Security(
+ SecurityExchangeHours.AlwaysOpen(TimeZones.Utc),
+ config,
+ new Cash(quoteCurrency, 0, 1m),
+ SymbolProperties.GetDefault(quoteCurrency),
+ ErrorCurrencyConverter.Instance,
+ RegisteredSecurityDataTypesProvider.Null,
+ new SecurityCache());
+
+ security.SetMarketPrice(new Tick(DateTime.UtcNow, symbol, price, price));
+ return security;
+ }
+ }
+}