Skip to content

Commit f01700d

Browse files
committed
Refactor GetExchangeHours and add unit tests
1 parent 5b26b61 commit f01700d

File tree

3 files changed

+186
-72
lines changed

3 files changed

+186
-72
lines changed

Common/Securities/SecurityExchangeHours.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public HashSet<DateTime> BankHolidays
117117
/// </summary>
118118
public static SecurityExchangeHours AlwaysOpen(DateTimeZone timeZone)
119119
{
120-
var dayOfWeeks = Enum.GetValues(typeof (DayOfWeek)).OfType<DayOfWeek>();
120+
var dayOfWeeks = Enum.GetValues(typeof(DayOfWeek)).OfType<DayOfWeek>();
121121
return new SecurityExchangeHours(timeZone,
122122
Enumerable.Empty<DateTime>(),
123123
dayOfWeeks.Select(LocalMarketHours.OpenAllDay).ToDictionary(x => x.DayOfWeek),
@@ -282,7 +282,7 @@ public DateTime GetPreviousMarketOpen(DateTime localDateTime, bool extendedMarke
282282
for (int i = 0; i < 7; i++)
283283
{
284284
DateTime? potentialResult = null;
285-
foreach(var segment in marketHours.Segments.Reverse())
285+
foreach (var segment in marketHours.Segments.Reverse())
286286
{
287287
if ((time.Date + segment.Start <= localDateTime) &&
288288
(segment.State == MarketHoursState.Market || extendedMarketHours))
@@ -601,16 +601,26 @@ public LocalMarketHours GetMarketHours(DateTime localDateTime)
601601
// and add it before the segments previous to the lateOpenTime
602602
// Otherwise, just take the segments previous to the lateOpenTime
603603
List<MarketHoursSegment> segmentsLateOpen = null;
604+
var closeAllDay = false;
604605
if (hasLateOpen)
605606
{
606607
var index = 0;
607608
segmentsLateOpen = new List<MarketHoursSegment>();
608-
for(var i = 0; i < marketHoursSegments.Count; i++)
609+
for (var i = 0; i < marketHoursSegments.Count; i++)
609610
{
610611
var segment = marketHoursSegments[i];
612+
// If we're at the last segment and it ends before the late open time, the market is closed all day
613+
if (segment.End < lateOpenTime && i == marketHoursSegments.Count - 1)
614+
{
615+
closeAllDay = true;
616+
// Set index to the count to ensure TakeLast(0) later,
617+
// meaning no segments will be included after this point.
618+
index = marketHoursSegments.Count;
619+
break;
620+
}
611621
if (segment.Start <= lateOpenTime && lateOpenTime <= segment.End)
612622
{
613-
segmentsLateOpen.Add(new (segment.State, lateOpenTime, segment.End));
623+
segmentsLateOpen.Add(new(segment.State, lateOpenTime, segment.End));
614624
index = i + 1;
615625
break;
616626
}
@@ -627,7 +637,8 @@ public LocalMarketHours GetMarketHours(DateTime localDateTime)
627637

628638
// Since it could be the case we have a late open after an early close (the market resumes after the early close), we need to take
629639
// the segments before the early close and the segments after the late open and append them to obtain the expected market hours
630-
if (segmentsEarlyClose != null && hasLateOpen && earlyCloseTime <= lateOpenTime)
640+
// unless market is closed all day
641+
if (segmentsEarlyClose != null && hasLateOpen && earlyCloseTime <= lateOpenTime && !closeAllDay)
631642
{
632643
segmentsEarlyClose.AddRange(segmentsLateOpen);
633644
marketHoursSegments = segmentsEarlyClose;

Tests/Common/Securities/SecurityExchangeHoursTests.cs

Lines changed: 52 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -575,57 +575,11 @@ public void RegularMarketDurationIsFromMostCommonLocalMarketHours()
575575
Assert.AreEqual(TimeSpan.FromHours(5), exchangeHours.RegularMarketDuration);
576576
}
577577

578-
[Test]
579-
public void FillForwardDoesNotOccurOnLateOpenDates()
580-
{
581-
// Set resolution for data and fill forward to one day
582-
var dataResolution = Time.OneDay;
583-
var fillForwardResolution = Time.OneDay;
584-
585-
// Define the initial time and subscription end time
586-
var time = new DateTime(2020, 6, 28, 8, 30, 0);
587-
var subscriptionEndTime = time.AddDays(30);
588-
589-
var enumerator = new List<BaseData>
590-
{
591-
new TradeBar { Time = new DateTime(2020, 6, 28, 8, 30, 0), EndTime = new DateTime(2020, 6, 28, 16, 0, 0), Value = 1, Volume = 100},
592-
new TradeBar { Time = new DateTime(2020, 7, 6, 8, 30, 0), EndTime = new DateTime(2020, 7, 6, 16, 0, 0), Value = 1, Volume = 100},
593-
}.GetEnumerator();
594-
595-
var closeDate = new DateTime(2020, 7, 3);
596-
var exchangeHours = CreateFuture6JExchangeHours(new DateTime(), closeDate);
597-
var exchange = new SecurityExchange(exchangeHours);
598-
using var fillForwardEnumerator = new FillForwardEnumerator(enumerator, exchange, Ref.Create(fillForwardResolution), false, subscriptionEndTime, dataResolution, exchange.TimeZone, true);
599-
600-
// Date to check for late open
601-
int dataCount = 0;
602-
603-
// Set to store unique dates
604-
SortedSet<DateTime> uniqueDates = new SortedSet<DateTime>();
605-
606-
// Iterate through the enumerator
607-
while (fillForwardEnumerator.MoveNext())
608-
{
609-
var currentValue = fillForwardEnumerator.Current;
610-
611-
// Add unique end time to the sorted set and increment data count
612-
uniqueDates.Add(currentValue.EndTime);
613-
dataCount++;
614-
615-
// Ensure that no fill forward occurs on the late open date (5 PM)
616-
Assert.AreNotEqual(closeDate.Date, currentValue.EndTime);
617-
Assert.IsFalse(fillForwardEnumerator.Current.EndTime > subscriptionEndTime);
618-
}
619-
620-
// Ensure there are no duplicate dates in the result
621-
Assert.AreEqual(dataCount, uniqueDates.Count);
622-
}
623-
624578
[TestCaseSource(nameof(GetTestCases))]
625579
public void GetMarketHoursWorksCorrectly(DateTime earlyClose, DateTime lateOpen, LocalMarketHours expected)
626580
{
627581
var testDate = new DateTime(2020, 7, 3); // Friday
628-
var exchangeHours = CreateFuture6JExchangeHours(earlyClose, lateOpen);
582+
var exchangeHours = CreateCustomFutureExchangeHours(earlyClose, lateOpen);
629583
var actual = exchangeHours.GetMarketHours(testDate);
630584

631585
// Extracts the time segments for detailed comparison
@@ -668,49 +622,47 @@ private static TestCaseData[] GetTestCases()
668622
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(8, 30, 0)),
669623
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(12, 0, 0)))
670624
),
671-
// 2.2 Early close before market opens (should have no effect)
625+
// 2.2 Early close before market opens (should remove market segment)
672626
new TestCaseData(
673-
new DateTime(2020, 7, 3, 7, 0, 0),
627+
new DateTime(2020, 7, 3, 7, 0, 0), // Early close before open
674628
new DateTime(),
675629
new LocalMarketHours(DayOfWeek.Friday,
676630
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(7, 0, 0)))
677631
),
678632
// 2.3 Early close after market closes (should have no effect)
679633
new TestCaseData(
680-
new DateTime(2020, 7, 3, 17, 0, 0),
634+
new DateTime(2020, 7, 3, 17, 0, 0), // Early close after regular close
681635
new DateTime(),
682636
new LocalMarketHours(DayOfWeek.Friday,
683637
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(8, 30, 0)),
684638
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(16, 0, 0)))
685639
),
686640

687641
// 3. Late open only scenarios
688-
// 3.1 Late open during regular market hours
642+
// 3.1 Late open during regular market hours (should adjust market open)
689643
new TestCaseData(
690644
new DateTime(),
691645
new DateTime(2020, 7, 3, 10, 0, 0), // Late open at 10am
692646
new LocalMarketHours(DayOfWeek.Friday,
693647
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(10, 0, 0), new TimeSpan(16, 0, 0)))
694648
),
695-
// 3.2 Late open before market opens (should adjust pre-market)
649+
// 3.2 Late open before market opens (should delay premarket start)
696650
new TestCaseData(
697651
new DateTime(),
698-
new DateTime(2020, 7, 3, 7, 0, 0),
652+
new DateTime(2020, 7, 3, 7, 0, 0), // Late open before market
699653
new LocalMarketHours(DayOfWeek.Friday,
700654
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(7, 0, 0), new TimeSpan(8, 30, 0)),
701655
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(16, 0, 0)))
702656
),
703-
// 3.3 Late open after market closes
657+
// 3.3 Late open after market close (market should be closed all day)
704658
new TestCaseData(
705659
new DateTime(),
706660
new DateTime(2020, 7, 3, 17, 0, 0), // Late open at 17
707-
new LocalMarketHours(DayOfWeek.Friday,
708-
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(8, 30, 0)),
709-
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(16, 0, 0)))
661+
LocalMarketHours.ClosedAllDay(DayOfWeek.Friday)
710662
),
711663

