Files
managing-apps/src/Managing.Application.Tests/BacktestTests.cs

238 lines
9.9 KiB
C#

using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services;
using Managing.Application.Bots;
using Managing.Application.Bots.Grains;
using Managing.Core;
using Managing.Domain.Accounts;
using Managing.Domain.Bots;
using Managing.Domain.Candles;
using Managing.Domain.Scenarios;
using Managing.Domain.Strategies;
using Managing.Domain.Users;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json;
using Xunit;
using static Managing.Common.Enums;
namespace Managing.Application.Tests;
public class BacktestTests : BaseTests
{
private readonly BacktestTradingBotGrain _backtestGrain;
private readonly Mock<IServiceScopeFactory> _scopeFactory;
private readonly User _testUser;
public BacktestTests() : base()
{
// Setup mock dependencies for BacktestTradingBotGrain
var backtestRepository = new Mock<IBacktestRepository>().Object;
var grainLogger = new Mock<ILogger<BacktestTradingBotGrain>>().Object;
// Setup service scope factory to return ALL TradingBotBase dependencies
_scopeFactory = new Mock<IServiceScopeFactory>();
var mockScope = new Mock<IServiceScope>();
var mockServiceProvider = new Mock<IServiceProvider>();
// Setup TradingBotBase logger
var tradingBotLogger = TradingBaseTests.CreateTradingBotLogger();
mockServiceProvider.Setup(x => x.GetService(typeof(ILogger<TradingBotBase>)))
.Returns(tradingBotLogger);
// Setup all services that TradingBotBase might need
mockServiceProvider.Setup(x => x.GetService(typeof(IExchangeService)))
.Returns(_exchangeService);
mockServiceProvider.Setup(x => x.GetService(typeof(IAccountService)))
.Returns(_accountService.Object);
mockServiceProvider.Setup(x => x.GetService(typeof(ITradingService)))
.Returns(_tradingService.Object);
mockServiceProvider.Setup(x => x.GetService(typeof(IMoneyManagementService)))
.Returns(_moneyManagementService.Object);
// Setup additional services that might be needed
var mockBotService = new Mock<IBotService>();
mockServiceProvider.Setup(x => x.GetService(typeof(IBotService)))
.Returns(mockBotService.Object);
var mockMessengerService = new Mock<IMessengerService>();
mockServiceProvider.Setup(x => x.GetService(typeof(IMessengerService)))
.Returns(mockMessengerService.Object);
mockScope.Setup(x => x.ServiceProvider).Returns(mockServiceProvider.Object);
_scopeFactory.Setup(x => x.CreateScope()).Returns(mockScope.Object);
// Create test user with account
_testUser = new User
{
Id = 1,
Name = "Test User",
Accounts = new List<Account> { _account }
};
// Create BacktestTradingBotGrain instance directly
_backtestGrain = new BacktestTradingBotGrain(grainLogger, _scopeFactory.Object, backtestRepository);
}
/// <summary>
/// Helper test to fetch candles from ExchangeService and save them to Data folder.
/// This test is useful for generating or updating test data files.
/// Skip this test in normal test runs by commenting out the [Fact] attribute.
/// </summary>
[Fact(Skip = "Run manually to generate test data")]
public async Task GenerateTestData_FetchAndSaveETHCandles()
{
// Arrange
var ticker = Ticker.ETH;
var timeframe = Timeframe.FifteenMinutes;
var daysBack = -10; // Fetch last 30 days of data
var startDate = DateTime.UtcNow.AddDays(daysBack);
var endDate = DateTime.UtcNow;
// Act - Fetch candles from exchange
var candles = await _exchangeService.GetCandles(_account, ticker, startDate, timeframe);
Assert.NotNull(candles);
Assert.NotEmpty(candles);
// Convert to list and sort by date
var candleList = candles.OrderBy(c => c.Date).ToList();
// Serialize to JSON
var json = JsonConvert.SerializeObject(candleList, Formatting.Indented);
// Determine the output path - save to source Data folder, not bin folder
// Navigate from bin folder to source: bin/Debug/net8.0 -> ../../..
var assemblyLocation = typeof(BacktestTests).Assembly.Location;
var binDirectory = Path.GetDirectoryName(assemblyLocation);
var projectDirectory = Path.GetFullPath(Path.Combine(binDirectory, "..", "..", ".."));
var dataDirectory = Path.Combine(projectDirectory, "Data");
// Ensure Data directory exists
if (!Directory.Exists(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
var fileName = $"{ticker}-{timeframe}-candles.json";
var filePath = Path.Combine(dataDirectory, fileName);
// Save to file
File.WriteAllText(filePath, json);
// Output information
Console.WriteLine($"✅ Successfully saved {candleList.Count} candles");
Console.WriteLine($"📁 File Location: {filePath}");
Console.WriteLine($"📂 Data Directory: {dataDirectory}");
Console.WriteLine(
$"📊 Date Range: {candleList.First().Date:yyyy-MM-dd HH:mm:ss} to {candleList.Last().Date:yyyy-MM-dd HH:mm:ss}");
Console.WriteLine($"📈 Price Range: ${candleList.Min(c => c.Low):F2} - ${candleList.Max(c => c.High):F2}");
// Assert
Assert.True(File.Exists(filePath), $"File should exist at {filePath}");
// Verify we can read it back
var readBack = FileHelpers.ReadJson<List<Candle>>($"Data/{fileName}");
Assert.NotNull(readBack);
Assert.Equal(candleList.Count, readBack.Count);
}
/// <summary>
/// Main backtest test that verifies consistent behavior of TradingBotBase.
/// Note: Run GenerateTestData_FetchAndSaveETHCandles first if the data file doesn't exist.
/// After the first run, update the assertions with the actual expected values to ensure
/// future changes to TradingBotBase don't break the backtest behavior.
/// </summary>
[Fact]
public async Task ETH_FifteenMinutes_Backtest_Should_Return_Consistent_Results()
{
// Arrange
var candles = FileHelpers.ReadJson<List<Candle>>("Data/ETH-FifteenMinutes-candles.json");
Assert.NotNull(candles);
Assert.NotEmpty(candles);
var scenario = new Scenario("ETH_BacktestScenario");
var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14);
scenario.Indicators = new List<IndicatorBase> { (IndicatorBase)rsiDivIndicator };
scenario.LoopbackPeriod = 15;
var config = new TradingBotConfig
{
AccountName = _account.Name,
MoneyManagement = MoneyManagement,
Ticker = Ticker.ETH,
Scenario = LightScenario.FromScenario(scenario),
Timeframe = Timeframe.FifteenMinutes,
IsForWatchingOnly = false,
BotTradingBalance = 1000,
IsForBacktest = true,
CooldownPeriod = 1,
MaxLossStreak = 0,
FlipPosition = false,
Name = "ETH_FifteenMinutes_Test",
FlipOnlyWhenInProfit = true,
MaxPositionTimeHours = null,
CloseEarlyWhenProfitable = false
};
// Act - Call BacktestTradingBotGrain directly (no Orleans needed)
var backtestResult = await _backtestGrain.RunBacktestAsync(
config,
candles.ToHashSet(),
_testUser,
save: false,
withCandles: false);
// Output the result to console for review
var json = JsonConvert.SerializeObject(new
{
backtestResult.FinalPnl,
backtestResult.WinRate,
backtestResult.GrowthPercentage,
backtestResult.HodlPercentage,
backtestResult.Fees,
backtestResult.NetPnl,
backtestResult.MaxDrawdown,
backtestResult.SharpeRatio,
backtestResult.Score,
backtestResult.InitialBalance,
StartDate = backtestResult.StartDate.ToString("yyyy-MM-dd HH:mm:ss"),
EndDate = backtestResult.EndDate.ToString("yyyy-MM-dd HH:mm:ss")
}, Formatting.Indented);
Console.WriteLine("Backtest Results:");
Console.WriteLine(json);
// Assert - Verify consistent backtest behavior
// These values ensure that changes to TradingBotBase don't break the backtest logic
Assert.NotNull(backtestResult);
// Financial metrics - using decimal precision
Assert.Equal(-106.56m, Math.Round(backtestResult.FinalPnl, 2));
Assert.Equal(-187.36m, Math.Round(backtestResult.NetPnl, 2));
Assert.Equal(80.80m, Math.Round(backtestResult.Fees, 2));
Assert.Equal(1000.0m, backtestResult.InitialBalance);
// Performance metrics
Assert.Equal(31, backtestResult.WinRate);
Assert.Equal(-10.66m, Math.Round(backtestResult.GrowthPercentage, 2));
Assert.Equal(-0.67m, Math.Round(backtestResult.HodlPercentage, 2));
// Risk metrics
Assert.Equal(247.62m, Math.Round(backtestResult.MaxDrawdown.Value, 2));
Assert.Equal(-0.021, Math.Round(backtestResult.SharpeRatio.Value, 3));
Assert.Equal(0.0, backtestResult.Score);
// Date range validation
Assert.Equal(new DateTime(2025, 10, 14, 12, 0, 0), backtestResult.StartDate);
Assert.Equal(new DateTime(2025, 10, 24, 11, 45, 0), backtestResult.EndDate);
// Additional validation - strategy didn't outperform HODL
Assert.True(backtestResult.GrowthPercentage < backtestResult.HodlPercentage,
"Strategy underperformed HODL as expected for this test scenario");
}
}