feat(core): add shared DTOs, MqttTopics constants, DatabaseBootstrapper, and ManagedMqttClient extensions

This commit is contained in:
2026-08-24 21:35:24 +02:00
parent 6ab84fe1de
commit 44b161d509
39 changed files with 2545 additions and 709 deletions
+438
View File
@@ -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();
}
}