using FluentAssertions; using Managing.Domain.Candles; using Xunit; using static Managing.Common.Enums; namespace Managing.Domain.Tests; /// /// Tests for CandleHelpers static utility class. /// Covers time calculations, intervals, grain keys, and candle boundary logic. /// Critical for ensuring accurate candle fetching, bot synchronization, and backtest timing. /// public class CandleHelpersTests { #region GetBaseIntervalInSeconds Tests [Theory] [InlineData(Timeframe.FiveMinutes, 300)] [InlineData(Timeframe.FifteenMinutes, 900)] [InlineData(Timeframe.ThirtyMinutes, 1800)] [InlineData(Timeframe.OneHour, 3600)] [InlineData(Timeframe.FourHour, 14400)] [InlineData(Timeframe.OneDay, 86400)] public void GetBaseIntervalInSeconds_WithValidTimeframe_ReturnsCorrectSeconds(Timeframe timeframe, int expectedSeconds) { // Act var result = CandleHelpers.GetBaseIntervalInSeconds(timeframe); // Assert result.Should().Be(expectedSeconds); } #endregion #region GetUnixInterval Tests [Theory] [InlineData(Timeframe.FiveMinutes, 300)] [InlineData(Timeframe.FifteenMinutes, 900)] [InlineData(Timeframe.OneHour, 3600)] [InlineData(Timeframe.FourHour, 14400)] [InlineData(Timeframe.OneDay, 86400)] public void GetUnixInterval_WithValidTimeframe_ReturnsCorrectInterval(Timeframe timeframe, int expectedInterval) { // Act var result = timeframe.GetUnixInterval(); // Assert result.Should().Be(expectedInterval); } [Fact] public void GetUnixInterval_WithThirtyMinutes_ThrowsNotImplementedException() { // Act Action act = () => Timeframe.ThirtyMinutes.GetUnixInterval(); // Assert act.Should().Throw(); } #endregion #region GetIntervalInMinutes Tests [Theory] [InlineData(Timeframe.FiveMinutes, 1.0)] // 300 / 5 / 60 = 1 minute [InlineData(Timeframe.FifteenMinutes, 3.0)] // 900 / 5 / 60 = 3 minutes [InlineData(Timeframe.ThirtyMinutes, 6.0)] // 1800 / 5 / 60 = 6 minutes [InlineData(Timeframe.OneHour, 12.0)] // 3600 / 5 / 60 = 12 minutes [InlineData(Timeframe.FourHour, 48.0)] // 14400 / 5 / 60 = 48 minutes [InlineData(Timeframe.OneDay, 288.0)] // 86400 / 5 / 60 = 288 minutes public void GetIntervalInMinutes_WithValidTimeframe_ReturnsOneFifthOfCandleDuration(Timeframe timeframe, double expectedMinutes) { // Act var result = CandleHelpers.GetIntervalInMinutes(timeframe); // Assert result.Should().Be(expectedMinutes); } #endregion #region GetIntervalFromTimeframe Tests [Theory] [InlineData(Timeframe.FiveMinutes, 60000)] // 300 / 5 * 1000 = 60000 ms [InlineData(Timeframe.FifteenMinutes, 180000)] // 900 / 5 * 1000 = 180000 ms [InlineData(Timeframe.OneHour, 720000)] // 3600 / 5 * 1000 = 720000 ms public void GetIntervalFromTimeframe_ReturnsMillisecondsForOneFifthOfCandleDuration(Timeframe timeframe, int expectedMilliseconds) { // Act var result = CandleHelpers.GetIntervalFromTimeframe(timeframe); // Assert result.Should().Be(expectedMilliseconds); } #endregion #region GetMinimalDays Tests [Theory] [InlineData(Timeframe.FiveMinutes, -1)] [InlineData(Timeframe.FifteenMinutes, -5)] [InlineData(Timeframe.ThirtyMinutes, -10)] [InlineData(Timeframe.OneHour, -30)] [InlineData(Timeframe.FourHour, -60)] [InlineData(Timeframe.OneDay, -360)] public void GetMinimalDays_WithValidTimeframe_ReturnsCorrectNegativeDays(Timeframe timeframe, double expectedDays) { // Act var result = CandleHelpers.GetMinimalDays(timeframe); // Assert result.Should().Be(expectedDays); } #endregion #region GetBotPreloadSinceFromTimeframe Tests [Fact] public void GetBotPreloadSinceFromTimeframe_WithFiveMinutes_ReturnsOneDayAgo() { // Arrange var before = DateTime.UtcNow.AddDays(-1); // Act var result = CandleHelpers.GetBotPreloadSinceFromTimeframe(Timeframe.FiveMinutes); // Assert var after = DateTime.UtcNow.AddDays(-1); result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); } [Fact] public void GetBotPreloadSinceFromTimeframe_WithOneHour_Returns30DaysAgo() { // Arrange var before = DateTime.UtcNow.AddDays(-30); // Act var result = CandleHelpers.GetBotPreloadSinceFromTimeframe(Timeframe.OneHour); // Assert var after = DateTime.UtcNow.AddDays(-30); result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); } [Fact] public void GetBotPreloadSinceFromTimeframe_WithOneDay_Returns360DaysAgo() { // Arrange var before = DateTime.UtcNow.AddDays(-360); // Act var result = CandleHelpers.GetBotPreloadSinceFromTimeframe(Timeframe.OneDay); // Assert var after = DateTime.UtcNow.AddDays(-360); result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); } #endregion #region GetPreloadSinceFromTimeframe Tests [Fact] public void GetPreloadSinceFromTimeframe_WithFiveMinutes_ReturnsOneDayAgo() { // Arrange var expectedDays = -1; var before = DateTime.UtcNow.AddDays(expectedDays); // Act var result = CandleHelpers.GetPreloadSinceFromTimeframe(Timeframe.FiveMinutes); // Assert var after = DateTime.UtcNow.AddDays(expectedDays); result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); } [Fact] public void GetPreloadSinceFromTimeframe_UsesGetMinimalDays() { // Arrange var timeframe = Timeframe.OneHour; var minimalDays = CandleHelpers.GetMinimalDays(timeframe); // Act var result = CandleHelpers.GetPreloadSinceFromTimeframe(timeframe); // Assert var expectedBefore = DateTime.UtcNow.AddDays(minimalDays); var expectedAfter = DateTime.UtcNow.AddDays(minimalDays); result.Should().BeOnOrAfter(expectedBefore.AddSeconds(-1)).And.BeOnOrBefore(expectedAfter.AddSeconds(1)); } #endregion #region GetCandleStoreGrainKey & ParseCandleStoreGrainKey Tests [Theory] [InlineData(TradingExchanges.Binance, Ticker.BTC, Timeframe.OneHour, "Binance-BTC-OneHour")] [InlineData(TradingExchanges.Kraken, Ticker.ETH, Timeframe.FiveMinutes, "Kraken-ETH-FiveMinutes")] [InlineData(TradingExchanges.GmxV2, Ticker.SOL, Timeframe.OneDay, "GmxV2-SOL-OneDay")] public void GetCandleStoreGrainKey_WithValidParameters_ReturnsCorrectKey( TradingExchanges exchange, Ticker ticker, Timeframe timeframe, string expectedKey) { // Act var result = CandleHelpers.GetCandleStoreGrainKey(exchange, ticker, timeframe); // Assert result.Should().Be(expectedKey); } [Theory] [InlineData("Binance-BTC-OneHour", TradingExchanges.Binance, Ticker.BTC, Timeframe.OneHour)] [InlineData("Kraken-ETH-FiveMinutes", TradingExchanges.Kraken, Ticker.ETH, Timeframe.FiveMinutes)] [InlineData("GmxV2-SOL-OneDay", TradingExchanges.GmxV2, Ticker.SOL, Timeframe.OneDay)] public void ParseCandleStoreGrainKey_WithValidKey_ReturnsCorrectComponents( string grainKey, TradingExchanges expectedExchange, Ticker expectedTicker, Timeframe expectedTimeframe) { // Act var (exchange, ticker, timeframe) = CandleHelpers.ParseCandleStoreGrainKey(grainKey); // Assert exchange.Should().Be(expectedExchange); ticker.Should().Be(expectedTicker); timeframe.Should().Be(expectedTimeframe); } [Fact] public void GetCandleStoreGrainKey_RoundTrip_PreservesOriginalValues() { // Arrange var originalExchange = TradingExchanges.Binance; var originalTicker = Ticker.BTC; var originalTimeframe = Timeframe.FifteenMinutes; // Act var grainKey = CandleHelpers.GetCandleStoreGrainKey(originalExchange, originalTicker, originalTimeframe); var (parsedExchange, parsedTicker, parsedTimeframe) = CandleHelpers.ParseCandleStoreGrainKey(grainKey); // Assert parsedExchange.Should().Be(originalExchange); parsedTicker.Should().Be(originalTicker); parsedTimeframe.Should().Be(originalTimeframe); } #endregion #region GetNextExpectedCandleTime Tests [Fact] public void GetNextExpectedCandleTime_WithFiveMinutes_AlignsToFiveMinuteBoundary() { // Arrange - Use a specific time to ensure predictable results var now = new DateTime(2024, 1, 1, 12, 3, 30, DateTimeKind.Utc); // 12:03:30 // Act var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FiveMinutes, now); // Assert // Next 5-minute boundary is 12:05:00, minus 1 second = 12:04:59 var expected = new DateTime(2024, 1, 1, 12, 4, 59, DateTimeKind.Utc); result.Should().Be(expected); } [Fact] public void GetNextExpectedCandleTime_WithFifteenMinutes_AlignsToFifteenMinuteBoundary() { // Arrange var now = new DateTime(2024, 1, 1, 12, 8, 0, DateTimeKind.Utc); // 12:08:00 // Act var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FifteenMinutes, now); // Assert // Next 15-minute boundary is 12:15:00, minus 1 second = 12:14:59 var expected = new DateTime(2024, 1, 1, 12, 14, 59, DateTimeKind.Utc); result.Should().Be(expected); } [Fact] public void GetNextExpectedCandleTime_WithOneHour_AlignsToHourBoundary() { // Arrange var now = new DateTime(2024, 1, 1, 12, 30, 0, DateTimeKind.Utc); // 12:30:00 // Act var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.OneHour, now); // Assert // Next hour boundary is 13:00:00, minus 1 second = 12:59:59 var expected = new DateTime(2024, 1, 1, 12, 59, 59, DateTimeKind.Utc); result.Should().Be(expected); } [Fact] public void GetNextExpectedCandleTime_WithFourHour_AlignsToFourHourBoundary() { // Arrange var now = new DateTime(2024, 1, 1, 10, 0, 0, DateTimeKind.Utc); // 10:00:00 // Act var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FourHour, now); // Assert // Next 4-hour boundary is 12:00:00, minus 1 second = 11:59:59 var expected = new DateTime(2024, 1, 1, 11, 59, 59, DateTimeKind.Utc); result.Should().Be(expected); } [Fact] public void GetNextExpectedCandleTime_WithOneDay_AlignsToDayBoundary() { // Arrange var now = new DateTime(2024, 1, 1, 15, 0, 0, DateTimeKind.Utc); // Jan 1, 15:00 // Act var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.OneDay, now); // Assert // Next day boundary is Jan 2 00:00:00, minus 1 second = Jan 1 23:59:59 var expected = new DateTime(2024, 1, 1, 23, 59, 59, DateTimeKind.Utc); result.Should().Be(expected); } [Fact] public void GetNextExpectedCandleTime_WithoutNowParameter_UsesCurrentTime() { // Act var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FiveMinutes); // Assert result.Should().BeAfter(DateTime.UtcNow.AddMinutes(-1)); result.Should().BeBefore(DateTime.UtcNow.AddMinutes(10)); } #endregion #region GetDueTimeForTimeframe Tests [Fact] public void GetDueTimeForTimeframe_WithFiveMinutes_ReturnsTimeToNextBoundary() { // Arrange - Use a specific time var now = new DateTime(2024, 1, 1, 12, 3, 30, DateTimeKind.Utc); // 12:03:30 // Act var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.FiveMinutes, now); // Assert // Next 5-minute boundary is 12:05:00, plus 1 second = 12:05:01 // Time from 12:03:30 to 12:05:01 = 1 minute 31 seconds result.TotalSeconds.Should().BeApproximately(91, 1); result.Should().BePositive(); } [Fact] public void GetDueTimeForTimeframe_WithOneHour_ReturnsTimeToNextHourBoundary() { // Arrange var now = new DateTime(2024, 1, 1, 12, 30, 0, DateTimeKind.Utc); // 12:30:00 // Act var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.OneHour, now); // Assert // Next hour boundary is 13:00:00, plus 1 second = 13:00:01 // Time from 12:30:00 to 13:00:01 = 30 minutes 1 second result.TotalMinutes.Should().BeApproximately(30, 1); result.Should().BePositive(); } [Fact] public void GetDueTimeForTimeframe_ResultIsAlwaysPositive() { // Arrange - Test at various times var testTimes = new[] { new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), new DateTime(2024, 1, 1, 23, 59, 0, DateTimeKind.Utc) }; foreach (var testTime in testTimes) { // Act var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.FifteenMinutes, testTime); // Assert result.Should().BePositive($"time {testTime} should produce positive due time"); } } [Fact] public void GetDueTimeForTimeframe_WithFourHour_CalculatesCorrectDueTime() { // Arrange var now = new DateTime(2024, 1, 1, 10, 0, 0, DateTimeKind.Utc); // 10:00:00 // Act var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.FourHour, now); // Assert // Next 4-hour boundary is 12:00:00, plus 1 second = 12:00:01 // Time from 10:00:00 to 12:00:01 = 2 hours 1 second result.TotalHours.Should().BeApproximately(2, 0.1); } #endregion #region Edge Cases and Integration Tests [Fact] public void GetBaseIntervalInSeconds_AndGetUnixInterval_ReturnSameValuesForSupportedTimeframes() { // Arrange var supportedTimeframes = new[] { Timeframe.FiveMinutes, Timeframe.FifteenMinutes, Timeframe.OneHour, Timeframe.FourHour, Timeframe.OneDay }; foreach (var timeframe in supportedTimeframes) { // Act var baseInterval = CandleHelpers.GetBaseIntervalInSeconds(timeframe); var unixInterval = timeframe.GetUnixInterval(); // Assert baseInterval.Should().Be(unixInterval, $"{timeframe} should return consistent values"); } } [Fact] public void GetIntervalFromTimeframe_ReturnsConsistentlyOneFifthOfBaseInterval() { // Arrange var timeframes = new[] { Timeframe.FiveMinutes, Timeframe.FifteenMinutes, Timeframe.ThirtyMinutes, Timeframe.OneHour, Timeframe.FourHour, Timeframe.OneDay }; foreach (var timeframe in timeframes) { // Act var intervalMs = CandleHelpers.GetIntervalFromTimeframe(timeframe); var baseIntervalSeconds = CandleHelpers.GetBaseIntervalInSeconds(timeframe); var expectedIntervalMs = (baseIntervalSeconds / 5) * 1000; // Assert intervalMs.Should().Be(expectedIntervalMs, $"{timeframe} should be 1/5th of base interval"); } } [Fact] public void TimeCalculationMethods_AreConsistentWithEachOther() { // Arrange var timeframe = Timeframe.FifteenMinutes; // Act var baseSeconds = CandleHelpers.GetBaseIntervalInSeconds(timeframe); var intervalMinutes = CandleHelpers.GetIntervalInMinutes(timeframe); var intervalMs = CandleHelpers.GetIntervalFromTimeframe(timeframe); // Assert // intervalMinutes should be baseSeconds / 5 / 60 intervalMinutes.Should().Be((double)baseSeconds / 5 / 60); // intervalMs should be baseSeconds / 5 * 1000 intervalMs.Should().Be((baseSeconds / 5) * 1000); } #endregion }