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