Add agent fees

This commit is contained in:
2025-09-28 20:57:42 +07:00
parent fd2387932e
commit 16a56bd26c
20 changed files with 108 additions and 166 deletions

View File

@@ -391,7 +391,7 @@ public class DataController : ControllerBase
try
{
// Get all agent summaries
var allAgentSummaries = await _mediator.Send(new GetAllAgentSummariesCommand("Total"));
var allAgentSummaries = await _mediator.Send(new GetAllAgentSummariesCommand());
// Filter agents with valid PnL data and order by PnL
var agentsWithPnL = allAgentSummaries
@@ -638,6 +638,7 @@ public class DataController : ControllerBase
ActiveStrategiesCount = agentSummary.ActiveStrategiesCount,
TotalVolume = agentSummary.TotalVolume,
TotalBalance = agentSummary.TotalBalance,
TotalFees = agentSummary.TotalFees,
};
agentSummaryViewModels.Add(agentSummaryViewModel);

View File

@@ -47,6 +47,11 @@ namespace Managing.Api.Models.Responses
/// Total balance including USDC and open position values (without leverage, including PnL)
/// </summary>
public decimal TotalBalance { get; set; }
/// <summary>
/// Total fees paid by this agent across all positions
/// </summary>
public decimal TotalFees { get; set; }
}
/// <summary>

View File

@@ -26,6 +26,12 @@ public interface IPlatformSummaryGrain : IGrainWithStringKey
[OneWay]
Task UpdateActiveStrategyCountAsync(int newActiveCount);
/// <summary>
/// Increments the total agent count when a new agent is activated
/// </summary>
[OneWay]
Task IncrementAgentCountAsync();
[OneWay]
Task OnPositionClosedAsync(PositionClosedEvent evt);

View File

@@ -1,14 +1,13 @@
using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Models;
using Managing.Application.Abstractions.Services;
using Managing.Application.Bots.Grains;
using Managing.Application.Bots.Models;
using Managing.Domain.Bots;
using Managing.Domain.Statistics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using static Managing.Common.Enums;
namespace Managing.Application.Tests;
@@ -22,6 +21,7 @@ public class AgentGrainTests
private readonly Mock<IUserService> _mockUserService;
private readonly Mock<IAccountService> _mockAccountService;
private readonly Mock<ITradingService> _mockTradingService;
private readonly Mock<IServiceScopeFactory> _mockScopeFactory;
public AgentGrainTests()
{
@@ -33,6 +33,7 @@ public class AgentGrainTests
_mockUserService = new Mock<IUserService>();
_mockAccountService = new Mock<IAccountService>();
_mockTradingService = new Mock<ITradingService>();
_mockScopeFactory = new Mock<IServiceScopeFactory>();
// Setup default state
_mockState.Setup(x => x.State).Returns(new AgentGrainState
@@ -42,51 +43,6 @@ public class AgentGrainTests
});
}
[Fact]
public async Task OnAgentSummaryUpdateAsync_WithValidBotId_ShouldCallUpdateSummary()
{
// Arrange
var agentGrain = CreateAgentGrain();
var botId = _mockState.Object.State.BotIds.First();
var updateEvent = new AgentSummaryUpdateEvent
{
BotId = botId,
EventType = NotificationEventType.PositionOpened,
Timestamp = DateTime.UtcNow
};
// Setup mocks
_mockBotService.Setup(x => x.GetBotsByIdsAsync(It.IsAny<HashSet<Guid>>()))
.ReturnsAsync(new List<Bot>());
_mockAgentService.Setup(x => x.SaveOrUpdateAgentSummary(It.IsAny<AgentSummary>()))
.Returns(Task.CompletedTask);
// Act
await agentGrain.OnAgentSummaryUpdateAsync(updateEvent);
// Assert
_mockAgentService.Verify(x => x.SaveOrUpdateAgentSummary(It.IsAny<AgentSummary>()), Times.Once);
}
[Fact]
public async Task OnAgentSummaryUpdateAsync_WithInvalidBotId_ShouldNotCallUpdateSummary()
{
// Arrange
var agentGrain = CreateAgentGrain();
var updateEvent = new AgentSummaryUpdateEvent
{
BotId = Guid.NewGuid(), // Different bot ID
EventType = NotificationEventType.PositionOpened,
Timestamp = DateTime.UtcNow
};
// Act
await agentGrain.OnAgentSummaryUpdateAsync(updateEvent);
// Assert
_mockAgentService.Verify(x => x.SaveOrUpdateAgentSummary(It.IsAny<AgentSummary>()), Times.Never);
}
[Fact]
public async Task RegisterBotAsync_ShouldUpdateSummary()
{
@@ -137,6 +93,7 @@ public class AgentGrainTests
_mockExchangeService.Object,
_mockUserService.Object,
_mockAccountService.Object,
_mockTradingService.Object);
_mockTradingService.Object,
_mockScopeFactory.Object);
}
}

