Files
managing-apps/src/Managing.Domain.Tests/CandleHelpersTests.cs

490 lines
16 KiB
C#

using FluentAssertions;
using Managing.Domain.Candles;
using Xunit;
using static Managing.Common.Enums;
namespace Managing.Domain.Tests;
/// <summary>
/// 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.
/// </summary>
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<NotImplementedException>();
}
#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
}