Add test to check if backtest behavior changed
This commit is contained in:
238
src/Managing.Application.Tests/BacktestTests.cs
Normal file
238
src/Managing.Application.Tests/BacktestTests.cs
Normal 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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user