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; }
}