View File

@@ -56,10 +56,10 @@ public class AgentGrain : Grain, IAgentGrain
_scopeFactory = scopeFactory;
}
public override Task OnActivateAsync(CancellationToken cancellationToken)
public override async Task OnActivateAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("AgentGrain activated for user {UserId}", this.GetPrimaryKeyLong());
return base.OnActivateAsync(cancellationToken);
await base.OnActivateAsync(cancellationToken);
}
public async Task InitializeAsync(int userId, string agentName)
@@ -86,6 +86,14 @@ public class AgentGrain : Grain, IAgentGrain
await _agentService.SaveOrUpdateAgentSummary(emptySummary);
_logger.LogInformation("Agent {UserId} initialized with name {AgentName} and empty summary", userId, agentName);
// Notify platform summary about new agent activation
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
{
var platformGrain = grainFactory.GetGrain<IPlatformSummaryGrain>("platform-summary");
await platformGrain.IncrementAgentCountAsync();
_logger.LogDebug("Notified platform summary about new agent activation for user {UserId}", userId);
});
}
public async Task UpdateAgentNameAsync(string agentName)
@@ -162,14 +170,19 @@ public class AgentGrain : Grain, IAgentGrain
var totalVolume = positions.Sum(p => p.Open.Price * p.Open.Quantity * p.Open.Leverage);
var collateral = positions.Sum(p => p.Open.Price * p.Open.Quantity);
var totalFees = positions.Sum(p => p.CalculateTotalFees());
// Store total fees in grain state for caching
_state.State.TotalFees = totalFees;
// Calculate wins/losses from position PnL
var totalWins = positions.Count(p => (p.ProfitAndLoss?.Realized ?? 0) > 0);
var totalLosses = positions.Count(p => (p.ProfitAndLoss?.Realized ?? 0) <= 0);
// Calculate ROI based on PnL minus fees
var netPnL = totalPnL - totalFees;
var totalROI = collateral switch
{
> 0 => (totalPnL / collateral) * 100,
> 0 => (netPnL / collateral) * 100,
>= 0 => 0,
_ => 0
};
@@ -223,7 +236,7 @@ public class AgentGrain : Grain, IAgentGrain
{
UserId = (int)this.GetPrimaryKeyLong(),
AgentName = _state.State.AgentName,
TotalPnL = totalPnL,
TotalPnL = totalPnL, // Use net PnL without fees
Wins = totalWins,
Losses = totalLosses,
TotalROI = totalROI,
@@ -231,13 +244,14 @@ public class AgentGrain : Grain, IAgentGrain
ActiveStrategiesCount = activeStrategiesCount,
TotalVolume = totalVolume,
TotalBalance = totalBalance,
TotalFees = totalFees,
};
// Save summary to database
await _agentService.SaveOrUpdateAgentSummary(summary);
_logger.LogDebug("Updated agent summary from position data for user {UserId}: PnL={PnL}, Volume={Volume}, Wins={Wins}, Losses={Losses}",
this.GetPrimaryKeyLong(), totalPnL, totalVolume, totalWins, totalLosses);
_logger.LogDebug("Updated agent summary from position data for user {UserId}: NetPnL={NetPnL}, TotalPnL={TotalPnL}, Fees={Fees}, Volume={Volume}, Wins={Wins}, Losses={Losses}",
this.GetPrimaryKeyLong(), netPnL, totalPnL, totalFees, totalVolume, totalWins, totalLosses);
}
catch (Exception ex)
{

View File

@@ -24,6 +24,12 @@ namespace Managing.Application.Bots.Models
/// </summary>
[Id(4)]
public CachedBalanceData? CachedBalanceData { get; set; } = null;
/// <summary>
/// Total fees paid by this agent across all positions
/// </summary>
[Id(5)]
public decimal TotalFees { get; set; } = 0;
}
/// <summary>

View File

