Add test to check if backtest behavior changed

This commit is contained in:
2025-10-24 19:08:10 +07:00
parent fc4369a008
commit 38e6998ff3
12 changed files with 11797 additions and 38 deletions

View File

@@ -0,0 +1,238 @@
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(-105.45m, Math.Round(backtestResult.FinalPnl, 2));
Assert.Equal(-210.40m, Math.Round(backtestResult.NetPnl, 2));
Assert.Equal(104.94m, Math.Round(backtestResult.Fees, 2));
Assert.Equal(1000.0m, backtestResult.InitialBalance);
// Performance metrics
Assert.Equal(31, backtestResult.WinRate);
Assert.Equal(-10.55m, Math.Round(backtestResult.GrowthPercentage, 2));
Assert.Equal(-0.67m, Math.Round(backtestResult.HodlPercentage, 2));
// Risk metrics
Assert.Equal(243.84m, 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");
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,15 +8,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MathNet.Numerics" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.TestPlatform.AdapterUtilities" Version="17.9.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="MSTest.TestAdapter" Version="3.3.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.3.1" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="MathNet.Numerics" Version="5.0.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.5"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.7"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0"/>
<PackageReference Include="Microsoft.TestPlatform.AdapterUtilities" Version="17.9.0"/>
<PackageReference Include="Moq" Version="4.20.70"/>
<PackageReference Include="MSTest.TestAdapter" Version="3.3.1"/>
<PackageReference Include="MSTest.TestFramework" Version="3.3.1"/>
<PackageReference Include="xunit" Version="2.8.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -24,23 +24,26 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Managing.Api\Managing.Api.csproj" />
<ProjectReference Include="..\Managing.Application\Managing.Application.csproj" />
<ProjectReference Include="..\Managing.Common\Managing.Common.csproj" />
<ProjectReference Include="..\Managing.Core\Managing.Core.csproj" />
<ProjectReference Include="..\Managing.Domain\Managing.Domain.csproj" />
<ProjectReference Include="..\Managing.Infrastructure.Exchanges\Managing.Infrastructure.Exchanges.csproj" />
<ProjectReference Include="..\Managing.Infrastructure.Tests\Managing.Infrastructure.Tests.csproj" />
<ProjectReference Include="..\Managing.Api\Managing.Api.csproj"/>
<ProjectReference Include="..\Managing.Application\Managing.Application.csproj"/>
<ProjectReference Include="..\Managing.Common\Managing.Common.csproj"/>
<ProjectReference Include="..\Managing.Core\Managing.Core.csproj"/>
<ProjectReference Include="..\Managing.Domain\Managing.Domain.csproj"/>
<ProjectReference Include="..\Managing.Infrastructure.Exchanges\Managing.Infrastructure.Exchanges.csproj"/>
<ProjectReference Include="..\Managing.Infrastructure.Tests\Managing.Infrastructure.Tests.csproj"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Data\" />
<Folder Include="Data\"/>
</ItemGroup>
<ItemGroup>
<None Update="Data\candles.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Data\ETH-FifteenMinutes-candles.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1250,15 +1250,14 @@ public class TradingBotBase : ITradingBot
Logger.LogDebug(
$"🔍 Fetching Position History from GMX\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
List<Position> positionHistory = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
_scopeFactory,
async exchangeService =>
{
// Get position history from the last 24 hours
var fromDate = DateTime.UtcNow.AddHours(-1);
var toDate = DateTime.UtcNow;
positionHistory =
await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate);
return await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate);
});
// Find the matching position in history based on the most recent closed position
@@ -1349,9 +1348,11 @@ public class TradingBotBase : ITradingBot
{
}
}
Logger.LogWarning(
$"⚠️ No GMX Position History Found\nPosition: `{position.Identifier}`\nFalling back to candle-based calculation");
else
{
Logger.LogWarning(
$"⚠️ No GMX Position History Found\nPosition: `{position.Identifier}`\nFalling back to candle-based calculation");
}
}
catch (Exception ex)
{
@@ -2537,7 +2538,7 @@ public class TradingBotBase : ITradingBot
async exchangeService =>
{
// Get position history from the last 24 hours
var fromDate = position.Date.AddMinutes(-5);
var fromDate = DateTime.UtcNow.AddMinutes(-10);
var toDate = DateTime.UtcNow;
positionHistory =
await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate);

View File

@@ -135,7 +135,7 @@ public static class ScenarioHelpers
int? smoothPeriods = null,
int? cyclePeriods = null)
{
IIndicator indicator = null;
IIndicator indicator = new IndicatorBase(name, type);
switch (type)
{

View File

@@ -5,7 +5,7 @@
# Configuration
BASE_URL="http://localhost:4111"
ACCOUNT="0xb54a2f65D79bDeD20F9cBd9a1F85C3855EC3c210"
ACCOUNT="0x987b67313ee4827FE55e1FBcd8883D3bb0Bde83b"
# Calculate dates (last 1 hour)
TO_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")

View File

@@ -4,7 +4,7 @@ import {getClientForAddress, getPositionHistoryImpl} from '../../src/plugins/cus
test('GMX get position history - Closed positions with actual PnL', async (t) => {
await t.test('should get closed positions with actual GMX PnL data', async () => {
const sdk = await getClientForAddress('0xb54a2f65d79bded20f9cbd9a1f85c3855ec3c210')
const sdk = await getClientForAddress('0x987b67313ee4827FE55e1FBcd8883D3bb0Bde83b')
const result = await getPositionHistoryImpl(
sdk,
@@ -50,7 +50,7 @@ test('GMX get position history - Closed positions with actual PnL', async (t) =>
})
await t.test('should get closed positions with date range', async () => {
const sdk = await getClientForAddress('0xb54a2f65D79bDeD20F9cBd9a1F85C3855EC3c210')
const sdk = await getClientForAddress('0x987b67313ee4827FE55e1FBcd8883D3bb0Bde83b')
// Get positions from the last 1 hour
const toDate = new Date()
@@ -89,7 +89,7 @@ test('GMX get position history - Closed positions with actual PnL', async (t) =>
})
await t.test('should verify PnL data is suitable for reconciliation', async () => {
const sdk = await getClientForAddress('0xb54a2f65d79bded20f9cbd9a1f85c3855ec3c210')
const sdk = await getClientForAddress('0x987b67313ee4827FE55e1FBcd8883D3bb0Bde83b')
const result = await getPositionHistoryImpl(sdk, 0, 5)

View File

@@ -1,10 +1,10 @@
import { test } from 'node:test'
import {test} from 'node:test'
import assert from 'node:assert'
import { getClientForAddress, getGmxPositionsImpl } from '../../src/plugins/custom/gmx'
import {getClientForAddress, getGmxPositionsImpl} from '../../src/plugins/custom/gmx'
test('GMX get positions', async (t) => {
await t.test('should get positions', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
const sdk = await getClientForAddress('0x987b67313ee4827FE55e1FBcd8883D3bb0Bde83b')
const result = await getGmxPositionsImpl(
sdk