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; + } + } +}