@@ -406,10 +406,13 @@ public class TradingBotBase : ITradingBot
await UpdatePositionDatabase(internalPosition);
if (previousPositionStatus != PositionStatus.Filled && internalPosition.Status == PositionStatus.Filled)
if (previousPositionStatus != PositionStatus.Filled &&
internalPosition.Status == PositionStatus.Filled)
{
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionOpened, internalPosition);
}else{
}
else
{
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, internalPosition);
}
}
@@ -750,8 +753,6 @@ public class TradingBotBase : ITradingBot
currentPrice, true);
}
}
}
catch (Exception ex)
{
@@ -763,10 +764,8 @@ public class TradingBotBase : ITradingBot
private async Task UpdatePositionDatabase(Position position)
{
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory, async tradingService =>
{
await tradingService.UpdatePositionAsync(position);
});
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory,
async tradingService => { await tradingService.UpdatePositionAsync(position); });
}
private async Task<Position> OpenPosition(LightSignal signal)
@@ -1347,6 +1346,7 @@ public class TradingBotBase : ITradingBot
// Update position in database with all trade changes
if (!Config.IsForBacktest)
{
position.Status = PositionStatus.Finished;
await UpdatePositionDatabase(position);
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionClosed, position);
}
@@ -1468,10 +1468,13 @@ public class TradingBotBase : ITradingBot
{
if (Positions[identifier].ProfitAndLoss == null)
{
Positions[identifier].ProfitAndLoss = new ProfitAndLoss(){
Positions[identifier].ProfitAndLoss = new ProfitAndLoss()
{
Realized = realized
};
}else{
}
else
{
Positions[identifier].ProfitAndLoss.Realized = realized;
}
}

View File

@@ -76,11 +76,16 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
_state.State.DailySnapshots.Add(initialSnapshot);
_state.State.LastSnapshot = initialSnapshot.Date;
_state.State.LastUpdated = initialSnapshot.Date;
_logger.LogInformation("Created initial empty daily snapshot for {Date}", today);
}
_state.State.TotalAgents = await _agentService.GetTotalAgentCount();
await RefreshDataAsync();
}
await base.OnActivateAsync(cancellationToken);
}
public async Task<PlatformSummaryGrainState> GetPlatformSummaryAsync()
@@ -129,7 +134,7 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
// Calculate fees and PnL for all positions
totalFees += position.CalculateTotalFees();
totalPnL += position.ProfitAndLoss?.Realized ?? 0;
// Count all positions
totalPositionCount++;
@@ -142,6 +147,7 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
{
_state.State.VolumeByAsset[ticker] = 0;
}
_state.State.VolumeByAsset[ticker] += positionVolume;
// Position count breakdown by asset - update state directly
@@ -149,6 +155,7 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
{
_state.State.PositionCountByAsset[ticker] = 0;
}
_state.State.PositionCountByAsset[ticker]++;
// Position count breakdown by direction - update state directly
@@ -156,10 +163,10 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
{
_state.State.PositionCountByDirection[direction] = 0;
}
_state.State.PositionCountByDirection[direction]++;
}
_state.State.TotalAgents = await _agentService.GetTotalAgentCount();
_state.State.TotalPlatformVolume = totalVolume;
_state.State.TotalPlatformFees = totalFees;
_state.State.TotalPlatformPnL = totalPnL;
@@ -169,7 +176,7 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
_logger.LogDebug(
"Updated position breakdown from positions: {AssetCount} assets, Long={LongPositions}, Short={ShortPositions}",
_state.State.PositionCountByAsset.Count,
_state.State.PositionCountByAsset.Count,
_state.State.PositionCountByDirection.GetValueOrDefault(TradeDirection.Long, 0),
_state.State.PositionCountByDirection.GetValueOrDefault(TradeDirection.Short, 0));
@@ -207,6 +214,24 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
}
}
public async Task IncrementAgentCountAsync()
{
try
{
_logger.LogInformation("Incrementing agent count from {CurrentCount} to {NewCount}",
_state.State.TotalAgents, _state.State.TotalAgents + 1);
_state.State.TotalAgents++;
await _state.WriteStateAsync();
_logger.LogInformation("Agent count incremented to: {NewCount}", _state.State.TotalAgents);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error incrementing agent count");
}
}
public async Task OnPositionClosedAsync(PositionClosedEvent evt)
{
try
@@ -282,20 +307,6 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable
await _state.WriteStateAsync();
}
private async Task RefreshPnLFromDatabaseAsync()
{
try
{
var totalPnL = await _tradingService.GetGlobalPnLFromPositionsAsync();
_state.State.TotalPlatformPnL = totalPnL;
_logger.LogDebug("Refreshed PnL from database: {TotalPnL}", totalPnL);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing PnL from database");
}
}
private bool IsDataStale()
{
var timeSinceLastUpdate = DateTime.UtcNow - _state.State.LastUpdated;

View File

@@ -8,14 +8,8 @@ namespace Managing.Application.ManageBot.Commands
/// </summary>
public class GetAllAgentSummariesCommand : IRequest<IEnumerable<AgentSummary>>
{
/// <summary>
/// Optional time filter to apply (24H, 3D, 1W, 1M, 1Y, Total)
/// </summary>
public string TimeFilter { get; }
public GetAllAgentSummariesCommand(string timeFilter = "Total")
public GetAllAgentSummariesCommand()
{
TimeFilter = timeFilter;
}
}
}

