* 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
1061 lines
40 KiB
C#
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; }
|
|
} |