diff --git a/src/KArtSell.Host/Jobs/GenerateDailyRecommendationJob.cs b/src/KArtSell.Host/Jobs/GenerateDailyRecommendationJob.cs
new file mode 100644
index 00000000..c007b0b0
--- /dev/null
+++ b/src/KArtSell.Host/Jobs/GenerateDailyRecommendationJob.cs
@@ -0,0 +1,55 @@
+using Hangfire;
+using KArtSell.BuildingBlocks.Time;
+using Microsoft.Extensions.Logging;
+
+namespace KArtSell.Host.Jobs;
+
+///
+/// Daily recommendation report generation job.
+/// Runs at 09:00 KST (market open), summarizes sell decisions from previous trading day.
+/// Idempotent: keyed by trading date to prevent duplicate reports.
+///
+public sealed class GenerateDailyRecommendationJob
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IClock _clock;
+ private readonly ILogger _logger;
+
+ public GenerateDailyRecommendationJob(
+ IServiceProvider serviceProvider,
+ IClock clock,
+ ILogger logger)
+ {
+ _serviceProvider = serviceProvider;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ [Queue("q-recommendation")]
+ public async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("DailyRecommendation job started");
+
+ try
+ {
+ using var scope = _serviceProvider.CreateAsyncScope();
+ var reportGenerator = scope.ServiceProvider.GetRequiredService();
+
+ var now = _clock.UtcNow;
+ var reportDate = now.DateTime.Date;
+
+ var report = await reportGenerator.GenerateDailyRecommendationAsync(reportDate, cancellationToken);
+ await reportGenerator.SendRecommendationReportAsync(report, cancellationToken);
+
+ _logger.LogInformation(
+ "Daily recommendation report sent. Date={Date}, Recommendations={Count}",
+ reportDate,
+ report.Recommendations.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Daily recommendation job failed");
+ throw;
+ }
+ }
+}
diff --git a/src/KArtSell.Host/Jobs/GenerateMonthlyRecommendationJob.cs b/src/KArtSell.Host/Jobs/GenerateMonthlyRecommendationJob.cs
new file mode 100644
index 00000000..07a56b36
--- /dev/null
+++ b/src/KArtSell.Host/Jobs/GenerateMonthlyRecommendationJob.cs
@@ -0,0 +1,58 @@
+using Hangfire;
+using KArtSell.BuildingBlocks.Time;
+using Microsoft.Extensions.Logging;
+
+namespace KArtSell.Host.Jobs;
+
+///
+/// Monthly recommendation report generation job.
+/// Runs on the 1st of every month at 09:00 KST, summarizes sell decisions from previous month.
+/// Idempotent: keyed by month start date to prevent duplicate reports.
+///
+public sealed class GenerateMonthlyRecommendationJob
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IClock _clock;
+ private readonly ILogger _logger;
+
+ public GenerateMonthlyRecommendationJob(
+ IServiceProvider serviceProvider,
+ IClock clock,
+ ILogger logger)
+ {
+ _serviceProvider = serviceProvider;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ [Queue("q-recommendation")]
+ public async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("MonthlyRecommendation job started");
+
+ try
+ {
+ using var scope = _serviceProvider.CreateAsyncScope();
+ var reportGenerator = scope.ServiceProvider.GetRequiredService();
+
+ var now = _clock.UtcNow;
+ var currentDate = now.DateTime.Date;
+
+ // Get start of current month
+ var monthStart = new DateTime(currentDate.Year, currentDate.Month, 1);
+
+ var report = await reportGenerator.GenerateMonthlyRecommendationAsync(monthStart, cancellationToken);
+ await reportGenerator.SendRecommendationReportAsync(report, cancellationToken);
+
+ _logger.LogInformation(
+ "Monthly recommendation report sent. Month={Month}, Recommendations={Count}",
+ monthStart.ToString("yyyy-MM"),
+ report.Recommendations.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Monthly recommendation job failed");
+ throw;
+ }
+ }
+}
diff --git a/src/KArtSell.Host/Jobs/GenerateWeeklyRecommendationJob.cs b/src/KArtSell.Host/Jobs/GenerateWeeklyRecommendationJob.cs
new file mode 100644
index 00000000..f666cdb2
--- /dev/null
+++ b/src/KArtSell.Host/Jobs/GenerateWeeklyRecommendationJob.cs
@@ -0,0 +1,61 @@
+using Hangfire;
+using KArtSell.BuildingBlocks.Time;
+using Microsoft.Extensions.Logging;
+
+namespace KArtSell.Host.Jobs;
+
+///
+/// Weekly recommendation report generation job.
+/// Runs every Monday at 09:00 KST, summarizes sell decisions from previous week.
+/// Idempotent: keyed by week start date to prevent duplicate reports.
+///
+public sealed class GenerateWeeklyRecommendationJob
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IClock _clock;
+ private readonly ILogger _logger;
+
+ public GenerateWeeklyRecommendationJob(
+ IServiceProvider serviceProvider,
+ IClock clock,
+ ILogger logger)
+ {
+ _serviceProvider = serviceProvider;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ [Queue("q-recommendation")]
+ public async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("WeeklyRecommendation job started");
+
+ try
+ {
+ using var scope = _serviceProvider.CreateAsyncScope();
+ var reportGenerator = scope.ServiceProvider.GetRequiredService();
+
+ var now = _clock.UtcNow;
+ var currentDate = now.DateTime.Date;
+
+ // Get start of current week (Saturday)
+ var daysToSubtract = (int)currentDate.DayOfWeek - (int)DayOfWeek.Saturday;
+ if (daysToSubtract < 0)
+ daysToSubtract += 7;
+ var weekStart = currentDate.AddDays(-daysToSubtract);
+
+ var report = await reportGenerator.GenerateWeeklyRecommendationAsync(weekStart, cancellationToken);
+ await reportGenerator.SendRecommendationReportAsync(report, cancellationToken);
+
+ _logger.LogInformation(
+ "Weekly recommendation report sent. WeekStart={Date}, Recommendations={Count}",
+ weekStart,
+ report.Recommendations.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Weekly recommendation job failed");
+ throw;
+ }
+ }
+}
diff --git a/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs b/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs
new file mode 100644
index 00000000..f5b59e62
--- /dev/null
+++ b/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs
@@ -0,0 +1,247 @@
+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; }
+}
diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs
index 07419836..23e98415 100644
--- a/src/KArtSell.Host/Program.cs
+++ b/src/KArtSell.Host/Program.cs
@@ -90,6 +90,12 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
+// Recommendation Report Services
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
builder.Services.AddProblemDetails();
builder.Services.AddFastEndpoints();
@@ -182,6 +188,27 @@ RecurringJob.AddOrUpdate(
"* * * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
+// Recommendation report generation (KST timezone, market open 09:00)
+var kstTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
+
+RecurringJob.AddOrUpdate(
+ "daily-recommendation",
+ job => job.ExecuteAsync(CancellationToken.None),
+ "0 9 * * *", // 09:00 every day
+ new RecurringJobOptions { TimeZone = kstTimeZone });
+
+RecurringJob.AddOrUpdate(
+ "weekly-recommendation",
+ job => job.ExecuteAsync(CancellationToken.None),
+ "0 9 * * 6", // 09:00 every Saturday
+ new RecurringJobOptions { TimeZone = kstTimeZone });
+
+RecurringJob.AddOrUpdate(
+ "monthly-recommendation",
+ job => job.ExecuteAsync(CancellationToken.None),
+ "0 9 1 * *", // 09:00 on the 1st of every month
+ new RecurringJobOptions { TimeZone = kstTimeZone });
+
app.MapGet("/health/live", () => Results.Ok(new
{
status = "ok",