View File

@@ -9,14 +9,8 @@ namespace Managing.Application.ManageBot.Commands
/// </summary>
public class GetAllAgentsCommand : IRequest<Dictionary<User, List<Bot>>>
{
/// <summary>
/// Optional time filter to apply (24H, 3D, 1W, 1M, 1Y, Total)
/// </summary>
public string TimeFilter { get; }
public GetAllAgentsCommand(string timeFilter = "Total")
public GetAllAgentsCommand()
{
TimeFilter = timeFilter;
}
}
}

View File

@@ -24,31 +24,8 @@ namespace Managing.Application.ManageBot
// Get all agent summaries from the database
var allAgentSummaries = await _agentService.GetAllAgentSummaries();
if (request.TimeFilter != "Total")
{
var cutoffDate = GetCutoffDate(request.TimeFilter);
allAgentSummaries = allAgentSummaries.Where(a =>
a.UpdatedAt >= cutoffDate ||
(a.Runtime.HasValue && a.Runtime.Value >= cutoffDate));
}
return allAgentSummaries;
}
/// <summary>
/// Gets the cutoff date based on the time filter
/// </summary>
private DateTime GetCutoffDate(string timeFilter)
{
return timeFilter switch
{
"24H" => DateTime.UtcNow.AddHours(-24),
"3D" => DateTime.UtcNow.AddDays(-3),
"1W" => DateTime.UtcNow.AddDays(-7),
"1M" => DateTime.UtcNow.AddMonths(-1),
"1Y" => DateTime.UtcNow.AddYears(-1),
_ => DateTime.MinValue // Default to include all data
};
}
}
}

View File

@@ -35,35 +35,11 @@ namespace Managing.Application.ManageBot
var userBots = await _botService.GetBotsByUser(user.Id);
var botList = userBots.ToList();
// Apply time filter if specified
if (request.TimeFilter != "Total")
{
var cutoffDate = GetCutoffDate(request.TimeFilter);
botList = botList.Where(bot =>
bot.StartupTime >= cutoffDate ||
bot.CreateDate >= cutoffDate).ToList();
}
result[user] = botList;
}
return result;
}
/// <summary>
/// Gets the cutoff date based on the time filter
/// </summary>
private DateTime GetCutoffDate(string timeFilter)
{
return timeFilter switch
{
"24H" => DateTime.UtcNow.AddHours(-24),
"3D" => DateTime.UtcNow.AddDays(-3),
"1W" => DateTime.UtcNow.AddDays(-7),
"1M" => DateTime.UtcNow.AddMonths(-1),
"1Y" => DateTime.UtcNow.AddYears(-1),
_ => DateTime.MinValue // Default to include all data
};
}
}
}

View File

