using KArtSell.BuildingBlocks.Data; using Microsoft.Extensions.Logging; using System.Net.Http; namespace KArtSell.Host.Jobs; /// /// Generates and sends algorithm-based recommendation reports (Daily/Weekly/Monthly). /// Reports summarize recent sell decisions from SignalEngine. /// Sends via Telegram with formatted markdown output. /// Idempotency is handled by Hangfire's recurring job scheduling (same job name = no duplicates). /// public sealed class RecommendationReportGenerator { private readonly IDbConnectionFactory _connectionFactory; private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly string _telegramBotToken; private readonly string _telegramChatId; public RecommendationReportGenerator( IDbConnectionFactory connectionFactory, HttpClient httpClient, ILogger logger) { _connectionFactory = connectionFactory; _httpClient = httpClient; _logger = logger; _telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty; _telegramChatId = Environment.GetEnvironmentVariable("CHAT_ID") ?? string.Empty; } public async Task GenerateDailyRecommendationAsync( DateTime reportDate, CancellationToken cancellationToken) { var startDate = reportDate.Date; var endDate = startDate.AddDays(1).AddTicks(-1); var recommendations = await GetSellDecisionsAsync(startDate, endDate, cancellationToken); return new RecommendationReport { ReportType = "Daily", ReportDate = reportDate, PeriodStart = startDate, PeriodEnd = endDate, Recommendations = recommendations }; } public async Task GenerateWeeklyRecommendationAsync( DateTime weekStart, CancellationToken cancellationToken) { var startDate = weekStart.Date; var endDate = startDate.AddDays(7).AddTicks(-1); var recommendations = await GetSellDecisionsAsync(startDate, endDate, cancellationToken); return new RecommendationReport { ReportType = "Weekly", ReportDate = DateTime.UtcNow, PeriodStart = startDate, PeriodEnd = endDate, Recommendations = recommendations }; } public async Task GenerateMonthlyRecommendationAsync( DateTime monthStart, CancellationToken cancellationToken) { var startDate = monthStart.Date; var endDate = startDate.AddMonths(1).AddTicks(-1); var recommendations = await GetSellDecisionsAsync(startDate, endDate, cancellationToken); return new RecommendationReport { ReportType = "Monthly", ReportDate = DateTime.UtcNow, PeriodStart = startDate, PeriodEnd = endDate, Recommendations = recommendations }; } public async Task SendRecommendationReportAsync( RecommendationReport report, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_telegramBotToken) || string.IsNullOrEmpty(_telegramChatId)) { _logger.LogWarning("Telegram credentials not configured. Report not sent"); return; } var message = FormatReportAsMarkdown(report); await SendTelegramMessageAsync(message, cancellationToken); } public Task HasReportBeenSentAsync(string idempotencyKey, CancellationToken cancellationToken) { return Task.FromResult(false); } public Task MarkReportSentAsync(string idempotencyKey, CancellationToken cancellationToken) { return Task.CompletedTask; } private async Task> GetSellDecisionsAsync( DateTime startDate, DateTime endDate, CancellationToken cancellationToken) { var sql = @" SELECT id as DecisionId, action as Action, sell_ratio_of_lot as SellRatio, policy_id as PolicyId, reason_code as ReasonCode, created_at as CreatedAt FROM signal_engine.sell_decisions WHERE created_at >= $1 AND created_at < $2 AND action = 'SELL' ORDER BY created_at DESC"; await using var connection = await _connectionFactory.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = sql; command.Parameters.Add(command.CreateParameter()); command.Parameters[0].Value = startDate; command.Parameters.Add(command.CreateParameter()); command.Parameters[1].Value = endDate; var recommendations = new List(); await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) { while (await reader.ReadAsync(cancellationToken)) { recommendations.Add(new SellRecommendation { DecisionId = reader.GetGuid(0), Action = reader.GetString(1), SellRatio = reader.GetDecimal(2), PolicyId = reader.GetString(3), ReasonCode = reader.GetString(4), CreatedAt = reader.GetDateTime(5) }); } } return recommendations.AsReadOnly(); } private static string FormatReportAsMarkdown(RecommendationReport report) { var message = $@"📊 *{report.ReportType} Recommendation Report* - K-ArtSell Aegis Period: {report.PeriodStart:yyyy-MM-dd} → {report.PeriodEnd:yyyy-MM-dd} Total Recommendations: {report.Recommendations.Count} "; if (report.Recommendations.Count == 0) { message += "_No sell recommendations for this period._"; return message; } // Group by policy var byPolicy = report.Recommendations .GroupBy(r => r.PolicyId) .OrderByDescending(g => g.Count()); foreach (var group in byPolicy.Take(5)) { message += $@" *{group.Key}* ({group.Count()})"; foreach (var rec in group.Take(3)) { message += $@" • {rec.ReasonCode} (Ratio: {rec.SellRatio:P2}) @ {rec.CreatedAt:HH:mm}"; } if (group.Count() > 3) { message += $@" • +{group.Count() - 3} more"; } } message += @" _Generated by K-ArtSell Aegis Algorithm_ _Evidence Preserved · Audit Logged_"; return message; } private async Task SendTelegramMessageAsync(string message, CancellationToken cancellationToken) { var url = $"https://api.telegram.org/bot{_telegramBotToken}/sendMessage"; var content = new FormUrlEncodedContent(new Dictionary { { "chat_id", _telegramChatId }, { "text", message }, { "parse_mode", "Markdown" } }); try { var response = await _httpClient.PostAsync(url, content, cancellationToken); response.EnsureSuccessStatusCode(); } catch (Exception ex) { _logger.LogError(ex, "Failed to send recommendation report to Telegram"); throw; } } } public sealed record RecommendationReport { public required string ReportType { get; init; } public required DateTime ReportDate { get; init; } public required DateTime PeriodStart { get; init; } public required DateTime PeriodEnd { get; init; } public required IReadOnlyList Recommendations { get; init; } } public sealed record SellRecommendation { public required Guid DecisionId { get; init; } public required string Action { get; init; } public required decimal SellRatio { get; init; } public required string PolicyId { get; init; } public required string ReasonCode { get; init; } public required DateTime CreatedAt { get; init; } }