Enhance trading bot functionality and LLM system message clarity

- Added BotTradingBalance property to UserStrategyDetailsViewModel for better tracking of bot allocations.
- Updated DataController to include BotTradingBalance in the response model.
- Improved LlmController by refining the system message to ensure LLM understands its response capabilities and tool usage.
- Introduced new MCP tools for running and analyzing bundle backtests, enhancing backtesting capabilities for users.
- Implemented security measures in BotTools to ensure users can only access their own bots, improving data privacy.
This commit is contained in:
2026-01-04 23:26:59 +07:00
parent df27bbdfa1
commit a227c72e1f
7 changed files with 534 additions and 17 deletions

View File

@@ -632,7 +632,8 @@ public class DataController : ControllerBase
Identifier = strategy.Identifier, Identifier = strategy.Identifier,
WalletBalances = walletBalances, WalletBalances = walletBalances,
Ticker = strategy.Ticker, Ticker = strategy.Ticker,
MasterAgentName = strategy.MasterBotUser?.AgentName MasterAgentName = strategy.MasterBotUser?.AgentName,
BotTradingBalance = strategy.BotTradingBalance
}; };
} }

View File

@@ -57,23 +57,28 @@ public class LlmController : BaseController
var availableTools = await _mcpService.GetAvailableToolsAsync(); var availableTools = await _mcpService.GetAvailableToolsAsync();
request.Tools = availableTools.ToList(); request.Tools = availableTools.ToList();
// Add system message to clarify that tools are optional and the LLM can respond directly // Add or prepend system message to ensure LLM knows it can respond directly
// Check if a system message already exists // Remove any existing system messages first to ensure our directive is clear
var hasSystemMessage = request.Messages.Any(m => m.Role == "system"); var existingSystemMessages = request.Messages.Where(m => m.Role == "system").ToList();
if (!hasSystemMessage) foreach (var msg in existingSystemMessages)
{ {
var systemMessage = new LlmMessage request.Messages.Remove(msg);
{
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);
} }
// 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 // Send chat request to LLM
var response = await _llmService.ChatAsync(user, request); var response = await _llmService.ChatAsync(user, request);

View File

@@ -112,5 +112,11 @@ namespace Managing.Api.Models.Responses
/// The agent name of the master bot's owner (for copy trading bots) /// The agent name of the master bot's owner (for copy trading bots)
/// </summary> /// </summary>
public string MasterAgentName { get; set; } public string MasterAgentName { get; set; }
/// <summary>
/// The trading balance allocated to this bot
/// </summary>
[Required]
public decimal BotTradingBalance { get; set; }
} }
} }

View File

@@ -48,6 +48,8 @@ public class McpService : IMcpService
"get_bundle_backtest_by_id" => await _backtestMcpTools.ExecuteGetBundleBacktestById(user, parameters), "get_bundle_backtest_by_id" => await _backtestMcpTools.ExecuteGetBundleBacktestById(user, parameters),
"delete_bundle_backtest" => await _backtestMcpTools.ExecuteDeleteBundleBacktest(user, parameters), "delete_bundle_backtest" => await _backtestMcpTools.ExecuteDeleteBundleBacktest(user, parameters),
"run_backtest" => await _backtestMcpTools.ExecuteRunBacktest(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 // Data tools
"get_tickers" => await _dataMcpTools.ExecuteGetTickers(user, parameters), "get_tickers" => await _dataMcpTools.ExecuteGetTickers(user, parameters),

View File

@@ -359,6 +359,95 @@ public class BacktestMcpTools : BaseMcpTool
DefaultValue = true 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<string, McpParameterDefinition>
{
["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<string, McpParameterDefinition>
{
["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, name,
save); save);
} }
public async Task<object> ExecuteRunBundleBacktest(User user, Dictionary<string, object>? parameters)
{
var name = GetParameterValue<string>(parameters, "name", string.Empty);
var timeframe = GetParameterValue<string>(parameters, "timeframe", string.Empty);
var startDateString = GetParameterValue<string>(parameters, "startDate", string.Empty);
var endDateString = GetParameterValue<string>(parameters, "endDate", string.Empty);
var balance = GetParameterValue<decimal>(parameters, "balance", 0);
var tickers = GetParameterValue<string>(parameters, "tickers", string.Empty);
var moneyManagementVariants = GetParameterValue<string>(parameters, "moneyManagementVariants", string.Empty);
var scenarioName = GetParameterValue<string?>(parameters, "scenarioName", null);
var scenarioJson = GetParameterValue<string?>(parameters, "scenarioJson", null);
var tradingTypeString = GetParameterValue<string?>(parameters, "tradingType", null);
var saveAsTemplate = GetParameterValue<bool>(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<TradingType>(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<object> ExecuteAnalyzeBundleBacktest(User user, Dictionary<string, object>? parameters)
{
var idString = GetParameterValue<string>(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);
}
} }

View File

@@ -5,6 +5,7 @@ using Managing.Domain.Bots;
using Managing.Domain.MoneyManagements; using Managing.Domain.MoneyManagements;
using Managing.Domain.Users; using Managing.Domain.Users;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Text.Json;
using static Managing.Common.Enums; using static Managing.Common.Enums;
namespace Managing.Mcp.Tools; namespace Managing.Mcp.Tools;
@@ -288,7 +289,8 @@ public class BacktestTools
var filter = new BundleBacktestRequestsFilter var filter = new BundleBacktestRequestsFilter
{ {
Status = status, 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( var (bundleRequests, totalCount) = await _backtester.GetBundleBacktestRequestsPaginatedAsync(
@@ -497,4 +499,328 @@ public class BacktestTools
throw new InvalidOperationException($"Failed to run backtest: {ex.Message}", ex); throw new InvalidOperationException($"Failed to run backtest: {ex.Message}", ex);
} }
} }
/// <summary>
/// Runs a new bundle backtest with the specified configuration
/// </summary>
public async Task<object> 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>(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<Ticker>()
: tickers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => Enum.TryParse<Ticker>(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<MoneyManagementVariant> mmVariants;
if (string.IsNullOrWhiteSpace(moneyManagementVariants))
{
throw new ArgumentException("At least one money management variant is required");
}
try
{
mmVariants = JsonSerializer.Deserialize<List<MoneyManagementVariant>>(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<ScenarioRequest>(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<DateTimeRange>
{
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);
}
}
/// <summary>
/// Analyzes a bundle backtest by retrieving all associated backtests and aggregating statistics
/// </summary>
public async Task<object> 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);
}
}
} }

View File

@@ -43,13 +43,23 @@ public class BotTools
if (pageNumber < 1) pageNumber = 1; if (pageNumber < 1) pageNumber = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 10; 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( var (bots, totalCount) = await _botRepository.GetBotsPaginatedAsync(
pageNumber, pageNumber,
pageSize, pageSize,
status, status,
name, name,
ticker, ticker,
agentName, userAgentName, // Filter by user's agentName to ensure security
minBalance, minBalance,
maxBalance, maxBalance,
sortBy, sortBy,