712664
// 4. Both early close and late open scenarios
713-
// 4.1 Early close before late open (market closes early and reopens)
665+
// 4.1 Open <= Earlyclose <= Close and EarlyClose < LateOpen (market closes then reopens)
714666
new TestCaseData(
715667
new DateTime(2020, 7, 3, 12, 0, 0), // Close at noon
716668
new DateTime(2020, 7, 3, 13, 0, 0), // Reopen at 1pm
@@ -719,42 +671,75 @@ private static TestCaseData[] GetTestCases()
719671
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(12, 0, 0)),
720672
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(13, 0, 0), new TimeSpan(16, 0, 0)))
721673
),
722-
// 4.2 Early close after late open (only early close applies)
674+
// 4.2 Open <= Earlyclose <= Close and EarlyClose > LateOpen (only one market segment should exist)
723675
new TestCaseData(
724676
new DateTime(2020, 7, 3, 15, 0, 0), // Close at 3pm
725677
new DateTime(2020, 7, 3, 14, 0, 0), // Late open at 2pm
726678
new LocalMarketHours(DayOfWeek.Friday,
727679
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(14, 0, 0), new TimeSpan(15, 0, 0)))
728680
),
729-
// 4.3 Both outside market hours
681+
// 4.3 Open <= Earlyclose <= Close and LateOpen > Close (market closed all day)
730682
new TestCaseData(
731-
new DateTime(2020, 7, 3, 7, 0, 0), // Before open
732-
new DateTime(2020, 7, 3, 17, 0, 0), // After close
683+
new DateTime(2020, 7, 3, 13, 0, 0),
684+
new DateTime(2020, 7, 3, 17, 0, 0),
685+
LocalMarketHours.ClosedAllDay(DayOfWeek.Friday)
686+
),
687+
// 4.4 Earlyclose <= Open and LateOpen > Close (market closed all day)
688+
new TestCaseData(
689+
new DateTime(2020, 7, 3, 7, 0, 0),
690+
new DateTime(2020, 7, 3, 17, 0, 0),
691+
LocalMarketHours.ClosedAllDay(DayOfWeek.Friday)
692+
),
693+
// 4.5 Earlyclose <= Open and EarlyClose < LateOpen <= Close
694+
new TestCaseData(
695+
new DateTime(2020, 7, 3, 7, 0, 0),
696+
new DateTime(2020, 7, 3, 14, 0, 0),
733697
new LocalMarketHours(DayOfWeek.Friday,
734698
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(7, 0, 0)),
735-
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(8, 30, 0)),
736-
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(16, 0, 0)))
699+
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(14, 0, 0), new TimeSpan(16, 0, 0)))
700+
),
701+
// 4.6 LateOpen < Earlyclose <= Open
702+
new TestCaseData(
703+
new DateTime(2020, 7, 3, 7, 0, 0),
704+
new DateTime(2020, 7, 3, 6, 0, 0),
705+
new LocalMarketHours(DayOfWeek.Friday,
706+
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(6, 0, 0), new TimeSpan(7, 0, 0)))
737707
),
738708

