diff --git a/src/Managing.Api/Controllers/DataController.cs b/src/Managing.Api/Controllers/DataController.cs index 3f082106..8e4b590f 100644 --- a/src/Managing.Api/Controllers/DataController.cs +++ b/src/Managing.Api/Controllers/DataController.cs @@ -632,7 +632,8 @@ public class DataController : ControllerBase Identifier = strategy.Identifier, WalletBalances = walletBalances, Ticker = strategy.Ticker, - MasterAgentName = strategy.MasterBotUser?.AgentName + MasterAgentName = strategy.MasterBotUser?.AgentName, + BotTradingBalance = strategy.BotTradingBalance }; } diff --git a/src/Managing.Api/Controllers/LlmController.cs b/src/Managing.Api/Controllers/LlmController.cs index d02bb899..9a421a80 100644 --- a/src/Managing.Api/Controllers/LlmController.cs +++ b/src/Managing.Api/Controllers/LlmController.cs @@ -57,23 +57,28 @@ public class LlmController : BaseController var availableTools = await _mcpService.GetAvailableToolsAsync(); request.Tools = availableTools.ToList(); - // Add system message to clarify that tools are optional and the LLM can respond directly - // Check if a system message already exists - var hasSystemMessage = request.Messages.Any(m => m.Role == "system"); - if (!hasSystemMessage) + // Add or prepend system message to ensure LLM knows it can respond directly + // Remove any existing system messages first to ensure our directive is clear + var existingSystemMessages = request.Messages.Where(m => m.Role == "system").ToList(); + foreach (var msg in existingSystemMessages) { - var systemMessage = new LlmMessage - { - Role = "system", - Content = "You are a helpful AI assistant with expertise in quantitative finance, algorithmic trading, and financial mathematics. " + - "You can answer questions directly using your knowledge. " + - "Tools are available for specific operations (backtesting, agent management, market data retrieval, etc.) but are optional. " + - "Use tools only when they are needed for the specific task. " + - "For general questions, explanations, calculations, or discussions, respond directly without using tools." - }; - request.Messages.Insert(0, systemMessage); + request.Messages.Remove(msg); } + // Add explicit system message at the beginning + var systemMessage = new LlmMessage + { + Role = "system", + Content = "You are an expert AI assistant specializing in quantitative finance, algorithmic trading, and financial mathematics. " + + "You have full knowledge and can answer ANY question directly using your training data and expertise. " + + "IMPORTANT: You MUST answer general questions, explanations, calculations, and discussions directly without using tools. " + + "Tools are ONLY for specific system operations like backtesting, agent management, or retrieving real-time market data. " + + "For questions about financial concepts, mathematical formulas (like Black-Scholes), trading strategies, or any theoretical knowledge, " + + "you MUST provide a direct answer using your knowledge. Do NOT refuse to answer or claim you can only use tools. " + + "Only use tools when the user explicitly needs to perform a system operation (e.g., 'run a backtest', 'get market data', 'manage agents')." + }; + request.Messages.Insert(0, systemMessage); + // Send chat request to LLM var response = await _llmService.ChatAsync(user, request); diff --git a/src/Managing.Api/Models/Responses/UserStrategyDetailsViewModel.cs b/src/Managing.Api/Models/Responses/UserStrategyDetailsViewModel.cs index 37a7c669..09e82f2d 100644 --- a/src/Managing.Api/Models/Responses/UserStrategyDetailsViewModel.cs +++ b/src/Managing.Api/Models/Responses/UserStrategyDetailsViewModel.cs @@ -112,5 +112,11 @@ namespace Managing.Api.Models.Responses /// The agent name of the master bot's owner (for copy trading bots) /// public string MasterAgentName { get; set; } + + /// + /// The trading balance allocated to this bot + /// + [Required] + public decimal BotTradingBalance { get; set; } } } \ No newline at end of file diff --git a/src/Managing.Mcp/McpService.cs b/src/Managing.Mcp/McpService.cs index 535a7ed6..40d56ecc 100644 --- a/src/Managing.Mcp/McpService.cs +++ b/src/Managing.Mcp/McpService.cs @@ -48,6 +48,8 @@ public class McpService : IMcpService "get_bundle_backtest_by_id" => await _backtestMcpTools.ExecuteGetBundleBacktestById(user, parameters), "delete_bundle_backtest" => await _backtestMcpTools.ExecuteDeleteBundleBacktest(user, parameters), "run_backtest" => await _backtestMcpTools.ExecuteRunBacktest(user, parameters), + "run_bundle_backtest" => await _backtestMcpTools.ExecuteRunBundleBacktest(user, parameters), + "analyze_bundle_backtest" => await _backtestMcpTools.ExecuteAnalyzeBundleBacktest(user, parameters), // Data tools "get_tickers" => await _dataMcpTools.ExecuteGetTickers(user, parameters), diff --git a/src/Managing.Mcp/McpTools/BacktestMcpTools.cs b/src/Managing.Mcp/McpTools/BacktestMcpTools.cs index 2b3b57e3..f3fdf36d 100644 --- a/src/Managing.Mcp/McpTools/BacktestMcpTools.cs +++ b/src/Managing.Mcp/McpTools/BacktestMcpTools.cs @@ -359,6 +359,95 @@ public class BacktestMcpTools : BaseMcpTool DefaultValue = true } } + }, + new McpToolDefinition + { + Name = "run_bundle_backtest", + Description = "Runs a new bundle backtest with multiple tickers and money management variants. Creates a bundle backtest request that will generate multiple backtest jobs.", + Parameters = new Dictionary + { + ["name"] = new McpParameterDefinition + { + Type = "string", + Description = "Display name for the bundle backtest", + Required = true + }, + ["timeframe"] = new McpParameterDefinition + { + Type = "string", + Description = "The timeframe for the backtest (e.g., 'FiveMinutes', 'FifteenMinutes', 'OneHour', 'FourHours', 'OneDay')", + Required = true + }, + ["startDate"] = new McpParameterDefinition + { + Type = "string", + Description = "Start date for the backtest in ISO 8601 format (e.g., '2024-01-01T00:00:00Z')", + Required = true + }, + ["endDate"] = new McpParameterDefinition + { + Type = "string", + Description = "End date for the backtest in ISO 8601 format (e.g., '2024-12-31T23:59:59Z')", + Required = true + }, + ["balance"] = new McpParameterDefinition + { + Type = "number", + Description = "Starting balance for the backtest (must be greater than zero)", + Required = true + }, + ["tickers"] = new McpParameterDefinition + { + Type = "string", + Description = "Comma-separated list of tickers to test (e.g., 'BTC,ETH,SOL')", + Required = true + }, + ["moneyManagementVariants"] = new McpParameterDefinition + { + Type = "string", + Description = "JSON array of money management variants. Each variant should have a 'moneyManagement' object with 'stopLoss', 'takeProfit', 'leverage', 'name', and 'timeframe' properties. Example: [{\"moneyManagement\":{\"stopLoss\":2.5,\"takeProfit\":5.0,\"leverage\":1,\"name\":\"Conservative\",\"timeframe\":\"OneHour\"}}]", + Required = true + }, + ["scenarioName"] = new McpParameterDefinition + { + Type = "string", + Description = "The name of the trading scenario/strategy to use (alternative to scenarioJson)", + Required = false + }, + ["scenarioJson"] = new McpParameterDefinition + { + Type = "string", + Description = "JSON object representing the scenario configuration with 'name', 'indicators' array, and optional 'lookbackPeriod' (alternative to scenarioName)", + Required = false + }, + ["tradingType"] = new McpParameterDefinition + { + Type = "string", + Description = "Trading type (Spot, Futures, BacktestSpot, BacktestFutures, Paper). Defaults to BacktestSpot", + Required = false + }, + ["saveAsTemplate"] = new McpParameterDefinition + { + Type = "boolean", + Description = "Whether to save only as a template without running (defaults to false)", + Required = false, + DefaultValue = false + } + } + }, + new McpToolDefinition + { + Name = "analyze_bundle_backtest", + Description = "Analyzes a bundle backtest by retrieving all associated backtests and aggregating statistics including averages, best/worst performers, and breakdown by ticker.", + Parameters = new Dictionary + { + ["id"] = new McpParameterDefinition + { + Type = "string", + Description = "The ID of the bundle backtest request to analyze", + Required = true + } + } } }; } @@ -543,4 +632,82 @@ public class BacktestMcpTools : BaseMcpTool name, save); } + + public async Task ExecuteRunBundleBacktest(User user, Dictionary? parameters) + { + var name = GetParameterValue(parameters, "name", string.Empty); + var timeframe = GetParameterValue(parameters, "timeframe", string.Empty); + var startDateString = GetParameterValue(parameters, "startDate", string.Empty); + var endDateString = GetParameterValue(parameters, "endDate", string.Empty); + var balance = GetParameterValue(parameters, "balance", 0); + var tickers = GetParameterValue(parameters, "tickers", string.Empty); + var moneyManagementVariants = GetParameterValue(parameters, "moneyManagementVariants", string.Empty); + var scenarioName = GetParameterValue(parameters, "scenarioName", null); + var scenarioJson = GetParameterValue(parameters, "scenarioJson", null); + var tradingTypeString = GetParameterValue(parameters, "tradingType", null); + var saveAsTemplate = GetParameterValue(parameters, "saveAsTemplate", false); + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Bundle name is required"); + } + + if (string.IsNullOrWhiteSpace(timeframe)) + { + throw new ArgumentException("Timeframe is required"); + } + + if (!DateTime.TryParse(startDateString, out var startDate)) + { + throw new ArgumentException("Valid start date is required"); + } + + if (!DateTime.TryParse(endDateString, out var endDate)) + { + throw new ArgumentException("Valid end date is required"); + } + + if (string.IsNullOrWhiteSpace(tickers)) + { + throw new ArgumentException("At least one ticker is required"); + } + + if (string.IsNullOrWhiteSpace(moneyManagementVariants)) + { + throw new ArgumentException("At least one money management variant is required"); + } + + // Parse tradingType enum + TradingType? tradingType = null; + if (!string.IsNullOrWhiteSpace(tradingTypeString) && + Enum.TryParse(tradingTypeString, true, out var parsedTradingType)) + { + tradingType = parsedTradingType; + } + + return await _backtestTools.RunBundleBacktest( + user, + name, + timeframe, + startDate, + endDate, + balance, + tickers, + moneyManagementVariants, + scenarioName, + scenarioJson, + tradingType, + saveAsTemplate); + } + + public async Task ExecuteAnalyzeBundleBacktest(User user, Dictionary? parameters) + { + var idString = GetParameterValue(parameters, "id", string.Empty); + if (string.IsNullOrWhiteSpace(idString) || !Guid.TryParse(idString, out var id)) + { + throw new ArgumentException("Valid bundle backtest ID is required"); + } + + return await _backtestTools.AnalyzeBundleBacktest(user, id); + } } diff --git a/src/Managing.Mcp/Tools/BacktestTools.cs b/src/Managing.Mcp/Tools/BacktestTools.cs index dbbb0625..506dab61 100644 --- a/src/Managing.Mcp/Tools/BacktestTools.cs +++ b/src/Managing.Mcp/Tools/BacktestTools.cs @@ -5,6 +5,7 @@ using Managing.Domain.Bots; using Managing.Domain.MoneyManagements; using Managing.Domain.Users; using Microsoft.Extensions.Logging; +using System.Text.Json; using static Managing.Common.Enums; namespace Managing.Mcp.Tools; @@ -288,7 +289,8 @@ public class BacktestTools var filter = new BundleBacktestRequestsFilter { Status = status, - NameContains = name + NameContains = name, + UserId = user.Id // Ensure we only get bundle backtests for the current user }; var (bundleRequests, totalCount) = await _backtester.GetBundleBacktestRequestsPaginatedAsync( @@ -497,4 +499,328 @@ public class BacktestTools throw new InvalidOperationException($"Failed to run backtest: {ex.Message}", ex); } } + + /// + /// Runs a new bundle backtest with the specified configuration + /// + public async Task RunBundleBacktest( + User user, + string name, + string timeframe, + DateTime startDate, + DateTime endDate, + decimal balance, + string tickers, + string moneyManagementVariants, + string? scenarioName = null, + string? scenarioJson = null, + TradingType? tradingType = null, + bool saveAsTemplate = false) + { + try + { + // Parse timeframe enum + if (!Enum.TryParse(timeframe, true, out var parsedTimeframe)) + { + throw new ArgumentException($"Invalid timeframe: {timeframe}"); + } + + // Validate dates + if (startDate >= endDate) + { + throw new ArgumentException("Start date must be before end date"); + } + + if (balance <= 0) + { + throw new ArgumentException("Balance must be greater than zero"); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Bundle name is required"); + } + + // Parse tickers + var tickerList = string.IsNullOrWhiteSpace(tickers) + ? new List() + : tickers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => Enum.TryParse(t, true, out var ticker) ? ticker : throw new ArgumentException($"Invalid ticker: {t}")) + .ToList(); + + if (tickerList.Count == 0) + { + throw new ArgumentException("At least one ticker is required"); + } + + // Parse money management variants (JSON array) + List mmVariants; + if (string.IsNullOrWhiteSpace(moneyManagementVariants)) + { + throw new ArgumentException("At least one money management variant is required"); + } + + try + { + mmVariants = JsonSerializer.Deserialize>(moneyManagementVariants); + if (mmVariants == null || mmVariants.Count == 0) + { + throw new ArgumentException("At least one money management variant is required"); + } + } + catch (JsonException ex) + { + throw new ArgumentException($"Invalid money management variants JSON: {ex.Message}"); + } + + // Get user's first account + var accounts = await _accountService.GetAccountsByUserAsync(user, hideSecrets: true, getBalance: false); + var firstAccount = accounts.FirstOrDefault(); + + if (firstAccount == null) + { + throw new InvalidOperationException($"No accounts found for user {user.AgentName}"); + } + + // Build universal config + var universalConfig = new BundleBacktestUniversalConfig + { + Timeframe = parsedTimeframe, + IsForWatchingOnly = false, + BotTradingBalance = balance, + BotName = name, + TradingType = tradingType ?? TradingType.BacktestSpot, + FlipPosition = false, + CooldownPeriod = 0, + MaxLossStreak = 0, + ScenarioName = scenarioName, + Save = true + }; + + // Parse scenario if provided as JSON + if (!string.IsNullOrWhiteSpace(scenarioJson)) + { + try + { + var scenario = JsonSerializer.Deserialize(scenarioJson); + if (scenario != null) + { + universalConfig.Scenario = scenario; + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse scenario JSON, using scenario name instead"); + } + } + + // Create date time ranges (single range for now, can be extended) + var dateTimeRanges = new List + { + new DateTimeRange + { + StartDate = startDate, + EndDate = endDate + } + }; + + // Create the bundle backtest request + var bundleRequest = new BundleBacktestRequest + { + User = user, + UniversalConfigJson = JsonSerializer.Serialize(universalConfig), + DateTimeRangesJson = JsonSerializer.Serialize(dateTimeRanges), + MoneyManagementVariantsJson = JsonSerializer.Serialize(mmVariants), + TickerVariantsJson = JsonSerializer.Serialize(tickerList), + TotalBacktests = dateTimeRanges.Count * mmVariants.Count * tickerList.Count, + CompletedBacktests = 0, + FailedBacktests = 0, + Status = saveAsTemplate + ? BundleBacktestRequestStatus.Saved + : BundleBacktestRequestStatus.Pending, + Name = name + }; + + // Save bundle request + await _backtester.SaveBundleBacktestRequestAsync(user, bundleRequest); + + // If not saving as template, create jobs + if (!saveAsTemplate) + { + await _backtester.CreateJobsForBundleRequestAsync(bundleRequest); + } + + return new + { + Success = true, + BundleRequestId = bundleRequest.RequestId, + Message = saveAsTemplate + ? $"Bundle backtest template saved successfully. Bundle ID: {bundleRequest.RequestId}" + : $"Bundle backtest job created successfully. Bundle ID: {bundleRequest.RequestId}", + TotalBacktests = bundleRequest.TotalBacktests, + Config = new + { + Name = name, + Timeframe = parsedTimeframe.ToString(), + StartDate = startDate, + EndDate = endDate, + Balance = balance, + TickerCount = tickerList.Count, + MoneyManagementVariantCount = mmVariants.Count, + ScenarioName = scenarioName, + TradingType = universalConfig.TradingType.ToString() + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running bundle backtest for user {UserId}", user.Id); + throw new InvalidOperationException($"Failed to run bundle backtest: {ex.Message}", ex); + } + } + + /// + /// Analyzes a bundle backtest by retrieving all associated backtests and aggregating statistics + /// + public async Task AnalyzeBundleBacktest(User user, Guid bundleRequestId) + { + try + { + // Get the bundle request + var bundleRequest = await _backtester.GetBundleBacktestRequestByIdForUserAsync(user, bundleRequestId); + if (bundleRequest == null) + { + throw new InvalidOperationException($"Bundle backtest request with ID '{bundleRequestId}' not found"); + } + + // Get all backtests for this bundle + var backtests = await _backtester.GetBacktestsByRequestIdAsync(bundleRequestId); + var backtestList = backtests.ToList(); + + if (backtestList.Count == 0) + { + return new + { + BundleRequestId = bundleRequestId, + BundleName = bundleRequest.Name, + Status = bundleRequest.Status.ToString(), + TotalBacktests = bundleRequest.TotalBacktests, + CompletedBacktests = bundleRequest.CompletedBacktests, + FailedBacktests = bundleRequest.FailedBacktests, + Message = "No backtests found for this bundle yet", + Statistics = (object?)null + }; + } + + // Aggregate statistics + // Backtest has non-nullable Score, WinRate, GrowthPercentage + var completedBacktests = backtestList.ToList(); + var totalCount = completedBacktests.Count; + + if (totalCount == 0) + { + return new + { + BundleRequestId = bundleRequestId, + BundleName = bundleRequest.Name, + Status = bundleRequest.Status.ToString(), + TotalBacktests = bundleRequest.TotalBacktests, + CompletedBacktests = bundleRequest.CompletedBacktests, + FailedBacktests = bundleRequest.FailedBacktests, + Message = "No completed backtests found yet", + Statistics = (object?)null + }; + } + + var avgScore = completedBacktests.Average(b => b.Score); + var avgWinRate = completedBacktests.Average(b => b.WinRate); + var avgGrowthPercentage = completedBacktests.Average(b => (double)b.GrowthPercentage); + // PerformanceMetrics from Exilion.TradingAtomics + // Both SharpeRatio and MaxDrawdown appear to be non-nullable + var backtestsWithStats = completedBacktests.Where(b => b.Statistics != null).ToList(); + var statsCount = backtestsWithStats.Count; + + double avgSharpeRatio = 0.0; + double avgMaxDrawdown = 0.0; + + if (statsCount > 0) + { + // Both are non-nullable, so use directly + avgSharpeRatio = backtestsWithStats.Average(b => (double)b.Statistics!.SharpeRatio); + avgMaxDrawdown = backtestsWithStats.Average(b => (double)b.Statistics!.MaxDrawdown); + } + var totalFinalPnl = completedBacktests.Sum(b => b.FinalPnl); + var totalNetPnl = completedBacktests.Sum(b => b.NetPnl); + var totalFees = completedBacktests.Sum(b => b.Fees); + var totalPositions = completedBacktests.Sum(b => b.PositionCount); + + var bestBacktest = completedBacktests.OrderByDescending(b => b.Score).FirstOrDefault(); + var worstBacktest = completedBacktests.OrderBy(b => b.Score).FirstOrDefault(); + + // Group by ticker + var byTicker = completedBacktests + .GroupBy(b => b.Config?.Ticker.ToString() ?? "Unknown") + .Select(g => new + { + Ticker = g.Key, + Count = g.Count(), + AvgScore = g.Average(b => b.Score), + AvgWinRate = g.Average(b => b.WinRate), + AvgGrowth = g.Average(b => (double)b.GrowthPercentage), + TotalPnl = g.Sum(b => b.FinalPnl) + }) + .OrderByDescending(x => x.AvgScore) + .ToList(); + + return new + { + BundleRequestId = bundleRequestId, + BundleName = bundleRequest.Name, + Status = bundleRequest.Status.ToString(), + TotalBacktests = bundleRequest.TotalBacktests, + CompletedBacktests = bundleRequest.CompletedBacktests, + FailedBacktests = bundleRequest.FailedBacktests, + ProgressPercentage = bundleRequest.ProgressPercentage, + CreatedAt = bundleRequest.CreatedAt, + CompletedAt = bundleRequest.CompletedAt, + Statistics = new + { + TotalCompleted = totalCount, + AverageScore = Math.Round(avgScore, 2), + AverageWinRate = Math.Round(avgWinRate, 2), + AverageGrowthPercentage = Math.Round(avgGrowthPercentage, 2), + AverageSharpeRatio = Math.Round(avgSharpeRatio, 3), + AverageMaxDrawdown = Math.Round(avgMaxDrawdown, 2), + TotalFinalPnl = Math.Round(totalFinalPnl, 2), + TotalNetPnl = Math.Round(totalNetPnl, 2), + TotalFees = Math.Round(totalFees, 2), + TotalPositions = totalPositions, + BestBacktest = bestBacktest != null ? new + { + bestBacktest.Id, + Score = bestBacktest.Score, + WinRate = bestBacktest.WinRate, + GrowthPercentage = (double)bestBacktest.GrowthPercentage, + FinalPnl = bestBacktest.FinalPnl, + Ticker = bestBacktest.Config?.Ticker.ToString() + } : null, + WorstBacktest = worstBacktest != null ? new + { + worstBacktest.Id, + Score = worstBacktest.Score, + WinRate = worstBacktest.WinRate, + GrowthPercentage = (double)worstBacktest.GrowthPercentage, + FinalPnl = worstBacktest.FinalPnl, + Ticker = worstBacktest.Config?.Ticker.ToString() + } : null, + ByTicker = byTicker + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing bundle backtest {BundleId} for user {UserId}", bundleRequestId, user.Id); + throw new InvalidOperationException($"Failed to analyze bundle backtest: {ex.Message}", ex); + } + } } \ No newline at end of file diff --git a/src/Managing.Mcp/Tools/BotTools.cs b/src/Managing.Mcp/Tools/BotTools.cs index 6f9dccab..81863066 100644 --- a/src/Managing.Mcp/Tools/BotTools.cs +++ b/src/Managing.Mcp/Tools/BotTools.cs @@ -43,13 +43,23 @@ public class BotTools if (pageNumber < 1) pageNumber = 1; if (pageSize < 1 || pageSize > 100) pageSize = 10; + // Ensure we only get bots for the current user by filtering by user's agentName + // If agentName parameter is provided, verify it matches the user's agentName + if (!string.IsNullOrWhiteSpace(agentName) && !agentName.Equals(user.AgentName, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("You can only access bots for your own agent"); + } + + // Use user's agentName to filter bots + var userAgentName = user.AgentName; + var (bots, totalCount) = await _botRepository.GetBotsPaginatedAsync( pageNumber, pageSize, status, name, ticker, - agentName, + userAgentName, // Filter by user's agentName to ensure security minBalance, maxBalance, sortBy,