Add copy trading functionality with StartCopyTrading endpoint and related models. Implemented position copying from master bot and subscription to copy trading stream in LiveTradingBotGrain. Updated TradingBotConfig to support copy trading parameters.
This commit is contained in:
@@ -146,6 +146,35 @@ public class BotController : BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error starting copy trading bot");
|
||||||
|
return StatusCode(500, $"Error starting copy trading bot: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves a bot configuration without starting it.
|
/// Saves a bot configuration without starting it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -964,3 +993,19 @@ public class StartBotRequest
|
|||||||
public class SaveBotRequest : StartBotRequest
|
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; }
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using Managing.Domain.Trades;
|
|||||||
using Managing.Domain.Users;
|
using Managing.Domain.Users;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Orleans.Streams;
|
||||||
using static Managing.Common.Enums;
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
namespace Managing.Application.Bots.Grains;
|
namespace Managing.Application.Bots.Grains;
|
||||||
@@ -31,6 +32,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
|||||||
private TradingBotBase? _tradingBot;
|
private TradingBotBase? _tradingBot;
|
||||||
private IDisposable? _timer;
|
private IDisposable? _timer;
|
||||||
private string _reminderName = "RebootReminder";
|
private string _reminderName = "RebootReminder";
|
||||||
|
private StreamSubscriptionHandle<Position>? _copyTradingStreamHandle;
|
||||||
|
|
||||||
public LiveTradingBotGrain(
|
public LiveTradingBotGrain(
|
||||||
[PersistentState("live-trading-bot", "bot-store")]
|
[PersistentState("live-trading-bot", "bot-store")]
|
||||||
@@ -222,6 +224,9 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
|||||||
RegisterAndStartTimer();
|
RegisterAndStartTimer();
|
||||||
await RegisterReminder();
|
await RegisterReminder();
|
||||||
|
|
||||||
|
// Subscribe to copy trading stream if configured
|
||||||
|
await SubscribeToCopyTradingStreamAsync();
|
||||||
|
|
||||||
// Update both database and registry status
|
// Update both database and registry status
|
||||||
await SaveBotAsync(BotStatus.Running);
|
await SaveBotAsync(BotStatus.Running);
|
||||||
await UpdateBotRegistryStatus(BotStatus.Running);
|
await UpdateBotRegistryStatus(BotStatus.Running);
|
||||||
@@ -337,6 +342,9 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
|||||||
StopAndDisposeTimer();
|
StopAndDisposeTimer();
|
||||||
await UnregisterReminder();
|
await UnregisterReminder();
|
||||||
|
|
||||||
|
// Unsubscribe from copy trading stream
|
||||||
|
await UnsubscribeFromCopyTradingStreamAsync();
|
||||||
|
|
||||||
// Track runtime: accumulate current session runtime when stopping
|
// Track runtime: accumulate current session runtime when stopping
|
||||||
if (_state.State.LastStartTime.HasValue)
|
if (_state.State.LastStartTime.HasValue)
|
||||||
{
|
{
|
||||||
@@ -386,6 +394,76 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to the copy trading stream if this bot is configured for copy trading
|
||||||
|
/// </summary>
|
||||||
|
private async Task SubscribeToCopyTradingStreamAsync()
|
||||||
|
{
|
||||||
|
// Only subscribe if this is a copy trading bot and we have a master bot identifier
|
||||||
|
if (!_state.State.Config.IsForCopyTrading || !_state.State.Config.MasterBotIdentifier.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var streamProvider = this.GetStreamProvider("ManagingStreamProvider");
|
||||||
|
var streamId = StreamId.Create("CopyTrading", _state.State.Config.MasterBotIdentifier.Value);
|
||||||
|
_copyTradingStreamHandle = await streamProvider.GetStream<Position>(streamId)
|
||||||
|
.SubscribeAsync(OnCopyTradingPositionReceivedAsync);
|
||||||
|
|
||||||
|
_logger.LogInformation("LiveTradingBotGrain {GrainId} subscribed to copy trading stream for master bot {MasterBotId}",
|
||||||
|
this.GetPrimaryKey(), _state.State.Config.MasterBotIdentifier.Value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to subscribe to copy trading stream for bot {GrainId}", this.GetPrimaryKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes from the copy trading stream
|
||||||
|
/// </summary>
|
||||||
|
private async Task UnsubscribeFromCopyTradingStreamAsync()
|
||||||
|
{
|
||||||
|
if (_copyTradingStreamHandle != null)
|
||||||
|
{
|
||||||
|
await _copyTradingStreamHandle.UnsubscribeAsync();
|
||||||
|
_copyTradingStreamHandle = null;
|
||||||
|
_logger.LogInformation("LiveTradingBotGrain {GrainId} unsubscribed from copy trading stream", this.GetPrimaryKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles incoming positions from the copy trading stream
|
||||||
|
/// </summary>
|
||||||
|
private async Task OnCopyTradingPositionReceivedAsync(Position masterPosition, StreamSequenceToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_tradingBot == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Received copy trading position {PositionId} but trading bot is not running for bot {GrainId}",
|
||||||
|
masterPosition.Identifier, this.GetPrimaryKey());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("📡 Copy trading: Received position {PositionId} from master bot for bot {GrainId}",
|
||||||
|
masterPosition.Identifier, this.GetPrimaryKey());
|
||||||
|
|
||||||
|
// Create a copy of the position for this bot
|
||||||
|
await _tradingBot.CopyPositionFromMasterAsync(masterPosition);
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Copy trading: Successfully copied position {PositionId} for bot {GrainId}",
|
||||||
|
masterPosition.Identifier, this.GetPrimaryKey());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to handle copy trading position {PositionId} for bot {GrainId}",
|
||||||
|
masterPosition.Identifier, this.GetPrimaryKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a TradingBotBase instance using composition
|
/// Creates a TradingBotBase instance using composition
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -398,7 +476,8 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
|||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
var streamProvider = this.GetStreamProvider("ManagingStreamProvider");
|
||||||
|
var tradingBot = new TradingBotBase(logger, _scopeFactory, config, streamProvider);
|
||||||
|
|
||||||
// Load state into the trading bot instance
|
// Load state into the trading bot instance
|
||||||
LoadStateIntoTradingBot(tradingBot);
|
LoadStateIntoTradingBot(tradingBot);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using Managing.Domain.Trades;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Orleans.Streams;
|
||||||
using static Managing.Common.Enums;
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
namespace Managing.Application.Bots;
|
namespace Managing.Application.Bots;
|
||||||
@@ -28,6 +29,7 @@ public class TradingBotBase : ITradingBot
|
|||||||
{
|
{
|
||||||
public readonly ILogger<TradingBotBase> Logger;
|
public readonly ILogger<TradingBotBase> Logger;
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IStreamProvider? _streamProvider;
|
||||||
private const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders
|
private const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders
|
||||||
|
|
||||||
private const int
|
private const int
|
||||||
@@ -48,10 +50,12 @@ public class TradingBotBase : ITradingBot
|
|||||||
public TradingBotBase(
|
public TradingBotBase(
|
||||||
ILogger<TradingBotBase> logger,
|
ILogger<TradingBotBase> logger,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
TradingBotConfig config
|
TradingBotConfig config,
|
||||||
|
IStreamProvider? streamProvider = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
|
_streamProvider = streamProvider;
|
||||||
Logger = logger;
|
Logger = logger;
|
||||||
Config = config;
|
Config = config;
|
||||||
Signals = new Dictionary<string, LightSignal>();
|
Signals = new Dictionary<string, LightSignal>();
|
||||||
@@ -228,8 +232,12 @@ public class TradingBotBase : ITradingBot
|
|||||||
// Update signals for live trading only
|
// Update signals for live trading only
|
||||||
if (!Config.IsForBacktest)
|
if (!Config.IsForBacktest)
|
||||||
{
|
{
|
||||||
await UpdateSignals();
|
|
||||||
await LoadLastCandle();
|
await LoadLastCandle();
|
||||||
|
|
||||||
|
if (!Config.IsForCopyTrading)
|
||||||
|
{
|
||||||
|
await UpdateSignals();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Config.IsForWatchingOnly)
|
if (!Config.IsForWatchingOnly)
|
||||||
@@ -1139,6 +1147,9 @@ public class TradingBotBase : ITradingBot
|
|||||||
}
|
}
|
||||||
|
|
||||||
await LogDebug($"✅ Position requested successfully for signal: `{signal.Identifier}`");
|
await LogDebug($"✅ Position requested successfully for signal: `{signal.Identifier}`");
|
||||||
|
|
||||||
|
await SendPositionToCopyTrading(position);
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1175,6 +1186,83 @@ public class TradingBotBase : ITradingBot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SendPositionToCopyTrading(Position position)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Only send to copy trading stream if this is not a copy trading bot itself
|
||||||
|
if (Config.IsForCopyTrading || _streamProvider == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stream keyed by this bot's identifier for copy trading bots to subscribe to
|
||||||
|
var streamId = StreamId.Create("CopyTrading", Identifier);
|
||||||
|
var stream = _streamProvider.GetStream<Position>(streamId);
|
||||||
|
|
||||||
|
// Publish the position to the stream
|
||||||
|
await stream.OnNextAsync(position);
|
||||||
|
|
||||||
|
await LogDebug($"📡 Position {position.Identifier} sent to copy trading stream for bot {Identifier}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to send position {PositionId} to copy trading stream for bot {BotId}",
|
||||||
|
position.Identifier, Identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of a position from a master bot for copy trading
|
||||||
|
/// </summary>
|
||||||
|
public async Task CopyPositionFromMasterAsync(Position masterPosition)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a copy signal based on the master position using the proper constructor
|
||||||
|
var copySignal = new LightSignal(
|
||||||
|
ticker: Config.Ticker,
|
||||||
|
direction: masterPosition.OriginDirection,
|
||||||
|
confidence: Confidence.Medium, // Default confidence for copy trading
|
||||||
|
candle: LastCandle ?? new Candle
|
||||||
|
{
|
||||||
|
Ticker = Config.Ticker,
|
||||||
|
Timeframe = Config.Timeframe,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Open = masterPosition.Open.Price,
|
||||||
|
Close = masterPosition.Open.Price,
|
||||||
|
High = masterPosition.Open.Price,
|
||||||
|
Low = masterPosition.Open.Price,
|
||||||
|
Volume = 0
|
||||||
|
},
|
||||||
|
date: masterPosition.Open.Date,
|
||||||
|
exchange: TradingExchanges.GmxV2, // Default exchange
|
||||||
|
indicatorType: IndicatorType.Composite,
|
||||||
|
signalType: SignalType.Signal,
|
||||||
|
indicatorName: "CopyTrading"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Override the identifier to include master position info
|
||||||
|
copySignal.Identifier = $"copy-{masterPosition.SignalIdentifier}-{Guid.NewGuid()}";
|
||||||
|
|
||||||
|
// Store the signal
|
||||||
|
Signals[copySignal.Identifier] = copySignal;
|
||||||
|
|
||||||
|
await LogInformation($"📋 Copy trading: Created copy signal {copySignal.Identifier} for master position {masterPosition.Identifier}");
|
||||||
|
|
||||||
|
// Attempt to open the position using the existing OpenPosition method
|
||||||
|
// This will handle all the position creation logic properly
|
||||||
|
await OpenPosition(copySignal);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to copy position {MasterPositionId} for bot {BotId}",
|
||||||
|
masterPosition.Identifier, Identifier);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<bool> CanOpenPosition(LightSignal signal)
|
private async Task<bool> CanOpenPosition(LightSignal signal)
|
||||||
{
|
{
|
||||||
// Early return if we're in backtest mode and haven't executed yet
|
// Early return if we're in backtest mode and haven't executed yet
|
||||||
|
|||||||
@@ -18,4 +18,18 @@ namespace Managing.Application.ManageBot.Commands
|
|||||||
CreateOnly = createOnly;
|
CreateOnly = createOnly;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class StartCopyTradingCommand : IRequest<string>
|
||||||
|
{
|
||||||
|
public Guid MasterBotIdentifier { get; }
|
||||||
|
public decimal BotTradingBalance { get; }
|
||||||
|
public User User { get; }
|
||||||
|
|
||||||
|
public StartCopyTradingCommand(Guid masterBotIdentifier, decimal botTradingBalance, User user)
|
||||||
|
{
|
||||||
|
MasterBotIdentifier = masterBotIdentifier;
|
||||||
|
BotTradingBalance = botTradingBalance;
|
||||||
|
User = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using Managing.Application.Abstractions;
|
||||||
|
using Managing.Application.Abstractions.Grains;
|
||||||
|
using Managing.Application.Abstractions.Services;
|
||||||
|
using Managing.Application.ManageBot.Commands;
|
||||||
|
using Managing.Common;
|
||||||
|
using Managing.Domain.Accounts;
|
||||||
|
using Managing.Domain.Bots;
|
||||||
|
using MediatR;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Application.ManageBot
|
||||||
|
{
|
||||||
|
public class StartCopyTradingCommandHandler : IRequestHandler<StartCopyTradingCommand, string>
|
||||||
|
{
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly IGrainFactory _grainFactory;
|
||||||
|
private readonly IBotService _botService;
|
||||||
|
|
||||||
|
public StartCopyTradingCommandHandler(
|
||||||
|
IAccountService accountService, IGrainFactory grainFactory, IBotService botService)
|
||||||
|
{
|
||||||
|
_accountService = accountService;
|
||||||
|
_grainFactory = grainFactory;
|
||||||
|
_botService = botService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> Handle(StartCopyTradingCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Validate the trading balance
|
||||||
|
if (request.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Bot trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the master bot configuration
|
||||||
|
var masterBot = await _botService.GetBotByIdentifier(request.MasterBotIdentifier);
|
||||||
|
if (masterBot == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Master bot with identifier {request.MasterBotIdentifier} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user owns the master bot keys
|
||||||
|
// if (masterBot.User?.Name != request.User.Name)
|
||||||
|
// {
|
||||||
|
// throw new UnauthorizedAccessException("You don't have permission to copy trades from this bot");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Get the master bot configuration
|
||||||
|
var masterConfig = await _botService.GetBotConfig(request.MasterBotIdentifier);
|
||||||
|
if (masterConfig == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not retrieve configuration for master bot {request.MasterBotIdentifier}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account information from the requesting user's accounts
|
||||||
|
var userAccounts = await _accountService.GetAccountsByUserAsync(request.User, true, true);
|
||||||
|
var firstAccount = userAccounts.FirstOrDefault();
|
||||||
|
if (firstAccount == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"User '{request.User.Name}' has no accounts configured.");
|
||||||
|
}
|
||||||
|
Account account = firstAccount;
|
||||||
|
|
||||||
|
// Check balances for EVM/GMX V2 accounts before starting
|
||||||
|
if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2)
|
||||||
|
{
|
||||||
|
var balanceCheckResult = await _botService.CheckAccountBalancesAsync(account);
|
||||||
|
if (!balanceCheckResult.IsSuccessful)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(balanceCheckResult.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Balance usdcBalance = null;
|
||||||
|
// For other exchanges, keep the original USDC balance check
|
||||||
|
if (account.Exchange != TradingExchanges.Evm && account.Exchange != TradingExchanges.GmxV2)
|
||||||
|
{
|
||||||
|
usdcBalance = account.Balances.FirstOrDefault(b => b.TokenName == Ticker.USDC.ToString());
|
||||||
|
|
||||||
|
if (usdcBalance == null ||
|
||||||
|
usdcBalance.Value < Constants.GMX.Config.MinimumPositionAmount ||
|
||||||
|
usdcBalance.Value < request.BotTradingBalance)
|
||||||
|
{
|
||||||
|
throw new Exception(
|
||||||
|
$"Account {masterConfig.AccountName} has no USDC balance or not enough balance for copy trading");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce allocation limit across all bots using the same account
|
||||||
|
var availableAllocation = await _botService.GetAvailableAllocationUsdAsync(account, default);
|
||||||
|
if (request.BotTradingBalance > availableAllocation)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Insufficient available allocation. Requested: {request.BotTradingBalance:F2} USDC, " +
|
||||||
|
$"Balance : {usdcBalance?.Value:F2 ?? 0} Available: {availableAllocation:F2} USDC.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create copy trading configuration based on master bot
|
||||||
|
var copyTradingConfig = new TradingBotConfig
|
||||||
|
{
|
||||||
|
// Copy all master configuration
|
||||||
|
AccountName = account.Name,
|
||||||
|
MoneyManagement = masterConfig.MoneyManagement,
|
||||||
|
Ticker = masterConfig.Ticker,
|
||||||
|
Scenario = masterConfig.Scenario,
|
||||||
|
ScenarioName = masterConfig.ScenarioName,
|
||||||
|
Timeframe = masterConfig.Timeframe,
|
||||||
|
IsForWatchingOnly = masterConfig.IsForWatchingOnly,
|
||||||
|
BotTradingBalance = request.BotTradingBalance, // Use the provided trading balance
|
||||||
|
CooldownPeriod = masterConfig.CooldownPeriod,
|
||||||
|
MaxLossStreak = masterConfig.MaxLossStreak,
|
||||||
|
MaxPositionTimeHours = masterConfig.MaxPositionTimeHours,
|
||||||
|
FlipOnlyWhenInProfit = masterConfig.FlipOnlyWhenInProfit,
|
||||||
|
CloseEarlyWhenProfitable = masterConfig.CloseEarlyWhenProfitable,
|
||||||
|
UseSynthApi = masterConfig.UseSynthApi,
|
||||||
|
UseForPositionSizing = masterConfig.UseForPositionSizing,
|
||||||
|
UseForSignalFiltering = masterConfig.UseForSignalFiltering,
|
||||||
|
UseForDynamicStopLoss = masterConfig.UseForDynamicStopLoss,
|
||||||
|
FlipPosition = masterConfig.FlipPosition,
|
||||||
|
|
||||||
|
// Set copy trading specific properties
|
||||||
|
IsForCopyTrading = true,
|
||||||
|
MasterBotIdentifier = request.MasterBotIdentifier,
|
||||||
|
|
||||||
|
// Set computed/default properties
|
||||||
|
IsForBacktest = false,
|
||||||
|
Name = masterConfig.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var botGrain = _grainFactory.GetGrain<ILiveTradingBotGrain>(Guid.NewGuid());
|
||||||
|
await botGrain.CreateAsync(copyTradingConfig, request.User);
|
||||||
|
|
||||||
|
// Start the copy trading bot immediately
|
||||||
|
await botGrain.StartAsync();
|
||||||
|
|
||||||
|
return $"Copy trading bot started successfully, following master bot '{masterConfig.Name}' with {request.BotTradingBalance:F2} USDC balance";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new Exception($"Failed to start copy trading bot: {ex.Message}, {ex.StackTrace}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,4 +104,15 @@ public class TradingBotConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Id(20)]
|
[Id(20)]
|
||||||
public bool UseForDynamicStopLoss { get; set; } = true;
|
public bool UseForDynamicStopLoss { get; set; } = true;
|
||||||
|
/// <summary>
|
||||||
|
/// Parameter to indicate if the bot is for copy trading
|
||||||
|
/// </summary>
|
||||||
|
[Id(21)]
|
||||||
|
public bool IsForCopyTrading { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The identifier of the master bot to copy trades from when IsForCopyTrading is true
|
||||||
|
/// </summary>
|
||||||
|
[Id(22)]
|
||||||
|
public Guid? MasterBotIdentifier { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1479,6 +1479,45 @@ export class BotClient extends AuthorizedApiBase {
|
|||||||
return Promise.resolve<string>(null as any);
|
return Promise.resolve<string>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bot_StartCopyTrading(request: StartCopyTradingRequest): Promise<string> {
|
||||||
|
let url_ = this.baseUrl + "/Bot/StartCopyTrading";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(request);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||||
|
return this.http.fetch(url_, transformedOptions_);
|
||||||
|
}).then((_response: Response) => {
|
||||||
|
return this.processBot_StartCopyTrading(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBot_StartCopyTrading(response: Response): Promise<string> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as string;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<string>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
bot_Save(request: SaveBotRequest): Promise<string> {
|
bot_Save(request: SaveBotRequest): Promise<string> {
|
||||||
let url_ = this.baseUrl + "/Bot/Save";
|
let url_ = this.baseUrl + "/Bot/Save";
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
@@ -4772,6 +4811,8 @@ export interface TradingBotConfig {
|
|||||||
useForPositionSizing?: boolean;
|
useForPositionSizing?: boolean;
|
||||||
useForSignalFiltering?: boolean;
|
useForSignalFiltering?: boolean;
|
||||||
useForDynamicStopLoss?: boolean;
|
useForDynamicStopLoss?: boolean;
|
||||||
|
isForCopyTrading?: boolean;
|
||||||
|
masterBotIdentifier?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LightMoneyManagement {
|
export interface LightMoneyManagement {
|
||||||
@@ -5334,6 +5375,11 @@ export interface StartBotRequest {
|
|||||||
config?: TradingBotConfigRequest | null;
|
config?: TradingBotConfigRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartCopyTradingRequest {
|
||||||
|
masterBotIdentifier?: string;
|
||||||
|
botTradingBalance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SaveBotRequest extends StartBotRequest {
|
export interface SaveBotRequest extends StartBotRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,8 @@ export interface TradingBotConfig {
|
|||||||
useForPositionSizing?: boolean;
|
useForPositionSizing?: boolean;
|
||||||
useForSignalFiltering?: boolean;
|
useForSignalFiltering?: boolean;
|
||||||
useForDynamicStopLoss?: boolean;
|
useForDynamicStopLoss?: boolean;
|
||||||
|
isForCopyTrading?: boolean;
|
||||||
|
masterBotIdentifier?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LightMoneyManagement {
|
export interface LightMoneyManagement {
|
||||||
@@ -896,6 +898,11 @@ export interface StartBotRequest {
|
|||||||
config?: TradingBotConfigRequest | null;
|
config?: TradingBotConfigRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartCopyTradingRequest {
|
||||||
|
masterBotIdentifier?: string;
|
||||||
|
botTradingBalance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SaveBotRequest extends StartBotRequest {
|
export interface SaveBotRequest extends StartBotRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import {ChartBarIcon, CogIcon, EyeIcon, PlayIcon, PlusCircleIcon, StopIcon, TrashIcon} from '@heroicons/react/solid'
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
CogIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
EyeIcon,
|
||||||
|
PlayIcon,
|
||||||
|
PlusCircleIcon,
|
||||||
|
StopIcon,
|
||||||
|
TrashIcon
|
||||||
|
} from '@heroicons/react/solid'
|
||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import useApiUrlStore from '../../app/store/apiStore'
|
import useApiUrlStore from '../../app/store/apiStore'
|
||||||
import {useCurrentUser} from '../../app/store/userStore'
|
import {useCurrentUser} from '../../app/store/userStore'
|
||||||
import {CardPosition, CardSignal, CardText, Toast,} from '../../components/mollecules'
|
import {Toast,} from '../../components/mollecules'
|
||||||
import ManualPositionModal from '../../components/mollecules/ManualPositionModal'
|
import ManualPositionModal from '../../components/mollecules/ManualPositionModal'
|
||||||
import TradesModal from '../../components/mollecules/TradesModal/TradesModal'
|
import TradesModal from '../../components/mollecules/TradesModal/TradesModal'
|
||||||
import {TradeChart, UnifiedTradingModal} from '../../components/organism'
|
import Table from '../../components/mollecules/Table/Table'
|
||||||
|
import {UnifiedTradingModal} from '../../components/organism'
|
||||||
import {
|
import {
|
||||||
BotClient,
|
BotClient,
|
||||||
|
BotSortableColumn,
|
||||||
BotStatus,
|
BotStatus,
|
||||||
MoneyManagement,
|
MoneyManagement,
|
||||||
Position,
|
StartCopyTradingRequest,
|
||||||
TradingBotConfig,
|
TradingBotConfig,
|
||||||
TradingBotResponse
|
TradingBotResponse
|
||||||
} from '../../generated/ManagingApi'
|
} from '../../generated/ManagingApi'
|
||||||
@@ -55,11 +67,25 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
// Call the hook at the top level
|
// Call the hook at the top level
|
||||||
const { user } = useCurrentUser()
|
const { user } = useCurrentUser()
|
||||||
|
|
||||||
|
// Filter and pagination states
|
||||||
|
const [pageNumber, setPageNumber] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<BotStatus | undefined>(undefined)
|
||||||
|
const [agentFilter, setAgentFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [sortBy, setSortBy] = useState<BotSortableColumn>(BotSortableColumn.Roi)
|
||||||
|
const [sortDirection, setSortDirection] = useState<'Asc' | 'Desc'>('Desc')
|
||||||
|
|
||||||
// Use the user data for bot ownership checking
|
// Use the user data for bot ownership checking
|
||||||
const checkIsBotOwner = (botAgentName: string) => {
|
const checkIsBotOwner = (botAgentName: string) => {
|
||||||
return user?.agentName === botAgentName
|
return user?.agentName === botAgentName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch paginated bot data
|
||||||
|
const { data: paginatedBots, isLoading } = useQuery({
|
||||||
|
queryFn: () => client.bot_GetBotsPaginated(pageNumber, pageSize, statusFilter, undefined, undefined, agentFilter, sortBy, sortDirection),
|
||||||
|
queryKey: ['bots', pageNumber, pageSize, statusFilter, agentFilter, sortBy, sortDirection],
|
||||||
|
})
|
||||||
|
|
||||||
const [showMoneyManagementModal, setShowMoneyManagementModal] =
|
const [showMoneyManagementModal, setShowMoneyManagementModal] =
|
||||||
useState(false)
|
useState(false)
|
||||||
const [selectedMoneyManagement, setSelectedMoneyManagement] =
|
const [selectedMoneyManagement, setSelectedMoneyManagement] =
|
||||||
@@ -73,6 +99,9 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
identifier: string
|
identifier: string
|
||||||
config: TradingBotConfig
|
config: TradingBotConfig
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [showCopyTradingModal, setShowCopyTradingModal] = useState(false)
|
||||||
|
const [selectedBotForCopy, setSelectedBotForCopy] = useState<{ name: string; identifier: string } | null>(null)
|
||||||
|
const [copyTradingBalance, setCopyTradingBalance] = useState<number>(1000)
|
||||||
|
|
||||||
function getDeleteBadge(identifier: string) {
|
function getDeleteBadge(identifier: string) {
|
||||||
const classes = baseBadgeClass() + 'bg-error'
|
const classes = baseBadgeClass() + 'bg-error'
|
||||||
@@ -145,6 +174,17 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCopyTradingBadge(name: string, botIdentifier: string) {
|
||||||
|
const classes = baseBadgeClass() + ' bg-purple-500'
|
||||||
|
return (
|
||||||
|
<button className={classes} onClick={() => openCopyTradingModal(name, botIdentifier)}>
|
||||||
|
<p className="text-primary-content flex">
|
||||||
|
<DocumentDuplicateIcon width={15}></DocumentDuplicateIcon>
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function openManualPositionModal(botIdentifier: string) {
|
function openManualPositionModal(botIdentifier: string) {
|
||||||
setSelectedBotForManualPosition(botIdentifier)
|
setSelectedBotForManualPosition(botIdentifier)
|
||||||
setShowManualPositionModal(true)
|
setShowManualPositionModal(true)
|
||||||
@@ -155,6 +195,11 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
setShowTradesModal(true)
|
setShowTradesModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCopyTradingModal(name: string, botIdentifier: string) {
|
||||||
|
setSelectedBotForCopy({ name: name, identifier: botIdentifier })
|
||||||
|
setShowCopyTradingModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
function toggleBotStatus(status: BotStatus, identifier: string) {
|
function toggleBotStatus(status: BotStatus, identifier: string) {
|
||||||
const isUp = status == BotStatus.Running
|
const isUp = status == BotStatus.Running
|
||||||
const t = new Toast(isUp ? 'Stoping bot' : 'Restarting bot')
|
const t = new Toast(isUp ? 'Stoping bot' : 'Restarting bot')
|
||||||
@@ -193,6 +238,29 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyTrading() {
|
||||||
|
if (!selectedBotForCopy) return
|
||||||
|
|
||||||
|
const t = new Toast('Starting copy trading bot')
|
||||||
|
|
||||||
|
const request: StartCopyTradingRequest = {
|
||||||
|
masterBotIdentifier: selectedBotForCopy.identifier,
|
||||||
|
botTradingBalance: copyTradingBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.bot_StartCopyTrading(request)
|
||||||
|
.then((result) => {
|
||||||
|
t.update('success', result || 'Copy trading bot started successfully', { autoClose: 3000 })
|
||||||
|
setShowCopyTradingModal(false)
|
||||||
|
setSelectedBotForCopy(null)
|
||||||
|
setCopyTradingBalance(1000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
t.update('error', err.message || 'Failed to start copy trading bot', { autoClose: 5000 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getUpdateBotBadge(bot: TradingBotResponse) {
|
function getUpdateBotBadge(bot: TradingBotResponse) {
|
||||||
const classes = baseBadgeClass() + ' bg-orange-500'
|
const classes = baseBadgeClass() + ' bg-orange-500'
|
||||||
return (
|
return (
|
||||||
@@ -221,94 +289,158 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Table columns definition
|
||||||
|
const columns = React.useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: 'name',
|
||||||
|
Cell: ({ row }: any) => (
|
||||||
|
<div className="font-medium">{row.original.name}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Status',
|
||||||
|
accessor: 'status',
|
||||||
|
Cell: ({ row }: any) => {
|
||||||
|
const status = row.original.status as BotStatus
|
||||||
|
let statusClass = 'badge '
|
||||||
|
switch (status) {
|
||||||
|
case BotStatus.Running:
|
||||||
|
statusClass += 'badge-success'
|
||||||
|
break
|
||||||
|
case BotStatus.Stopped:
|
||||||
|
statusClass += 'badge-warning'
|
||||||
|
break
|
||||||
|
case BotStatus.Saved:
|
||||||
|
statusClass += 'badge-info'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
statusClass += 'badge-neutral'
|
||||||
|
}
|
||||||
|
return <div className={statusClass}>{BotStatus[status]}</div>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Ticker',
|
||||||
|
accessor: 'ticker',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Agent',
|
||||||
|
accessor: 'agentName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Win Rate %',
|
||||||
|
accessor: 'winRate',
|
||||||
|
Cell: ({ value }: any) => value?.toFixed(2) || '0.00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'PNL $',
|
||||||
|
accessor: 'profitAndLoss',
|
||||||
|
Cell: ({ value }: any) => value?.toFixed(2) || '0.00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Actions',
|
||||||
|
accessor: 'actions',
|
||||||
|
disableSortBy: true,
|
||||||
|
Cell: ({ row }: any) => {
|
||||||
|
const bot = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap m-4 -mx-4">
|
<div className="flex gap-1">
|
||||||
{list.map((bot: TradingBotResponse, index: number) => (
|
{getToggleBotStatusBadge(bot.status, bot.identifier)}
|
||||||
<div
|
|
||||||
key={index.toString()}
|
|
||||||
className="sm:w-1 md:w-1/2 xl:w-1/2 w-full p-2"
|
|
||||||
>
|
|
||||||
<div className={cardClasses(bot.status as BotStatus)}>
|
|
||||||
<figure className="w-full">
|
|
||||||
{bot.candles && bot.candles.length > 0 ? (
|
|
||||||
<TradeChart
|
|
||||||
candles={bot.candles}
|
|
||||||
positions={Object.values(bot.positions)}
|
|
||||||
signals={Object.values(bot.signals)}
|
|
||||||
></TradeChart>
|
|
||||||
) : null}
|
|
||||||
</figure>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="mb-4">
|
|
||||||
{/* Bot Name - Always on its own line */}
|
|
||||||
<h2 className="card-title text-sm mb-3">
|
|
||||||
{bot.name}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Badge Container - Responsive */}
|
|
||||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
|
||||||
|
|
||||||
{/* Action Badges - Only show for bot owners */}
|
|
||||||
{ (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{getToggleBotStatusBadge(bot.status as BotStatus, bot.identifier)}
|
|
||||||
{getUpdateBotBadge(bot)}
|
{getUpdateBotBadge(bot)}
|
||||||
{getManualPositionBadge(bot.identifier)}
|
{getManualPositionBadge(bot.identifier)}
|
||||||
|
{getCopyTradingBadge(bot.name, bot.identifier)}
|
||||||
|
{getTradesBadge(bot.name, bot.agentName, bot.identifier)}
|
||||||
{getDeleteBadge(bot.identifier)}
|
{getDeleteBadge(bot.identifier)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-4 items-center">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Status</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm"
|
||||||
|
value={statusFilter || ''}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value ? (Number(e.target.value) as unknown as BotStatus) : undefined)}
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value={BotStatus.Running}>Running</option>
|
||||||
|
<option value={BotStatus.Stopped}>Stopped</option>
|
||||||
|
<option value={BotStatus.Saved}>Saved</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Agent</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by agent name"
|
||||||
|
className="input input-bordered input-sm"
|
||||||
|
value={agentFilter || ''}
|
||||||
|
onChange={(e) => setAgentFilter(e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Sort By</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(Number(e.target.value) as unknown as BotSortableColumn)}
|
||||||
|
>
|
||||||
|
<option value={BotSortableColumn.Roi}>ROI</option>
|
||||||
|
<option value={BotSortableColumn.Pnl}>Profit & Loss</option>
|
||||||
|
<option value={BotSortableColumn.WinRate}>Win Rate</option>
|
||||||
|
<option value={BotSortableColumn.Name}>Name</option>
|
||||||
|
<option value={BotSortableColumn.Status}>Status</option>
|
||||||
|
<option value={BotSortableColumn.CreateDate}>Created At</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Sort Direction</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm"
|
||||||
|
value={sortDirection}
|
||||||
|
onChange={(e) => setSortDirection(e.target.value as 'Asc' | 'Desc')}
|
||||||
|
>
|
||||||
|
<option value="Desc">Descending</option>
|
||||||
|
<option value="Asc">Ascending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="loading loading-spinner loading-lg"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedBots?.items || []}
|
||||||
|
showPagination={true}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="columns-2">
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<CardText
|
|
||||||
title="Ticker"
|
|
||||||
content={bot.ticker}
|
|
||||||
></CardText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="columns-2">
|
|
||||||
<CardText
|
|
||||||
title="Agent"
|
|
||||||
content={bot.agentName}
|
|
||||||
></CardText>
|
|
||||||
<CardSignal signals={Object.values(bot.signals ?? {})}></CardSignal>
|
|
||||||
</div>
|
|
||||||
<div className="columns-2">
|
|
||||||
<CardPosition
|
|
||||||
positivePosition={true}
|
|
||||||
positions={Object.values(bot.positions ?? {}).filter((p: Position) => {
|
|
||||||
const realized = p.ProfitAndLoss?.realized ?? 0
|
|
||||||
return realized > 0 ? p : null
|
|
||||||
})}
|
|
||||||
></CardPosition>
|
|
||||||
<CardPosition
|
|
||||||
positivePosition={false}
|
|
||||||
positions={Object.values(bot.positions ?? {}).filter((p: Position) => {
|
|
||||||
const realized = p.ProfitAndLoss?.realized ?? 0
|
|
||||||
return realized <= 0 ? p : null
|
|
||||||
})}
|
|
||||||
></CardPosition>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="card-actions justify-center pt-2">
|
|
||||||
<div className={baseBadgeClass(true)}>
|
|
||||||
WR {bot.winRate?.toFixed(2).toString()} %
|
|
||||||
</div>
|
|
||||||
<div className={baseBadgeClass(true)}>
|
|
||||||
PNL {bot.profitAndLoss.toFixed(2).toString()} $
|
|
||||||
</div>
|
|
||||||
{getTradesBadge(bot.name, bot.agentName, bot.identifier)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
<MoneyManagementModal
|
<MoneyManagementModal
|
||||||
showModal={showMoneyManagementModal}
|
showModal={showMoneyManagementModal}
|
||||||
moneyManagement={selectedMoneyManagement}
|
moneyManagement={selectedMoneyManagement}
|
||||||
@@ -345,6 +477,51 @@ const BotList: React.FC<IBotList> = ({ list }) => {
|
|||||||
config: selectedBotForUpdate.config
|
config: selectedBotForUpdate.config
|
||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Copy Trading Modal */}
|
||||||
|
{showCopyTradingModal && selectedBotForCopy && (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-bold text-lg">Start Copy Trading</h3>
|
||||||
|
<p className="py-4">
|
||||||
|
Copy trades from <strong>{selectedBotForCopy.name}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Trading Balance (USDC)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter trading balance"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={copyTradingBalance}
|
||||||
|
onChange={(e) => setCopyTradingBalance(Number(e.target.value))}
|
||||||
|
min="1"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCopyTradingModal(false)
|
||||||
|
setSelectedBotForCopy(null)
|
||||||
|
setCopyTradingBalance(1000)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={copyTrading}
|
||||||
|
disabled={copyTradingBalance <= 0}
|
||||||
|
>
|
||||||
|
Start Copy Trading
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1,16 @@
|
|||||||
import {ViewGridAddIcon} from '@heroicons/react/solid'
|
|
||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import 'react-toastify/dist/ReactToastify.css'
|
|
||||||
import useApiUrlStore from '../../app/store/apiStore'
|
|
||||||
import {useCurrentAgentName} from '../../app/store/userStore'
|
|
||||||
import {UnifiedTradingModal} from '../../components/organism'
|
import {UnifiedTradingModal} from '../../components/organism'
|
||||||
import {BotClient, BotSortableColumn, BotStatus} from '../../generated/ManagingApi'
|
|
||||||
import {useQuery} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import BotList from './botList'
|
import BotList from './botList'
|
||||||
|
|
||||||
const Bots: React.FC = () => {
|
const Bots: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
|
||||||
const [showBotConfigModal, setShowBotConfigModal] = useState(false)
|
const [showBotConfigModal, setShowBotConfigModal] = useState(false)
|
||||||
const [pageNumber, setPageNumber] = useState(1)
|
|
||||||
const [pageSize] = useState(50) // Fixed page size for now
|
|
||||||
|
|
||||||
// Reset page number when tab changes
|
|
||||||
const handleTabChange = (newTab: number) => {
|
|
||||||
setActiveTab(newTab)
|
|
||||||
setPageNumber(1) // Reset to first page when changing tabs
|
|
||||||
}
|
|
||||||
const { apiUrl } = useApiUrlStore()
|
|
||||||
const botClient = new BotClient({}, apiUrl)
|
|
||||||
|
|
||||||
// Use the new user store hook to get current agent name
|
|
||||||
const currentAgentName = useCurrentAgentName()
|
|
||||||
|
|
||||||
// Query for paginated bots using the new endpoint
|
|
||||||
const { data: paginatedBots } = useQuery({
|
|
||||||
queryFn: () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 0: // All Active Bots
|
|
||||||
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Running, undefined, undefined, undefined, BotSortableColumn.Roi, 'Desc')
|
|
||||||
case 1: // My Active Bots
|
|
||||||
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Running, undefined, undefined, currentAgentName, BotSortableColumn.Roi, 'Desc')
|
|
||||||
case 2: // My Down Bots
|
|
||||||
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Stopped, undefined, undefined, currentAgentName, BotSortableColumn.Roi, 'Desc')
|
|
||||||
case 3: // Saved Bots
|
|
||||||
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Saved, undefined, undefined, currentAgentName, BotSortableColumn.Roi, 'Desc')
|
|
||||||
default:
|
|
||||||
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, undefined, undefined, undefined, undefined, BotSortableColumn.Roi, 'Desc')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
queryKey: ['paginatedBots', activeTab, pageNumber, pageSize, currentAgentName],
|
|
||||||
enabled: !!currentAgentName,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredBots = paginatedBots?.items || []
|
|
||||||
|
|
||||||
function openCreateBotModal() {
|
|
||||||
setShowBotConfigModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ label: 'All Active Bots', index: 0 },
|
|
||||||
{ label: 'My Active Bots', index: 1 },
|
|
||||||
{ label: 'My Down Bots', index: 2 },
|
|
||||||
{ label: 'Saved Bots', index: 3 },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<div className="tooltip" data-tip="Create new bot">
|
|
||||||
<button className="btn btn-primary m-1 text-xs" onClick={openCreateBotModal}>
|
|
||||||
<ViewGridAddIcon width="20"></ViewGridAddIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="tabs tabs-boxed mb-4">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.index}
|
|
||||||
className={`tab ${activeTab === tab.index ? 'tab-active' : ''}`}
|
|
||||||
onClick={() => handleTabChange(tab.index)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bot List */}
|
{/* Bot List */}
|
||||||
<BotList list={filteredBots} />
|
<BotList list={[]} />
|
||||||
|
|
||||||
{/* Unified Trading Modal */}
|
{/* Unified Trading Modal */}
|
||||||
<UnifiedTradingModal
|
<UnifiedTradingModal
|
||||||
|
|||||||
Reference in New Issue
Block a user