@@ -45,25 +45,19 @@ namespace Managing.Application.Trading.Handlers
}
// Gas fee check for EVM exchanges
decimal gasFeeUsd = 0;
if (!request.IsForPaperTrading)
{
if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2)
{
gasFeeUsd = await exchangeService.GetFee(account);
if (gasFeeUsd > Constants.GMX.Config.MaximumGasFeeUsd)
var currentGasFees = await exchangeService.GetFee(account);
if (currentGasFees > Constants.GMX.Config.MaximumGasFeeUsd)
{
throw new InsufficientFundsException(
$"Gas fee too high for position opening: {gasFeeUsd:F2} USD (threshold: {Constants.GMX.Config.MaximumGasFeeUsd} USD). Position opening cancelled.",
$"Gas fee too high for position opening: {currentGasFees:F2} USD (threshold: {Constants.GMX.Config.MaximumGasFeeUsd} USD). Position opening cancelled.",
InsufficientFundsType.InsufficientEth);
}
}
}
else
{
gasFeeUsd = Constants.GMX.Config.GasFeePerTransaction;
}
var price = request.IsForPaperTrading && request.Price.HasValue
? request.Price.Value
@@ -94,19 +88,11 @@ namespace Managing.Application.Trading.Handlers
position.Open = trade;
// Calculate and set fees for the position
var positionSizeUsd = (position.Open.Price * position.Open.Quantity) * position.Open.Leverage;
// Set gas fees (only for EVM exchanges)
if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2)
{
position.GasFees = gasFeeUsd;
}
else
{
position.GasFees = TradingHelpers.CalculateOpeningGasFees();
}
position.GasFees = TradingHelpers.CalculateOpeningGasFees();
// Set UI fees for opening
var positionSizeUsd = TradingHelpers.GetVolumeForPosition(position);
position.UiFees = TradingHelpers.CalculateOpeningUiFees(positionSizeUsd);
var closeDirection = request.Direction == TradeDirection.Long

View File

@@ -116,6 +116,8 @@ public class UserService : IUserService
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
await agentGrain.InitializeAsync(user.Id, string.Empty);
_logger.LogInformation("AgentGrain initialized for new user {UserId}", user.Id);
}
catch (Exception ex)
{

View File

@@ -55,7 +55,7 @@ services:
- managing-network
restart: unless-stopped
environment:
- REDIS_PASSWORD=SuperSecretPassword
- REDIS_PASSWORD=
command: >
sh -c "
if [ -n \"$$REDIS_PASSWORD\" ]; then
@@ -65,4 +65,4 @@ services:
redis-server --appendonly yes
redis-cli
fi
"
"SuperSecretPassword

View File

@@ -47,4 +47,7 @@ public class AgentSummary
[Id(13)]
public decimal TotalBalance { get; set; }
[Id(14)]
public decimal TotalFees { get; set; }
}

View File

@@ -99,6 +99,9 @@ namespace Managing.Infrastructure.Databases.Migrations
.HasPrecision(18, 8)
.HasColumnType("numeric(18,8)");
b.Property<decimal>("TotalFees")
.HasColumnType("numeric");
b.Property<decimal>("TotalPnL")
.HasColumnType("decimal(18,8)");

View File

@@ -249,7 +249,8 @@ public class AgentSummaryRepository : IAgentSummaryRepository
UpdatedAt = domain.UpdatedAt,
ActiveStrategiesCount = domain.ActiveStrategiesCount,
TotalVolume = domain.TotalVolume,
TotalBalance = domain.TotalBalance
TotalBalance = domain.TotalBalance,
TotalFees = domain.TotalFees
};
}
@@ -265,6 +266,7 @@ public class AgentSummaryRepository : IAgentSummaryRepository
entity.ActiveStrategiesCount = domain.ActiveStrategiesCount;
entity.TotalVolume = domain.TotalVolume;
entity.TotalBalance = domain.TotalBalance;
entity.TotalFees = domain.TotalFees;
}
private static AgentSummary MapToDomain(AgentSummaryEntity entity)
@@ -284,6 +286,7 @@ public class AgentSummaryRepository : IAgentSummaryRepository
ActiveStrategiesCount = entity.ActiveStrategiesCount,
TotalVolume = entity.TotalVolume,
TotalBalance = entity.TotalBalance,
TotalFees = entity.TotalFees,
User = PostgreSqlMappers.Map(entity.User)
};
}

View File

@@ -15,6 +15,7 @@ public class AgentSummaryEntity
public int ActiveStrategiesCount { get; set; }
public decimal TotalVolume { get; set; }
public decimal TotalBalance { get; set; }
public decimal TotalFees { get; set; }
// Navigation property
public UserEntity User { get; set; }

View File

@@ -82,7 +82,7 @@
"prettier-plugin-tailwind-css": "^1.5.0",
"tailwindcss": "^3.0.23",
"typescript": "^5.7.3",
"vite": "^6.3.5",
"vite": "^6.3.6",
"whatwg-fetch": "^3.6.2"
},
"msw": {