739709
// 5. Edge cases
740-
// 5.1 Early close exactly at market open
710+
// 5.1 Early close exactly at market open (no market segment)
741711
new TestCaseData(
742712
new DateTime(2020, 7, 3, 8, 30, 0),
743713
new DateTime(),
744714
new LocalMarketHours(DayOfWeek.Friday,
745715
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(8, 30, 0)))
746716
),
747-
// 5.2 Late open exactly at market close
717+
// 5.2 Late open exactly at market close (market segment has zero duration)
748718
new TestCaseData(
749719
new DateTime(),
750720
new DateTime(2020, 7, 3, 16, 0, 0),
751721
new LocalMarketHours(DayOfWeek.Friday,
752722
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(16, 0, 0), new TimeSpan(16, 0, 0)))
723+
),
724+
// 5.3 Early close and late open at the same time (split into two segments with zero-duration overlap)
725+
new TestCaseData(
726+
new DateTime(2020, 7, 3, 13, 0, 0),
727+
new DateTime(2020, 7, 3, 13, 0, 0),
728+
new LocalMarketHours(DayOfWeek.Friday,
729+
new MarketHoursSegment(MarketHoursState.PreMarket, new TimeSpan(0, 0, 0), new TimeSpan(8, 30, 0)),
730+
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(8, 30, 0), new TimeSpan(13, 0, 0)),
731+
new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(13, 0, 0), new TimeSpan(13, 0, 0)))
732+
),
733+
// 5.4 EarlyOpen > Close and LateOpen > Close (market closed all day)
734+
new TestCaseData(
735+
new DateTime(2020, 7, 3, 17, 0, 0),
736+
new DateTime(2020, 7, 3, 17, 0, 0),
737+
LocalMarketHours.ClosedAllDay(DayOfWeek.Friday)
753738
)
754739
};
755740
}
756741

757-
public static SecurityExchangeHours CreateFuture6JExchangeHours(DateTime earlyClose, DateTime lateOpen)
742+
private static SecurityExchangeHours CreateCustomFutureExchangeHours(DateTime earlyClose, DateTime lateOpen)
758743
{
759744
var sunday = new LocalMarketHours(
760745
DayOfWeek.Sunday,

0 commit comments

Comments
 (0)