Compare commits
5 Commits
2b48f37ca8
...
ba02debf9e
| Author | SHA1 | Date | |
|---|---|---|---|
| ba02debf9e | |||
| eb106d578e | |||
| 9a2d939bb6 | |||
| 4519fa8231 | |||
| e35f744e4c |
@@ -0,0 +1,106 @@
|
||||
using System.Net.Http;
|
||||
using Serilog;
|
||||
using Serilog.Configuration;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace KArtSell.Host.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Serilog sink for sending critical logs to Telegram
|
||||
/// Triggers on ERROR and FATAL events
|
||||
/// </summary>
|
||||
public sealed class TelegramSink : ILogEventSink
|
||||
{
|
||||
private readonly string _telegramBotToken;
|
||||
private readonly string _telegramChatId;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
public TelegramSink(string telegramBotToken, string telegramChatId, HttpClient? httpClient = null)
|
||||
{
|
||||
_telegramBotToken = telegramBotToken;
|
||||
_telegramChatId = telegramChatId;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
// Only send critical logs (Error and Fatal)
|
||||
if (logEvent.Level < LogEventLevel.Error)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
SendTelegramMessage(logEvent);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore Telegram errors to prevent logging loops
|
||||
}
|
||||
}
|
||||
|
||||
private void SendTelegramMessage(LogEvent logEvent)
|
||||
{
|
||||
var emoji = logEvent.Level == LogEventLevel.Fatal ? "🔴" : "⚠️";
|
||||
var levelName = logEvent.Level.ToString().ToUpperInvariant();
|
||||
|
||||
var message = $@"{emoji} *{levelName}* - K-ArtSell Aegis
|
||||
|
||||
{logEvent.MessageTemplate.Render(logEvent.Properties)}
|
||||
|
||||
_Timestamp: {logEvent.Timestamp:O}_";
|
||||
|
||||
if (logEvent.Exception != null)
|
||||
{
|
||||
message += $@"
|
||||
|
||||
```
|
||||
{logEvent.Exception.GetType().Name}: {logEvent.Exception.Message}
|
||||
```";
|
||||
}
|
||||
|
||||
SendMessage(message);
|
||||
}
|
||||
|
||||
private void SendMessage(string message)
|
||||
{
|
||||
var url = $"https://api.telegram.org/bot{_telegramBotToken}/sendMessage";
|
||||
|
||||
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "chat_id", _telegramChatId },
|
||||
{ "text", message },
|
||||
{ "parse_mode", "Markdown" }
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var response = _httpClient.PostAsync(url, content).GetAwaiter().GetResult();
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail - don't want logging to break application
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serilog extension for adding Telegram sink
|
||||
/// </summary>
|
||||
public static class TelegramSinkExtensions
|
||||
{
|
||||
public static LoggerConfiguration Telegram(
|
||||
this LoggerSinkConfiguration loggerConfiguration,
|
||||
string telegramBotToken,
|
||||
string telegramChatId,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
return loggerConfiguration.Sink(
|
||||
new TelegramSink(telegramBotToken, telegramChatId, httpClient));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace KArtSell.Host.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Async Telegram sink for Serilog: non-blocking queue + exponential backoff
|
||||
/// Processes ERROR/FATAL logs via background channel, prevents logging from blocking
|
||||
/// </summary>
|
||||
public sealed class TelegramSinkAsync : ILogEventSink, IAsyncDisposable
|
||||
{
|
||||
private readonly string _telegramBotToken;
|
||||
private readonly string _telegramChatId;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly Channel<LogEvent> _queue;
|
||||
private readonly Task _backgroundTask;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public TelegramSinkAsync(string telegramBotToken, string telegramChatId, HttpClient? httpClient = null)
|
||||
{
|
||||
_telegramBotToken = telegramBotToken;
|
||||
_telegramChatId = telegramChatId;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_queue = Channel.CreateUnbounded<LogEvent>();
|
||||
_cts = new CancellationTokenSource();
|
||||
_backgroundTask = ProcessQueueAsync(_cts.Token);
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
// Only queue ERROR and FATAL
|
||||
if (logEvent.Level < LogEventLevel.Error)
|
||||
return;
|
||||
|
||||
// Non-blocking: enqueue only
|
||||
_queue.Writer.TryWrite(logEvent);
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var logEvent in _queue.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
// Rate limit: 100ms spacer between messages
|
||||
await Task.Delay(100, cancellationToken);
|
||||
|
||||
// Retry: 3x with exponential backoff
|
||||
var backoffMs = 100;
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendTelegramMessageAsync(logEvent, cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
if (attempt < 2)
|
||||
{
|
||||
backoffMs *= 2;
|
||||
await Task.Delay(backoffMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail to prevent logging loops
|
||||
if (attempt == 2) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected during shutdown
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendTelegramMessageAsync(LogEvent logEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var emoji = logEvent.Level == LogEventLevel.Fatal ? "🔴" : "⚠️";
|
||||
var levelName = logEvent.Level.ToString().ToUpperInvariant();
|
||||
|
||||
var message = $@"{emoji} *{levelName}* - K-ArtSell Aegis
|
||||
|
||||
{logEvent.MessageTemplate.Render(logEvent.Properties)}
|
||||
|
||||
_Timestamp: {logEvent.Timestamp:O}_";
|
||||
|
||||
if (logEvent.Exception != null)
|
||||
{
|
||||
message += $@"
|
||||
|
||||
```
|
||||
{logEvent.Exception.GetType().Name}: {logEvent.Exception.Message}
|
||||
```";
|
||||
}
|
||||
|
||||
var url = $"https://api.telegram.org/bot{_telegramBotToken}/sendMessage";
|
||||
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "chat_id", _telegramChatId },
|
||||
{ "text", message },
|
||||
{ "parse_mode", "Markdown" }
|
||||
});
|
||||
|
||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_queue.Writer.Complete();
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
await _backgroundTask;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Hangfire;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Host.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class GenerateDailyRecommendationJob
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IClock _clock;
|
||||
private readonly ILogger<GenerateDailyRecommendationJob> _logger;
|
||||
|
||||
public GenerateDailyRecommendationJob(
|
||||
IServiceProvider serviceProvider,
|
||||
IClock clock,
|
||||
ILogger<GenerateDailyRecommendationJob> 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<RecommendationReportGenerator>();
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
var reportDate = now.DateTime.Date;
|
||||
|
||||
var idempotencyKey = $"daily:{reportDate:yyyy-MM-dd}";
|
||||
if (await reportGenerator.HasReportBeenSentAsync(idempotencyKey, cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Daily report already sent for {Date}. Skipping", reportDate);
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await reportGenerator.GenerateDailyRecommendationAsync(reportDate, cancellationToken);
|
||||
await reportGenerator.SendRecommendationReportAsync(report, cancellationToken);
|
||||
await reportGenerator.MarkReportSentAsync(idempotencyKey, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Hangfire;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Host.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class GenerateMonthlyRecommendationJob
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IClock _clock;
|
||||
private readonly ILogger<GenerateMonthlyRecommendationJob> _logger;
|
||||
|
||||
public GenerateMonthlyRecommendationJob(
|
||||
IServiceProvider serviceProvider,
|
||||
IClock clock,
|
||||
ILogger<GenerateMonthlyRecommendationJob> 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<RecommendationReportGenerator>();
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
var currentDate = now.DateTime.Date;
|
||||
|
||||
// Get start of current month
|
||||
var monthStart = new DateTime(currentDate.Year, currentDate.Month, 1);
|
||||
|
||||
var idempotencyKey = $"monthly:{monthStart:yyyy-MM-dd}";
|
||||
if (await reportGenerator.HasReportBeenSentAsync(idempotencyKey, cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Monthly report already sent for {Month}. Skipping", monthStart.ToString("yyyy-MM"));
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await reportGenerator.GenerateMonthlyRecommendationAsync(monthStart, cancellationToken);
|
||||
await reportGenerator.SendRecommendationReportAsync(report, cancellationToken);
|
||||
await reportGenerator.MarkReportSentAsync(idempotencyKey, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Hangfire;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Host.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class GenerateWeeklyRecommendationJob
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IClock _clock;
|
||||
private readonly ILogger<GenerateWeeklyRecommendationJob> _logger;
|
||||
|
||||
public GenerateWeeklyRecommendationJob(
|
||||
IServiceProvider serviceProvider,
|
||||
IClock clock,
|
||||
ILogger<GenerateWeeklyRecommendationJob> 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<RecommendationReportGenerator>();
|
||||
|
||||
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 idempotencyKey = $"weekly:{weekStart:yyyy-MM-dd}";
|
||||
if (await reportGenerator.HasReportBeenSentAsync(idempotencyKey, cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Weekly report already sent for week starting {Date}. Skipping", weekStart);
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await reportGenerator.GenerateWeeklyRecommendationAsync(weekStart, cancellationToken);
|
||||
await reportGenerator.SendRecommendationReportAsync(report, cancellationToken);
|
||||
await reportGenerator.MarkReportSentAsync(idempotencyKey, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace KArtSell.Host.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed class RecommendationReportGenerator
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<RecommendationReportGenerator> _logger;
|
||||
private readonly string _telegramBotToken;
|
||||
private readonly string _telegramChatId;
|
||||
|
||||
public RecommendationReportGenerator(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
HttpClient httpClient,
|
||||
ILogger<RecommendationReportGenerator> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty;
|
||||
_telegramChatId = Environment.GetEnvironmentVariable("CHAT_ID") ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<RecommendationReport> 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<RecommendationReport> 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<RecommendationReport> 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 async Task<bool> HasReportBeenSentAsync(string idempotencyKey, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT COUNT(1)
|
||||
FROM recommendation_sent_log
|
||||
WHERE idempotency_key = $1";
|
||||
|
||||
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 = idempotencyKey;
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return (long?)result > 0;
|
||||
}
|
||||
|
||||
public async Task MarkReportSentAsync(string idempotencyKey, CancellationToken cancellationToken)
|
||||
{
|
||||
const string createTableSql = @"
|
||||
CREATE TABLE IF NOT EXISTS recommendation_sent_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)";
|
||||
|
||||
await using var connection = await _connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var createCmd = connection.CreateCommand();
|
||||
createCmd.CommandText = createTableSql;
|
||||
await createCmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
const string insertSql = @"
|
||||
INSERT INTO recommendation_sent_log (idempotency_key, sent_at)
|
||||
VALUES ($1, NOW())
|
||||
ON CONFLICT (idempotency_key) DO NOTHING";
|
||||
|
||||
await using var insertCmd = connection.CreateCommand();
|
||||
insertCmd.CommandText = insertSql;
|
||||
insertCmd.Parameters.Add(insertCmd.CreateParameter());
|
||||
insertCmd.Parameters[0].Value = idempotencyKey;
|
||||
await insertCmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SellRecommendation>> 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<SellRecommendation>();
|
||||
|
||||
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<string, string>
|
||||
{
|
||||
{ "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<SellRecommendation> 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; }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Host.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory API call metrics tracking (24h retention).
|
||||
/// Records: success/failure, latency, retry count, rate limiting, quota remaining.
|
||||
/// </summary>
|
||||
public sealed class ApiCallMetricsService : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ApiMetric> _metrics = new();
|
||||
private readonly ILogger<ApiCallMetricsService> _logger;
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
public ApiCallMetricsService(ILogger<ApiCallMetricsService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
// Cleanup old entries every hour
|
||||
_cleanupTimer = new Timer(CleanupOldEntries, null, TimeSpan.FromHours(1), TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer?.Dispose();
|
||||
}
|
||||
|
||||
public void RecordApiCall(
|
||||
string apiName,
|
||||
bool success,
|
||||
int latencyMs = 0,
|
||||
int retryCount = 0,
|
||||
bool rateLimited = false,
|
||||
int? remainingQuota = null)
|
||||
{
|
||||
var key = $"{apiName}:{DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm}";
|
||||
|
||||
_metrics.AddOrUpdate(key, _ =>
|
||||
new ApiMetric
|
||||
{
|
||||
ApiName = apiName,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Success = success,
|
||||
LatencyMs = latencyMs,
|
||||
RetryCount = retryCount,
|
||||
RateLimited = rateLimited,
|
||||
RemainingQuota = remainingQuota
|
||||
},
|
||||
(_, existing) => existing); // Keep first entry per minute
|
||||
|
||||
if (rateLimited)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"API rate limited: {ApiName}, remaining: {Quota}, retry: {RetryCount}",
|
||||
apiName, remainingQuota, retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ApiMetric> GetMetrics(string? apiNameFilter = null)
|
||||
{
|
||||
var results = _metrics.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(apiNameFilter))
|
||||
{
|
||||
results = results.Where(m => m.ApiName.Contains(apiNameFilter, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return results.OrderByDescending(m => m.Timestamp).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public Dictionary<string, ApiSummary> GetSummary()
|
||||
{
|
||||
var summary = new Dictionary<string, ApiSummary>();
|
||||
|
||||
foreach (var group in _metrics.Values.GroupBy(m => m.ApiName))
|
||||
{
|
||||
var metrics = group.ToList();
|
||||
summary[group.Key] = new ApiSummary
|
||||
{
|
||||
TotalCalls = metrics.Count,
|
||||
SuccessCount = metrics.Count(m => m.Success),
|
||||
FailureCount = metrics.Count(m => !m.Success),
|
||||
RateLimitCount = metrics.Count(m => m.RateLimited),
|
||||
AverageLatencyMs = metrics.Average(m => m.LatencyMs),
|
||||
MinRemainingQuota = metrics.Where(m => m.RemainingQuota.HasValue).Min(m => m.RemainingQuota),
|
||||
LastUpdated = metrics.Max(m => m.Timestamp)
|
||||
};
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private void CleanupOldEntries(object? state)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
|
||||
var oldKeys = _metrics.Where(kvp => kvp.Value.Timestamp < cutoff).Select(kvp => kvp.Key).ToList();
|
||||
|
||||
foreach (var key in oldKeys)
|
||||
{
|
||||
_metrics.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (oldKeys.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} old API metrics", oldKeys.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ApiMetric
|
||||
{
|
||||
public required string ApiName { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public required int LatencyMs { get; init; }
|
||||
public required int RetryCount { get; init; }
|
||||
public required bool RateLimited { get; init; }
|
||||
public required int? RemainingQuota { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApiSummary
|
||||
{
|
||||
public required int TotalCalls { get; init; }
|
||||
public required int SuccessCount { get; init; }
|
||||
public required int FailureCount { get; init; }
|
||||
public required int RateLimitCount { get; init; }
|
||||
public required double AverageLatencyMs { get; init; }
|
||||
public required int? MinRemainingQuota { get; init; }
|
||||
public required DateTimeOffset LastUpdated { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using KArtSell.BuildingBlocks.Capabilities;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using KArtSell.Host.Jobs;
|
||||
using KArtSell.Host.Configuration;
|
||||
using KArtSell.Host.Infrastructure;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using KArtSell.BuildingBlocks.Reliability;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
@@ -18,14 +19,28 @@ using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, services, logger) => logger
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console());
|
||||
// Load Telegram secrets for Serilog notifications
|
||||
var telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty;
|
||||
var telegramChatId = Environment.GetEnvironmentVariable("CHAT_ID") ?? string.Empty;
|
||||
|
||||
builder.Host.UseSerilog((context, services, logger) =>
|
||||
{
|
||||
var config = logger
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
|
||||
// Add async Telegram sink for ERROR and FATAL logs (non-blocking queue)
|
||||
if (!string.IsNullOrEmpty(telegramBotToken) && !string.IsNullOrEmpty(telegramChatId))
|
||||
{
|
||||
config = config.WriteTo.Sink(new TelegramSinkAsync(telegramBotToken, telegramChatId), LogEventLevel.Error);
|
||||
}
|
||||
});
|
||||
|
||||
// Load secrets from environment variables (set by CI/CD or user-secrets in dev)
|
||||
var connectionString = ResolveSecret(
|
||||
@@ -75,6 +90,15 @@ builder.Services.AddScoped<KArtSell.Modules.ModelOperations.ShadowRun.ShadowRunQ
|
||||
builder.Services.AddScoped<KArtSell.Host.Features.ShadowRun.InitiateShadowRunHandler>();
|
||||
builder.Services.AddScoped<KArtSell.Host.Features.ShadowRun.GetShadowRunQuery>();
|
||||
|
||||
// Recommendation Report Services
|
||||
builder.Services.AddScoped<RecommendationReportGenerator>();
|
||||
builder.Services.AddScoped<GenerateDailyRecommendationJob>();
|
||||
builder.Services.AddScoped<GenerateWeeklyRecommendationJob>();
|
||||
builder.Services.AddScoped<GenerateMonthlyRecommendationJob>();
|
||||
|
||||
// API Metrics
|
||||
builder.Services.AddSingleton<KArtSell.Host.Observability.ApiCallMetricsService>();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddFastEndpoints();
|
||||
|
||||
@@ -167,6 +191,27 @@ RecurringJob.AddOrUpdate<DownstreamConsumerJob>(
|
||||
"* * * * *",
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
|
||||
// Recommendation report generation (KST timezone, market open 09:00)
|
||||
var kstTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
|
||||
|
||||
RecurringJob.AddOrUpdate<GenerateDailyRecommendationJob>(
|
||||
"daily-recommendation",
|
||||
job => job.ExecuteAsync(CancellationToken.None),
|
||||
"0 9 * * *", // 09:00 every day
|
||||
new RecurringJobOptions { TimeZone = kstTimeZone });
|
||||
|
||||
RecurringJob.AddOrUpdate<GenerateWeeklyRecommendationJob>(
|
||||
"weekly-recommendation",
|
||||
job => job.ExecuteAsync(CancellationToken.None),
|
||||
"0 9 * * 6", // 09:00 every Saturday
|
||||
new RecurringJobOptions { TimeZone = kstTimeZone });
|
||||
|
||||
RecurringJob.AddOrUpdate<GenerateMonthlyRecommendationJob>(
|
||||
"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",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "Host=127.0.0.1;Port=5432;Database=kartselldb_test;Username=kartsell_test;Password=kartsell4321@!_test"
|
||||
},
|
||||
"Authentication": {
|
||||
"Mode": "DevelopmentHeader"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "${KARTSELL_POSTGRES}"
|
||||
"Postgres": "Host=127.0.0.1;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"
|
||||
},
|
||||
"ExternalApis": {
|
||||
"KrxOpenApi": {
|
||||
|
||||
@@ -43,16 +43,32 @@ public sealed class DataBackfiller(
|
||||
"Backfilling OHLCV: {TickerCount} tickers, {TradingDays} trading days ({Start:yyyy-MM-dd} to {End:yyyy-MM-dd})",
|
||||
tickers.Count, tradingSessions.Count, windowStart, windowEnd);
|
||||
|
||||
const int BatchDays = 30; // Batch size: ~252 days / 30 = 9 calls (vs 252)
|
||||
var bars = new List<OhlcvBar>();
|
||||
|
||||
foreach (var ticker in tickers)
|
||||
{
|
||||
var tickerBars = await krxData.GetDailyOhlcvAsync(
|
||||
ticker, windowStart, windowEnd, cancellationToken);
|
||||
var tickerBars = new List<OhlcvBar>();
|
||||
|
||||
// Fetch in 30-day batches
|
||||
for (var batchStart = windowStart; batchStart <= windowEnd; batchStart = batchStart.AddDays(BatchDays))
|
||||
{
|
||||
var batchEnd = batchStart.AddDays(BatchDays - 1) > windowEnd
|
||||
? windowEnd
|
||||
: batchStart.AddDays(BatchDays - 1);
|
||||
|
||||
// 100ms throttle between batches
|
||||
await Task.Delay(100, cancellationToken);
|
||||
|
||||
var batchBars = await krxData.GetDailyOhlcvAsync(
|
||||
ticker, batchStart, batchEnd, cancellationToken);
|
||||
tickerBars.AddRange(batchBars);
|
||||
}
|
||||
|
||||
bars.AddRange(tickerBars);
|
||||
}
|
||||
|
||||
logger.LogInformation("Backfilled {BarCount} OHLCV bars", bars.Count);
|
||||
logger.LogInformation("Backfilled {BarCount} OHLCV bars (batch mode: 30-day chunks)", bars.Count);
|
||||
return bars;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ public sealed class KrxDataService : IKrxDataService
|
||||
|
||||
private const int CacheDurationMinutes = 1440; // 24 hours
|
||||
private const int MaxRetries = 3;
|
||||
private const int RetryDelayMs = 1000;
|
||||
private const int InitialBackoffMs = 100;
|
||||
private const int MaxBackoffMs = 30000;
|
||||
private const string KrxApiBaseUrl = "https://openapi.krx.co.kr";
|
||||
|
||||
private static readonly Action<ILogger, string, DateOnly, DateOnly, Exception?> LogFetchingOhlcv =
|
||||
@@ -73,9 +74,10 @@ public sealed class KrxDataService : IKrxDataService
|
||||
return cached!;
|
||||
}
|
||||
|
||||
// Fetch with retry
|
||||
// Fetch with exponential backoff retry
|
||||
var bars = new List<DataBackfiller.OhlcvBar>();
|
||||
int attempt = 0;
|
||||
int backoffMs = InitialBackoffMs;
|
||||
|
||||
while (attempt < MaxRetries)
|
||||
{
|
||||
@@ -85,10 +87,19 @@ public sealed class KrxDataService : IKrxDataService
|
||||
bars = ParseOhlcvResponse(ticker, response);
|
||||
break;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests && attempt < MaxRetries - 1)
|
||||
{
|
||||
// 429: Rate limit hit → exponential backoff
|
||||
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
|
||||
LogRetryError(_logger, $"Rate limited (429), backoff {backoffMs}ms (attempt {attempt + 1}/{MaxRetries})", ex);
|
||||
await Task.Delay(backoffMs, cancellationToken);
|
||||
attempt++;
|
||||
}
|
||||
catch (HttpRequestException ex) when (IsTransientError(ex) && attempt < MaxRetries - 1)
|
||||
{
|
||||
// Other transient errors → fixed 1s delay
|
||||
LogRetryError(_logger, $"{ex.Message} (attempt {attempt + 1}/{MaxRetries})", ex);
|
||||
await Task.Delay(RetryDelayMs, cancellationToken);
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
attempt++;
|
||||
}
|
||||
catch (HttpRequestException ex) when (!IsTransientError(ex))
|
||||
@@ -174,6 +185,17 @@ public sealed class KrxDataService : IKrxDataService
|
||||
$"&isuCd={ticker}";
|
||||
|
||||
var response = await _httpClient.GetAsync(endpoint, cancellationToken);
|
||||
|
||||
// Check rate limit header
|
||||
if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining))
|
||||
{
|
||||
if (int.TryParse(remaining.First(), out var limit) && limit < 10)
|
||||
{
|
||||
_logger.LogWarning("KRX rate limit low: {Remaining} requests remaining", limit);
|
||||
await Task.Delay(5000, cancellationToken); // 5s pause
|
||||
}
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user