feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user