diff --git a/src/Managing.Application.Tests/CandleHelpersTests.cs b/src/Managing.Application.Tests/CandleHelpersTests.cs index fe784de8..f7c69294 100644 --- a/src/Managing.Application.Tests/CandleHelpersTests.cs +++ b/src/Managing.Application.Tests/CandleHelpersTests.cs @@ -19,16 +19,16 @@ namespace Managing.Application.Tests // Assert Assert.True(dueTime.TotalMinutes > 0, "Due time should be positive"); Assert.True(dueTime.TotalMinutes < 15, "Due time should be less than the timeframe interval"); - + // Next 15-minute boundary is 16:45:00, so execution should be at 16:45:01 // Time difference should be approximately 2 minutes and 25 seconds var expectedMinutes = 2; var expectedSeconds = 25; var expectedTotalSeconds = expectedMinutes * 60 + expectedSeconds; - - Assert.True(Math.Abs(dueTime.TotalSeconds - expectedTotalSeconds) <= 1, + + Assert.True(Math.Abs(dueTime.TotalSeconds - expectedTotalSeconds) <= 1, $"Expected approximately {expectedMinutes}m {expectedSeconds}s, but got {dueTime.TotalMinutes:F2}m {dueTime.Seconds}s"); - + // Verify the next execution time var nextExecution = testTime.Add(dueTime); Assert.Equal(16, nextExecution.Hour); @@ -71,5 +71,56 @@ namespace Managing.Application.Tests Assert.Equal(0, nextExecution.Minute); Assert.Equal(1, nextExecution.Second); } + + [Fact] + public void GetNextExpectedCandleTime_FifteenMinutes_ShouldReturnCorrectBoundary() + { + // Arrange + var timeframe = Timeframe.FifteenMinutes; + var testTime = new DateTime(2024, 1, 1, 16, 42, 36, DateTimeKind.Utc); // 16:42:36 + + // Act + var nextCandleTime = CandleHelpers.GetNextExpectedCandleTime(timeframe, testTime); + + // Assert + // Next 15-minute boundary should be 16:45:00 + Assert.Equal(16, nextCandleTime.Hour); + Assert.Equal(44, nextCandleTime.Minute); + Assert.Equal(59, nextCandleTime.Second); + } + + [Fact] + public void GetNextExpectedCandleTime_FiveMinutes_ShouldAlignToFiveMinuteBoundaries() + { + // Arrange + var timeframe = Timeframe.FiveMinutes; + var testTime = new DateTime(2024, 1, 1, 10, 23, 45, DateTimeKind.Utc); // 10:23:45 + + // Act + var nextCandleTime = CandleHelpers.GetNextExpectedCandleTime(timeframe, testTime); + + // Assert + // Next 5-minute boundary should be 10:25:00 + Assert.Equal(10, nextCandleTime.Hour); + Assert.Equal(24, nextCandleTime.Minute); + Assert.Equal(59, nextCandleTime.Second); + } + + [Fact] + public void GetNextExpectedCandleTime_OneHour_ShouldAlignToHourBoundaries() + { + // Arrange + var timeframe = Timeframe.OneHour; + var testTime = new DateTime(2024, 1, 1, 14, 30, 15, DateTimeKind.Utc); // 14:30:15 + + // Act + var nextCandleTime = CandleHelpers.GetNextExpectedCandleTime(timeframe, testTime); + + // Assert + // Next hour boundary should be 15:00:00 + Assert.Equal(14, nextCandleTime.Hour); + Assert.Equal(59, nextCandleTime.Minute); + Assert.Equal(59, nextCandleTime.Second); + } } } \ No newline at end of file diff --git a/src/Managing.Application/Grains/PriceFetcherGrain.cs b/src/Managing.Application/Grains/PriceFetcherGrain.cs index e1dadaab..c2eb7fa9 100644 --- a/src/Managing.Application/Grains/PriceFetcherGrain.cs +++ b/src/Managing.Application/Grains/PriceFetcherGrain.cs @@ -70,9 +70,6 @@ public class PriceFetcherGrain : Grain, IPriceFetcherGrain, IRemindable dueTime, TimeSpan.FromMinutes(intervalMinutes)); - // Optional immediate kick-off to avoid waiting until next boundary - _ = FetchAndPublishPricesAsync(); - await base.OnActivateAsync(cancellationToken); } @@ -134,6 +131,15 @@ public class PriceFetcherGrain : Grain, IPriceFetcherGrain, IRemindable var isFirstCall = !existingCandles.Any(); + // Check if the next expected candle is available yet + var nextExpectedCandleTime = CandleHelpers.GetNextExpectedCandleTime(timeframe); + if (nextExpectedCandleTime > DateTime.UtcNow) + { + _logger.LogDebug("Next candle for {Exchange}-{Ticker}-{Timeframe} not available yet. Expected at {NextCandleTime}, current time: {CurrentTime}", + exchange, ticker, timeframe, nextExpectedCandleTime, DateTime.UtcNow); + return; // Skip this fetch as the new candle won't be available + } + var startDate = !isFirstCall ? existingCandles.Last().Date : new DateTime(2017, 1, 1); diff --git a/src/Managing.Domain/Candles/CandleHelpers.cs b/src/Managing.Domain/Candles/CandleHelpers.cs index 6aaa849f..719ff722 100644 --- a/src/Managing.Domain/Candles/CandleHelpers.cs +++ b/src/Managing.Domain/Candles/CandleHelpers.cs @@ -107,17 +107,33 @@ public static class CandleHelpers public static TimeSpan GetDueTimeForTimeframe(Timeframe timeframe, DateTime now) { var intervalMinutes = GetBaseIntervalInSeconds(timeframe) / 60; - + // Calculate the next candle boundary var nextBoundary = GetNextCandleBoundary(now, intervalMinutes); - + // Add 1 second to ensure we're after the candle closes var targetTime = nextBoundary.AddSeconds(1); - + // Return the time difference return targetTime - now; } - + + /// + /// Gets the next expected candle time for the given timeframe. + /// This is useful to determine if a new candle should be available yet. + /// + /// The timeframe to calculate for + /// The current time (defaults to DateTime.UtcNow) + /// The next expected candle time + public static DateTime GetNextExpectedCandleTime(Timeframe timeframe, DateTime? now = null) + { + var currentTime = now ?? DateTime.UtcNow; + var intervalMinutes = GetBaseIntervalInSeconds(timeframe) / 60; + + // Calculate the next candle boundary + return GetNextCandleBoundary(currentTime, intervalMinutes).AddSeconds(-1); + } + private static DateTime GetNextCandleBoundary(DateTime now, double intervalMinutes) { // For different timeframes, we need to align to different boundaries @@ -159,7 +175,7 @@ public static class CandleHelpers var boundary = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0); return boundary.AddDays(1); } - + // Fallback to 5-minute intervals var fallbackMinute = (now.Minute / 5) * 5; var fallbackBoundary = new DateTime(now.Year, now.Month, now.Day, now.Hour, fallbackMinute, 0);