From 4ca5cbca7e0965a8ce2a8883ee826861543d945f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ricardo=20Andr=C3=A9s=20Marino=20Rojas?=
<47573394+Marinovsky@users.noreply.github.com>
Date: Fri, 7 Jun 2024 13:48:53 -0500
Subject: [PATCH] Add Yearly Date Rule (#8075)
* First draft of the solution
* Add improvements
* Nit changes
* Address requested changes
---
Common/Scheduling/DateRules.cs | 127 ++++++++++--
Tests/Common/Scheduling/DateRulesTests.cs | 242 ++++++++++++++++++++++
2 files changed, 355 insertions(+), 14 deletions(-)
diff --git a/Common/Scheduling/DateRules.cs b/Common/Scheduling/DateRules.cs
index 5877a155f804..cab348447101 100644
--- a/Common/Scheduling/DateRules.cs
+++ b/Common/Scheduling/DateRules.cs
@@ -129,6 +129,75 @@ public IDateRule EveryDay(Symbol symbol)
return new FuncDateRule($"{symbol.Value}: EveryDay", (start, end) => Time.EachTradeableDay(securitySchedule, start, end));
}
+ ///
+ /// Specifies an event should fire on the first of each year + offset
+ ///
+ /// The amount of days to offset the schedule by; must be between 0 and 365.
+ /// A date rule that fires on the first of each year + offset
+ public IDateRule YearStart(int daysOffset = 0)
+ {
+ return YearStart(null, daysOffset);
+ }
+
+ ///
+ /// Specifies an event should fire on the first tradable date + offset for the specified symbol of each year
+ ///
+ /// The symbol whose exchange is used to determine the first tradable date of the year
+ /// The amount of tradable days to offset the schedule by; must be between 0 and 365
+ /// A date rule that fires on the first tradable date + offset for the
+ /// specified security each year
+ public IDateRule YearStart(Symbol symbol, int daysOffset = 0)
+ {
+ // Check that our offset is allowed
+ if (daysOffset < 0 || 365 < daysOffset)
+ {
+ throw new ArgumentOutOfRangeException(nameof(daysOffset), "DateRules.YearStart() : Offset must be between 0 and 365");
+ }
+
+ SecurityExchangeHours securityExchangeHours = null;
+ if (symbol != null)
+ {
+ securityExchangeHours = GetSecurityExchangeHours(symbol);
+ }
+
+ // Create the new DateRule and return it
+ return new FuncDateRule(GetName(symbol, "YearStart", daysOffset), (start, end) => YearIterator(securityExchangeHours, start, end, daysOffset, true));
+ }
+
+ ///
+ /// Specifies an event should fire on the last of each year
+ ///
+ /// The amount of days to offset the schedule by; must be between 0 and 365
+ /// A date rule that fires on the last of each year - offset
+ public IDateRule YearEnd(int daysOffset = 0)
+ {
+ return YearEnd(null, daysOffset);
+ }
+
+ ///
+ /// Specifies an event should fire on the last tradable date - offset for the specified symbol of each year
+ ///
+ /// The symbol whose exchange is used to determine the last tradable date of the year
+ /// The amount of tradable days to offset the schedule by; must be between 0 and 365.
+ /// A date rule that fires on the last tradable date - offset for the specified security each year
+ public IDateRule YearEnd(Symbol symbol, int daysOffset = 0)
+ {
+ // Check that our offset is allowed
+ if (daysOffset < 0 || 365 < daysOffset)
+ {
+ throw new ArgumentOutOfRangeException(nameof(daysOffset), "DateRules.YearEnd() : Offset must be between 0 and 365");
+ }
+
+ SecurityExchangeHours securityExchangeHours = null;
+ if (symbol != null)
+ {
+ securityExchangeHours = GetSecurityExchangeHours(symbol);
+ }
+
+ // Create the new DateRule and return it
+ return new FuncDateRule(GetName(symbol, "YearEnd", -daysOffset), (start, end) => YearIterator(securityExchangeHours, start, end, daysOffset, false));
+ }
+
///
/// Specifies an event should fire on the first of each month + offset
///
@@ -339,7 +408,16 @@ private static DateTime GetScheduledDay(SecurityExchangeHours securityExchangeHo
return scheduledDate;
}
- private static IEnumerable MonthIterator(SecurityExchangeHours securitySchedule, DateTime start, DateTime end, int offset, bool searchForward)
+ private static IEnumerable BaseIterator(
+ SecurityExchangeHours securitySchedule,
+ DateTime start,
+ DateTime end,
+ int offset,
+ bool searchForward,
+ DateTime periodBegin,
+ DateTime periodEnd,
+ Func baseDateFunc,
+ Func boundaryDateFunc)
{
// No schedule means no security, set to open everyday
if (securitySchedule == null)
@@ -347,21 +425,12 @@ private static IEnumerable MonthIterator(SecurityExchangeHours securit
securitySchedule = SecurityExchangeHours.AlwaysOpen(TimeZones.NewYork);
}
- // Iterate all days between the beginning of "start" month, through end of "end" month.
- // Necessary to ensure we schedule events in the month we start and end.
- var beginningOfStartMonth = new DateTime(start.Year, start.Month, 1);
- var endOfEndMonth = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month));
-
- foreach (var date in Time.EachDay(beginningOfStartMonth, endOfEndMonth))
+ foreach (var date in Time.EachDay(periodBegin, periodEnd))
{
- var daysInMonth = DateTime.DaysInMonth(date.Year, date.Month);
-
- // Searching forward the first of the month is baseDay, with boundary being the last
- // Searching backward the last of the month is baseDay, with boundary being the first
- var baseDate = searchForward? new DateTime(date.Year, date.Month, 1) : new DateTime(date.Year, date.Month, daysInMonth);
- var boundaryDate = searchForward ? new DateTime(date.Year, date.Month, daysInMonth) : new DateTime(date.Year, date.Month, 1);
+ var baseDate = baseDateFunc(date);
+ var boundaryDate = boundaryDateFunc(date);
- // Determine the scheduled day for this month
+ // Determine the scheduled day for this period
if (date == baseDate)
{
var scheduledDay = GetScheduledDay(securitySchedule, baseDate, offset, searchForward, boundaryDate);
@@ -375,6 +444,36 @@ private static IEnumerable MonthIterator(SecurityExchangeHours securit
}
}
+ private static IEnumerable MonthIterator(SecurityExchangeHours securitySchedule, DateTime start, DateTime end, int offset, bool searchForward)
+ {
+ // Iterate all days between the beginning of "start" month, through end of "end" month.
+ // Necessary to ensure we schedule events in the month we start and end.
+ var beginningOfStartMonth = new DateTime(start.Year, start.Month, 1);
+ var endOfEndMonth = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month));
+
+ // Searching forward the first of the month is baseDay, with boundary being the last
+ // Searching backward the last of the month is baseDay, with boundary being the first
+ Func baseDateFunc = date => searchForward ? new DateTime(date.Year, date.Month, 1) : new DateTime(date.Year, date.Month, DateTime.DaysInMonth(date.Year, date.Month));
+ Func boundaryDateFunc = date => searchForward ? new DateTime(date.Year, date.Month, DateTime.DaysInMonth(date.Year, date.Month)) : new DateTime(date.Year, date.Month, 1);
+
+ return BaseIterator(securitySchedule, start, end, offset, searchForward, beginningOfStartMonth, endOfEndMonth, baseDateFunc, boundaryDateFunc);
+ }
+
+ private static IEnumerable YearIterator(SecurityExchangeHours securitySchedule, DateTime start, DateTime end, int offset, bool searchForward)
+ {
+ // Iterate all days between the beginning of "start" year, through end of "end" year
+ // Necessary to ensure we schedule events in the year we start and end.
+ var beginningOfStartOfYear = new DateTime(start.Year, start.Month, 1);
+ var endOfEndYear = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month));
+
+ // Searching forward the first of the year is baseDay, with boundary being the last
+ // Searching backward the last of the year is baseDay, with boundary being the first
+ Func baseDateFunc = date => searchForward ? new DateTime(date.Year, 1, 1) : new DateTime(date.Year, 12, 31);
+ Func boundaryDateFunc = date => searchForward ? new DateTime(date.Year, 12, 31) : new DateTime(date.Year, 1, 1);
+
+ return BaseIterator(securitySchedule, start, end, offset, searchForward, beginningOfStartOfYear, endOfEndYear, baseDateFunc, boundaryDateFunc);
+ }
+
private static IEnumerable WeekIterator(SecurityExchangeHours securitySchedule, DateTime start, DateTime end, int offset, bool searchForward)
{
// Determine the weekly base day and boundary to schedule off of
diff --git a/Tests/Common/Scheduling/DateRulesTests.cs b/Tests/Common/Scheduling/DateRulesTests.cs
index e85658d1dd1b..3e684e5f5783 100644
--- a/Tests/Common/Scheduling/DateRulesTests.cs
+++ b/Tests/Common/Scheduling/DateRulesTests.cs
@@ -313,6 +313,248 @@ public void EndOfMonthWithSymbolWithOffset(Symbols.SymbolsKey symbolKey, int[] e
}
}
+ [Test]
+ public void StartOfYearNoSymbol()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearStart();
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreEqual(1, date.Day);
+ Assert.AreEqual(1, date.Month);
+ }
+
+ Assert.AreEqual(11, count);
+ }
+
+ [Test]
+ public void StartOfYearNoSymbolMidYearStart()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearStart();
+ var dates = rule.GetDates(new DateTime(2000, 06, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreNotEqual(2000, date.Year);
+ Assert.AreEqual(1, date.Month);
+ Assert.AreEqual(1, date.Day);
+ }
+
+ Assert.AreEqual(10, count);
+ }
+
+ [Test]
+ public void StartOfYearNoSymbolWithOffset()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearStart(5);
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreEqual(1, date.Month);
+ Assert.AreEqual(6, date.Day);
+ }
+ Assert.AreEqual(11, count);
+ }
+
+ [TestCase(2)] // Before 11th
+ [TestCase(4)]
+ [TestCase(8)]
+ [TestCase(12)] // After 11th
+ [TestCase(16)]
+ [TestCase(20)]
+ public void StartOfYearSameYearSchedule(int startingDateDay)
+ {
+ var startingDate = new DateTime(2000, 1, startingDateDay);
+ var endingDate = new DateTime(2000, 12, 31);
+
+ var rules = GetDateRules();
+ var rule = rules.YearStart(10); // 11/1/2000
+ var dates = rule.GetDates(startingDate, endingDate);
+
+ Assert.AreEqual(startingDateDay > 11, dates.IsNullOrEmpty());
+
+ if (startingDateDay <= 11)
+ {
+ Assert.AreEqual(new DateTime(2000, 1, 11), dates.Single());
+ }
+ }
+
+ [Test]
+ public void StartOfYearWithSymbol()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearStart(Symbols.SPY);
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreNotEqual(DayOfWeek.Saturday, date.DayOfWeek);
+ Assert.AreNotEqual(DayOfWeek.Sunday, date.DayOfWeek);
+ Assert.IsTrue(date.Day <= 4);
+ Log.Debug(date.Day.ToString(CultureInfo.InvariantCulture));
+ }
+
+ Assert.AreEqual(11, count);
+ }
+
+ [Test]
+ public void StartOfYearWithSymbolMidYearStart()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearStart(Symbols.SPY);
+ var dates = rule.GetDates(new DateTime(2000, 06, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreNotEqual(2000, date.Year);
+ Assert.AreNotEqual(DayOfWeek.Saturday, date.DayOfWeek);
+ Assert.AreNotEqual(DayOfWeek.Sunday, date.DayOfWeek);
+ Assert.AreEqual(1, date.Month);
+ Assert.IsTrue(date.Day <= 4);
+ Log.Debug(date.Day.ToString(CultureInfo.InvariantCulture));
+ }
+
+ Assert.AreEqual(10, count);
+ }
+
+ [TestCase(Symbols.SymbolsKey.SPY, new[] { 10, 9, 9, 9, 9}, 5)]
+ [TestCase(Symbols.SymbolsKey.SPY, new[] { 20, 19, 18, 21, 21}, 12)] // Contains holiday 1/17
+ [TestCase(Symbols.SymbolsKey.SPY, new[] { 29, 31, 31, 31, 31}, 348)] // Always last trading day of the year
+ [TestCase(Symbols.SymbolsKey.BTCUSD, new[] { 6, 6, 6, 6, 6}, 5)]
+ [TestCase(Symbols.SymbolsKey.EURUSD, new[] { 7, 7, 7, 7, 7}, 5)]
+ public void StartOfYearWithSymbolWithOffset(Symbols.SymbolsKey symbolKey, int[] expectedDays, int offset)
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearStart(Symbols.Lookup(symbolKey), offset);
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2004, 12, 31)).ToList();
+
+ // Assert we have as many dates as expected
+ Assert.AreEqual(expectedDays.Length, dates.Count);
+
+ // Verify the days match up
+ var datesAndExpectedDays = dates.Zip(expectedDays, (date, expectedDay) => new { date, expectedDay });
+ foreach (var pair in datesAndExpectedDays)
+ {
+ Assert.AreEqual(pair.expectedDay, pair.date.Day);
+ }
+ }
+
+ [Test]
+ public void EndOfYearNoSymbol()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearEnd();
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreEqual(12, date.Month);
+ Assert.AreEqual(DateTime.DaysInMonth(date.Year, date.Month), date.Day);
+ }
+
+ Assert.AreEqual(11, count);
+ }
+
+ [Test]
+ public void EndOfYearNoSymbolWithOffset()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearEnd(5);
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreEqual(12, date.Month);
+ Assert.AreEqual(DateTime.DaysInMonth(date.Year, date.Month) - 5, date.Day);
+ }
+ Assert.AreEqual(11, count);
+ }
+
+ [TestCase(5)] // Before 21th
+ [TestCase(10)]
+ [TestCase(15)]
+ [TestCase(21)] // After 21th
+ [TestCase(25)]
+ [TestCase(30)]
+ public void EndOfYearSameMonthSchedule(int endingDateDay)
+ {
+ var startingDate = new DateTime(2000, 1, 1);
+ var endingDate = new DateTime(2000, 12, endingDateDay);
+
+ var rules = GetDateRules();
+ var rule = rules.YearEnd(10); // 12/21/2000
+ var dates = rule.GetDates(startingDate, endingDate);
+
+ Assert.AreEqual(endingDateDay < 21, dates.IsNullOrEmpty());
+
+ if (endingDateDay >= 21)
+ {
+ Assert.AreEqual(new DateTime(2000, 12, 21), dates.Single());
+ }
+ }
+
+ [Test]
+ public void EndOfYearWithSymbol()
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearEnd(Symbols.SPY);
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2010, 12, 31));
+
+ int count = 0;
+ foreach (var date in dates)
+ {
+ count++;
+ Assert.AreNotEqual(DayOfWeek.Saturday, date.DayOfWeek);
+ Assert.AreNotEqual(DayOfWeek.Sunday, date.DayOfWeek);
+ Assert.AreEqual(12, date.Month);
+ Assert.IsTrue(date.Day >= 28);
+ Log.Debug(date + " " + date.DayOfWeek);
+ }
+
+ Assert.AreEqual(11, count);
+ }
+
+ [TestCase(Symbols.SymbolsKey.SPY, new[] { 21, 21, 23, 23, 23 }, 5)] // This case contains two Holidays 12/25 & 12/29
+ [TestCase(Symbols.SymbolsKey.SPY, new[] { 12, 12, 12, 12, 14 }, 12)] // Contains holiday 1/25
+ [TestCase(Symbols.SymbolsKey.SPY, new[] { 1, 3, 3, 3, 3 }, 19)] // Always first trading day of the month (25 > than trading days)
+ [TestCase(Symbols.SymbolsKey.BTCUSD, new[] { 26, 26, 26, 26, 26 }, 5)]
+ [TestCase(Symbols.SymbolsKey.EURUSD, new[] { 25, 25, 25, 25, 26 }, 5)]
+ public void EndOfYearWithSymbolWithOffset(Symbols.SymbolsKey symbolKey, int[] expectedDays, int offset)
+ {
+ var rules = GetDateRules();
+ var rule = rules.YearEnd(Symbols.Lookup(symbolKey), offset);
+ var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2004, 12, 31)).ToList();
+
+ // Assert we have as many dates as expected
+ Assert.AreEqual(expectedDays.Length, dates.Count);
+
+ // Verify the days match up
+ var datesAndExpectedDays = dates.Zip(expectedDays, (date, expectedDay) => new { date, expectedDay });
+ foreach (var pair in datesAndExpectedDays)
+ {
+ Assert.AreEqual(pair.expectedDay, pair.date.Day);
+ }
+ }
+
[Test]
public void StartOfWeekNoSymbol()
{