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

346 lines
16 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 System.Collections.Generic;
using System.Linq;
using System.Threading;
using NUnit.Framework;
using QuantConnect.Orders;
using QuantConnect.Brokerages;
using QuantConnect.Orders.Fees;
using QuantConnect.Tests.Brokerages.Models;
using QuantConnect.Brokerages.Paper;
using QuantConnect.Data;
using QuantConnect.Data.Market;
using QuantConnect.Data.UniverseSelection;
using QuantConnect.Interfaces;
using QuantConnect.Lean.Engine;
using QuantConnect.Lean.Engine.DataFeeds;
using QuantConnect.Lean.Engine.RealTime;
using QuantConnect.Lean.Engine.Results;
using QuantConnect.Lean.Engine.TransactionHandlers;
using QuantConnect.Messaging;
using QuantConnect.Packets;
using QuantConnect.Securities;
using QuantConnect.Tests.Engine;
using QuantConnect.Tests.Engine.DataFeeds;
using QuantConnect.Util;
using QuantConnect.Brokerages.Backtesting;
namespace QuantConnect.Tests.Brokerages.Paper
{
[TestFixture]
public class PaperBrokerageTests
{
[Test]
public void AppliesDividendDistributionDirectlyToPortfolioCashBook()
{
// init algorithm
var algorithm = new AlgorithmStub(new MockDataFeed());
algorithm.AddSecurities(equities: new List<string> { "SPY" });
algorithm.PostInitialize();
// init holdings
var SPY = algorithm.Securities[Symbols.SPY];
SPY.SetMarketPrice(new Tick { Value = 100m });
SPY.Holdings.SetHoldings(100m, 1000);
// resolve expected outcome
var USD = algorithm.Portfolio.CashBook[Currencies.USD];
var preDistributionCash = USD.Amount;
var distributionPerShare = 10m;
var expectedTotalDistribution = distributionPerShare * SPY.Holdings.Quantity;
// create slice w/ dividend
var slice = new Slice(algorithm.Time, new List<BaseData>(), algorithm.Time);
slice.Dividends.Add(new Dividend(Symbols.SPY, algorithm.Time, distributionPerShare, 100m));
algorithm.SetCurrentSlice(slice);
// invoke brokerage
using var brokerage = new PaperBrokerage(algorithm, null);
brokerage.Scan();
// verify results
var postDistributionCash = USD.Amount;
Assert.AreEqual(preDistributionCash + expectedTotalDistribution, postDistributionCash);
}
[Test]
public void AppliesDividendsOnce()
{
// init algorithm
var algorithm = new AlgorithmStub(new MockDataFeed());
algorithm.SetLiveMode(true);
var dividend = new Dividend(Symbols.SPY, DateTime.UtcNow, 10m, 100m);
var feed = new MockDataFeed();
var marketHoursDatabase = MarketHoursDatabase.FromDataFolder();
var symbolPropertiesDataBase = SymbolPropertiesDatabase.FromDataFolder();
var dataPermissionManager = new DataPermissionManager();
var dataManager = new DataManager(feed,
new UniverseSelection(
algorithm,
new SecurityService(algorithm.Portfolio.CashBook, marketHoursDatabase, symbolPropertiesDataBase, algorithm, RegisteredSecurityDataTypesProvider.Null, new SecurityCacheProvider(algorithm.Portfolio), algorithm: algorithm),
dataPermissionManager,
TestGlobals.DataProvider),
algorithm,
algorithm.TimeKeeper,
marketHoursDatabase,
true,
RegisteredSecurityDataTypesProvider.Null,
dataPermissionManager);
var synchronizer = new NullSynchronizer(algorithm, dividend);
algorithm.SubscriptionManager.SetDataManager(dataManager);
algorithm.AddSecurities(equities: new List<string> { "SPY" });
algorithm.Securities[Symbols.SPY].Holdings.SetHoldings(100m, 1);
algorithm.PostInitialize();
var initializedCash = algorithm.Portfolio.CashBook[Currencies.USD].Amount;
// init algorithm manager
var manager = new AlgorithmManager(true);
var job = new LiveNodePacket
{
UserId = 1,
ProjectId = 2,
DeployId = $"{nameof(PaperBrokerageTests)}.{nameof(AppliesDividendsOnce)}"
};
var results = new LiveTradingResultHandler();
var transactions = new BacktestingTransactionHandler();
using var brokerage = new PaperBrokerage(algorithm, job);
// initialize results and transactions
using var eventMessagingHandler = new EventMessagingHandler();
using var api = new Api.Api();
results.Initialize(new(job, eventMessagingHandler, api, transactions, null));
results.SetAlgorithm(algorithm, algorithm.Portfolio.TotalPortfolioValue);
transactions.Initialize(algorithm, brokerage, results);
var realTime = new BacktestingRealTimeHandler();
try
{
using var nullLeanManager = new AlgorithmManagerTests.NullLeanManager();
using var tokenSource = new CancellationTokenSource();
// run algorithm manager
manager.Run(job,
algorithm,
synchronizer,
transactions,
results,
realTime,
nullLeanManager,
tokenSource,
new()
);
var postDividendCash = algorithm.Portfolio.CashBook[Currencies.USD].Amount;
Assert.AreEqual(initializedCash + dividend.Distribution, postDividendCash);
}
finally
{
realTime.Exit();
results.Exit();
transactions.Exit();
}
}
[Test]
public void PredictableCashSettlement()
{
var symbol = Symbols.SPY;
var securityPrice = 550m;
var initialCashBalance = 100_000m;
var defaultSettlementTime = Securities.Equity.Equity.DefaultSettlementTime;
// Time at which cash sync is typically performed, based on system log (TRACE:: Brokerage.PerformCashSync())
var performCashSyncTimeSpan = new TimeSpan(11, 45, 0);
var feed = new MockDataFeed();
var algorithm = new AlgorithmStub(feed);
algorithm.SubscriptionManager.SetDataManager(new DataManagerStub(algorithm, new MockDataFeed()));
// Initialize()
algorithm.SetStartDate(2025, 03, 30);
algorithm.SetEndDate(2025, 04, 02);
algorithm.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash);
var security = algorithm.AddSecurity(symbol.ID.SecurityType, symbol.ID.Symbol);
algorithm.PostInitialize();
// Update Security Price like AlgorithmManager
security.Update([new Tick(algorithm.Time, symbol, string.Empty, string.Empty, 10m, securityPrice)], typeof(TradeBar));
var portfolio = algorithm.Portfolio;
using var brokerage = new PaperBrokerageWithManualCashBalance(algorithm, new LiveNodePacket(), initialCashBalance: initialCashBalance);
// Sync initial cash state with the brokerage
brokerage.PerformCashSync(algorithm, algorithm.Time, () => TimeSpan.Zero);
// Market SPY 10
var buyQuantity = 10;
portfolio.ProcessFills([new OrderEvent(new MarketOrder(symbol, buyQuantity, algorithm.Time), algorithm.Time, OrderFee.Zero)
{ FillPrice = security.Price, FillQuantity = buyQuantity }]);
var totalMarginUserAfterBuy = portfolio.TotalMarginUsed;
var marginRemainingAfterBuy = portfolio.MarginRemaining;
// Manually decrease the brokerage cash balance to simulate the cash outflow
brokerage.DecreaseCashBalance(buyQuantity * security.Price);
brokerage.PerformCashSync(algorithm, algorithm.Time, () => TimeSpan.Zero);
// Advance to the next day to simulate settlement
var timeUtc = algorithm.Time.AddDays(1).ConvertToUtc(algorithm.TimeZone);
algorithm.SetDateTime(timeUtc);
portfolio.Securities[symbol].SettlementModel.Scan(new ScanSettlementModelParameters(portfolio, security, timeUtc));
brokerage.PerformCashSync(algorithm, timeUtc, () => TimeSpan.Zero);
// Validate: After syncing cash and waiting for settlement, portfolio state should be correct
Assert.AreEqual(portfolio.TotalMarginUsed, totalMarginUserAfterBuy);
Assert.AreEqual(portfolio.MarginRemaining, marginRemainingAfterBuy);
Assert.AreEqual(portfolio.UnsettledCash, 0m);
// Market SPY -10
var sellQuantity = -10;
portfolio.ProcessFills([new OrderEvent(new MarketOrder(symbol, sellQuantity, algorithm.Time), algorithm.Time, OrderFee.Zero)
{ FillPrice = security.Price, FillQuantity = sellQuantity }]);
// Simulate brokerage immediately crediting the cash from the sell, before Lean's internal settlement
brokerage.IncreaseCashBalance(Math.Abs(sellQuantity) * security.Price);
// Move to just before the settlement time (T+1 - 1 minute)
timeUtc = algorithm.Time.Add(defaultSettlementTime.Subtract(Time.OneMinute)).ConvertToUtc(algorithm.TimeZone);
algorithm.SetDateTime(timeUtc);
// At this point, brokerage has credited the cash, but Lean still considers it unsettled
Assert.Greater(portfolio.UnsettledCash, 0m);
// Advance 1 minute to reach full settlement time (T+1)
timeUtc = algorithm.Time.Add(Time.OneMinute).ConvertToUtc(algorithm.TimeZone);
algorithm.SetDateTime(timeUtc);
if (algorithm.Time.ConvertToUtc(algorithm.TimeZone).TimeOfDay < performCashSyncTimeSpan)
{
// Lean clears the unsettled cash to available balance
portfolio.Securities[symbol].SettlementModel.Scan(new ScanSettlementModelParameters(portfolio, security, timeUtc));
}
brokerage.PerformCashSync(algorithm, timeUtc, () => TimeSpan.Zero);
Assert.AreEqual(0m, portfolio.UnsettledCash);
// Brokerage UnsettledCash + Lean UnsettledCash
Assert.AreEqual(portfolio.TotalPortfolioValue, initialCashBalance);
var orderRequestMarginRemaining = portfolio.TotalMarginUsed * 2 + portfolio.MarginRemaining;
Assert.AreEqual(portfolio.TotalPortfolioValue, orderRequestMarginRemaining);
}
[Test]
public void PerformCashSyncDoesNotThrowWithKnownOrUnknownCurrencies()
{
var algorithm = new AlgorithmStub(new MockDataFeed());
var dataManager = new DataManagerStub(algorithm, new MockDataFeed());
algorithm.SubscriptionManager.SetDataManager(dataManager);
algorithm.AddCryptoEntry("BNFCRUSD", Market.Binance);
algorithm.SetStartDate(2025, 03, 30);
algorithm.SetEndDate(2025, 04, 02);
// Set the initial cash for the custom stablecoin "BNFCR"
algorithm.SetCash("BNFCR", 2000);
// Unknown currency without conversion
algorithm.SetCash("TEST", 5000);
algorithm.SetBrokerageModel(BrokerageName.Binance, AccountType.Cash);
algorithm.AddSecurity(SecurityType.Crypto, "BNFCRUSD", Resolution.Minute, Market.Binance, false, 1, false);
algorithm.PostInitialize();
// Ensure the cash book creates required data feeds (e.g., conversion rates)
algorithm.Portfolio.CashBook.EnsureCurrencyDataFeeds(algorithm.Securities, algorithm.SubscriptionManager, algorithm.BrokerageModel.DefaultMarkets, SecurityChanges.None, dataManager.SecurityService);
// Assert conversion rate is set to 1 for known stablecoin
Assert.AreEqual(1, algorithm.Portfolio.CashBook["BNFCR"].ConversionRate);
// Assert conversion rate is zero for unknown currency
Assert.AreEqual(0, algorithm.Portfolio.CashBook["TEST"].ConversionRate);
using var brokerage = new TestBrokerage(algorithm);
// Should not throw even if some currencies have no conversion pairs
Assert.DoesNotThrow(() => brokerage.PerformCashSync(algorithm, algorithm.Time, () => TimeSpan.Zero));
}
[Test]
public void PerformCashSyncDoesNotAddZeroQuantityCurrenciesExceptAccountCurrency()
{
var algorithm = new AlgorithmStub(new MockDataFeed());
algorithm.SetAccountCurrency("USD");
algorithm.SetCash(10000);
using var brokerage = new TestBrokerage(algorithm);
var currentTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var syncPerformed = brokerage.PerformCashSync(algorithm, currentTime, () => TimeSpan.FromMinutes(5));
Assert.IsTrue(syncPerformed);
Assert.IsTrue(algorithm.Portfolio.CashBook.ContainsKey("USD"));
Assert.IsTrue(algorithm.Portfolio.CashBook.ContainsKey("BNFCR"));
Assert.IsTrue(algorithm.Portfolio.CashBook.ContainsKey("TEST"));
Assert.IsFalse(algorithm.Portfolio.CashBook.ContainsKey("EUR"));
}
internal class TestBrokerage : BacktestingBrokerage
{
public TestBrokerage(IAlgorithm algorithm) : base(algorithm, "Test")
{
}
public override List<CashAmount> GetCashBalance()
{
return new List<CashAmount> { new CashAmount(0, Currencies.USD), new CashAmount(200, "BNFCR"), new CashAmount(300, "TEST"), new CashAmount(0, "EUR") };
}
}
class NullSynchronizer : ISynchronizer
{
private readonly IAlgorithm _algorithm;
private readonly Dividend _dividend;
private readonly Symbol _symbol;
private readonly TimeSliceFactory _timeSliceFactory;
public NullSynchronizer(IAlgorithm algorithm, Dividend dividend)
{
_algorithm = algorithm;
_dividend = dividend;
_symbol = dividend.Symbol;
_timeSliceFactory = new TimeSliceFactory(TimeZones.NewYork);
}
public IEnumerable<TimeSlice> StreamData(CancellationToken cancellationToken)
{
var dataFeedPacket = new DataFeedPacket(_algorithm.Securities[_symbol],
_algorithm.SubscriptionManager.Subscriptions.First(s => s.Symbol == _symbol),
new List<BaseData> { _dividend }, Ref.CreateReadOnly(() => false));
yield return _timeSliceFactory.Create(DateTime.UtcNow,
new List<DataFeedPacket> { dataFeedPacket },
SecurityChanges.None,
new Dictionary<Universe, BaseDataCollection>()
);
}
}
}
}