238 lines
9.9 KiB
C#
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");
|
|
}
|
|
} |