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

361 lines
15 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 NodaTime;
using NUnit.Framework;
using QuantConnect.Data;
using QuantConnect.Data.Market;
using QuantConnect.Lean.Engine.DataFeeds;
using QuantConnect.Securities;
using QuantConnect.Util;
namespace QuantConnect.Tests.Engine.DataFeeds
{
[TestFixture]
public class DateChangeTimeKeeperTests
{
private static DateTime _start = new DateTime(2024, 10, 01);
private static DateTime _end = new DateTime(2024, 10, 11);
private static TestCaseData[] TimeZonesTestCases => new TestCaseData[]
{
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(-1))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(-2))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(-3))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(-4))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(1))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(2))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(3))),
new(TimeZones.Utc, DateTimeZone.ForOffset(Offset.FromHours(4))),
new(TimeZones.Utc, TimeZones.Utc),
new(TimeZones.NewYork, TimeZones.NewYork),
new(TimeZones.HongKong, TimeZones.HongKong),
};
[TestCaseSource(nameof(TimeZonesTestCases))]
public void EmitsFirstExchangeDateEvent(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
using var timeKeeper = GetTimeKeeper(dataTimeZone, exchangeTimeZone, true, null, out var tradableDates, out var config, out var exchangeHours);
var emittedExchangeDates = new List<DateTime>();
void HandleNewTradableDate(object sender, DateTime date)
{
emittedExchangeDates.Add(date);
}
timeKeeper.NewExchangeDate += HandleNewTradableDate;
var firstDataDate = tradableDates[0];
var firstDataDateInExchangeTimeZone = firstDataDate.ConvertTo(dataTimeZone, exchangeTimeZone);
var firstExchangeDateIsBeforeFirstDataDate = firstDataDateInExchangeTimeZone < firstDataDate;
try
{
Assert.IsTrue(timeKeeper.TryAdvanceUntilNextDataDate());
Assert.AreEqual(1, emittedExchangeDates.Count);
if (firstExchangeDateIsBeforeFirstDataDate)
{
// The first exchange date is the day before the first data date when the exchange is behind the data
var expectedFirstExchangeDate = firstDataDate.AddDays(-1);
Assert.AreEqual(expectedFirstExchangeDate, emittedExchangeDates[0]);
Assert.AreEqual(expectedFirstExchangeDate.ConvertTo(exchangeTimeZone, dataTimeZone), timeKeeper.DataTime);
}
else
{
// If exchange is ahead of the data, even though technically at the first data date the exchange date hasn't changed,
// we emit the first one so that first daily actions are performed (mappings, delistings, etc).
Assert.AreEqual(firstDataDate, emittedExchangeDates[0]);
Assert.AreEqual(firstDataDate, timeKeeper.DataTime);
}
}
finally
{
timeKeeper.NewExchangeDate -= HandleNewTradableDate;
}
}
[TestCaseSource(nameof(TimeZonesTestCases))]
public void ExchangeDatesAreEmittedByAdvancingToNextDataDate(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
using var timeKeeper = GetTimeKeeper(dataTimeZone, exchangeTimeZone, false, null, out var tradableDates, out var config, out var exchangeHours);
var exchangeDateEmitted = false;
var emittedExchangeDates = new List<DateTime>();
void HandleNewTradableDate(object sender, DateTime date)
{
emittedExchangeDates.Add(date);
exchangeDateEmitted = true;
}
timeKeeper.NewExchangeDate += HandleNewTradableDate;
var expectedExchangeDates = new List<DateTime>()
{
new(2024, 10, 1),
new(2024, 10, 2),
new(2024, 10, 3),
new(2024, 10, 4),
new(2024, 10, 7),
new(2024, 10, 8),
new(2024, 10, 9),
new(2024, 10, 10),
new(2024, 10, 11),
};
var firstDataDate = tradableDates[0];
var firstDataDateInExchangeTimeZone = firstDataDate.ConvertTo(dataTimeZone, exchangeTimeZone);
var exchangeIsBehindData = firstDataDateInExchangeTimeZone < firstDataDate;
if (exchangeIsBehindData)
{
expectedExchangeDates.Insert(0, new(2024, 9, 30));
expectedExchangeDates.RemoveAt(expectedExchangeDates.Count - 1);
}
try
{
// Flush first date:
Assert.IsTrue(timeKeeper.TryAdvanceUntilNextDataDate());
Assert.IsTrue(exchangeDateEmitted);
for (var i = 1; i < tradableDates.Count; i++)
{
exchangeDateEmitted = false;
Assert.IsTrue(timeKeeper.TryAdvanceUntilNextDataDate());
if (timeKeeper.DataTime == timeKeeper.ExchangeTime)
{
Assert.AreEqual(tradableDates[i], timeKeeper.DataTime);
Assert.AreEqual(tradableDates[i], timeKeeper.ExchangeTime);
Assert.IsTrue(exchangeDateEmitted);
Assert.AreEqual(tradableDates[i], emittedExchangeDates[^1]);
}
else
{
if (exchangeIsBehindData)
{
Assert.IsFalse(exchangeDateEmitted);
Assert.AreEqual(tradableDates[i - 1], timeKeeper.DataTime);
}
else
{
Assert.IsTrue(exchangeDateEmitted);
Assert.AreEqual(emittedExchangeDates[^1], timeKeeper.ExchangeTime);
}
// Move again to the next data date or next exchange date
exchangeDateEmitted = false;
Assert.IsTrue(timeKeeper.TryAdvanceUntilNextDataDate());
if (exchangeIsBehindData)
{
Assert.IsTrue(exchangeDateEmitted);
Assert.AreEqual(emittedExchangeDates[^1], timeKeeper.ExchangeTime);
}
else
{
Assert.IsFalse(exchangeDateEmitted);
Assert.AreEqual(tradableDates[i], timeKeeper.DataTime);
}
}
}
CollectionAssert.AreEqual(expectedExchangeDates, emittedExchangeDates);
}
finally
{
timeKeeper.NewExchangeDate -= HandleNewTradableDate;
}
}
[TestCaseSource(nameof(TimeZonesTestCases))]
public void ExchangeDatesAreEmittedByAdvancingToNextGivenExchangeTime(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
using var timeKeeper = GetTimeKeeper(dataTimeZone, exchangeTimeZone, false, null, out var tradableDates, out var config, out var exchangeHours);
var exchangeDateEmitted = false;
var emittedExchangeDates = new List<DateTime>();
void HandleNewTradableDate(object sender, DateTime date)
{
emittedExchangeDates.Add(date);
exchangeDateEmitted = true;
}
timeKeeper.NewExchangeDate += HandleNewTradableDate;
var expectedExchangeDates = new List<DateTime>()
{
new(2024, 10, 1),
new(2024, 10, 2),
new(2024, 10, 3),
new(2024, 10, 4),
new(2024, 10, 7),
new(2024, 10, 8),
new(2024, 10, 9),
new(2024, 10, 10),
new(2024, 10, 11),
};
var firstDataDate = tradableDates[0];
var firstDataDateInExchangeTimeZone = firstDataDate.ConvertTo(dataTimeZone, exchangeTimeZone);
var exchangeIsBehindData = firstDataDateInExchangeTimeZone < firstDataDate;
if (exchangeIsBehindData)
{
expectedExchangeDates.Insert(0, new(2024, 9, 30));
expectedExchangeDates.RemoveAt(expectedExchangeDates.Count - 1);
}
try
{
// Flush first date:
Assert.IsTrue(timeKeeper.TryAdvanceUntilNextDataDate());
Assert.IsTrue(exchangeDateEmitted);
// Using multiple step sizes to simulate data coming in with gaps
var steps = new Queue<TimeSpan>();
steps.Enqueue(TimeSpan.FromMinutes(1));
steps.Enqueue(TimeSpan.FromMinutes(5));
steps.Enqueue(TimeSpan.FromMinutes(30));
steps.Enqueue(TimeSpan.FromHours(1));
while (emittedExchangeDates.Count != expectedExchangeDates.Count)
{
exchangeDateEmitted = false;
var currentExchangeTime = timeKeeper.ExchangeTime;
var step = steps.Dequeue();
steps.Enqueue(step);
var nextExchangeTime = currentExchangeTime + step;
timeKeeper.AdvanceTowardsExchangeTime(nextExchangeTime);
if (nextExchangeTime.Date != currentExchangeTime.Date &&
exchangeHours.IsDateOpen(nextExchangeTime.Date, config.ExtendedMarketHours))
{
Assert.IsTrue(exchangeDateEmitted);
Assert.AreEqual(emittedExchangeDates[^1], nextExchangeTime.Date);
Assert.AreEqual(timeKeeper.ExchangeTime, nextExchangeTime.Date);
}
else
{
Assert.IsFalse(exchangeDateEmitted);
Assert.AreEqual(timeKeeper.ExchangeTime, nextExchangeTime);
}
}
CollectionAssert.AreEqual(expectedExchangeDates, emittedExchangeDates);
}
finally
{
timeKeeper.NewExchangeDate -= HandleNewTradableDate;
}
}
[TestCaseSource(nameof(TimeZonesTestCases))]
public void DoesNotAdvancePastDelistingDateWhenAdvancingToNextDataDate(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
var delistingDate = _start.AddDays(7);
using var timeKeeper = GetTimeKeeper(dataTimeZone, exchangeTimeZone, false, delistingDate, out var _, out var _, out var _);
while (timeKeeper.TryAdvanceUntilNextDataDate())
{
Assert.LessOrEqual(timeKeeper.ExchangeTime.Date, delistingDate.AddDays(1));
}
}
[TestCaseSource(nameof(TimeZonesTestCases))]
public void ThrowsIfNotInitialized(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
using var timeKeeper = GetTimeKeeper(dataTimeZone, exchangeTimeZone, false, null, out var _, out var _, out var _);
Assert.Throws<InvalidOperationException>(() => timeKeeper.AdvanceTowardsExchangeTime(timeKeeper.ExchangeTime.AddHours(1)));
}
[TestCaseSource(nameof(TimeZonesTestCases))]
public void FinishesRightAwayIfThereAreNoTradableDates(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
using var timeKeeper = GetNoDatesTimeKeeper(dataTimeZone, exchangeTimeZone);
Assert.IsFalse(timeKeeper.TryAdvanceUntilNextDataDate());
}
private static DateChangeTimeKeeper GetTimeKeeper(DateTimeZone dataTimeZone,
DateTimeZone exchangeTimeZone,
bool exchangeAlwaysOpen,
DateTime? delistingDate,
out List<DateTime> tradableDates,
out SubscriptionDataConfig config,
out SecurityExchangeHours exchangeHours)
{
var symbol = Symbols.SPY;
exchangeHours = GetMarketHours(symbol, exchangeTimeZone, exchangeAlwaysOpen);
config = new SubscriptionDataConfig(typeof(TradeBar),
symbol,
Resolution.Minute,
dataTimeZone,
exchangeTimeZone,
false,
true,
false);
tradableDates = Time.EachTradeableDayInTimeZone(exchangeHours,
_start,
_end,
config.DataTimeZone,
config.ExtendedMarketHours).ToList();
return new DateChangeTimeKeeper(tradableDates, config, exchangeHours, delistingDate ?? symbol.GetDelistingDate());
}
private static DateChangeTimeKeeper GetNoDatesTimeKeeper(DateTimeZone dataTimeZone, DateTimeZone exchangeTimeZone)
{
var symbol = Symbols.SPY;
var exchangeHours = GetMarketHours(symbol, exchangeTimeZone, true);
var config = new SubscriptionDataConfig(typeof(TradeBar),
symbol,
Resolution.Minute,
dataTimeZone,
exchangeTimeZone,
false,
true,
false);
var tradableDates = Enumerable.Empty<DateTime>().ToList();
return new DateChangeTimeKeeper(tradableDates, config, exchangeHours, symbol.GetDelistingDate());
}
private static SecurityExchangeHours GetMarketHours(Symbol symbol, DateTimeZone exchangeTimeZone, bool alwaysOpen)
{
SecurityExchangeHours exchangeHours;
if (alwaysOpen)
{
exchangeHours = SecurityExchangeHours.AlwaysOpen(exchangeTimeZone);
}
else
{
var databaseExchangeHours = MarketHoursDatabase.FromDataFolder().GetExchangeHours(symbol.ID.Market, symbol, symbol.SecurityType);
exchangeHours = new SecurityExchangeHours(exchangeTimeZone,
databaseExchangeHours.Holidays,
databaseExchangeHours.MarketHours.ToDictionary(),
databaseExchangeHours.EarlyCloses,
databaseExchangeHours.LateOpens);
}
return exchangeHours;
}
}
}