feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies

This commit is contained in:
2026-08-24 21:36:05 +02:00
parent 12e7b57b16
commit f43ce2b7e9
36 changed files with 6792 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
using FinlyticCore.Models.Settings;
namespace FinlyticTechnicals.Util;
public static class SettingKeys
{
// --- Logging-Kanäle ---
public static readonly SettingKey<bool> TechnicalAnalysisChannel = new("Logging.Channel.TechnicalAnalysis", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
// --- Indikator-Konfiguration ---
public static readonly SettingKey<int> RsiPeriod = new("Indicators.RsiPeriod", 14);
public static readonly SettingKey<int> MacdFastPeriod = new("Indicators.MacdFastPeriod", 12);
public static readonly SettingKey<int> MacdSlowPeriod = new("Indicators.MacdSlowPeriod", 26);
public static readonly SettingKey<int> MacdSignalPeriod = new("Indicators.MacdSignalPeriod", 9);
public static readonly SettingKey<int> EmaShortPeriod = new("Indicators.EmaShortPeriod", 50);
public static readonly SettingKey<int> EmaLongPeriod = new("Indicators.EmaLongPeriod", 200);
public static readonly SettingKey<int> BollingerBandsPeriod = new("Indicators.BollingerBandsPeriod", 20);
public static readonly SettingKey<double> BollingerBandsStdDev = new("Indicators.BollingerBandsStdDev", 2.0);
public static readonly SettingKey<int> AtrPeriod = new("Indicators.AtrPeriod", 14);
// --- Cache & Performance ---
public static readonly SettingKey<int> CacheDurationMinutes = new("Cache.DurationMinutes", 60);
public static readonly SettingKey<bool> EnableAutoCache = new("Feature.EnableAutoCache", true);
}
+271
View File
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticTechnicals.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticTechnicals.Util;
public record GetSetupsRequest(
bool TopPicksOnly = false,
int Limit = 50,
decimal? MinScore = null
);
public record GetCandlesRequest(
string Isin = "",
string Timeframe = "15m"
);
public class TAMqttClient : ManagedMqttClient, IHostedService, ITAMqttRpcClient
{
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<TAMqttClient> _logger;
public TAMqttClient(
ILogger<TAMqttClient> logger,
IConfiguration configuration,
IServiceScopeFactory scopeFactory) : base(logger)
{
_logger = logger;
_configuration = configuration;
_scopeFactory = scopeFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticTechnicals");
_logger.LogInformation("Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Technical Analysis MQTT client.");
await DisconnectAsync();
}
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("Technical Analysis MQTT client connected. Registering RPC endpoints...");
await SubscribeAsync(MqttTopics.ResponseWildcard);
await SubscribeRpcAsync<IsinRequest, TechnicalAnalysisDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetAnalysis), HandleGetAnalysisRpcAsync);
await SubscribeRpcAsync<IsinRequest, List<StrategyResultDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetupsForIsin), HandleGetSetupsForIsinRpcAsync);
await SubscribeRpcAsync<GetSetupsRequest, List<StrategyResultDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetups), HandleGetSetupsRpcAsync);
await SubscribeRpcAsync<GetCandlesRequest, IReadOnlyList<CandleDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetCandles), HandleGetCandlesRpcAsync);
await SubscribeRpcAsync<object, List<WatchlistEntryDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetWatchlist), HandleGetWatchlistRpcAsync);
await SubscribeRpcAsync<GetRecentSetupHistoryRequest, List<StrategyResultDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetRecentSetupHistory), HandleGetRecentSetupHistoryRpcAsync);
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsGetAll), HandleSettingsGetAllRpcAsync);
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsUpdate), HandleSettingsUpdateRpcAsync);
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync);
// Subscribe to Sentiment Spikes & Stream
await SubscribeAsync(MqttTopics.SentimentWildcard);
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && (string.Equals(logDto.ServiceName, "FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || string.Equals(logDto.ServiceName, "FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase)))
{
await PublishAsync(MqttTopics.Logs("FinlyticTechnicals"), logDto);
}
};
}
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
{
if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return;
try
{
if (topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleSentimentEventAsync(topic, payloadStr);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TAMqttClient] Error handling message on topic {Topic}", topic);
}
}
private async Task HandleSentimentEventAsync(string topic, string payloadStr)
{
using var jsonDoc = JsonDocument.Parse(payloadStr);
var root = jsonDoc.RootElement;
string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null;
if (string.IsNullOrWhiteSpace(isin))
{
// Try extracting from topic if stream format: finlytic/sentiment/stream/{isin}
var parts = topic.Split('/');
if (parts.Length >= 4 && parts[2].Equals("stream", StringComparison.OrdinalIgnoreCase))
{
isin = parts[3];
}
}
if (string.IsNullOrWhiteSpace(isin)) return;
double compoundScore = 0.0;
string? trend = null;
if (root.TryGetProperty("currentSummary", out var currSummary))
{
if (currSummary.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble();
if (currSummary.TryGetProperty("trend", out var trProp)) trend = trProp.GetString();
}
else
{
if (root.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble();
if (root.TryGetProperty("trend", out var trProp)) trend = trProp.GetString();
}
bool isSpike = Math.Abs(compoundScore) >= 0.5 ||
string.Equals(trend, "IMPROVING", StringComparison.OrdinalIgnoreCase) ||
string.Equals(trend, "DETERIORATING", StringComparison.OrdinalIgnoreCase);
if (isSpike)
{
using var scope = _scopeFactory.CreateScope();
var universeManager = scope.ServiceProvider.GetRequiredService<ITechnicalUniverseManager>();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
await universeManager.AddOrUpdateAssetAsync(isin, null, UniverseSource.SentimentSpike, priority: 1, ttl: TimeSpan.FromMinutes(120));
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
"[TAMqttClient] Sentiment Spike event detected on {Topic} for ISIN {Isin} (Compound={Score:F2}, Trend={Trend}) -> Promoted to Priority 1 (TTL 120m)",
topic, isin, compoundScore, trend ?? "N/A");
}
}
private async Task<TechnicalAnalysisDto?> HandleGetAnalysisRpcAsync(IsinRequest? req, string correlationId)
{
if (string.IsNullOrWhiteSpace(req?.Isin)) return null;
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetAnalysis for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId);
return await scoringEngine.GetTechnicalAnalysisDtoAsync(req.Isin, req.Ticker);
}
private async Task<List<StrategyResultDto>> HandleGetSetupsForIsinRpcAsync(IsinRequest? req, string correlationId)
{
if (string.IsNullOrWhiteSpace(req?.Isin)) return [];
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
var universeManager = scope.ServiceProvider.GetRequiredService<ITechnicalUniverseManager>();
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetupsForIsin for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId);
// Attach the current universe-selection reason (if the ISIN is actively monitored) so a caller (e.g.
// FinlyticEngine's TradeLifecycleService) can record WHY this asset was being watched, not just its
// scores. Stays null for an ISIN nobody favorited/discovered/spiked - an honest ad hoc analysis.
var universeEntry = await universeManager.GetEntryAsync(req.Isin);
return await scoringEngine.AnalyzeIsinAsync(req.Isin, req.Ticker, universeEntry?.Source, universeEntry?.AddedAtUtc);
}
private async Task<List<StrategyResultDto>> HandleGetSetupsRpcAsync(GetSetupsRequest? req, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetups (TopPicks: {TopPicks}, Limit: {Limit}, MinScore: {MinScore}) [CorrelationId: {CorrelationId}]", req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore?.ToString() ?? "null", correlationId);
return await scoringEngine.GetActiveSetupsAsync(req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore);
}
private async Task<List<WatchlistEntryDto>> HandleGetWatchlistRpcAsync(object? req, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var universeManager = scope.ServiceProvider.GetRequiredService<ITechnicalUniverseManager>();
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetWatchlist [CorrelationId: {CorrelationId}]", correlationId);
var universe = await universeManager.GetActiveUniverseAsync();
return universe.Select(e => new WatchlistEntryDto(e.Isin, e.Symbol, e.Source.ToString(), e.Priority, e.AddedAtUtc, e.ExpiresAtUtc)).ToList();
}
private async Task<List<StrategyResultDto>> HandleGetRecentSetupHistoryRpcAsync(GetRecentSetupHistoryRequest? req, string correlationId)
{
if (string.IsNullOrWhiteSpace(req?.Isin)) return [];
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetRecentSetupHistory for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId);
return await scoringEngine.GetRecentSetupHistoryAsync(req.Isin, req.Limit);
}
private Task<IReadOnlyList<CandleDto>> HandleGetCandlesRpcAsync(GetCandlesRequest? req, string correlationId)
{
if (string.IsNullOrWhiteSpace(req?.Isin)) return Task.FromResult<IReadOnlyList<CandleDto>>([]);
using var scope = _scopeFactory.CreateScope();
var aggregator = scope.ServiceProvider.GetRequiredService<IMultiTimeframeCandleAggregator>();
var candles = aggregator.GetCandles(req.Isin, req.Timeframe ?? "15m");
return Task.FromResult(candles);
}
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_GetAll] Retrieving dynamic settings [CorrelationId: {CorrelationId}]", correlationId);
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
}
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_Update] Processing settings update [CorrelationId: {CorrelationId}]", correlationId);
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] Successfully updated {Count} settings in database and cache.", updates.Count);
}
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
}
private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId)
{
if (topic.Contains("FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || topic.Contains("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase))
{
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticTechnicals", "Online", DateTime.UtcNow, "Connected"));
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
await logger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticTechnicals] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId);
}
}
}