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 */}