feat(core): add shared DTOs, MqttTopics constants, DatabaseBootstrapper, and ManagedMqttClient extensions
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Npgsql;
|
||||
|
||||
namespace FinlyticCore.Util;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'.
|
||||
/// </summary>
|
||||
public static class CryptoSubtitleResolver
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, (string Subtitle, string? Name)> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an ISIN is a Trade Republic internal crypto ISIN (starts with 'X') and resolves its Subtitle from DB or heuristic.
|
||||
/// </summary>
|
||||
public static async Task<(string? Subtitle, string? Name)> ResolveCryptoInfoAsync(
|
||||
string isin,
|
||||
string? defaultConnectionString = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin)) return (null, null);
|
||||
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
if (!cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(cleanIsin, out var cached))
|
||||
{
|
||||
return (cached.Subtitle, cached.Name);
|
||||
}
|
||||
|
||||
// 1. Try querying PostgreSQL database (finlytic_assets)
|
||||
if (!string.IsNullOrWhiteSpace(defaultConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assetsConnStr = Regex.Replace(defaultConnectionString, @"Database=[^;]+", "Database=finlytic_assets", RegexOptions.IgnoreCase);
|
||||
await using var conn = new NpgsqlConnection(assetsConnStr);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT \"Subtitle\", \"SearchSubtitle\", \"Name\" FROM \"TradeRepublicAssets\" " +
|
||||
"WHERE \"Isin\" = @isin AND (\"AssetType\" = 'Crypto' OR \"InstrumentCategory\" = 'crypto' OR \"Subtitle\" IS NOT NULL) " +
|
||||
"LIMIT 1",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("isin", cleanIsin);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
string? sub = reader.IsDBNull(0) ? null : reader.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(sub) && !reader.IsDBNull(1))
|
||||
{
|
||||
sub = reader.GetString(1);
|
||||
}
|
||||
|
||||
string? name = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sub))
|
||||
{
|
||||
var cleanSub = sub.Trim().ToUpperInvariant();
|
||||
_cache[cleanIsin] = (cleanSub, name);
|
||||
return (cleanSub, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to heuristic if DB unreachable or different server
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Heuristic fallback for Trade Republic internal ISIN patterns (e.g. XF000BTC0017 -> BTC)
|
||||
var match = Regex.Match(cleanIsin, @"^X[A-Z0-9]*?000([A-Z0-9]{3,6})\d*$");
|
||||
if (match.Success)
|
||||
{
|
||||
var extracted = match.Groups[1].Value;
|
||||
_cache[cleanIsin] = (extracted, null);
|
||||
return (extracted, null);
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method returning just the crypto subtitle (e.g. "BTC").
|
||||
/// </summary>
|
||||
public static async Task<string?> ResolveCryptoSubtitleAsync(
|
||||
string isin,
|
||||
string? defaultConnectionString = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (sub, _) = await ResolveCryptoInfoAsync(isin, defaultConnectionString, cancellationToken);
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,8 @@ using FinlyticCore.Dtos.Sentiment;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Yahoo;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using System.Collections.Generic;
|
||||
using FinlyticAssets.Models;
|
||||
using FinlyticCore.Models.Assets;
|
||||
|
||||
namespace FinlyticCore.Util;
|
||||
|
||||
@@ -16,18 +15,11 @@ namespace FinlyticCore.Util;
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(TradeProposalDto))]
|
||||
[JsonSerializable(typeof(List<TradeProposalDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Logging.LogMessageDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Logging.LogMessageDto>))]
|
||||
[JsonSerializable(typeof(TradeAcceptanceDto))]
|
||||
[JsonSerializable(typeof(List<TradeAcceptanceDto>))]
|
||||
[JsonSerializable(typeof(CloseTradeRequest))]
|
||||
[JsonSerializable(typeof(ManualAnalysisResponseDto))]
|
||||
[JsonSerializable(typeof(N8nAnalysisResponseDto))]
|
||||
[JsonSerializable(typeof(TradeHourlyUpdateDto))]
|
||||
[JsonSerializable(typeof(TradeFeedbackRecord))]
|
||||
[JsonSerializable(typeof(List<TradeFeedbackRecord>))]
|
||||
[JsonSerializable(typeof(NewsArticleDto))]
|
||||
[JsonSerializable(typeof(List<NewsArticleDto>))]
|
||||
[JsonSerializable(typeof(DiscoveredArticle))]
|
||||
@@ -52,9 +44,12 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(IsinSentimentSummaryDto))]
|
||||
[JsonSerializable(typeof(IsinAnalysisEntry))]
|
||||
[JsonSerializable(typeof(SectorSentimentSummaryDto))]
|
||||
[JsonSerializable(typeof(GetSentimentByIsinRequest))]
|
||||
[JsonSerializable(typeof(GetSectorSentimentRequest))]
|
||||
|
||||
[JsonSerializable(typeof(CandleDto))]
|
||||
[JsonSerializable(typeof(List<CandleDto>))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<CandleDto>))]
|
||||
[JsonSerializable(typeof(ChartPatternDto))]
|
||||
[JsonSerializable(typeof(List<ChartPatternDto>))]
|
||||
[JsonSerializable(typeof(IndicatorValuesDto))]
|
||||
@@ -62,6 +57,9 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(MarketRegimeDto))]
|
||||
[JsonSerializable(typeof(StrategySignalDto))]
|
||||
[JsonSerializable(typeof(List<StrategySignalDto>))]
|
||||
[JsonSerializable(typeof(StrategyResultDto))]
|
||||
[JsonSerializable(typeof(List<StrategyResultDto>))]
|
||||
[JsonSerializable(typeof(UniverseSource))]
|
||||
[JsonSerializable(typeof(TechnicalAnalysisDto))]
|
||||
[JsonSerializable(typeof(LivePriceDto))]
|
||||
[JsonSerializable(typeof(string))]
|
||||
@@ -79,13 +77,75 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(AnalyzeSentimentRequest))]
|
||||
[JsonSerializable(typeof(EmptyRequest))]
|
||||
[JsonSerializable(typeof(GetEventsByMonthRequest))]
|
||||
[JsonSerializable(typeof(ManualAnalysisRpcRequest))]
|
||||
[JsonSerializable(typeof(GetTradeProposalsRequest))]
|
||||
[JsonSerializable(typeof(GetActiveTradesRequest))]
|
||||
[JsonSerializable(typeof(EvaluateAssetRequest))]
|
||||
[JsonSerializable(typeof(AddTradeFillRequest))]
|
||||
[JsonSerializable(typeof(UpdateTradeStopLossRequest))]
|
||||
[JsonSerializable(typeof(CloseEngineTradeRequest))]
|
||||
[JsonSerializable(typeof(AcceptTradeProposalRequest))]
|
||||
[JsonSerializable(typeof(CreateManualTradeRequest))]
|
||||
[JsonSerializable(typeof(RpcFaultCode))]
|
||||
[JsonSerializable(typeof(RpcErrorResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.ExecutionMode))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.TradeStatus))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.InstrumentCategoryType))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.TradeProposalDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Trading.TradeProposalDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.AssetEvaluationResultDto))]
|
||||
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.DerivativeSelectionDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.AiValidationResultDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.ValidationSource))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.TradeFillDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Trading.TradeFillDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.ActiveTradeDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Trading.ActiveTradeDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.TriggerSource))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.OutcomeReason))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.GetEvaluationHistoryRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.OutcomeReasonCountDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Trading.OutcomeReasonCountDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.EvaluationHistorySummaryDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse))]
|
||||
|
||||
// Simulation DTOs
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.BacktestRequestDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.BacktestTradeDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Simulation.BacktestTradeDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.EquityPointDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Simulation.EquityPointDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.BacktestReportDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Simulation.BacktestReportDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.GetReliabilityRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.GetBacktestHistoryRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.BacktestHistoryEntryDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Simulation.BacktestHistoryEntryDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.GetBacktestRunDetailRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.GetStrategyParametersRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.SaveStrategyParametersRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.StrategyParameterProfileDto))]
|
||||
|
||||
// Bot DTOs
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotExecutionVenue))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotPositionStatus))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotTradeOrderDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Bot.BotTradeOrderDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.AccountSummaryDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.ExecuteProposalRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotPortfolioSnapshotDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Bot.BotPortfolioSnapshotDto>))]
|
||||
|
||||
|
||||
[JsonSerializable(typeof(ServiceHealthResponse))]
|
||||
|
||||
[JsonSerializable(typeof(List<ServiceHealthResponse>))]
|
||||
[JsonSerializable(typeof(FetchLogoResponse))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(ServiceConfigUpdatePayload))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.AssetDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Assets.AssetDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.StockDto))]
|
||||
@@ -93,12 +153,14 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.CryptoDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.BondDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.DerivativeDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Assets.DerivativeDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.SyntheticDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.TagDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetValidAssetRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.SearchAssetsRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDiscoveryAssetsRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDerivativesRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicPriceTick))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicConnectRequest))]
|
||||
@@ -149,8 +211,19 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(YahooQuoteResultWrapperDto))]
|
||||
[JsonSerializable(typeof(YahooQuoteItemDto))]
|
||||
[JsonSerializable(typeof(List<AssetIndex>))]
|
||||
[JsonSerializable(typeof(N8nAnalysisRequestDto))]
|
||||
[JsonSerializable(typeof(TickMessageDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Settings.DynamicSettingDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Settings.DynamicSettingDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotStatusDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotTradeOrderDto))]
|
||||
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Bot.BotTradeOrderDto>))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.AccountSummaryDto))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.ExecuteProposalRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.UpdateBotSettingsRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.PanicCloseResultDto))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
public partial class FinlyticJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Models;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using FinlyticCore.Services;
|
||||
@@ -15,11 +17,19 @@ namespace FinlyticCore.Util;
|
||||
|
||||
/// <summary>
|
||||
/// An abstract, resilient MQTT client wrapper designed for microservice architectures.
|
||||
/// Handles automatic reconnection, structured JSON publishing, thread-safe subscription management, and synchronous Request-Reply (RPC).
|
||||
/// Handles automatic reconnection, structured JSON publishing, thread-safe subscription management,
|
||||
/// typed/generic message handling, and synchronous Request-Reply (RPC).
|
||||
/// Supports channel-controlled logging via <see cref="CoreSettingKeys.MqttChannel"/>.
|
||||
/// </summary>
|
||||
public abstract class ManagedMqttClient : IDisposable
|
||||
{
|
||||
protected static readonly JsonSerializerOptions DefaultJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ILogger<ManagedMqttClient> _logger;
|
||||
private readonly ISettingsService? _settingsService;
|
||||
private readonly IFinlyticLogger<ManagedMqttClient>? _finlyticLogger;
|
||||
@@ -29,6 +39,27 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
// Tracks pending RPC requests waiting for a specific correlation ID reply
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _pendingRequests = new();
|
||||
|
||||
/// <summary>
|
||||
/// Literal suffix appended to a normal RPC response topic to build its "fault" sibling topic, e.g.
|
||||
/// <c>services/response/{channel}/{correlationId}/error</c>. Publishing faults on a distinct topic (instead
|
||||
/// of on the regular response topic with some in-payload error marker) lets a caller recognize a fault
|
||||
/// deterministically from the topic string alone, before ever attempting to deserialize the body as the
|
||||
/// expected <c>TResponse</c> — which matters because a generic RPC client has no way to heuristically tell a
|
||||
/// legitimate <c>TResponse</c> payload apart from an error payload shaped like something else.
|
||||
/// It also makes the scheme degrade safely across a rolling deployment: an old client (pre-dating this
|
||||
/// suffix) that receives a new server's fault message extracts "error" as a bogus correlation ID, finds no
|
||||
/// matching pending request, and simply falls through — it keeps waiting and eventually times out exactly as
|
||||
/// it did before this feature existed, instead of crashing or misinterpreting the payload. Symmetrically, a
|
||||
/// new client talking to an old server that never publishes this topic at all simply times out as before.
|
||||
/// </summary>
|
||||
private const string ErrorTopicSuffix = "/error";
|
||||
|
||||
// Tracks registered topic handlers for direct routing
|
||||
private readonly ConcurrentDictionary<string, List<Func<string, string, Task>>> _topicHandlers = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Tracks all active topic filters for automatic re-subscription on reconnect
|
||||
private readonly ConcurrentDictionary<string, bool> _subscribedTopics = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the client is currently connected to the MQTT broker.
|
||||
/// </summary>
|
||||
@@ -89,7 +120,6 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
/// <summary>
|
||||
/// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop.
|
||||
/// </summary>
|
||||
/// <param name="config">The network and credential configuration options for the broker.</param>
|
||||
public async Task ConnectAsync(MqttConfiguration config)
|
||||
{
|
||||
if (IsConnected)
|
||||
@@ -116,6 +146,7 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
await _mqttClient.ConnectAsync(options, _cts.Token);
|
||||
await LogMqttInfoAsync("Successfully connected to MQTT broker.");
|
||||
|
||||
await ResubscribeAllAsync();
|
||||
await OnConnectedAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -124,14 +155,22 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gracefully disconnects from the broker and stops all ongoing background loops.
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
if (_cts != null)
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
try
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
if (_mqttClient.IsConnected)
|
||||
@@ -152,18 +191,269 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter.
|
||||
/// Subscribes to a specific MQTT topic filter without attaching a direct handler.
|
||||
/// </summary>
|
||||
/// <param name="topic">The topic pattern or wildcard to subscribe to.</param>
|
||||
/// <param name="noLocal">If set to <c>true</c>, the broker will not forward messages published by this client back to itself.</param>
|
||||
protected async Task SubscribeAsync(string topic, bool noLocal = false)
|
||||
public async Task SubscribeAsync(string topic, bool noLocal = false)
|
||||
{
|
||||
_subscribedTopics[topic] = noLocal;
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.LogWarning("Subscription to topic '{Topic}' delayed: Client is currently offline.", topic);
|
||||
_logger.LogWarning("Subscription to topic '{Topic}' queued: Client is currently offline.", topic);
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteSubscriptionAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter and maps an asynchronous raw string handler (topic, payload).
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync(string topic, Func<string, string, Task> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, handler);
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter and maps a synchronous raw string handler (topic, payload).
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync(string topic, Action<string, string> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, (t, p) => { handler(t, p); return Task.CompletedTask; });
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter and maps an asynchronous handler receiving the raw payload string.
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync(string topic, Func<string, Task> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, (_, p) => handler(p));
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into <typeparamref name="TPayload"/>,
|
||||
/// extracts the correlation ID, and invokes the asynchronous handler with (payload, topic, correlationId).
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync<TPayload>(string topic, Func<TPayload?, string, string, Task> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, async (t, p) =>
|
||||
{
|
||||
var data = DeserializePayload<TPayload>(p);
|
||||
var correlationId = ExtractCorrelationId(t);
|
||||
await handler(data, t, correlationId);
|
||||
});
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into <typeparamref name="TPayload"/>,
|
||||
/// extracts the correlation ID, and invokes the synchronous handler with (payload, topic, correlationId).
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync<TPayload>(string topic, Action<TPayload?, string, string> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, (t, p) =>
|
||||
{
|
||||
var data = DeserializePayload<TPayload>(p);
|
||||
var correlationId = ExtractCorrelationId(t);
|
||||
handler(data, t, correlationId);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a server-side RPC handler that listens on a request topic (e.g. "services/request/assets_Get/#"),
|
||||
/// executes the delegate, and publishes the returned <typeparamref name="TResponse"/> to "services/response/{channel}/{correlationId}".
|
||||
/// If the request payload cannot be deserialized into <typeparamref name="TRequest"/>, or if
|
||||
/// <paramref name="handler"/> throws, no response is silently dropped: a typed <see cref="RpcErrorResponse"/>
|
||||
/// fault is published instead (see <see cref="PublishRpcFaultAsync"/>), so a caller using
|
||||
/// <see cref="SendRpcRequestAsync{TResponse,TRequest}"/> observes a specific fault instead of only ever
|
||||
/// hitting its request timeout.
|
||||
/// </summary>
|
||||
public async Task SubscribeRpcAsync<TRequest, TResponse>(string requestTopic, Func<TRequest?, string, Task<TResponse>> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(requestTopic, async (t, p) =>
|
||||
{
|
||||
var correlationId = ExtractCorrelationId(t);
|
||||
if (string.IsNullOrEmpty(correlationId)) return;
|
||||
|
||||
var segments = t.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var channel = segments.Length >= 3 ? segments[2] : "unknown";
|
||||
var responseTopic = MqttTopics.ResponseTopic(channel, correlationId);
|
||||
|
||||
TRequest? req;
|
||||
try
|
||||
{
|
||||
req = DeserializePayload<TRequest>(p);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await PublishRpcFaultAsync(responseTopic, RpcFaultCode.InvalidArgument,
|
||||
"The request payload could not be parsed.", ex);
|
||||
return;
|
||||
}
|
||||
|
||||
TResponse result;
|
||||
try
|
||||
{
|
||||
result = await handler(req, correlationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await PublishRpcFaultAsync(responseTopic, ClassifyFault(ex), SafeFaultMessage(ex), ex);
|
||||
return;
|
||||
}
|
||||
|
||||
await PublishAsync(responseTopic, result);
|
||||
});
|
||||
await SubscribeAsync(requestTopic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an exception thrown by an RPC handler onto the small, coarse <see cref="RpcFaultCode"/> set so the
|
||||
/// caller-side <see cref="SendRpcRequestAsync{TResponse,TRequest}"/> can reconstruct an equivalent standard
|
||||
/// .NET exception type across the MQTT boundary (see <see cref="RpcFaultCode"/> for the mapping rationale).
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception thrown by the RPC handler.</param>
|
||||
/// <returns>The fault classification to report to the caller.</returns>
|
||||
private static RpcFaultCode ClassifyFault(Exception ex) => ex switch
|
||||
{
|
||||
ArgumentException => RpcFaultCode.InvalidArgument,
|
||||
KeyNotFoundException => RpcFaultCode.NotFound,
|
||||
UnauthorizedAccessException => RpcFaultCode.Unauthorized,
|
||||
InvalidOperationException => RpcFaultCode.Conflict,
|
||||
_ => RpcFaultCode.Internal
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Produces the message text that is safe to place on the (currently unauthenticated) MQTT broker for a
|
||||
/// given RPC handler exception. Exceptions that already carry a deliberately-authored, business-facing
|
||||
/// message (the four types <see cref="ClassifyFault"/> recognizes) are passed through as-is; anything else
|
||||
/// is replaced with a generic message, since it may be an unexpected infrastructure failure whose message
|
||||
/// could contain internal details. The original exception (including its stack trace) is always logged
|
||||
/// locally by <see cref="PublishRpcFaultAsync"/> regardless of which branch is taken.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception thrown by the RPC handler.</param>
|
||||
/// <returns>A short, safe message describing the fault to an external caller.</returns>
|
||||
private static string SafeFaultMessage(Exception ex) => ex switch
|
||||
{
|
||||
ArgumentException or KeyNotFoundException or UnauthorizedAccessException or InvalidOperationException
|
||||
=> ex.Message,
|
||||
_ => "An internal error occurred while processing the request."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Logs an RPC handler fault locally (with full exception detail) and publishes a corresponding
|
||||
/// <see cref="RpcErrorResponse"/> to the fault sibling of <paramref name="responseTopic"/> (see
|
||||
/// <see cref="ErrorTopicSuffix"/>), so the caller of <see cref="SendRpcRequestAsync{TResponse,TRequest}"/>
|
||||
/// observes a typed fault instead of silently timing out. If the fault publish itself fails (e.g. the
|
||||
/// broker connection dropped between receiving the request and reporting the fault), that secondary failure
|
||||
/// is logged but not rethrown, since the caller's request timeout is still a safe fallback in that case.
|
||||
/// </summary>
|
||||
/// <param name="responseTopic">The normal ("success") response topic for the failed request.</param>
|
||||
/// <param name="code">The machine-readable fault classification to report.</param>
|
||||
/// <param name="message">The safe, non-sensitive message to report.</param>
|
||||
/// <param name="ex">The original exception, logged locally in full but never placed on the wire.</param>
|
||||
private async Task PublishRpcFaultAsync(string responseTopic, RpcFaultCode code, string message, Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RPC handler faulted for response topic '{ResponseTopic}'. Reporting fault {FaultCode} to the caller.", responseTopic, code);
|
||||
|
||||
try
|
||||
{
|
||||
await PublishAsync(responseTopic + ErrorTopicSuffix, new RpcErrorResponse(code, message));
|
||||
}
|
||||
catch (Exception publishEx)
|
||||
{
|
||||
_logger.LogError(publishEx, "Failed to publish RPC fault response to '{ResponseTopic}'; the caller will fall back to its request timeout.", responseTopic + ErrorTopicSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a server-side RPC handler without correlation ID parameter in the delegate.
|
||||
/// </summary>
|
||||
public async Task SubscribeRpcAsync<TRequest, TResponse>(string requestTopic, Func<TRequest?, Task<TResponse>> handler, bool noLocal = false)
|
||||
{
|
||||
await SubscribeRpcAsync<TRequest, TResponse>(requestTopic, (req, _) => handler(req), noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into <typeparamref name="TPayload"/>,
|
||||
/// and invokes the asynchronous handler with (payload, topic).
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync<TPayload>(string topic, Func<TPayload?, string, Task> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, async (t, p) =>
|
||||
{
|
||||
var data = DeserializePayload<TPayload>(p);
|
||||
await handler(data, t);
|
||||
});
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into <typeparamref name="TPayload"/>,
|
||||
/// and invokes the asynchronous handler with the payload.
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync<TPayload>(string topic, Func<TPayload?, Task> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, async (_, p) =>
|
||||
{
|
||||
var data = DeserializePayload<TPayload>(p);
|
||||
await handler(data);
|
||||
});
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into <typeparamref name="TPayload"/>,
|
||||
/// and invokes the synchronous handler with (payload, topic).
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync<TPayload>(string topic, Action<TPayload?, string> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, (t, p) =>
|
||||
{
|
||||
var data = DeserializePayload<TPayload>(p);
|
||||
handler(data, t);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into <typeparamref name="TPayload"/>,
|
||||
/// and invokes the synchronous handler with the payload.
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync<TPayload>(string topic, Action<TPayload?> handler, bool noLocal = false)
|
||||
{
|
||||
RegisterTopicHandler(topic, (_, p) =>
|
||||
{
|
||||
var data = DeserializePayload<TPayload>(p);
|
||||
handler(data);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await SubscribeAsync(topic, noLocal);
|
||||
}
|
||||
|
||||
private void RegisterTopicHandler(string topic, Func<string, string, Task> handler)
|
||||
{
|
||||
_topicHandlers.AddOrUpdate(
|
||||
topic,
|
||||
_ => new List<Func<string, string, Task>> { handler },
|
||||
(_, list) =>
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
list.Add(handler);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ExecuteSubscriptionAsync(string topic, bool noLocal)
|
||||
{
|
||||
var filterBuilder = new MqttTopicFilterBuilder().WithTopic(topic);
|
||||
if (noLocal)
|
||||
{
|
||||
@@ -178,6 +468,21 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
await LogMqttDebugAsync("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal);
|
||||
}
|
||||
|
||||
private async Task ResubscribeAllAsync()
|
||||
{
|
||||
foreach (var kvp in _subscribedTopics)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExecuteSubscriptionAsync(kvp.Key, kvp.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to re-subscribe to topic '{Topic}' after reconnect.", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a raw string message payload to the specified topic.
|
||||
/// </summary>
|
||||
@@ -198,25 +503,43 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a generic object into a structured JSON string and publishes it to the specified topic.
|
||||
/// Utilizes .NET 8 JSON Source Generators for zero-reflection overhead, with reflection fallback for unregistered types.
|
||||
/// Uses standard System.Text.Json with fallback to Source Generators.
|
||||
/// </summary>
|
||||
public Task PublishAsync<T>(string topic, T data, bool retain = false)
|
||||
{
|
||||
byte[] jsonBytes;
|
||||
var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T))
|
||||
?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null);
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
|
||||
|
||||
if (typeInfo != null)
|
||||
byte[] jsonBytes;
|
||||
if (data is string str)
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo);
|
||||
jsonBytes = Encoding.UTF8.GetBytes(str);
|
||||
}
|
||||
else if (data is byte[] b)
|
||||
{
|
||||
jsonBytes = b;
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data);
|
||||
}
|
||||
try
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, DefaultJsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T))
|
||||
?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null);
|
||||
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
|
||||
if (typeInfo != null)
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var message = new MqttApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
@@ -230,6 +553,7 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Sends a parameterless request to an RPC channel and asynchronously blocks until a matching response arrives.
|
||||
/// See <see cref="SendRpcRequestAsync{TResponse,TRequest}"/> for the exact timeout/fault-propagation contract.
|
||||
/// </summary>
|
||||
public Task<TResponse?> SendRpcRequestAsync<TResponse>(
|
||||
string channel,
|
||||
@@ -239,10 +563,37 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
return SendRpcRequestAsync<TResponse, string>(channel, string.Empty, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a generic request payload to an RPC channel and asynchronously waits for a matching response.
|
||||
/// See <see cref="SendRpcRequestAsync{TResponse,TRequest}"/> for the exact timeout/fault-propagation contract.
|
||||
/// </summary>
|
||||
public Task<TResponse?> RequestAsync<TRequest, TResponse>(
|
||||
string channel,
|
||||
TRequest requestData,
|
||||
TimeSpan? timeout = null)
|
||||
where TResponse : class
|
||||
where TRequest : class
|
||||
{
|
||||
var cleanChannel = channel.StartsWith(MqttTopics.RequestPrefix) ? channel.Substring(MqttTopics.RequestPrefix.Length).TrimEnd('/') : channel;
|
||||
return SendRpcRequestAsync<TResponse, TRequest>(cleanChannel, requestData, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives.
|
||||
/// Uses the topic conventions: <c>services/request/{channel}/{correlationId}</c> and <c>services/response/{channel}/{correlationId}</c>.
|
||||
/// If the serving handler faulted, the server publishes an <see cref="RpcErrorResponse"/> on the sibling
|
||||
/// error topic (<see cref="ErrorTopicSuffix"/>) instead of the normal response; this method then throws a
|
||||
/// reconstructed exception (an <see cref="ArgumentException"/>, <see cref="InvalidOperationException"/>,
|
||||
/// <see cref="KeyNotFoundException"/>, <see cref="UnauthorizedAccessException"/>, or, for anything that does
|
||||
/// not map onto one of those, an <see cref="RpcFaultException"/>) instead of returning. This lets a caller
|
||||
/// distinguish a specific server-side fault from an unreachable/silent server, which still surfaces as a
|
||||
/// <see cref="TimeoutException"/>-driven <c>null</c> return exactly as before this fault channel existed.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">The remote handler reported <see cref="RpcFaultCode.InvalidArgument"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The remote handler reported <see cref="RpcFaultCode.Conflict"/>, or the client is offline.</exception>
|
||||
/// <exception cref="KeyNotFoundException">The remote handler reported <see cref="RpcFaultCode.NotFound"/>.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">The remote handler reported <see cref="RpcFaultCode.Unauthorized"/>.</exception>
|
||||
/// <exception cref="RpcFaultException">The remote handler reported <see cref="RpcFaultCode.Internal"/>, or its fault payload could not be parsed.</exception>
|
||||
public async Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
|
||||
string channel,
|
||||
TRequest requestData,
|
||||
@@ -253,21 +604,18 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Cannot execute RPC request: MQTT client is offline.");
|
||||
|
||||
// 1. Generate a unique Correlation ID for this specific transaction
|
||||
string correlationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingRequests.TryAdd(correlationId, tcs);
|
||||
|
||||
string requestTopic = $"services/request/{channel}/{correlationId}";
|
||||
string requestTopic = MqttTopics.RequestTopic(channel, correlationId);
|
||||
|
||||
// 2. Serialize and dispatch via the existing JSON helper
|
||||
await PublishAsync(requestTopic, requestData);
|
||||
await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId);
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Block asynchronously until the response loop resolves the token
|
||||
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25);
|
||||
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
|
||||
|
||||
@@ -276,13 +624,7 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
return rawJsonResult as TResponse;
|
||||
}
|
||||
|
||||
var respTypeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(TResponse));
|
||||
if (respTypeInfo != null)
|
||||
{
|
||||
return JsonSerializer.Deserialize(rawJsonResult, respTypeInfo) as TResponse;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TResponse>(rawJsonResult);
|
||||
return DeserializePayload<TResponse>(rawJsonResult);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
@@ -291,7 +633,6 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always clean up the dictionary to prevent memory leaks
|
||||
_pendingRequests.TryRemove(correlationId, out _);
|
||||
}
|
||||
}
|
||||
@@ -307,22 +648,62 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
await LogMqttDebugAsync("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0);
|
||||
|
||||
// Intercept message if it belongs to the RPC response convention
|
||||
if (topic.StartsWith("services/response/"))
|
||||
if (topic.StartsWith(MqttTopics.ResponsePrefix))
|
||||
{
|
||||
var lastSlashIndex = topic.LastIndexOf('/');
|
||||
// A fault sibling topic ends in ErrorTopicSuffix (see SubscribeRpcAsync/PublishRpcFaultAsync);
|
||||
// strip it before extracting the correlation ID so both topic shapes resolve the same pending
|
||||
// request. An old client build (pre-dating this suffix) would instead extract "error" itself
|
||||
// as a bogus correlation ID, find no matching pending request below, and fall through to time
|
||||
// out exactly as it did before this fault channel existed - see ErrorTopicSuffix remarks.
|
||||
bool isFault = topic.EndsWith(ErrorTopicSuffix, StringComparison.Ordinal);
|
||||
var correlationTopic = isFault ? topic[..^ErrorTopicSuffix.Length] : topic;
|
||||
|
||||
var lastSlashIndex = correlationTopic.LastIndexOf('/');
|
||||
if (lastSlashIndex != -1)
|
||||
{
|
||||
string correlationId = topic[(lastSlashIndex + 1)..];
|
||||
string correlationId = correlationTopic[(lastSlashIndex + 1)..];
|
||||
|
||||
if (_pendingRequests.TryRemove(correlationId, out var tcs))
|
||||
{
|
||||
tcs.SetResult(payload ?? string.Empty);
|
||||
if (isFault)
|
||||
{
|
||||
tcs.SetException(BuildFaultException(payload ?? string.Empty));
|
||||
}
|
||||
else
|
||||
{
|
||||
tcs.SetResult(payload ?? string.Empty);
|
||||
}
|
||||
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Pub/Sub message propagation
|
||||
// Match registered topic handlers
|
||||
foreach (var kvp in _topicHandlers)
|
||||
{
|
||||
if (TopicMatches(kvp.Key, topic))
|
||||
{
|
||||
List<Func<string, string, Task>> handlersCopy;
|
||||
lock (kvp.Value)
|
||||
{
|
||||
handlersCopy = new List<Func<string, string, Task>>(kvp.Value);
|
||||
}
|
||||
|
||||
for (int i = 0; i < handlersCopy.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await handlersCopy[i](topic, payload ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnError(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Pub/Sub message propagation (for overridden OnMessageReceivedAsync)
|
||||
await OnMessageReceivedAsync(topic, payload ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -357,6 +738,7 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
if (_mqttClient.IsConnected)
|
||||
{
|
||||
await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt);
|
||||
await ResubscribeAllAsync();
|
||||
await OnConnectedAsync();
|
||||
return;
|
||||
}
|
||||
@@ -369,6 +751,114 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON string payload into <typeparamref name="T"/> using standard System.Text.Json with fallback.
|
||||
/// </summary>
|
||||
public static T? DeserializePayload<T>(string payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload)) return default;
|
||||
if (typeof(T) == typeof(string)) return (T)(object)payload;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(payload, DefaultJsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T));
|
||||
if (typeInfo != null)
|
||||
{
|
||||
return (T?)JsonSerializer.Deserialize(payload, typeInfo);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the exception a caller should observe for a fault reported on an RPC error topic (see
|
||||
/// <see cref="ErrorTopicSuffix"/> / <see cref="PublishRpcFaultAsync"/>). Faults whose
|
||||
/// <see cref="RpcErrorResponse.Code"/> maps onto a familiar .NET exception type are thrown as that type
|
||||
/// (see <see cref="RpcFaultCode"/>), so pre-existing <c>catch</c> blocks written against the underlying
|
||||
/// service-layer exception types (e.g. in <c>FinlyticBackend</c> controllers) start working across the MQTT
|
||||
/// boundary without any changes on the caller's side. Anything else, including a fault payload that fails
|
||||
/// to parse, becomes an <see cref="RpcFaultException"/>.
|
||||
/// </summary>
|
||||
/// <param name="payload">The raw JSON payload received on the fault topic.</param>
|
||||
/// <returns>The exception to throw to the RPC caller.</returns>
|
||||
private Exception BuildFaultException(string payload)
|
||||
{
|
||||
RpcErrorResponse? fault;
|
||||
try
|
||||
{
|
||||
fault = DeserializePayload<RpcErrorResponse>(payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse RPC fault payload; propagating a generic RpcFaultException instead.");
|
||||
return new RpcFaultException(RpcFaultCode.Internal, "The remote service reported an error that could not be parsed.");
|
||||
}
|
||||
|
||||
if (fault == null)
|
||||
{
|
||||
return new RpcFaultException(RpcFaultCode.Internal, "The remote service reported an empty error response.");
|
||||
}
|
||||
|
||||
return fault.Code switch
|
||||
{
|
||||
RpcFaultCode.InvalidArgument => new ArgumentException(fault.Message),
|
||||
RpcFaultCode.Conflict => new InvalidOperationException(fault.Message),
|
||||
RpcFaultCode.NotFound => new KeyNotFoundException(fault.Message),
|
||||
RpcFaultCode.Unauthorized => new UnauthorizedAccessException(fault.Message),
|
||||
_ => new RpcFaultException(fault.Code, fault.Message)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an MQTT topic matches a topic filter with wildcards ('+' and '#').
|
||||
/// </summary>
|
||||
public static bool TopicMatches(string filter, string topic)
|
||||
{
|
||||
if (string.Equals(filter, topic, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (filter == "#") return true;
|
||||
|
||||
var filterSegments = filter.Split('/');
|
||||
var topicSegments = topic.Split('/');
|
||||
|
||||
for (int i = 0; i < filterSegments.Length; i++)
|
||||
{
|
||||
var f = filterSegments[i];
|
||||
if (f == "#")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (i >= topicSegments.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var t = topicSegments[i];
|
||||
if (f != "+" && !string.Equals(f, t, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return filterSegments.Length == topicSegments.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the Correlation ID from the end of an RPC request or response topic (e.g. services/request/abc/123 -> 123).
|
||||
/// </summary>
|
||||
public static string ExtractCorrelationId(string topic)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(topic)) return string.Empty;
|
||||
var lastSlash = topic.LastIndexOf('/');
|
||||
return lastSlash >= 0 && lastSlash < topic.Length - 1
|
||||
? topic[(lastSlash + 1)..]
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired automatically whenever a connection or reconnection is successfully established.
|
||||
/// </summary>
|
||||
@@ -377,7 +867,7 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
/// <summary>
|
||||
/// Fired whenever a new message lands on a registered subscription channel.
|
||||
/// </summary>
|
||||
protected abstract Task OnMessageReceivedAsync(string topic, string payload);
|
||||
protected virtual Task OnMessageReceivedAsync(string topic, string payload) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Virtual fallback method to catch and handle processing level exceptions inside the incoming pipeline.
|
||||
@@ -389,9 +879,43 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisconnectAsync().GetAwaiter().GetResult();
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { DisconnectAsync().GetAwaiter().GetResult(); } catch { }
|
||||
_cts?.Dispose();
|
||||
_mqttClient.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown client-side by <see cref="ManagedMqttClient.SendRpcRequestAsync{TResponse,TRequest}"/> when a remote
|
||||
/// RPC handler reported a fault (<see cref="RpcErrorResponse"/>) whose <see cref="RpcFaultCode"/> has no
|
||||
/// equivalent standard .NET exception type — i.e. <see cref="Dtos.RpcFaultCode.Internal"/>, or a fault payload
|
||||
/// that could not be parsed at all. Faults that DO map onto an existing exception type
|
||||
/// (<see cref="Dtos.RpcFaultCode.InvalidArgument"/> to <see cref="ArgumentException"/>,
|
||||
/// <see cref="Dtos.RpcFaultCode.Conflict"/> to <see cref="InvalidOperationException"/>,
|
||||
/// <see cref="Dtos.RpcFaultCode.NotFound"/> to <see cref="KeyNotFoundException"/>,
|
||||
/// <see cref="Dtos.RpcFaultCode.Unauthorized"/> to <see cref="UnauthorizedAccessException"/>) are deliberately
|
||||
/// thrown as that familiar type instead of this one: several existing callers (e.g.
|
||||
/// <c>FinlyticBackend/Controllers/UserTradesController.cs</c>) already have <c>catch (InvalidOperationException)</c>
|
||||
/// / <c>catch (ArgumentException)</c> blocks written for the exception types the underlying service-layer
|
||||
/// methods throw locally, and reusing those types here reactivates that existing code instead of requiring
|
||||
/// every caller to learn and catch a brand new exception type.
|
||||
/// </summary>
|
||||
public sealed class RpcFaultException : Exception
|
||||
{
|
||||
/// <summary>Gets the machine-readable fault classification reported by the remote RPC handler.</summary>
|
||||
public RpcFaultCode Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance carrying the remote fault's classification and its safe, non-sensitive message.
|
||||
/// </summary>
|
||||
/// <param name="code">The machine-readable fault classification reported by the remote RPC handler.</param>
|
||||
/// <param name="message">The safe, non-sensitive message reported by the remote handler.</param>
|
||||
public RpcFaultException(RpcFaultCode code, string message) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
namespace FinlyticCore.Util;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for every MQTT topic name and RPC channel name used across the Finlytic microservice
|
||||
/// fleet (FinlyticAssets, FinlyticNews, FinlyticSentiment, FinlyticFundamentals, FinlyticTechnicals,
|
||||
/// FinlyticEngine, FinlyticSimulation, FinlyticBot, and the FinlyticBackend aggregation bridge).
|
||||
/// Before this class existed, every service built topic strings via ad-hoc interpolation, so publishers and
|
||||
/// subscribers were only ever kept in sync by naming convention. Any new topic or RPC channel must be added
|
||||
/// here and referenced from call sites instead of being written as a literal.
|
||||
/// </summary>
|
||||
public static class MqttTopics
|
||||
{
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// RPC envelope: services/request/{channel}/{correlationId} <-> services/response/{channel}/{correlationId}
|
||||
// See ManagedMqttClient.SendRpcRequestAsync / SubscribeRpcAsync for the runtime mechanics.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private const string RequestRoot = "services/request";
|
||||
private const string ResponseRoot = "services/response";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("services/request/") that precedes every RPC channel name in a request topic.
|
||||
/// Used to strip the prefix back off when a caller passes a full topic instead of a bare channel name.
|
||||
/// </summary>
|
||||
public const string RequestPrefix = RequestRoot + "/";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("services/response/") that precedes every RPC channel name in a response topic.
|
||||
/// Used to detect whether an incoming message belongs to the RPC response convention.
|
||||
/// </summary>
|
||||
public const string ResponsePrefix = ResponseRoot + "/";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the wildcard filter that matches every RPC response, regardless of channel or correlation ID.
|
||||
/// Every service subscribes to this once at startup so pending <c>SendRpcRequestAsync</c> calls can resolve.
|
||||
/// </summary>
|
||||
public const string ResponseWildcard = ResponseRoot + "/#";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the concrete RPC request topic for a channel and correlation ID: <c>services/request/{channel}/{correlationId}</c>.
|
||||
/// </summary>
|
||||
public static string RequestTopic(string channel, string correlationId) => $"{RequestRoot}/{channel}/{correlationId}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the concrete RPC response topic for a channel and correlation ID: <c>services/response/{channel}/{correlationId}</c>.
|
||||
/// </summary>
|
||||
public static string ResponseTopic(string channel, string correlationId) => $"{ResponseRoot}/{channel}/{correlationId}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the subscription wildcard filter that matches every request on a given RPC channel: <c>services/request/{channel}/#</c>.
|
||||
/// </summary>
|
||||
public static string RequestFilter(string channel) => $"{RequestRoot}/{channel}/#";
|
||||
|
||||
/// <summary>
|
||||
/// Named RPC channel identifiers (the <c>{channel}</c> segment of the request/response envelope above),
|
||||
/// grouped by the service that owns/serves each channel.
|
||||
/// </summary>
|
||||
public static class Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared liveness-check channel implemented identically by every service. The request topic carries the
|
||||
/// target service name as an extra path segment so only the addressed service responds.
|
||||
/// </summary>
|
||||
public const string HealthPing = "health_Ping";
|
||||
|
||||
// ---- FinlyticAssets ----
|
||||
|
||||
/// <summary>Served by FinlyticAssets: resolves valid assets for an ISIN.</summary>
|
||||
public const string AssetsGet = "assets_Get";
|
||||
|
||||
/// <summary>Served by FinlyticAssets: returns the curated discovery/watchlist asset set.</summary>
|
||||
public const string AssetsGetDiscovery = "assets_GetDiscovery";
|
||||
|
||||
/// <summary>Served by FinlyticAssets: resolves derivative instruments for an underlying ISIN.</summary>
|
||||
public const string AssetsGetDerivatives = "assets_GetDerivatives";
|
||||
|
||||
/// <summary>Served by FinlyticAssets: returns a live Trade Republic price tick for an ISIN.</summary>
|
||||
public const string TrGetLivePrice = "tr_GetLivePrice";
|
||||
|
||||
/// <summary>Served by FinlyticAssets: returns all dynamic settings for the service.</summary>
|
||||
public const string AssetsSettingsGetAll = "assets_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticAssets: applies dynamic setting updates for the service.</summary>
|
||||
public const string AssetsSettingsUpdate = "assets_settings_Update";
|
||||
|
||||
// ---- FinlyticNews ----
|
||||
|
||||
/// <summary>Served by FinlyticNews: returns filtered/paginated news articles.</summary>
|
||||
public const string NewsGet = "news_Get";
|
||||
|
||||
/// <summary>Served by FinlyticNews: returns a single article by ID.</summary>
|
||||
public const string NewsGetById = "news_GetById";
|
||||
|
||||
/// <summary>Served by FinlyticNews: returns articles awaiting downstream sentiment analysis.</summary>
|
||||
public const string NewsGetPending = "news_GetPending";
|
||||
|
||||
/// <summary>Served by FinlyticNews: updates the processing status of an article.</summary>
|
||||
public const string NewsUpdateStatus = "news_UpdateStatus";
|
||||
|
||||
/// <summary>Served by FinlyticNews: returns all dynamic settings for the service.</summary>
|
||||
public const string NewsSettingsGetAll = "news_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticNews: applies dynamic setting updates for the service.</summary>
|
||||
public const string NewsSettingsUpdate = "news_settings_Update";
|
||||
|
||||
// ---- FinlyticSentiment ----
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: returns the pre-aggregated sentiment summary for an ISIN.</summary>
|
||||
public const string SentimentGetIsin = "sentiment_GetIsin";
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: returns the pre-aggregated sentiment summary for a sector.</summary>
|
||||
public const string SentimentGetSector = "sentiment_GetSector";
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: returns the persisted FinBERT analysis entry for a single article.</summary>
|
||||
public const string SentimentGetArticle = "sentiment_GetArticle";
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: returns paginated per-company sentiment summaries.</summary>
|
||||
public const string SentimentGetAll = "sentiment_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: runs FinBERT analysis for an inline article payload or article ID.</summary>
|
||||
public const string SentimentAnalyze = "sentiment_Analyze";
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: returns all dynamic settings for the service.</summary>
|
||||
public const string SentimentSettingsGetAll = "sentiment_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticSentiment: applies dynamic setting updates for the service.</summary>
|
||||
public const string SentimentSettingsUpdate = "sentiment_settings_Update";
|
||||
|
||||
// ---- FinlyticFundamentals ----
|
||||
|
||||
/// <summary>Served by FinlyticFundamentals: returns fundamentals data for an ISIN/ticker.</summary>
|
||||
public const string FundamentalsGet = "fundamentals_Get";
|
||||
|
||||
/// <summary>Served by FinlyticFundamentals: returns all known calendar events.</summary>
|
||||
public const string EventsGetAll = "events_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticFundamentals: returns calendar events for a given year/month.</summary>
|
||||
public const string EventsGetByMonth = "events_GetByMonth";
|
||||
|
||||
/// <summary>Served by FinlyticFundamentals: returns all dynamic settings for the service.</summary>
|
||||
public const string FundamentalsSettingsGetAll = "fundamentals_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticFundamentals: applies dynamic setting updates for the service.</summary>
|
||||
public const string FundamentalsSettingsUpdate = "fundamentals_settings_Update";
|
||||
|
||||
// ---- FinlyticTechnicals ----
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns the technical analysis DTO for an ISIN.</summary>
|
||||
public const string TaGetAnalysis = "ta_GetAnalysis";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns active strategy setups for a single ISIN.</summary>
|
||||
public const string TaGetSetupsForIsin = "ta_GetSetupsForIsin";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns active strategy setups across the universe.</summary>
|
||||
public const string TaGetSetups = "ta_GetSetups";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns aggregated candles for an ISIN/timeframe.</summary>
|
||||
public const string TaGetCandles = "ta_GetCandles";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns the current monitored scan universe ("watchlist").</summary>
|
||||
public const string TaGetWatchlist = "ta_GetWatchlist";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns an ISIN's recent setup/score history (see <see cref="FinlyticCore.Dtos.TechnicalAnalysis.GetRecentSetupHistoryRequest"/>).</summary>
|
||||
public const string TaGetRecentSetupHistory = "ta_GetRecentSetupHistory";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: returns all dynamic settings for the service.</summary>
|
||||
public const string TaSettingsGetAll = "ta_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticTechnicals: applies dynamic setting updates for the service.</summary>
|
||||
public const string TaSettingsUpdate = "ta_settings_Update";
|
||||
|
||||
// ---- FinlyticEngine ----
|
||||
|
||||
/// <summary>Served by FinlyticEngine: returns trade proposals.</summary>
|
||||
public const string EngineGetProposals = "engine_GetProposals";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: returns active trades.</summary>
|
||||
public const string EngineGetTrades = "engine_GetTrades";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: evaluates a single ISIN and returns a trade proposal if warranted.</summary>
|
||||
public const string EngineEvaluateIsin = "engine_EvaluateIsin";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: records a fill against an active trade.</summary>
|
||||
public const string EngineAddFill = "engine_AddFill";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: updates the stop-loss of an active trade.</summary>
|
||||
public const string EngineUpdateStopLoss = "engine_UpdateStopLoss";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: closes an active trade.</summary>
|
||||
public const string EngineCloseTrade = "engine_CloseTrade";
|
||||
|
||||
/// <summary>
|
||||
/// Served by FinlyticEngine: accepts a proposal on behalf of one user and creates a trade owned by that
|
||||
/// user. Takes an <see cref="AcceptTradeProposalRequest"/>. The proposal is NOT consumed — it stays
|
||||
/// available for other users until it expires.
|
||||
/// There is deliberately no counterpart channel for declining a proposal: declining has no server-side
|
||||
/// effect and is handled entirely in the client.
|
||||
/// </summary>
|
||||
public const string EngineAcceptProposal = "engine_AcceptProposal";
|
||||
|
||||
/// <summary>
|
||||
/// Served by FinlyticEngine: opens a trade owned by one user with no backing proposal (manual entry from
|
||||
/// the Web UI). Takes a <see cref="FinlyticCore.Dtos.CreateManualTradeRequest"/>. Unlike
|
||||
/// <see cref="EngineAcceptProposal"/>, the resulting trade's <c>ProposalId</c> is <see cref="Guid.Empty"/>.
|
||||
/// </summary>
|
||||
public const string EngineCreateManualTrade = "engine_CreateManualTrade";
|
||||
|
||||
/// <summary>
|
||||
/// Served by FinlyticEngine: returns a paginated, filtered history of every persisted evaluation
|
||||
/// snapshot (<c>EngineEvaluationSnapshotEntity</c>) for the admin-only "why no proposals" Web UI tab.
|
||||
/// Takes a <see cref="FinlyticCore.Dtos.Trading.GetEvaluationHistoryRequest"/> and returns a
|
||||
/// <see cref="FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse"/>.
|
||||
/// </summary>
|
||||
public const string EngineGetEvaluationHistory = "engine_GetEvaluationHistory";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: returns all dynamic settings for the service.</summary>
|
||||
public const string EngineSettingsGetAll = "engine_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticEngine: applies dynamic setting updates for the service.</summary>
|
||||
public const string EngineSettingsUpdate = "engine_settings_Update";
|
||||
|
||||
// ---- FinlyticSimulation ----
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: runs a quantitative backtest.</summary>
|
||||
public const string SimRunBacktest = "sim_RunBacktest";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: returns the reliability score for a strategy/asset/timeframe.</summary>
|
||||
public const string SimGetReliability = "sim_GetReliability";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: returns the full strategy reliability matrix for an asset.</summary>
|
||||
public const string SimGetMatrixForAsset = "sim_GetMatrixForAsset";
|
||||
|
||||
/// <summary>
|
||||
/// Served by FinlyticSimulation: returns a paginated, filterable summary history of past backtest runs
|
||||
/// for an ISIN - every run is already persisted (<c>SimulationRunEntity</c>) but was previously only
|
||||
/// reachable indirectly (it fed the reliability matrix), never queryable as a history in its own right.
|
||||
/// </summary>
|
||||
public const string SimGetBacktestHistory = "sim_GetBacktestHistory";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: returns the full, already-persisted report (trades + equity curve) for one past backtest run by its RunId.</summary>
|
||||
public const string SimGetBacktestRunDetail = "sim_GetBacktestRunDetail";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: returns a saved per-asset/per-strategy indicator parameter profile, or null if none was saved.</summary>
|
||||
public const string SimGetStrategyParameters = "sim_GetStrategyParameters";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: saves/updates a per-asset/per-strategy indicator parameter profile.</summary>
|
||||
public const string SimSaveStrategyParameters = "sim_SaveStrategyParameters";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: returns all dynamic settings for the service.</summary>
|
||||
public const string SimSettingsGetAll = "sim_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticSimulation: applies dynamic setting updates for the service.</summary>
|
||||
public const string SimSettingsUpdate = "sim_settings_Update";
|
||||
|
||||
// ---- FinlyticBot ----
|
||||
|
||||
/// <summary>Served by FinlyticBot: returns the current paper-trading bot status.</summary>
|
||||
public const string BotGetStatus = "bot_GetStatus";
|
||||
|
||||
/// <summary>Served by FinlyticBot: returns currently open paper-trading positions.</summary>
|
||||
public const string BotGetPositions = "bot_GetPositions";
|
||||
|
||||
/// <summary>Served by FinlyticBot: returns the paper-trading account summary.</summary>
|
||||
public const string BotGetSummary = "bot_GetSummary";
|
||||
|
||||
/// <summary>Served by FinlyticBot: executes a trade proposal as a paper trade.</summary>
|
||||
public const string BotExecuteProposal = "bot_ExecuteProposal";
|
||||
|
||||
/// <summary>
|
||||
/// Served by FinlyticBot: emergency-closes every open paper-trading position (synthetic ledger
|
||||
/// positions are closed unconditionally; Alpaca positions are only closed if the broker confirms the
|
||||
/// liquidation and are otherwise left open and reported as skipped — see the handler for details).
|
||||
/// </summary>
|
||||
public const string BotPanicClose = "bot_PanicClose";
|
||||
|
||||
/// <summary>Served by FinlyticBot: returns all dynamic settings for the service.</summary>
|
||||
public const string BotSettingsGetAll = "bot_settings_GetAll";
|
||||
|
||||
/// <summary>Served by FinlyticBot: applies dynamic setting updates for the service.</summary>
|
||||
public const string BotSettingsUpdate = "bot_settings_Update";
|
||||
|
||||
// ---- FinlyticBackend ----
|
||||
|
||||
/// <summary>
|
||||
/// Served by FinlyticBackend: returns the aggregated favorites list across all users. Centralized here
|
||||
/// even though FinlyticBackend is outside this refactor's scope, so no future service hardcodes it again.
|
||||
/// </summary>
|
||||
public const string BackendGetAggregatedFavorites = "backend_GetAggregatedFavorites";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Event / stream topics: plain fire-and-forget pub/sub outside the RPC envelope.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Published by FinlyticNews once an article finishes ingestion and asset matching. Consumed by
|
||||
/// FinlyticSentiment (to trigger analysis) and the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string NewsCompleted = "services/news/completed";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/news/") shared by every FinlyticNews event topic. <see cref="NewsStreamWildcard"/>
|
||||
/// and every per-ISIN <see cref="NewsStream"/> topic are derived from this constant so a StartsWith check (as used by
|
||||
/// the FinlyticBackend bridge) can never drift from the wildcard subscription filter.
|
||||
/// </summary>
|
||||
public const string NewsPrefix = "finlytic/news/";
|
||||
|
||||
private const string NewsStreamTemplate = NewsPrefix + "stream/{0}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-ISIN topic that FinlyticNews publishes newly matched articles to: <c>finlytic/news/stream/{isin}</c>.
|
||||
/// The ISIN is normalized (trimmed, lower-cased) to match the convention already used by every publisher/subscriber pair.
|
||||
/// </summary>
|
||||
public static string NewsStream(string isin) => string.Format(NewsStreamTemplate, NormalizeIsin(isin));
|
||||
|
||||
/// <summary>
|
||||
/// Wildcard filter matching every FinlyticNews stream topic, used by the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string NewsStreamWildcard = NewsPrefix + "#";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/sentiment/") shared by every FinlyticSentiment event topic. Used to
|
||||
/// detect whether an incoming message on the <see cref="SentimentWildcard"/> subscription is a sentiment event.
|
||||
/// <see cref="SentimentWildcard"/> and every per-ISIN <see cref="SentimentStream"/> topic are derived from this
|
||||
/// constant so they cannot drift apart.
|
||||
/// </summary>
|
||||
public const string SentimentPrefix = "finlytic/sentiment/";
|
||||
|
||||
private const string SentimentStreamTemplate = SentimentPrefix + "stream/{0}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-ISIN topic that FinlyticSentiment publishes updated sentiment summaries to: <c>finlytic/sentiment/stream/{isin}</c>.
|
||||
/// </summary>
|
||||
public static string SentimentStream(string isin) => string.Format(SentimentStreamTemplate, NormalizeIsin(isin));
|
||||
|
||||
/// <summary>
|
||||
/// Wildcard filter matching every FinlyticSentiment topic (currently only the per-ISIN stream). Used by
|
||||
/// FinlyticTechnicals to detect sentiment spikes and by the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string SentimentWildcard = SentimentPrefix + "#";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/engine/") shared by every FinlyticEngine event topic (proposals and
|
||||
/// trade status changes). <see cref="EngineWildcard"/> is derived from this constant, and
|
||||
/// <see cref="EngineProposalsPrefix"/>/<see cref="EngineTradesPrefix"/> are namespaced sub-prefixes of it, so
|
||||
/// none of the three can drift apart from one another.
|
||||
/// </summary>
|
||||
public const string EnginePrefix = "finlytic/engine/";
|
||||
|
||||
/// <summary>
|
||||
/// Wildcard filter matching every FinlyticEngine event topic (proposals and trade status changes). Used by
|
||||
/// the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string EngineWildcard = EnginePrefix + "#";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/engine/proposals/") shared by every FinlyticEngine proposal event
|
||||
/// topic. Used by the FinlyticBackend bridge to distinguish proposal events from trade status events on the
|
||||
/// shared <see cref="EngineWildcard"/> subscription.
|
||||
/// </summary>
|
||||
public const string EngineProposalsPrefix = EnginePrefix + "proposals/";
|
||||
|
||||
/// <summary>
|
||||
/// Published by FinlyticEngine whenever a new trade proposal is created. Consumed by FinlyticBot (to
|
||||
/// evaluate auto-execution) and the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string EngineProposalsCreated = EngineProposalsPrefix + "created";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/engine/trades/") shared by every FinlyticEngine trade lifecycle event
|
||||
/// topic. Used by the FinlyticBackend bridge to distinguish trade status events from proposal events on the
|
||||
/// shared <see cref="EngineWildcard"/> subscription.
|
||||
/// </summary>
|
||||
public const string EngineTradesPrefix = EnginePrefix + "trades/";
|
||||
|
||||
/// <summary>
|
||||
/// Published by FinlyticEngine whenever an active trade's lifecycle status changes (fills, stop-loss
|
||||
/// updates, closes). Consumed by the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string EngineTradesStatusChanged = EngineTradesPrefix + "status_changed";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/bot/") shared by every FinlyticBot event topic. <see cref="BotWildcard"/>
|
||||
/// and <see cref="BotTradesPrefix"/> are derived from this constant so they cannot drift apart.
|
||||
/// </summary>
|
||||
public const string BotPrefix = "finlytic/bot/";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/bot/trades/") shared by every FinlyticBot trade lifecycle event topic.
|
||||
/// Used by the FinlyticBackend bridge to distinguish trade stream events from other bot events on the shared
|
||||
/// <see cref="BotWildcard"/> subscription.
|
||||
/// </summary>
|
||||
public const string BotTradesPrefix = BotPrefix + "trades/";
|
||||
|
||||
/// <summary>
|
||||
/// Published by FinlyticBot whenever a paper-trading position's lifecycle status changes. Consumed by the
|
||||
/// FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string BotTradesStream = BotTradesPrefix + "stream";
|
||||
|
||||
/// <summary>
|
||||
/// Wildcard filter matching every FinlyticBot event topic. Used by the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string BotWildcard = BotPrefix + "#";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the literal prefix ("finlytic/logs/") shared by every structured-log broadcast topic.
|
||||
/// <see cref="LogsWildcard"/> and every per-service <see cref="Logs"/> topic are derived from this constant.
|
||||
/// </summary>
|
||||
public const string LogsPrefix = "finlytic/logs/";
|
||||
|
||||
private const string LogsTemplate = LogsPrefix + "{0}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the structured-log broadcast topic for a given service name (e.g. <c>finlytic/logs/FinlyticAssets</c>),
|
||||
/// published by every service's <see cref="FinlyticLogBroadcaster"/> hook and consumed by the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public static string Logs(string serviceName) => string.Format(LogsTemplate, serviceName);
|
||||
|
||||
/// <summary>
|
||||
/// Wildcard filter matching structured-log broadcasts from every service. Used by the FinlyticBackend bridge.
|
||||
/// </summary>
|
||||
public const string LogsWildcard = LogsPrefix + "#";
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes an ISIN for use as an MQTT topic path segment. MQTT topics are case-sensitive and every known
|
||||
/// publisher/subscriber pair in this system agreed on trimmed, lower-case ISINs; this keeps that convention
|
||||
/// in one place instead of repeating <c>.Trim().ToLowerInvariant()</c> at every call site.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="isin"/> is null, empty, or whitespace.</exception>
|
||||
private static string NormalizeIsin(string isin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin))
|
||||
throw new ArgumentException("ISIN must not be null or empty when building an MQTT topic.", nameof(isin));
|
||||
|
||||
return isin.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user