From 1e15d5f23bafdf43df6bda79dde73265edcba285 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Sun, 16 Nov 2025 14:54:17 +0700 Subject: [PATCH] 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. --- src/Managing.Api/Controllers/BotController.cs | 45 +++ .../Bots/Grains/LiveTradingBotGrain.cs | 81 +++- .../Bots/TradingBotBase.cs | 92 ++++- .../ManageBot/Commands/StartBotCommand.cs | 14 + .../StartCopyTradingCommandHandler.cs | 147 +++++++ src/Managing.Domain/Bots/TradingBotConfig.cs | 11 + .../src/generated/ManagingApi.ts | 46 +++ .../src/generated/ManagingApiTypes.ts | 7 + .../src/pages/botsPage/botList.tsx | 363 +++++++++++++----- .../src/pages/botsPage/bots.tsx | 78 +--- 10 files changed, 711 insertions(+), 173 deletions(-) create mode 100644 src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs diff --git a/src/Managing.Api/Controllers/BotController.cs b/src/Managing.Api/Controllers/BotController.cs index 181d7a23..d4dbfb67 100644 --- a/src/Managing.Api/Controllers/BotController.cs +++ b/src/Managing.Api/Controllers/BotController.cs @@ -146,6 +146,35 @@ public class BotController : BaseController } } + /// + /// Starts a copy trading bot that mirrors trades from a master bot. + /// + /// The request containing copy trading parameters. + /// A string indicating the result of the start operation. + [HttpPost] + [Route("StartCopyTrading")] + public async Task> 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}"); + } + } + /// /// Saves a bot configuration without starting it. /// @@ -963,4 +992,20 @@ public class StartBotRequest public class SaveBotRequest : StartBotRequest { +} + +/// +/// Request model for starting a copy trading bot +/// +public class StartCopyTradingRequest +{ + /// + /// The identifier of the master bot to copy trades from + /// + public Guid MasterBotIdentifier { get; set; } + + /// + /// The trading balance for the copy trading bot + /// + public decimal BotTradingBalance { get; set; } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs index 0f8d16bd..0938cb6c 100644 --- a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs @@ -12,6 +12,7 @@ using Managing.Domain.Trades; using Managing.Domain.Users; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Orleans.Streams; using static Managing.Common.Enums; namespace Managing.Application.Bots.Grains; @@ -31,6 +32,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable private TradingBotBase? _tradingBot; private IDisposable? _timer; private string _reminderName = "RebootReminder"; + private StreamSubscriptionHandle? _copyTradingStreamHandle; public LiveTradingBotGrain( [PersistentState("live-trading-bot", "bot-store")] @@ -222,6 +224,9 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable RegisterAndStartTimer(); await RegisterReminder(); + // Subscribe to copy trading stream if configured + await SubscribeToCopyTradingStreamAsync(); + // Update both database and registry status await SaveBotAsync(BotStatus.Running); await UpdateBotRegistryStatus(BotStatus.Running); @@ -337,6 +342,9 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable StopAndDisposeTimer(); await UnregisterReminder(); + // Unsubscribe from copy trading stream + await UnsubscribeFromCopyTradingStreamAsync(); + // Track runtime: accumulate current session runtime when stopping if (_state.State.LastStartTime.HasValue) { @@ -386,6 +394,76 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable } } + /// + /// Subscribes to the copy trading stream if this bot is configured for copy trading + /// + 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(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()); + } + } + + /// + /// Unsubscribes from the copy trading stream + /// + private async Task UnsubscribeFromCopyTradingStreamAsync() + { + if (_copyTradingStreamHandle != null) + { + await _copyTradingStreamHandle.UnsubscribeAsync(); + _copyTradingStreamHandle = null; + _logger.LogInformation("LiveTradingBotGrain {GrainId} unsubscribed from copy trading stream", this.GetPrimaryKey()); + } + } + + /// + /// Handles incoming positions from the copy trading stream + /// + 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()); + } + } + /// /// Creates a TradingBotBase instance using composition /// @@ -398,7 +476,8 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); - 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 LoadStateIntoTradingBot(tradingBot); diff --git a/src/Managing.Application/Bots/TradingBotBase.cs b/src/Managing.Application/Bots/TradingBotBase.cs index a48ccb2d..a1d42916 100644 --- a/src/Managing.Application/Bots/TradingBotBase.cs +++ b/src/Managing.Application/Bots/TradingBotBase.cs @@ -20,6 +20,7 @@ using Managing.Domain.Trades; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using Orleans.Streams; using static Managing.Common.Enums; namespace Managing.Application.Bots; @@ -28,6 +29,7 @@ public class TradingBotBase : ITradingBot { public readonly ILogger Logger; 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 @@ -48,10 +50,12 @@ public class TradingBotBase : ITradingBot public TradingBotBase( ILogger logger, IServiceScopeFactory scopeFactory, - TradingBotConfig config + TradingBotConfig config, + IStreamProvider? streamProvider = null ) { _scopeFactory = scopeFactory; + _streamProvider = streamProvider; Logger = logger; Config = config; Signals = new Dictionary(); @@ -228,8 +232,12 @@ public class TradingBotBase : ITradingBot // Update signals for live trading only if (!Config.IsForBacktest) { - await UpdateSignals(); await LoadLastCandle(); + + if (!Config.IsForCopyTrading) + { + await UpdateSignals(); + } } if (!Config.IsForWatchingOnly) @@ -1139,6 +1147,9 @@ public class TradingBotBase : ITradingBot } await LogDebug($"✅ Position requested successfully for signal: `{signal.Identifier}`"); + + await SendPositionToCopyTrading(position); + return position; } 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(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); + } + } + + /// + /// Creates a copy of a position from a master bot for copy trading + /// + 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 CanOpenPosition(LightSignal signal) { // Early return if we're in backtest mode and haven't executed yet diff --git a/src/Managing.Application/ManageBot/Commands/StartBotCommand.cs b/src/Managing.Application/ManageBot/Commands/StartBotCommand.cs index 32961ea3..8bc08129 100644 --- a/src/Managing.Application/ManageBot/Commands/StartBotCommand.cs +++ b/src/Managing.Application/ManageBot/Commands/StartBotCommand.cs @@ -18,4 +18,18 @@ namespace Managing.Application.ManageBot.Commands CreateOnly = createOnly; } } + + public class StartCopyTradingCommand : IRequest + { + 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; + } + } } \ No newline at end of file diff --git a/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs b/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs new file mode 100644 index 00000000..46fea9c4 --- /dev/null +++ b/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs @@ -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 + { + 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 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(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}"); + } + } + } +} diff --git a/src/Managing.Domain/Bots/TradingBotConfig.cs b/src/Managing.Domain/Bots/TradingBotConfig.cs index bb6417db..6b21ccc4 100644 --- a/src/Managing.Domain/Bots/TradingBotConfig.cs +++ b/src/Managing.Domain/Bots/TradingBotConfig.cs @@ -104,4 +104,15 @@ public class TradingBotConfig /// [Id(20)] public bool UseForDynamicStopLoss { get; set; } = true; + /// + /// Parameter to indicate if the bot is for copy trading + /// + [Id(21)] + public bool IsForCopyTrading { get; set; } = false; + + /// + /// The identifier of the master bot to copy trades from when IsForCopyTrading is true + /// + [Id(22)] + public Guid? MasterBotIdentifier { get; set; } } \ No newline at end of file diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 5047454c..83d0e35f 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -1479,6 +1479,45 @@ export class BotClient extends AuthorizedApiBase { return Promise.resolve(null as any); } + bot_StartCopyTrading(request: StartCopyTradingRequest): Promise { + 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 { + 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(null as any); + } + bot_Save(request: SaveBotRequest): Promise { let url_ = this.baseUrl + "/Bot/Save"; url_ = url_.replace(/[?&]$/, ""); @@ -4772,6 +4811,8 @@ export interface TradingBotConfig { useForPositionSizing?: boolean; useForSignalFiltering?: boolean; useForDynamicStopLoss?: boolean; + isForCopyTrading?: boolean; + masterBotIdentifier?: string | null; } export interface LightMoneyManagement { @@ -5334,6 +5375,11 @@ export interface StartBotRequest { config?: TradingBotConfigRequest | null; } +export interface StartCopyTradingRequest { + masterBotIdentifier?: string; + botTradingBalance?: number; +} + export interface SaveBotRequest extends StartBotRequest { } diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index ba4cb765..1f80da73 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -334,6 +334,8 @@ export interface TradingBotConfig { useForPositionSizing?: boolean; useForSignalFiltering?: boolean; useForDynamicStopLoss?: boolean; + isForCopyTrading?: boolean; + masterBotIdentifier?: string | null; } export interface LightMoneyManagement { @@ -896,6 +898,11 @@ export interface StartBotRequest { config?: TradingBotConfigRequest | null; } +export interface StartCopyTradingRequest { + masterBotIdentifier?: string; + botTradingBalance?: number; +} + export interface SaveBotRequest extends StartBotRequest { } diff --git a/src/Managing.WebApp/src/pages/botsPage/botList.tsx b/src/Managing.WebApp/src/pages/botsPage/botList.tsx index 97e34892..d116c5cc 100644 --- a/src/Managing.WebApp/src/pages/botsPage/botList.tsx +++ b/src/Managing.WebApp/src/pages/botsPage/botList.tsx @@ -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 {useQuery} from '@tanstack/react-query' import useApiUrlStore from '../../app/store/apiStore' 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 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 { BotClient, + BotSortableColumn, BotStatus, MoneyManagement, - Position, + StartCopyTradingRequest, TradingBotConfig, TradingBotResponse } from '../../generated/ManagingApi' @@ -51,15 +63,29 @@ function cardClasses(botStatus: BotStatus) { const BotList: React.FC = ({ list }) => { const { apiUrl } = useApiUrlStore() const client = new BotClient({}, apiUrl) - + // Call the hook at the top level const { user } = useCurrentUser() - + + // Filter and pagination states + const [pageNumber, setPageNumber] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [statusFilter, setStatusFilter] = useState(undefined) + const [agentFilter, setAgentFilter] = useState(undefined) + const [sortBy, setSortBy] = useState(BotSortableColumn.Roi) + const [sortDirection, setSortDirection] = useState<'Asc' | 'Desc'>('Desc') + // Use the user data for bot ownership checking const checkIsBotOwner = (botAgentName: string) => { 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] = useState(false) const [selectedMoneyManagement, setSelectedMoneyManagement] = @@ -73,6 +99,9 @@ const BotList: React.FC = ({ list }) => { identifier: string config: TradingBotConfig } | null>(null) + const [showCopyTradingModal, setShowCopyTradingModal] = useState(false) + const [selectedBotForCopy, setSelectedBotForCopy] = useState<{ name: string; identifier: string } | null>(null) + const [copyTradingBalance, setCopyTradingBalance] = useState(1000) function getDeleteBadge(identifier: string) { const classes = baseBadgeClass() + 'bg-error' @@ -145,6 +174,17 @@ const BotList: React.FC = ({ list }) => { ) } + function getCopyTradingBadge(name: string, botIdentifier: string) { + const classes = baseBadgeClass() + ' bg-purple-500' + return ( + + ) + } + function openManualPositionModal(botIdentifier: string) { setSelectedBotForManualPosition(botIdentifier) setShowManualPositionModal(true) @@ -155,6 +195,11 @@ const BotList: React.FC = ({ list }) => { setShowTradesModal(true) } + function openCopyTradingModal(name: string, botIdentifier: string) { + setSelectedBotForCopy({ name: name, identifier: botIdentifier }) + setShowCopyTradingModal(true) + } + function toggleBotStatus(status: BotStatus, identifier: string) { const isUp = status == BotStatus.Running const t = new Toast(isUp ? 'Stoping bot' : 'Restarting bot') @@ -193,6 +238,29 @@ const BotList: React.FC = ({ 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) { const classes = baseBadgeClass() + ' bg-orange-500' return ( @@ -206,7 +274,7 @@ const BotList: React.FC = ({ list }) => { async function openUpdateBotModal(bot: TradingBotResponse) { const t = new Toast('Loading bot configuration...') - + try { const config = await client.bot_GetBotConfig(bot.identifier) setSelectedBotForUpdate({ @@ -221,94 +289,158 @@ const BotList: React.FC = ({ list }) => { } } - return ( -
- {list.map((bot: TradingBotResponse, index: number) => ( -
-
-
- {bot.candles && bot.candles.length > 0 ? ( - - ) : null} -
-
-
- {/* Bot Name - Always on its own line */} -

- {bot.name} -

- - {/* Badge Container - Responsive */} -
- - {/* Action Badges - Only show for bot owners */} - { ( -
- {getToggleBotStatusBadge(bot.status as BotStatus, bot.identifier)} - {getUpdateBotBadge(bot)} - {getManualPositionBadge(bot.identifier)} - {getDeleteBadge(bot.identifier)} -
- )} -
-
- -
-
-
- -
-
-
-
- - -
-
- { - const realized = p.ProfitAndLoss?.realized ?? 0 - return realized > 0 ? p : null - })} - > - { - const realized = p.ProfitAndLoss?.realized ?? 0 - return realized <= 0 ? p : null - })} - > -
-
-
-
- WR {bot.winRate?.toFixed(2).toString()} % -
-
- PNL {bot.profitAndLoss.toFixed(2).toString()} $ -
- {getTradesBadge(bot.name, bot.agentName, bot.identifier)} -
-
+ // Table columns definition + const columns = React.useMemo( + () => [ + { + Header: 'Name', + accessor: 'name', + Cell: ({ row }: any) => ( +
{row.original.name}
+ ), + }, + { + 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
{BotStatus[status]}
+ }, + }, + { + 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 ( +
+ {getToggleBotStatusBadge(bot.status, bot.identifier)} + {getUpdateBotBadge(bot)} + {getManualPositionBadge(bot.identifier)} + {getCopyTradingBadge(bot.name, bot.identifier)} + {getTradesBadge(bot.name, bot.agentName, bot.identifier)} + {getDeleteBadge(bot.identifier)}
-
-
- ))} + ) + }, + }, + ], + [] + ) + return ( +
+ {/* Filters */} +
+
+ + +
+ +
+ + setAgentFilter(e.target.value || undefined)} + /> +
+ +
+ + +
+ +
+ + +
+
+ + {/* Table */} + {isLoading ? ( +
+
+
+ ) : ( + + )} + + {/* Modals */} = ({ list }) => { config: selectedBotForUpdate.config } : undefined} /> + + {/* Copy Trading Modal */} + {showCopyTradingModal && selectedBotForCopy && ( +
+
+

Start Copy Trading

+

+ Copy trades from {selectedBotForCopy.name} +

+
+ + setCopyTradingBalance(Number(e.target.value))} + min="1" + step="0.01" + /> +
+
+ + +
+
+
+ )} ) } diff --git a/src/Managing.WebApp/src/pages/botsPage/bots.tsx b/src/Managing.WebApp/src/pages/botsPage/bots.tsx index 33e43f19..4259b9ef 100644 --- a/src/Managing.WebApp/src/pages/botsPage/bots.tsx +++ b/src/Managing.WebApp/src/pages/botsPage/bots.tsx @@ -1,92 +1,16 @@ -import {ViewGridAddIcon} from '@heroicons/react/solid' 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 {BotClient, BotSortableColumn, BotStatus} from '../../generated/ManagingApi' -import {useQuery} from '@tanstack/react-query' import BotList from './botList' const Bots: React.FC = () => { - const [activeTab, setActiveTab] = useState(0) 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 (
- {/* Action Buttons */} -
-
- -
-
- - {/* Tabs */} -
- {tabs.map((tab) => ( - - ))} -
- {/* Bot List */} - + {/* Unified Trading Modal */}