diff --git a/Common/Brokerages/BrokerageName.cs b/Common/Brokerages/BrokerageName.cs index 7f6edaab7a5e..9a1b7a994c71 100644 --- a/Common/Brokerages/BrokerageName.cs +++ b/Common/Brokerages/BrokerageName.cs @@ -197,6 +197,11 @@ public enum BrokerageName /// /// Transaction and submit/execution rules will use dYdX models /// - DYDX + DYDX, + + /// + /// Transaction and submit/execution rules will use Webull models + /// + Webull } } diff --git a/Common/Brokerages/IBrokerageModel.cs b/Common/Brokerages/IBrokerageModel.cs index 4544c388d8c1..c8c43f2ff69a 100644 --- a/Common/Brokerages/IBrokerageModel.cs +++ b/Common/Brokerages/IBrokerageModel.cs @@ -291,6 +291,9 @@ public static IBrokerageModel Create(IOrderProvider orderProvider, BrokerageName case BrokerageName.DYDX: return new dYdXBrokerageModel(accountType); + case BrokerageName.Webull: + return new WebullBrokerageModel(accountType); + default: throw new ArgumentOutOfRangeException(nameof(brokerage), brokerage, null); } @@ -394,6 +397,9 @@ public static BrokerageName GetBrokerageName(IBrokerageModel brokerageModel) case TastytradeBrokerageModel: return BrokerageName.Tastytrade; + case WebullBrokerageModel: + return BrokerageName.Webull; + case DefaultBrokerageModel _: return BrokerageName.Default; diff --git a/Common/Brokerages/WebullBrokerageModel.cs b/Common/Brokerages/WebullBrokerageModel.cs new file mode 100644 index 000000000000..4d1922860e8d --- /dev/null +++ b/Common/Brokerages/WebullBrokerageModel.cs @@ -0,0 +1,158 @@ +/* + * 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.Collections.Generic; +using QuantConnect.Logging; +using QuantConnect.Orders; +using QuantConnect.Orders.Fees; +using QuantConnect.Orders.TimeInForces; +using QuantConnect.Securities; + +namespace QuantConnect.Brokerages +{ + /// + /// Represents a brokerage model specific to Webull. + /// + public class WebullBrokerageModel : DefaultBrokerageModel + { + /// + /// Flag to track if we've already logged a message about market orders only supporting Day TIF. We only want to log this once to avoid spamming the logs. + /// + private bool _marketOrderDayTimeInForceLogged; + + /// + /// Maps each supported security type to the order types Webull allows for it. + /// + private static readonly Dictionary> _supportedOrderTypesBySecurityType = + new Dictionary> + { + { + SecurityType.Equity, new HashSet + { + OrderType.Market, + OrderType.Limit, + OrderType.StopMarket, + OrderType.StopLimit, + OrderType.TrailingStop + } + }, + { + SecurityType.Option, new HashSet + { + OrderType.Market, + OrderType.Limit, + OrderType.StopMarket, + OrderType.StopLimit + } + }, + { + SecurityType.IndexOption, new HashSet + { + OrderType.Market, + OrderType.Limit, + OrderType.StopMarket, + OrderType.StopLimit + } + } + }; + + /// + /// Constructor for Webull brokerage model. + /// + /// Cash or Margin + public WebullBrokerageModel(AccountType accountType = AccountType.Margin) + : base(accountType) + { + } + + /// + /// Provides the Webull fee model. + /// + /// Security + /// Webull fee model + public override IFeeModel GetFeeModel(Security security) + { + return new WebullFeeModel(); + } + + /// + /// Returns true if the brokerage could accept this order. This takes into account + /// order type, security type, and order size limits. + /// + /// + /// For example, a brokerage may have no connectivity at certain times, or an order rate/size limit. + /// + /// 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) + { + message = default; + + if (!_supportedOrderTypesBySecurityType.TryGetValue(security.Type, out var supportedOrderTypes)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedSecurityType(this, security)); + return false; + } + + if (!supportedOrderTypes.Contains(order.Type)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedOrderType(this, order, supportedOrderTypes)); + return false; + } + + if (!_marketOrderDayTimeInForceLogged && order.Type == OrderType.Market && order.TimeInForce is not DayTimeInForce) + { + _marketOrderDayTimeInForceLogged = true; + Log.Trace("WebullBrokerageModel.CanSubmitOrder: Market orders support only Day TIF, which is set automatically by the brokerage."); + } + + // Options and IndexOptions have per-direction TimeInForce restrictions. + // https://developer.webull.com/apis/docs/trade-api/options#time-in-force + // - Sell orders: Day only + if (security.Type == SecurityType.Option || security.Type == SecurityType.IndexOption) + { + if (order.Direction == OrderDirection.Sell && order.TimeInForce is not DayTimeInForce) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.WebullBrokerageModel.InvalidTimeInForceForOptionSellOrder(order)); + return false; + } + } + + if (order.Properties is WebullOrderProperties { OutsideRegularTradingHours: true }) + { + if (security.Type is not SecurityType.Equity) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.WebullBrokerageModel.OutsideRegularTradingHoursNotSupportedForSecurityType(security)); + return false; + } + + if (order.Type == OrderType.Market) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.WebullBrokerageModel.MarketOrdersNotSupportedOutsideRegularTradingHours()); + return false; + } + } + + return base.CanSubmitOrder(security, order, out message); + } + } +} diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index e5528f99d851..dcc74d469230 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -595,6 +595,39 @@ public static string UnsupportedOrderType(Orders.Order order) } } + /// + /// Provides user-facing messages for the class and its consumers or related classes + /// + public static class WebullBrokerageModel + { + /// + /// Returns a message explaining that Options and IndexOptions sell orders only support Day time in force. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string InvalidTimeInForceForOptionSellOrder(Orders.Order order) + { + return Invariant($"{order.Symbol.SecurityType} sell orders only support {nameof(DayTimeInForce)} time in force, but {order.TimeInForce.GetType().Name} was specified."); + } + + /// + /// Returns a message explaining that OutsideRegularTradingHours is only supported for Equity orders. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string OutsideRegularTradingHoursNotSupportedForSecurityType(Securities.Security security) + { + return Invariant($"{nameof(WebullOrderProperties.OutsideRegularTradingHours)} is only supported for {nameof(SecurityType.Equity)} orders, but {security.Type} was specified."); + } + + /// + /// Returns a message explaining that Market orders are not supported outside regular trading hours. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string MarketOrdersNotSupportedOutsideRegularTradingHours() + { + return Invariant($"Market orders are not supported outside regular trading hours."); + } + } + /// /// Provides user-facing messages for the class and its consumers or related classes /// diff --git a/Common/Orders/Fees/WebullFeeModel.cs b/Common/Orders/Fees/WebullFeeModel.cs new file mode 100644 index 000000000000..a708ed01baa0 --- /dev/null +++ b/Common/Orders/Fees/WebullFeeModel.cs @@ -0,0 +1,226 @@ +/* + * 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 Webull. + /// + /// + /// + /// Equity and standard options trades are commission-free on Webull. + /// Index options carry a flat $0.50 Webull contract fee plus a variable exchange proprietary fee + /// that depends on the underlying index symbol and the option's market price. + /// + public class WebullFeeModel : FeeModel + { + /// + /// Webull contract fee applied to every index option contract, regardless of underlying. + /// + private const decimal _webullIndexOptionContractFee = 0.50m; + + /// + /// Exchange proprietary fee for SPX options priced below $1.00. + /// + private const decimal _spxExchangeFeeBelow1 = 0.57m; + + /// + /// Exchange proprietary fee for SPX options priced at or above $1.00. + /// + private const decimal _spxExchangeFeeAbove1 = 0.66m; + + /// + /// Exchange proprietary fee for SPXW options priced below $1.00. + /// + private const decimal _spxwExchangeFeeBelow1 = 0.50m; + + /// + /// Exchange proprietary fee for SPXW options priced at or above $1.00. + /// + private const decimal _spxwExchangeFeeAbove1 = 0.59m; + + /// + /// VIX/VIXW exchange fee tier 1: option price at or below $0.10. + /// + private const decimal _vixExchangeFeeTier1 = 0.10m; + + /// + /// VIX/VIXW exchange fee tier 2: option price between $0.11 and $0.99. + /// + private const decimal _vixExchangeFeeTier2 = 0.25m; + + /// + /// VIX/VIXW exchange fee tier 3: option price between $1.00 and $1.99. + /// + private const decimal _vixExchangeFeeTier3 = 0.40m; + + /// + /// VIX/VIXW exchange fee tier 4: option price at or above $2.00. + /// + private const decimal _vixExchangeFeeTier4 = 0.45m; + + /// + /// XSP exchange fee for orders with fewer than 10 contracts. + /// + private const decimal _xspExchangeFeeSmall = 0.00m; + + /// + /// XSP exchange fee for orders with 10 or more contracts. + /// + private const decimal _xspExchangeFeeLarge = 0.07m; + + /// + /// DJX flat exchange proprietary fee per contract. + /// + private const decimal _djxExchangeFee = 0.18m; + + /// + /// NDX/NDXP exchange fee for single-leg orders with premium below $25. + /// + private const decimal _ndxSingleLegFeeBelow25 = 0.50m; + + /// + /// NDX/NDXP exchange fee for single-leg orders with premium at or above $25. + /// + private const decimal _ndxSingleLegFeeAbove25 = 0.75m; + + /// + /// NDX/NDXP exchange fee for multi-leg orders with premium below $25. + /// + private const decimal _ndxMultiLegFeeBelow25 = 0.65m; + + /// + /// NDX/NDXP exchange fee for multi-leg orders with premium at or above $25. + /// + private const decimal _ndxMultiLegFeeAbove25 = 0.90m; + + /// + /// Gets the order fee for a given security and order. + /// + /// The parameters including the security and order details. + /// + /// for equity and standard options; + /// a per-contract fee for index options. + /// + public override OrderFee GetOrderFee(OrderFeeParameters parameters) + { + switch (parameters.Security.Type) + { + case SecurityType.IndexOption: + return new OrderFee(new CashAmount(GetIndexOptionFee(parameters), Currencies.USD)); + default: + // Equity and Option are commission-free on Webull. + return OrderFee.Zero; + } + } + + /// + /// Calculates the total per-contract fee for an index option order. + /// The total fee = (exchange proprietary fee + Webull contract fee) × quantity. + /// + /// Order fee parameters containing the security and order. + /// Total fee amount in USD. + private static decimal GetIndexOptionFee(OrderFeeParameters parameters) + { + var order = parameters.Order; + var security = parameters.Security; + var quantity = order.AbsoluteQuantity; + var price = security.Price; + var underlying = security.Symbol.Underlying?.Value?.ToUpperInvariant() ?? string.Empty; + var isMultiLeg = order.Type == OrderType.ComboMarket + || order.Type == OrderType.ComboLimit + || order.Type == OrderType.ComboLegLimit; + + var exchangeFee = GetIndexOptionExchangeFee(underlying, price, quantity, isMultiLeg); + return quantity * (exchangeFee + _webullIndexOptionContractFee); + } + + /// + /// Returns the exchange proprietary fee per contract for an index option, based on + /// the underlying ticker, the option's current price, order quantity, and leg type. + /// + /// Uppercase underlying ticker (e.g. "SPX", "VIX"). + /// Current market price of the option. + /// Absolute number of contracts in the order. + /// True when the order is a combo/multi-leg order. + /// Exchange fee per contract in USD. + private static decimal GetIndexOptionExchangeFee(string underlying, decimal price, decimal quantity, bool isMultiLeg) + { + switch (underlying) + { + case "SPX": + return price < 1m ? _spxExchangeFeeBelow1 : _spxExchangeFeeAbove1; + + case "SPXW": + return price < 1m ? _spxwExchangeFeeBelow1 : _spxwExchangeFeeAbove1; + + case "VIX": + case "VIXW": + return GetVixExchangeFee(price); + + case "XSP": + return quantity < 10m ? _xspExchangeFeeSmall : _xspExchangeFeeLarge; + + case "DJX": + return _djxExchangeFee; + + case "NDX": + case "NDXP": + return GetNdxExchangeFee(price, isMultiLeg); + + default: + return 0m; + } + } + + /// + /// Returns the VIX/VIXW exchange fee for a simple order based on the option price tier. + /// + private static decimal GetVixExchangeFee(decimal price) + { + if (price <= 0.10m) + { + return _vixExchangeFeeTier1; + } + + if (price <= 0.99m) + { + return _vixExchangeFeeTier2; + } + + if (price <= 1.99m) + { + return _vixExchangeFeeTier3; + } + + return _vixExchangeFeeTier4; + } + + /// + /// Returns the NDX/NDXP exchange fee per contract based on premium tier and order leg type. + /// + private static decimal GetNdxExchangeFee(decimal price, bool isMultiLeg) + { + if (isMultiLeg) + { + return price < 25m ? _ndxMultiLegFeeBelow25 : _ndxMultiLegFeeAbove25; + } + + return price < 25m ? _ndxSingleLegFeeBelow25 : _ndxSingleLegFeeAbove25; + } + } +} diff --git a/Common/Orders/WebullOrderProperties.cs b/Common/Orders/WebullOrderProperties.cs new file mode 100644 index 000000000000..ce7932379a19 --- /dev/null +++ b/Common/Orders/WebullOrderProperties.cs @@ -0,0 +1,34 @@ +/* + * 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. + * +*/ + +namespace QuantConnect.Orders +{ + /// + /// Represents the properties of an order in Webull. + /// + public class WebullOrderProperties : OrderProperties + { + /// + /// If set to true, allows the order to trigger or fill outside of regular trading hours + /// (pre-market and after-hours sessions). + /// + /// + /// Applicable to Equity orders only. Extended-hours trading carries additional risks, + /// including lower liquidity and wider bid/ask spreads. + /// + public bool OutsideRegularTradingHours { get; set; } + } +} diff --git a/Launcher/config.json b/Launcher/config.json index 659c302bbd7e..73ce69b12598 100644 --- a/Launcher/config.json +++ b/Launcher/config.json @@ -255,6 +255,13 @@ "charles-schwab-authorization-code-from-url": "", "charles-schwab-redirect-url": "", + // Webull configuration + "webull-api-url": "https://api.webull.com", + "webull-trade-grpc-url": "https://events-api.webull.com", + "webull-app-key": "", + "webull-app-secret": "", + "webull-account-id": "", + // Tastytrade configuration "tastytrade-api-url": "", "tastytrade-websocket-url": "", @@ -758,6 +765,21 @@ "history-provider": [ "BrokerageHistoryProvider", "SubscriptionDataReaderHistoryProvider" ] }, + // defines the 'live-webull' environment + "live-webull": { + "live-mode": true, + + // real brokerage implementations require the BrokerageTransactionHandler + "live-mode-brokerage": "WebullBrokerage", + "data-queue-handler": [ "WebullBrokerage" ], + "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/TestHelpers.cs b/Tests/Brokerages/TestHelpers.cs index 58396da12747..eeb4a31aa1fa 100644 --- a/Tests/Brokerages/TestHelpers.cs +++ b/Tests/Brokerages/TestHelpers.cs @@ -58,9 +58,12 @@ private static SubscriptionDataConfig CreateConfig(string symbol, string market, break; case SecurityType.Option: - case SecurityType.IndexOption: actualSymbol = Symbols.CreateOptionSymbol(symbol, OptionRight.Call, 1000, new DateTime(2020, 3, 26)); break; + case SecurityType.IndexOption: + var index = Symbols.CreateIndexSymbol(symbol); + actualSymbol = Symbol.CreateOption(index, index.ID.Market, SecurityType.IndexOption.DefaultOptionStyle(), OptionRight.Call, 6500m, new(2026, 04, 13)); + break; default: actualSymbol = Symbol.Create(symbol, securityType, market); diff --git a/Tests/Common/Brokerages/WebullBrokerageModelTests.cs b/Tests/Common/Brokerages/WebullBrokerageModelTests.cs new file mode 100644 index 000000000000..ec9d708b23b0 --- /dev/null +++ b/Tests/Common/Brokerages/WebullBrokerageModelTests.cs @@ -0,0 +1,309 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using NUnit.Framework; +using QuantConnect.Orders; +using QuantConnect.Brokerages; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; +using QuantConnect.Tests.Brokerages; + +namespace QuantConnect.Tests.Common.Brokerages +{ + [TestFixture] + public class WebullBrokerageModelTests + { + private readonly WebullBrokerageModel _brokerageModel = new WebullBrokerageModel(); + + // Equity: all five order types supported + [TestCase(SecurityType.Equity, OrderType.Market)] + [TestCase(SecurityType.Equity, OrderType.Limit)] + [TestCase(SecurityType.Equity, OrderType.StopMarket)] + [TestCase(SecurityType.Equity, OrderType.StopLimit)] + [TestCase(SecurityType.Equity, OrderType.TrailingStop)] + // Option: Market and TrailingStop are not supported + [TestCase(SecurityType.Option, OrderType.Limit)] + [TestCase(SecurityType.Option, OrderType.StopMarket)] + [TestCase(SecurityType.Option, OrderType.StopLimit)] + // IndexOption: same restrictions as Option + [TestCase(SecurityType.IndexOption, OrderType.Limit)] + [TestCase(SecurityType.IndexOption, OrderType.StopMarket)] + [TestCase(SecurityType.IndexOption, OrderType.StopLimit)] + public void CanSubmitOrderValidSecurityAndOrderTypeReturnsTrue(SecurityType securityType, OrderType orderType) + { + // Arrange + var security = GetSecurityForType(securityType); + var order = CreateOrder(orderType, security.Symbol); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.True); + Assert.That(message, Is.Null); + } + + [TestCase(SecurityType.Forex)] + [TestCase(SecurityType.Cfd)] + [TestCase(SecurityType.Future)] + [TestCase(SecurityType.Crypto)] + public void CanSubmitOrderUnsupportedSecurityTypeReturnsFalse(SecurityType securityType) + { + // Arrange + var security = GetSecurityForType(securityType); + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + } + + // Equity does not support exchange-session orders or combo orders + [TestCase(SecurityType.Equity, OrderType.MarketOnClose)] + [TestCase(SecurityType.Equity, OrderType.MarketOnOpen)] + [TestCase(SecurityType.Equity, OrderType.ComboMarket)] + // Option does not support TrailingStop + [TestCase(SecurityType.Option, OrderType.TrailingStop)] + // IndexOption has the same restrictions as Option + [TestCase(SecurityType.IndexOption, OrderType.TrailingStop)] + public void CanSubmitOrder_UnsupportedOrderTypeForSecurityType_ReturnsFalse( + SecurityType securityType, OrderType orderType) + { + // Arrange + var security = GetSecurityForType(securityType); + var order = CreateOrder(orderType, security.Symbol); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + } + + // ── CanSubmitOrder — Option/IndexOption TimeInForce restrictions ──────── + // https://developer.webull.com/apis/docs/trade-api/options#time-in-force + // Sell → Day only | Buy → GoodTilCanceled only + + [TestCase(SecurityType.Option, OrderDirection.Sell)] // Sell + Day + [TestCase(SecurityType.Option, OrderDirection.Buy)] // Buy + GTC + [TestCase(SecurityType.IndexOption, OrderDirection.Sell)] + [TestCase(SecurityType.IndexOption, OrderDirection.Buy)] + public void CanSubmitOrderOptionOrderWithValidTimeInForceReturnsTrue(SecurityType securityType, OrderDirection direction) + { + // Arrange + var security = GetSecurityForType(securityType); + var tif = direction == OrderDirection.Sell + ? TimeInForce.Day + : TimeInForce.GoodTilCanceled; + var order = CreateLimitOrder(security.Symbol, direction, tif); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.True); + Assert.That(message, Is.Null); + } + + [TestCase(SecurityType.Option, OrderDirection.Sell)] // Sell + GTC → rejected + [TestCase(SecurityType.IndexOption, OrderDirection.Sell)] + public void CanSubmitOrderOptionOrderWithInvalidTimeInForceReturnsFalse( + SecurityType securityType, OrderDirection direction) + { + // Arrange + var security = GetSecurityForType(securityType); + // Deliberately use the wrong TIF for the direction + var tif = direction == OrderDirection.Sell + ? TimeInForce.GoodTilCanceled + : TimeInForce.Day; + var order = CreateLimitOrder(security.Symbol, direction, tif); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + Assert.That(message.Message, Does.Contain(tif.GetType().Name)); + Assert.That(message.Message, Does.Contain(security.Type.ToString())); + } + + [TestCase(SecurityType.Equity, nameof(TimeInForce.Day))] + [TestCase(SecurityType.Equity, nameof(TimeInForce.GoodTilCanceled))] + [TestCase(SecurityType.Option, nameof(TimeInForce.Day))] + [TestCase(SecurityType.Option, nameof(TimeInForce.GoodTilCanceled))] + public void CanSubmitOrderMarketOrderTimeInForceValidation(SecurityType securityType, string timeInForce) + { + // Arrange + var security = GetSecurityForType(securityType); + var isDayTimeInForce = timeInForce.Equals(nameof(TimeInForce.Day), StringComparison.OrdinalIgnoreCase); + + var marketOrder = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow, properties: new OrderProperties + { + TimeInForce = isDayTimeInForce ? TimeInForce.Day : TimeInForce.GoodTilCanceled + }); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, marketOrder, out var message); + + // Assert + Assert.That(message, Is.Null); + Assert.That(canSubmit, Is.True); + } + + // ── CanSubmitOrder — OutsideRegularTradingHours ────────────────────────── + // https://developer.webull.com/apis/docs/trade-api — Applicable to U.S. stock market orders only. + + [Test] + public void CanSubmitOrderOutsideRegularTradingHoursOnEquityReturnsTrue() + { + // Arrange + var security = GetSecurityForType(SecurityType.Equity); + var properties = new WebullOrderProperties { OutsideRegularTradingHours = true }; + var order = new LimitOrder(security.Symbol, 1m, 100m, DateTime.UtcNow, properties: properties); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.True); + Assert.That(message, Is.Null); + } + + [Test] + public void CanSubmitOrderMarketOrderOutsideRegularTradingHoursOnEquityReturnsFalse() + { + // Arrange + var security = GetSecurityForType(SecurityType.Equity); + var properties = new WebullOrderProperties { OutsideRegularTradingHours = true }; + var order = new MarketOrder(security.Symbol, 1m, DateTime.UtcNow, properties: properties); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + Assert.That(message.Message, Does.Contain("Market")); + Assert.That(message.Message, Does.Contain("regular trading hours")); + } + + [TestCase(SecurityType.Option)] + [TestCase(SecurityType.IndexOption)] + public void CanSubmitOrderOutsideRegularTradingHoursOnNonEquityReturnsFalse(SecurityType securityType) + { + // Arrange + var security = GetSecurityForType(securityType); + var properties = new WebullOrderProperties { OutsideRegularTradingHours = true }; + var order = new LimitOrder(security.Symbol, 1m, 100m, DateTime.UtcNow, properties: properties); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.False); + Assert.That(message, Is.Not.Null); + Assert.That(message.Message, Does.Contain(nameof(WebullOrderProperties.OutsideRegularTradingHours))); + Assert.That(message.Message, Does.Contain(securityType.ToString())); + } + + [TestCase(SecurityType.Option)] + [TestCase(SecurityType.IndexOption)] + public void CanSubmitOrderOutsideRegularTradingHoursFalseOnNonEquityReturnsTrue(SecurityType securityType) + { + // Arrange + var security = GetSecurityForType(securityType); + var properties = new WebullOrderProperties { OutsideRegularTradingHours = false }; + var order = new LimitOrder(security.Symbol, 1m, 100m, DateTime.UtcNow, properties: properties); + + // Act + var canSubmit = _brokerageModel.CanSubmitOrder(security, order, out var message); + + // Assert + Assert.That(canSubmit, Is.True); + Assert.That(message, Is.Null); + } + + [Test] + public void GetFeeModelReturnsWebullFeeModel() + { + // Arrange + var security = TestsHelpers.GetSecurity(securityType: SecurityType.Equity, symbol: "AAPL", market: Market.USA); + + // Act / Assert + Assert.That(_brokerageModel.GetFeeModel(security), Is.InstanceOf()); + } + + private static Security GetSecurityForType(SecurityType securityType) + { + switch (securityType) + { + case SecurityType.Future: + return TestsHelpers.GetSecurity(securityType: SecurityType.Future, + symbol: Futures.Indices.SP500EMini, market: Market.CME); + case SecurityType.Crypto: + return TestsHelpers.GetSecurity(securityType: SecurityType.Crypto, + symbol: "BTCUSD", market: Market.Coinbase); + case SecurityType.Forex: + case SecurityType.Cfd: + return TestsHelpers.GetSecurity(securityType: securityType, + symbol: "EURUSD", market: Market.Oanda); + case SecurityType.IndexOption: + return TestsHelpers.GetSecurity(securityType: SecurityType.IndexOption, + symbol: "SPX", market: Market.CBOE); + default: + return TestsHelpers.GetSecurity(securityType: securityType, + symbol: "AAPL", market: Market.USA); + } + } + + private static LimitOrder CreateLimitOrder(Symbol symbol, OrderDirection direction, TimeInForce timeInForce) + { + var quantity = direction == OrderDirection.Buy ? 1m : -1m; + var properties = new OrderProperties { TimeInForce = timeInForce }; + return new LimitOrder(symbol, quantity, 100m, DateTime.UtcNow, properties: properties); + } + + private static Order CreateOrder(OrderType orderType, Symbol symbol) + { + switch (orderType) + { + case OrderType.Market: + return new MarketOrder(symbol, 1m, DateTime.UtcNow); + 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); + case OrderType.ComboMarket: + return new ComboMarketOrder(symbol, 1m, DateTime.UtcNow, new GroupOrderManager(1, 1, 1m)); + default: + throw new ArgumentOutOfRangeException(nameof(orderType), orderType, null); + } + } + } +} diff --git a/Tests/Common/Orders/Fees/WebullFeeModelTests.cs b/Tests/Common/Orders/Fees/WebullFeeModelTests.cs new file mode 100644 index 000000000000..d7dc1d4d2bec --- /dev/null +++ b/Tests/Common/Orders/Fees/WebullFeeModelTests.cs @@ -0,0 +1,170 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Orders; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; +using QuantConnect.Tests.Common.Securities; + +namespace QuantConnect.Tests.Common.Orders.Fees +{ + [TestFixture] + public class WebullFeeModelTests + { + private readonly WebullFeeModel _feeModel = new WebullFeeModel(); + + private static IEnumerable ZeroFeeSecurities() + { + var equity = SecurityTests.GetSecurity(); + equity.SetMarketPrice(new Tick(DateTime.UtcNow, equity.Symbol, 100m, 100m)); + yield return equity; + yield return CreateSecurity(SecurityType.Option, 5m, "AAPL"); + } + + /// + /// Equity and non-index options are commission-free on Webull. + /// + [TestCaseSource(nameof(ZeroFeeSecurities))] + public void GetOrderFeeReturnsZeroForFreeAssets(Security security) + { + var order = new MarketOrder(security.Symbol, 10m, DateTime.UtcNow); + + var fee = _feeModel.GetOrderFee(new OrderFeeParameters(security, order)); + + Assert.That(fee.Value.Amount, Is.EqualTo(0m)); + } + + /// + /// SPX/SPXW exchange fee tiers (per contract) + Webull $0.50/contract: + /// SPX price < $1 -> $0.57 + $0.50 = $1.07 + /// SPX price >= $1 -> $0.66 + $0.50 = $1.16 + /// SPXW price < $1 -> $0.50 + $0.50 = $1.00 + /// SPXW price >= $1 -> $0.59 + $0.50 = $1.09 + /// + [TestCase("SPX", 0.50, 2, 2.14, Description = "SPX price < $1 -> $1.07/contract")] + [TestCase("SPX", 1.50, 3, 3.48, Description = "SPX price >= $1 -> $1.16/contract")] + [TestCase("SPXW", 0.80, 1, 1.00, Description = "SPXW price < $1 -> $1.00/contract")] + [TestCase("SPXW", 2.00, 4, 4.36, Description = "SPXW price >= $1 -> $1.09/contract")] + public void GetOrderFeeSpxPriceTierReturnsCorrectFee(string ticker, decimal price, decimal quantity, decimal expectedFee) + { + var security = CreateSecurity(SecurityType.IndexOption, price, ticker); + 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)); + } + + /// + /// VIX exchange fee tiers (per contract) + Webull $0.50/contract: + /// Tier 1: price ≤ $0.10 -> $0.10 + $0.50 = $0.60 + /// Tier 2: price $0.11–$0.99 -> $0.25 + $0.50 = $0.75 + /// Tier 3: price $1.00–$1.99 -> $0.40 + $0.50 = $0.90 + /// Tier 4: price >= $2.00 -> $0.45 + $0.50 = $0.95 + /// + [TestCase(0.05, 4, 2.40, Description = "Tier 1: price ≤ $0.10 -> $0.60/contract")] + [TestCase(0.50, 2, 1.50, Description = "Tier 2: price $0.11–$0.99 -> $0.75/contract")] + [TestCase(1.50, 1, 0.90, Description = "Tier 3: price $1.00–$1.99 -> $0.90/contract")] + [TestCase(3.00, 5, 4.75, Description = "Tier 4: price >= $2.00 -> $0.95/contract")] + public void GetOrderFeeVixPriceTierReturnsCorrectFee(decimal price, decimal quantity, decimal expectedFee) + { + var security = CreateSecurity(SecurityType.IndexOption, price, "VIX"); + 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)); + } + + /// + /// VIXW uses identical tier schedule to VIX; verify tier 2. + /// + [Test] + public void GetOrderFeeVixwPriceTier2MatchesVixFee() + { + var vix = CreateSecurity(SecurityType.IndexOption, 0.50m, "VIX"); + var vixw = CreateSecurity(SecurityType.IndexOption, 0.50m, "VIXW"); + var order = new MarketOrder(vix.Symbol, 2m, DateTime.UtcNow); + + var vixFee = _feeModel.GetOrderFee(new OrderFeeParameters(vix, order)); + var vixwFee = _feeModel.GetOrderFee(new OrderFeeParameters(vixw, new MarketOrder(vixw.Symbol, 2m, DateTime.UtcNow))); + + Assert.That(vixFee.Value.Amount, Is.EqualTo(vixwFee.Value.Amount)); + } + + /// + /// Index option fee schedule (per contract) + Webull $0.50/contract: + /// XSP qty < 10 -> $0.00 + $0.50 = $0.50 + /// XSP qty >= 10 -> $0.07 + $0.50 = $0.57 + /// DJX flat -> $0.18 + $0.50 = $0.68 + /// NDX price < $25 -> $0.50 + $0.50 = $1.00 (NDXP shares the same schedule) + /// NDX price >= $25 -> $0.75 + $0.50 = $1.25 (NDXP shares the same schedule) + /// + [TestCase("XSP", 1.00, 5, 2.50, Description = "XSP qty < 10 -> $0.50/contract")] + [TestCase("XSP", 1.00, 10, 5.70, Description = "XSP qty >= 10 -> $0.57/contract")] + [TestCase("DJX", 2.00, 2, 1.36, Description = "DJX flat -> $0.68/contract")] + [TestCase("NDX", 10.00, 3, 3.00, Description = "NDX price < $25 -> $1.00/contract")] + [TestCase("NDX", 50.00, 2, 2.50, Description = "NDX price >= $25 -> $1.25/contract")] + [TestCase("NDXP", 10.00, 3, 3.00, Description = "NDXP price < $25 matches NDX schedule")] + [TestCase("NDXP", 50.00, 2, 2.50, Description = "NDXP price >= $25 matches NDX schedule")] + public void GetOrderFeeIndexOptionReturnsCorrectFee(string ticker, decimal price, decimal quantity, decimal expectedFee) + { + var security = CreateSecurity(SecurityType.IndexOption, price, ticker); + 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)); + } + + /// + /// Creates a test security of the requested priced at . + /// Supported types: , . + /// identifies the underlying symbol. + /// + private static Security CreateSecurity(SecurityType securityType, decimal price, string ticker = "AAPL") + { + var isIndex = securityType == SecurityType.IndexOption; + var underlying = Symbol.Create(ticker, isIndex ? SecurityType.Index : SecurityType.Equity, Market.USA); + var symbol = Symbol.CreateOption( + underlying, Market.USA, + isIndex ? OptionStyle.European : OptionStyle.American, + OptionRight.Call, + isIndex ? 1000m : 150m, + new DateTime(2026, 6, 20)); + + var config = new SubscriptionDataConfig( + typeof(TradeBar), symbol, Resolution.Minute, + TimeZones.Utc, TimeZones.Utc, false, true, false); + + var security = new Security( + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache()); + + security.SetMarketPrice(new Tick(DateTime.UtcNow, symbol, price, price)); + return security; + } + } +}