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() {