Files
managing-apps/src/Managing.Api/Controllers/BotController.cs
Oda 9d536ea49e Refactoring TradingBotBase.cs + clean architecture (#38)
* Refactoring TradingBotBase.cs + clean architecture

* Fix basic tests

* Fix tests

* Fix workers

* Fix open positions

* Fix closing position stucking the grain

* Fix comments

* Refactor candle handling to use IReadOnlyList for chronological order preservation across various components
2025-12-01 19:32:06 +07:00

1061 lines
40 KiB
C#

using Managing.Api.Models.Requests;
using Managing.Api.Models.Responses;
using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Services;
using Managing.Application.Hubs;
using Managing.Application.ManageBot.Commands;
using Managing.Application.Shared;
using Managing.Common;
using Managing.Core;
using Managing.Core.Exceptions;
using Managing.Domain.Accounts;
using Managing.Domain.Backtests;
using Managing.Domain.Bots;
using Managing.Domain.Indicators;
using Managing.Domain.MoneyManagements;
using Managing.Domain.Scenarios;
using Managing.Domain.Strategies;
using Managing.Domain.Trades;
using Managing.Domain.Users;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using static Managing.Common.Enums;
namespace Managing.Api.Controllers;
/// <summary>
/// Controller for bot operations such as starting, stopping, deleting, and managing bots.
/// Requires authorization for access and produces JSON responses.
/// </summary>
[ApiController]
[Authorize]
[Route("[controller]")]
[Produces("application/json")]
public class BotController : BaseController
{
private readonly IMediator _mediator;
private readonly ILogger<BotController> _logger;
private readonly IHubContext<BotHub> _hubContext;
private readonly IBacktester _backtester;
private readonly IBotService _botService;
private readonly IAccountService _accountService;
private readonly IMoneyManagementService _moneyManagementService;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAdminConfigurationService _adminService;
private readonly IConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the <see cref="BotController"/> class.
/// </summary>
/// <param name="logger">Logger for logging information.</param>
/// <param name="mediator">Mediator for handling commands and requests.</param>
/// <param name="hubContext">SignalR hub context for real-time communication.</param>
/// <param name="backtester">Backtester for running backtests on bots.</param>
/// <param name="accountService"></param>
/// <param name="moneyManagementService"></param>
/// <param name="botService"></param>
/// <param name="userService"></param>
/// <param name="scopeFactory"></param>
/// <param name="configuration">Configuration for accessing environment variables.</param>
public BotController(ILogger<BotController> logger, IMediator mediator, IHubContext<BotHub> hubContext,
IBacktester backtester, IBotService botService, IUserService userService,
IAccountService accountService, IMoneyManagementService moneyManagementService,
IServiceScopeFactory scopeFactory, IAdminConfigurationService adminService,
IConfiguration configuration) : base(userService)
{
_logger = logger;
_mediator = mediator;
_hubContext = hubContext;
_backtester = backtester;
_botService = botService;
_accountService = accountService;
_moneyManagementService = moneyManagementService;
_scopeFactory = scopeFactory;
_adminService = adminService;
_configuration = configuration;
}
/// <summary>
/// Checks if the current authenticated user owns the account associated with the specified bot or account name
/// </summary>
/// <param name="identifier">The identifier of the bot to check</param>
/// <param name="accountName">Optional account name to check when creating a new bot</param>
/// <returns>True if the user owns the account or is admin, False otherwise</returns>
private async Task<bool> UserOwnsBotAccount(Guid identifier, string accountName = null)
{
try
{
var user = await GetUser();
if (user == null)
return false;
// Admin users can access all bots
if (await _adminService.IsUserAdminAsync(user.Name))
return true;
if (identifier != default)
{
// For existing bots, check if the user owns the bot's account
var bot = await _botService.GetBotByIdentifier(identifier);
if (bot != null)
{
return bot.User != null && bot.User.Name == user.Name;
}
}
// For new bot creation, check if the user owns the account provided in the request
if (!string.IsNullOrEmpty(accountName))
{
var account = await ServiceScopeHelpers.WithScopedService<IAccountService, Account>(_scopeFactory,
async accountService => { return await accountService.GetAccount(accountName, true, false); });
// Compare the user names
return account != null && account.User != null && account.User.Name == user.Name;
}
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking if user owns bot account");
return false;
}
}
/// <summary>
/// Starts a bot with the specified parameters.
/// </summary>
/// <param name="request">The request containing bot start parameters.</param>
/// <returns>A string indicating the result of the start operation.</returns>
[HttpPost]
[Route("Start")]
public async Task<ActionResult<string>> Start(StartBotRequest request)
{
try
{
var (config, user) = await ValidateAndPrepareBotRequest(request, "start");
var result = await _mediator.Send(new StartBotCommand(config, user, false)); // createOnly = false
await NotifyBotSubscriberAsync();
return Ok(result);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already have a strategy"))
{
// Return 400 for validation errors about existing strategies on same ticker
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting bot");
return StatusCode(500, $"Error starting bot: {ex.Message}");
}
}
/// <summary>
/// Starts a copy trading bot that mirrors trades from a master bot.
/// </summary>
/// <param name="request">The request containing copy trading parameters.</param>
/// <returns>A string indicating the result of the start operation.</returns>
[HttpPost]
[Route("StartCopyTrading")]
public async Task<ActionResult<string>> StartCopyTrading(StartCopyTradingRequest request)
{
try
{
var user = await GetUser();
if (user == null)
{
return Unauthorized("User not found");
}
var result =
await _mediator.Send(new StartCopyTradingCommand(request.MasterBotIdentifier, request.BotTradingBalance,
user));
await NotifyBotSubscriberAsync();
return Ok(result);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already have a strategy"))
{
// Return 400 for validation errors about existing strategies on same ticker
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting copy trading bot");
return StatusCode(500, $"Error starting copy trading bot: {ex.Message}");
}
}
/// <summary>
/// Saves a bot configuration without starting it.
/// </summary>
/// <param name="request">The request containing bot configuration parameters.</param>
/// <returns>A string indicating the result of the save operation.</returns>
[HttpPost]
[Route("Save")]
public async Task<ActionResult<string>> Save(SaveBotRequest request)
{
try
{
var (config, user) = await ValidateAndPrepareBotRequest(request, "save");
var result = await _mediator.Send(new StartBotCommand(config, user, true)); // createOnly = true
return Ok(result);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already have a strategy"))
{
// Return 400 for validation errors about existing strategies on same ticker
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving bot");
return StatusCode(500, $"Error saving bot: {ex.Message}");
}
}
/// <summary>
/// Stops a bot specified by type and name.
/// </summary>
/// <param name="identifier">The identifier of the bot to stop.</param>
/// <returns>A string indicating the result of the stop operation.</returns>
[HttpGet]
[Route("Stop")]
public async Task<ActionResult<BotStatus>> Stop(Guid identifier)
{
try
{
// Check if user owns the account
if (!await UserOwnsBotAccount(identifier))
{
return Forbid("You don't have permission to stop this bot");
}
var result = await _mediator.Send(new StopBotCommand(identifier));
_logger.LogInformation($"Bot identifier {identifier} is now {result}");
await NotifyBotSubscriberAsync();
return Ok(result);
}
catch (ServiceUnavailableException ex)
{
// ServiceUnavailableException is already user-friendly (e.g., from Orleans exception conversion)
_logger.LogWarning(ex, "Service unavailable error stopping bot {Identifier}", identifier);
return StatusCode(503, ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping bot {Identifier}", identifier);
// Check if this is an Orleans exception that wasn't caught earlier
if (OrleansExceptionHelper.IsOrleansException(ex))
{
var userMessage = OrleansExceptionHelper.GetUserFriendlyMessage(ex, "bot stop");
_logger.LogWarning(
"Orleans exception detected in controller for bot {Identifier}: {ExceptionType}",
identifier, ex.GetType().Name);
return StatusCode(503, userMessage);
}
return StatusCode(500, $"Error stopping bot: {ex.Message}");
}
}
/// <summary>
/// Stops all active bots for the connected user.
/// </summary>
/// <returns>A boolean indicating the result of the stop all operation.</returns>
[HttpGet]
[Route("StopAll")]
public async Task<ActionResult<bool>> StopAll()
{
try
{
var user = await GetUser();
if (user == null)
{
return Unauthorized("User not found");
}
var result = await _mediator.Send(new StopAllUserBotsCommand(user));
if (result)
{
await NotifyBotSubscriberAsync();
_logger.LogInformation($"All bots stopped successfully for user {user.Name}");
}
else
{
_logger.LogWarning($"Failed to stop all bots for user {user.Name}");
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping all bots");
return StatusCode(500, $"Error stopping all bots: {ex.Message}");
}
}
/// <summary>
/// Deletes a bot specified by name.
/// </summary>
/// <param name="identifier">The identifier of the bot to delete.</param>
/// <returns>A boolean indicating the result of the delete operation.</returns>
[HttpDelete]
[Route("Delete")]
public async Task<ActionResult<bool>> Delete(Guid identifier)
{
try
{
// Check if user owns the account
if (!await UserOwnsBotAccount(identifier))
{
return Forbid("You don't have permission to delete this bot");
}
var result = await _botService.DeleteBot(identifier);
await NotifyBotSubscriberAsync();
return result ? Ok(result) : Problem($"Failed to delete bot with identifier {identifier}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting bot");
return StatusCode(500, $"Error deleting bot: {ex.Message}");
}
}
/// <summary>
/// Restarts a bot specified by type and name.
/// </summary>
/// <param name="identifier">The identifier of the bot to restart.</param>
/// <returns>A string indicating the result of the restart operation.</returns>
[HttpGet]
[Route("Restart")]
public async Task<ActionResult<string>> Restart(Guid identifier)
{
try
{
// Check if user owns the account
if (!await UserOwnsBotAccount(identifier))
{
return Forbid("You don't have permission to restart this bot");
}
var result = await _mediator.Send(new RestartBotCommand(identifier));
_logger.LogInformation($"Bot with identifier {identifier} is now {result}");
return Ok(result);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already have another strategy"))
{
// Return 400 for validation errors about existing strategies on same ticker
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error restarting bot");
return StatusCode(500, $"Error restarting bot: {ex.Message}");
}
}
/// <summary>
/// Retrieves a list of active bots.
/// </summary>
/// <returns>A list of active trading bots.</returns>
[HttpGet]
public async Task<List<TradingBotResponse>> GetActiveBots()
{
return await GetBotsByStatusAsync(BotStatus.Running);
}
/// <summary>
/// Retrieves a list of bots by status.
/// </summary>
/// <param name="status">The status to filter bots by (None, Down, Up)</param>
/// <returns>A list of trading bots with the specified status.</returns>
[HttpGet]
[Route("ByStatus/{status}")]
public async Task<List<TradingBotResponse>> GetBotsByStatus(BotStatus status)
{
return await GetBotsByStatusAsync(status);
}
/// <summary>
/// Retrieves a list of saved bots (status None) for the current user.
/// </summary>
/// <returns>A list of saved trading bots for the current user.</returns>
[HttpGet]
[Route("GetMySavedBots")]
public async Task<List<TradingBotResponse>> GetMySavedBots()
{
try
{
var user = await GetUser();
if (user == null)
{
return new List<TradingBotResponse>();
}
var result = await _mediator.Send(new GetBotsByUserAndStatusCommand(user.Id, BotStatus.Saved));
return MapBotsToTradingBotResponse(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting saved bots for user");
return new List<TradingBotResponse>();
}
}
/// <summary>
/// Retrieves a paginated list of bots with filtering and sorting capabilities.
/// </summary>
/// <param name="pageNumber">Page number (1-based). Default is 1.</param>
/// <param name="pageSize">Number of items per page. Default is 10, maximum is 100.</param>
/// <param name="status">Filter by bot status. If null, returns bots of all statuses.</param>
/// <param name="name">Filter by bot name (partial match, case-insensitive). If null, no name filtering is applied.</param>
/// <param name="ticker">Filter by ticker (partial match, case-insensitive). If null, no ticker filtering is applied.</param>
/// <param name="agentName">Filter by agent name (partial match, case-insensitive). If null, no agent name filtering is applied.</param>
/// <param name="sortBy">Sort field. Valid values: "Name", "Ticker", "Status", "CreateDate", "StartupTime", "Pnl", "WinRate", "AgentName". Default is "CreateDate".</param>
/// <param name="sortDirection">Sort direction. Default is "Desc".</param>
/// <returns>A paginated response containing trading bots</returns>
[HttpGet]
[Route("Paginated")]
public async Task<PaginatedResponse<TradingBotResponse>> GetBotsPaginated(
int pageNumber = 1,
int pageSize = 10,
BotStatus? status = null,
string? name = null,
string? ticker = null,
string? agentName = null,
BotSortableColumn sortBy = BotSortableColumn.CreateDate,
string sortDirection = "Desc")
{
try
{
// Validate parameters
if (pageNumber < 1)
{
pageNumber = 1;
}
if (pageSize < 1 || pageSize > 100)
{
pageSize = Math.Min(Math.Max(pageSize, 1), 100);
}
// Check environment variable for filtering profitable bots only
var showOnlyProfitable = _configuration.GetValue<bool>("showOnlyProfitable", false);
// Get paginated bots from service
var (bots, totalCount) = await _botService.GetBotsPaginatedAsync(
pageNumber,
pageSize,
status,
name,
ticker,
agentName,
sortBy,
sortDirection,
showOnlyProfitable);
// Map to response objects
var tradingBotResponses = MapBotsToTradingBotResponse(bots);
// Calculate pagination metadata
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
var hasPreviousPage = pageNumber > 1;
var hasNextPage = pageNumber < totalPages;
return new PaginatedResponse<TradingBotResponse>
{
Items = tradingBotResponses,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize,
TotalPages = totalPages,
HasPreviousPage = hasPreviousPage,
HasNextPage = hasNextPage
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting paginated bots");
return new PaginatedResponse<TradingBotResponse>
{
Items = new List<TradingBotResponse>(),
TotalCount = 0,
PageNumber = pageNumber,
PageSize = pageSize,
TotalPages = 0,
HasPreviousPage = false,
HasNextPage = false
};
}
}
/// <summary>
/// Retrieves a list of bots by status by sending a command to the mediator.
/// </summary>
/// <param name="status">The status to filter bots by</param>
/// <returns>A list of trading bots.</returns>
private async Task<List<TradingBotResponse>> GetBotsByStatusAsync(BotStatus status)
{
var result = await _mediator.Send(new GetBotsByStatusCommand(status));
return MapBotsToTradingBotResponse(result);
}
/// <summary>
/// Maps a collection of Bot entities to TradingBotResponse objects.
/// </summary>
/// <param name="bots">The collection of bots to map</param>
/// <returns>A list of TradingBotResponse objects</returns>
private static List<TradingBotResponse> MapBotsToTradingBotResponse(IEnumerable<Bot> bots)
{
var list = new List<TradingBotResponse>();
foreach (var item in bots)
{
list.Add(new TradingBotResponse
{
Status = item.Status.ToString(),
WinRate = (item.TradeWins + item.TradeLosses) != 0
? item.TradeWins / (item.TradeWins + item.TradeLosses)
: 0,
ProfitAndLoss = item.Pnl,
Roi = item.Roi,
Identifier = item.Identifier.ToString(),
AgentName = item.User.AgentName,
MasterAgentName = item.MasterBotUser?.AgentName,
CreateDate = item.CreateDate,
StartupTime = item.StartupTime,
Name = item.Name,
Ticker = item.Ticker,
});
}
return list;
}
/// <summary>
/// Notifies subscribers about the current list of bots via SignalR.
/// </summary>
private async Task NotifyBotSubscriberAsync()
{
var botsList = await GetBotsByStatusAsync(BotStatus.Running);
await _hubContext.Clients.All.SendAsync("BotsSubscription", botsList);
}
/// <summary>
/// Manually create a signal for a specified bot with the given parameters.
/// </summary>
/// <param name="request">The request containing position parameters.</param>
/// <returns>A response indicating the result of the operation.</returns>
[HttpPost]
[Route("CreateManualSignal")]
public async Task<ActionResult<LightSignal>> CreateManualSignalAsync([FromBody] CreateManualSignalRequest request)
{
try
{
// Check if user owns the account
if (!await UserOwnsBotAccount(request.Identifier))
{
return Forbid("You don't have permission to open positions for this bot");
}
var bot = await _botService.GetBotByIdentifier(request.Identifier);
if (bot == null)
{
return NotFound($"Bot with identifier {request.Identifier} not found or is not a trading bot");
}
if (bot.Status != BotStatus.Running)
{
return BadRequest($"Bot with identifier {request.Identifier} is not running");
}
var signal = await _botService.CreateManualSignalAsync(request.Identifier, request.Direction);
return Ok(signal);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating signal manually");
return StatusCode(500,
$"Error creating signal: {ex.Message}, {ex.InnerException?.Message} or {ex.StackTrace}");
}
}
/// <summary>
/// Closes a specific position for a trading bot
/// </summary>
/// <param name="request">The request containing the position close parameters</param>
/// <returns>The closed position or an error</returns>
[HttpPost]
[Route("ClosePosition")]
public async Task<ActionResult<Position>> ClosePosition([FromBody] ClosePositionRequest request)
{
try
{
// Check if user owns the account
if (!await UserOwnsBotAccount(request.Identifier))
{
return Forbid("You don't have permission to close positions for this bot");
}
var position = await _botService.ClosePositionAsync(request.Identifier, request.PositionId);
if (position == null)
{
return NotFound(
$"Position with ID {request.PositionId} not found for bot with identifier {request.Identifier}");
}
return Ok(position);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error closing position");
return StatusCode(500, $"Error closing position: {ex.Message}");
}
}
/// <summary>
/// Retrieves the configuration of an existing bot.
/// </summary>
/// <param name="identifier">The identifier of the bot to get configuration for</param>
/// <returns>The bot configuration</returns>
[HttpGet]
[Route("GetConfig/{identifier}")]
public async Task<ActionResult<TradingBotConfig>> GetBotConfig(Guid identifier)
{
try
{
// Check if user owns the account
if (!await UserOwnsBotAccount(identifier))
{
return Forbid("You don't have permission to view this bot's configuration");
}
var config = await _botService.GetBotConfig(identifier);
if (config == null)
{
return NotFound($"Bot with identifier {identifier} not found");
}
return Ok(config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting bot configuration for identifier {Identifier}", identifier);
return StatusCode(500, $"Error getting bot configuration: {ex.Message}");
}
}
/// <summary>
/// Updates the configuration of an existing bot.
/// </summary>
/// <param name="request">The update request containing the bot identifier and new configuration</param>
/// <returns>Success message</returns>
[HttpPut]
[Route("UpdateConfig")]
public async Task<ActionResult<string>> UpdateBotConfig([FromBody] UpdateBotConfigRequest request)
{
try
{
var user = await GetUser();
if (user == null)
{
return Unauthorized("User not found");
}
if (request.Identifier == Guid.Empty)
{
return BadRequest("Bot identifier is required");
}
if (request.Config == null)
{
return BadRequest("Bot configuration is required");
}
// First, check if the user owns the existing bot
if (!await UserOwnsBotAccount(request.Identifier))
{
return Forbid("You don't have permission to update this bot");
}
// Get the existing bot to ensure it exists and get current config
var existingBot = await _botService.GetBotByIdentifier(request.Identifier);
if (existingBot == null)
{
return NotFound($"Bot with identifier '{request.Identifier}' not found");
}
var config = await _botService.GetBotConfig(request.Identifier);
// If the account is being changed, verify the user owns the new account too
// TODO : Uncomment this for security
// if (config.AccountName != request.Config.AccountName)
// {
// if (!await UserOwnsBotAccount(request.Identifier, request.Config.AccountName))
// {
// return Forbid("You don't have permission to use this account");
// }
// }
// Validate and get the money management
LightMoneyManagement moneyManagement = null;
if (!string.IsNullOrEmpty(request.MoneyManagementName))
{
// Load money management by name
var fullMoneyManagement =
await _moneyManagementService.GetMoneyMangement(user, request.MoneyManagementName);
if (fullMoneyManagement == null)
{
return BadRequest($"Money management '{request.MoneyManagementName}' not found");
}
if (fullMoneyManagement.User?.Name != user.Name)
{
return Forbid("You don't have permission to use this money management");
}
// Convert to LightMoneyManagement
moneyManagement = new LightMoneyManagement
{
Name = fullMoneyManagement.Name,
Timeframe = fullMoneyManagement.Timeframe,
StopLoss = fullMoneyManagement.StopLoss,
TakeProfit = fullMoneyManagement.TakeProfit,
Leverage = fullMoneyManagement.Leverage
};
}
else if (request.Config.MoneyManagement != null)
{
// Use provided money management object
moneyManagement = new LightMoneyManagement
{
Name = request.Config.Name,
Timeframe = request.Config.Timeframe,
StopLoss = request.Config.MoneyManagement.StopLoss,
TakeProfit = request.Config.MoneyManagement.TakeProfit,
Leverage = request.Config.MoneyManagement.Leverage
};
}
// Validate CloseEarlyWhenProfitable requires MaxPositionTimeHours
if (request.Config.CloseEarlyWhenProfitable && !request.Config.MaxPositionTimeHours.HasValue)
{
return BadRequest("CloseEarlyWhenProfitable requires MaxPositionTimeHours to be set");
}
// Handle scenario - either from ScenarioRequest or ScenarioName
Scenario scenarioForUpdate = null;
if (request.Config.Scenario != null)
{
// Convert ScenarioRequest to Scenario domain object
scenarioForUpdate = new Scenario(request.Config.Scenario.Name, request.Config.Scenario.LoopbackPeriod)
{
User = user
};
// Convert IndicatorRequest objects to Indicator domain objects
foreach (var indicatorRequest in request.Config.Scenario.Indicators)
{
var indicator = new IndicatorBase(indicatorRequest.Name, indicatorRequest.Type)
{
SignalType = indicatorRequest.SignalType,
MinimumHistory = indicatorRequest.MinimumHistory,
Period = indicatorRequest.Period,
FastPeriods = indicatorRequest.FastPeriods,
SlowPeriods = indicatorRequest.SlowPeriods,
SignalPeriods = indicatorRequest.SignalPeriods,
Multiplier = indicatorRequest.Multiplier,
SmoothPeriods = indicatorRequest.SmoothPeriods,
StochPeriods = indicatorRequest.StochPeriods,
CyclePeriods = indicatorRequest.CyclePeriods,
User = user
};
scenarioForUpdate.AddIndicator(indicator);
}
}
// Map the request to the full TradingBotConfig
var updatedConfig = new TradingBotConfig
{
MoneyManagement = moneyManagement,
Ticker = request.Config.Ticker,
Scenario = LightScenario.FromScenario(scenarioForUpdate), // Convert to LightScenario for Orleans
ScenarioName = request.Config.ScenarioName, // Fallback to scenario name if scenario object not provided
Timeframe = request.Config.Timeframe,
IsForWatchingOnly = request.Config.IsForWatchingOnly,
BotTradingBalance = request.Config.BotTradingBalance,
CooldownPeriod = request.Config.CooldownPeriod ?? 1,
MaxLossStreak = request.Config.MaxLossStreak,
MaxPositionTimeHours = request.Config.MaxPositionTimeHours,
FlipOnlyWhenInProfit = request.Config.FlipOnlyWhenInProfit,
CloseEarlyWhenProfitable = request.Config.CloseEarlyWhenProfitable,
UseSynthApi = request.Config.UseSynthApi,
UseForPositionSizing = request.Config.UseForPositionSizing,
UseForSignalFiltering = request.Config.UseForSignalFiltering,
UseForDynamicStopLoss = request.Config.UseForDynamicStopLoss,
// Set computed/default properties
TradingType = TradingType.Futures,
FlipPosition = request.Config.FlipPosition,
Name = request.Config.Name
};
var success = await _botService.UpdateBotConfiguration(request.Identifier, updatedConfig);
if (success)
{
return Ok("Bot configuration updated successfully");
}
else
{
return BadRequest("Failed to update bot configuration");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating bot configuration for identifier {Identifier}", request.Identifier);
return StatusCode(500, $"Error updating bot configuration: {ex.Message}");
}
}
/// <summary>
/// Validates and prepares the bot request by performing all necessary validations and mapping.
/// </summary>
/// <param name="request">The bot request to validate and prepare</param>
/// <param name="operation">The operation being performed (start/save) for error messages</param>
/// <returns>The prepared TradingBotConfig and User</returns>
/// <exception cref="ArgumentException">Thrown when validation fails</exception>
/// <exception cref="UnauthorizedAccessException">Thrown when user doesn't have permission</exception>
private async Task<(TradingBotConfig, User)> ValidateAndPrepareBotRequest(StartBotRequest request,
string operation)
{
if (request.Config == null)
{
throw new ArgumentException("Bot configuration is required");
}
// Check if user owns the account specified in the request
if (!await UserOwnsBotAccount(Guid.Empty, request.Config.AccountName))
{
throw new UnauthorizedAccessException($"You don't have permission to {operation} a bot with this account");
}
// Validate that either money management name or object is provided
if (string.IsNullOrEmpty(request.Config.MoneyManagementName) && request.Config.MoneyManagement == null)
{
throw new ArgumentException("Either money management name or money management object is required");
}
var cachedUser = await GetUser();
var user = await _userService.GetUserByName(cachedUser.Name);
if (string.IsNullOrEmpty(user.AgentName))
{
throw new ArgumentException(
$"Agent name is required to {operation} a bot. Please configure your agent name in the user profile.");
}
// Get money management - either by name lookup or use provided object
LightMoneyManagement moneyManagement;
if (!string.IsNullOrEmpty(request.Config.MoneyManagementName))
{
moneyManagement =
await _moneyManagementService.GetMoneyMangement(user, request.Config.MoneyManagementName);
if (moneyManagement == null)
{
throw new ArgumentException("Money management not found");
}
}
else
{
moneyManagement = Map(request.Config.MoneyManagement);
// Format percentage values if using custom money management
moneyManagement?.FormatPercentage();
}
// Validate initialTradingBalance
if (request.Config.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
{
throw new ArgumentException(
$"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
}
// Validate cooldown period
if (request.Config.CooldownPeriod < 1)
{
throw new ArgumentException("Cooldown period must be at least 1 candle");
}
// Validate max loss streak
if (request.Config.MaxLossStreak < 0)
{
throw new ArgumentException("Max loss streak cannot be negative");
}
// Validate max position time hours
if (request.Config.MaxPositionTimeHours.HasValue && request.Config.MaxPositionTimeHours.Value <= 0)
{
throw new ArgumentException("Max position time hours must be greater than 0 if specified");
}
// Validate CloseEarlyWhenProfitable consistency
if (request.Config.CloseEarlyWhenProfitable && !request.Config.MaxPositionTimeHours.HasValue)
{
throw new ArgumentException(
"CloseEarlyWhenProfitable can only be enabled when MaxPositionTimeHours is set");
}
// Handle scenario - either from ScenarioRequest or ScenarioName
Scenario scenario = null;
if (request.Config.Scenario != null)
{
// Convert ScenarioRequest to Scenario domain object
scenario = new Scenario(request.Config.Scenario.Name, request.Config.Scenario.LoopbackPeriod)
{
User = user
};
// Convert IndicatorRequest objects to Indicator domain objects
foreach (var indicatorRequest in request.Config.Scenario.Indicators)
{
var indicator = new IndicatorBase(indicatorRequest.Name, indicatorRequest.Type)
{
SignalType = indicatorRequest.SignalType,
MinimumHistory = indicatorRequest.MinimumHistory,
Period = indicatorRequest.Period,
FastPeriods = indicatorRequest.FastPeriods,
SlowPeriods = indicatorRequest.SlowPeriods,
SignalPeriods = indicatorRequest.SignalPeriods,
Multiplier = indicatorRequest.Multiplier,
SmoothPeriods = indicatorRequest.SmoothPeriods,
StochPeriods = indicatorRequest.StochPeriods,
CyclePeriods = indicatorRequest.CyclePeriods,
User = user
};
scenario.AddIndicator(indicator);
}
}
// Map the request to the full TradingBotConfig
var config = new TradingBotConfig
{
AccountName = request.Config.AccountName,
MoneyManagement = moneyManagement,
Ticker = request.Config.Ticker,
Scenario = LightScenario.FromScenario(scenario), // Convert to LightScenario for Orleans
ScenarioName = request.Config.ScenarioName, // Fallback to scenario name if scenario object not provided
Timeframe = request.Config.Timeframe,
IsForWatchingOnly = request.Config.IsForWatchingOnly,
BotTradingBalance = request.Config.BotTradingBalance,
CooldownPeriod = request.Config.CooldownPeriod ?? 1,
MaxLossStreak = request.Config.MaxLossStreak,
MaxPositionTimeHours = request.Config.MaxPositionTimeHours,
FlipOnlyWhenInProfit = request.Config.FlipOnlyWhenInProfit,
CloseEarlyWhenProfitable = request.Config.CloseEarlyWhenProfitable,
UseSynthApi = request.Config.UseSynthApi,
UseForPositionSizing = request.Config.UseForPositionSizing,
UseForSignalFiltering = request.Config.UseForSignalFiltering,
UseForDynamicStopLoss = request.Config.UseForDynamicStopLoss,
// Set computed/default properties
TradingType = TradingType.Futures,
FlipPosition = request.Config.FlipPosition,
Name = request.Config.Name
};
return (config, user);
}
public MoneyManagement Map(MoneyManagementRequest moneyManagementRequest)
{
return new MoneyManagement
{
Name = moneyManagementRequest.Name,
StopLoss = moneyManagementRequest.StopLoss,
TakeProfit = moneyManagementRequest.TakeProfit,
Leverage = moneyManagementRequest.Leverage,
Timeframe = moneyManagementRequest.Timeframe
};
}
}
/// <summary>
/// Request model for opening a position manually
/// </summary>
public class CreateManualSignalRequest
{
/// <summary>
/// The identifier of the bot
/// </summary>
public Guid Identifier { get; set; }
/// <summary>
/// The direction of the position
/// </summary>
public TradeDirection Direction { get; set; }
}
/// <summary>
/// Request model for closing a position
/// </summary>
public class ClosePositionRequest
{
/// <summary>
/// The identifier of the bot
/// </summary>
public Guid Identifier { get; set; }
/// <summary>
/// The ID of the position to close
/// </summary>
public Guid PositionId { get; set; }
}
/// <summary>
/// Request model for starting a bot
/// </summary>
public class StartBotRequest
{
/// <summary>
/// The trading bot configuration request with primary properties
/// </summary>
public TradingBotConfigRequest Config { get; set; }
}
public class SaveBotRequest : StartBotRequest
{
}
/// <summary>
/// Request model for starting a copy trading bot
/// </summary>
public class StartCopyTradingRequest
{
/// <summary>
/// The identifier of the master bot to copy trades from
/// </summary>
public Guid MasterBotIdentifier { get; set; }
/// <summary>
/// The trading balance for the copy trading bot
/// </summary>
public decimal BotTradingBalance { get; set; }
}