Files
2026-07-13 13:02:50 +08:00

310 lines
14 KiB
C#

/*
* 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<WebullFeeModel>());
}
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);
}
}
}
}