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
@@ -0,0 +1,79 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace FinlyticCore.Database;
/// <summary>
/// Utility for auto-bootstrapping PostgreSQL databases in a multi-service architecture.
/// Ensures the target catalog database exists prior to EF Core connection and migration execution.
/// </summary>
public static class DatabaseBootstrapper
{
/// <summary>
/// Checks if the target PostgreSQL database exists. If not, connects to the default administrative
/// database ('postgres') and executes CREATE DATABASE so that EF Core migrations can succeed.
/// </summary>
public static async Task EnsureDatabaseCreatedAsync(
string connectionString,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(connectionString)) return;
try
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
string targetDb = builder.Database ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetDb) || string.Equals(targetDb, "postgres", StringComparison.OrdinalIgnoreCase))
{
return;
}
// Temporarily connect to the default 'postgres' database to query pg_database
builder.Database = "postgres";
string adminConnStr = builder.ConnectionString;
await using var conn = new NpgsqlConnection(adminConnStr);
await conn.OpenAsync(cancellationToken);
await using var checkCmd = new NpgsqlCommand(
"SELECT 1 FROM pg_database WHERE datname = @dbname;", conn);
checkCmd.Parameters.AddWithValue("dbname", targetDb);
var exists = await checkCmd.ExecuteScalarAsync(cancellationToken);
if (exists == null || exists == DBNull.Value)
{
logger?.LogInformation("[DatabaseBootstrapper] Database '{TargetDb}' does not exist on PostgreSQL host. Creating it automatically...", targetDb);
// CREATE DATABASE cannot be executed as a parameterized identifier
await using var createCmd = new NpgsqlCommand(
$"CREATE DATABASE \"{targetDb.Replace("\"", "\"\"")}\";", conn);
await createCmd.ExecuteNonQueryAsync(cancellationToken);
logger?.LogInformation("[DatabaseBootstrapper] Successfully created database '{TargetDb}'.", targetDb);
}
}
catch (Exception ex)
{
logger?.LogWarning(ex, "[DatabaseBootstrapper] Auto-creation check failed or skipped for connection. Continuing with migration.");
}
}
/// <summary>
/// Combines catalog database auto-creation and EF Core Migration execution in a single call.
/// </summary>
public static async Task MigrateWithBootstrapAsync<TContext>(
this TContext context,
string connectionString,
ILogger? logger = null,
CancellationToken cancellationToken = default) where TContext : DbContext
{
await EnsureDatabaseCreatedAsync(connectionString, logger, cancellationToken);
await context.Database.MigrateAsync(cancellationToken);
}
}
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticCore.Dtos.Bot;
[JsonConverter(typeof(JsonStringEnumConverter<BotExecutionVenue>))]
public enum BotExecutionVenue
{
AlpacaPaperTrading, // Offizielle Alpaca API (US-Equities / ETFs)
SyntheticPaperBroker // Interner Engine-Broker (EU / Knock-Outs)
}
[JsonConverter(typeof(JsonStringEnumConverter<BotPositionStatus>))]
public enum BotPositionStatus
{
Pending,
Active,
BreakEvenTriggered,
Tp1Hit,
Tp2Hit,
Closed,
StoppedOut,
KnockedOut,
Canceled
}
public record BotTradeOrderDto(
Guid OrderId,
Guid ProposalId,
string Isin,
string Symbol,
BotExecutionVenue Venue,
string? AlpacaOrderId,
string? ClientOrderId,
SignalDirection Direction,
decimal RequestedQuantity,
decimal FilledQuantity,
decimal EntryPrice,
decimal AverageBuyIn,
decimal InitialStopLoss,
decimal CurrentStopLoss,
decimal TakeProfit1,
decimal TakeProfit2,
decimal CurrentPrice,
decimal UnrealizedPnlEur,
decimal RealizedPnlEur,
BotPositionStatus Status,
ExitPlan ExitPlan,
DateTime CreatedAtUtc,
DateTime? FilledAtUtc,
DateTime? ClosedAtUtc
);
public record AccountSummaryDto(
decimal Equity,
decimal Cash,
decimal BuyingPower,
string Currency,
string Status
);
public record BotStatusDto(
bool IsRunning,
bool AutoExecutionEnabled,
int ActivePositionsCount,
int MaxPositions,
decimal RiskPerTradePercent,
int MinCompositeScore,
string VenuesActive
);
public record ExecuteProposalRequest(
Guid ProposalId,
BotExecutionVenue? PreferredVenue = null,
decimal? CustomQuantity = null
);
public record BotPortfolioSnapshotDto(
Guid Id,
DateTime SnapshotDateUtc,
decimal TotalEquityEur,
decimal CashEur,
int OpenPositionsCount,
decimal DailyRealizedPnlEur,
decimal TotalUnrealizedPnlEur,
decimal? WinRatePercent
);
public record UpdateBotSettingsRequest(
bool? AutoExecutionEnabled,
int? MaxPositions,
decimal? RiskPerTradePercent,
int? MinCompositeScore
);
/// <summary>
/// Result of an emergency "panic close" of every open paper-trading position (see
/// <see cref="FinlyticCore.Util.MqttTopics.Channels.BotPanicClose"/>). <see cref="SkippedCount"/> is
/// non-zero whenever an Alpaca position could not be liquidated (Alpaca not configured or the broker call
/// failed) — callers MUST surface that count to the user instead of only reporting <see cref="ClosedCount"/>
/// as if the whole operation succeeded (Rules.md §4: no fabricated full success on a partial result).
/// </summary>
public record PanicCloseResultDto(
int ClosedCount,
int SkippedCount,
List<BotTradeOrderDto> ClosedOrders
);
@@ -39,4 +39,28 @@ public record AssetFundamentalsDto
/// </summary> /// </summary>
[JsonPropertyName("lastUpdatedAt")] [JsonPropertyName("lastUpdatedAt")]
public DateTime LastUpdatedAt { get; init; } = DateTime.UtcNow; public DateTime LastUpdatedAt { get; init; } = DateTime.UtcNow;
/// <summary>
/// Berechnete Tage bis zum nächsten Quartalszahlen-Termin (Earnings Lockout Check).
/// </summary>
[JsonPropertyName("daysToNextEarnings")]
public int? DaysToNextEarnings => Events?
.Where(e => (e.Type.Equals("Earnings", StringComparison.OrdinalIgnoreCase) || e.EventType.Equals("Earnings", StringComparison.OrdinalIgnoreCase)) && e.Date >= DateTime.UtcNow.Date)
.OrderBy(e => e.Date)
.Select(e => (int?)(e.Date.Date - DateTime.UtcNow.Date).TotalDays)
.FirstOrDefault();
/// <summary>
/// Berechnete Tage bis zum nächsten Ex-Dividenden-Tag (Dividend Gate Check). Nur Events mit dem
/// kanonischen Type "Dividend" zählen - dieser wird ausschließlich aus Trade Republics strukturierten
/// Dividend-Feldern (ExpectedDividend/Dividends, echtes ExDate) befüllt, nicht aus dem generischen
/// Events/PastEvents-Feed, dessen freie Type/Title-Strings nicht zuverlässig auf "Dividende" gemappt werden
/// können (Rules.md §4: kein Raten anhand unsicherer Freitext-Strings).
/// </summary>
[JsonPropertyName("daysToNextExDividend")]
public int? DaysToNextExDividend => Events?
.Where(e => e.Type.Equals("Dividend", StringComparison.OrdinalIgnoreCase) && e.Date >= DateTime.UtcNow.Date)
.OrderBy(e => e.Date)
.Select(e => (int?)(e.Date.Date - DateTime.UtcNow.Date).TotalDays)
.FirstOrDefault();
} }
+181 -43
View File
@@ -60,6 +60,20 @@ public record ArticleRequest(
[property: JsonPropertyName("id")] string? Id = null [property: JsonPropertyName("id")] string? Id = null
); );
/// <summary>
/// Request payload for fetching sentiment by ISIN.
/// </summary>
public record GetSentimentByIsinRequest(
[property: JsonPropertyName("isin")] string Isin
);
/// <summary>
/// Request payload for fetching sentiment by Sector.
/// </summary>
public record GetSectorSentimentRequest(
[property: JsonPropertyName("sector")] string Sector
);
/// <summary> /// <summary>
/// Request payload for triggering a manual sentiment analysis for an article or ISIN. /// Request payload for triggering a manual sentiment analysis for an article or ISIN.
/// </summary> /// </summary>
@@ -84,36 +98,6 @@ public record GetEventsByMonthRequest(
[property: JsonPropertyName("month")] int Month [property: JsonPropertyName("month")] int Month
); );
/// <summary>
/// Request payload for triggering a manual AI analysis.
/// </summary>
public record ManualAnalysisRpcRequest(
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("sector")] string Sector,
[property: JsonPropertyName("headline")]
string Headline,
[property: JsonPropertyName("currentPrice")]
decimal CurrentPrice,
[property: JsonPropertyName("riskScore")]
int RiskScore,
[property: JsonPropertyName("minTimeframeValue")]
int MinTimeframeValue,
[property: JsonPropertyName("maxTimeframeValue")]
int MaxTimeframeValue,
[property: JsonPropertyName("timeframeUnit")]
string TimeframeUnit,
[property: JsonPropertyName("instrumentType")]
string InstrumentType,
[property: JsonPropertyName("userNotes")]
string UserNotes,
[property: JsonPropertyName("taData")] FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? TaData,
[property: JsonPropertyName("fundamentalsData")]
FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? FundamentalsData,
[property: JsonPropertyName("sentimentData")]
FinlyticCore.Dtos.Sentiment.IsinSentimentSummaryDto? SentimentData
);
/// <summary> /// <summary>
/// Response payload returned by microservice health pings over MQTT. /// Response payload returned by microservice health pings over MQTT.
/// </summary> /// </summary>
@@ -137,22 +121,176 @@ public record FetchLogoResponse(
bool Success bool Success
); );
/// <summary>
/// Payload published to MQTT when the Admin Panel updates a microservice's configuration.
/// Replaces the anonymous type to be compatible with AOT/source-gen JSON serialization.
/// </summary>
public record ServiceConfigUpdatePayload(
[property: JsonPropertyName("serviceName")]
string ServiceName,
[property: JsonPropertyName("timestamp")]
DateTime Timestamp,
[property: JsonPropertyName("settings")]
Dictionary<string, string> Settings
);
/// <summary> /// <summary>
/// Payload published to MQTT when a live market tick is received. /// Payload published to MQTT when a live market tick is received.
/// </summary> /// </summary>
public record TickMessageDto( public record TickMessageDto(
[property: JsonPropertyName("price")] decimal Price [property: JsonPropertyName("price")] decimal Price
); );
/// <summary>
/// Request payload for fetching trade proposals from FinlyticEngine.
/// </summary>
public record GetTradeProposalsRequest(
[property: JsonPropertyName("onlyActive")] bool OnlyActive = true,
[property: JsonPropertyName("limit")] int Limit = 50
);
/// <summary>
/// Request payload for fetching active trades from FinlyticEngine. <see cref="UserId"/> is mandatory
/// (not defaulted/optional) so FinlyticEngine always filters trades to their owner server-side; a caller
/// can never accidentally list every user's trades by omitting it (see Rules.md multi-tenancy requirement).
/// </summary>
public record GetActiveTradesRequest(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("mode")] FinlyticCore.Dtos.Trading.ExecutionMode? Mode = null
);
/// <summary>
/// Request payload for triggering an on-demand evaluation in FinlyticEngine. <see cref="UserId"/> identifies
/// the human caller for the resulting <c>EngineEvaluationSnapshotEntity.TriggeredByUserId</c> audit trail
/// (this RPC channel is only ever reached from the manual Web UI flows - the autonomous
/// <c>OpportunityPollerBackgroundService</c> calls <c>ITradeLifecycleService.EvaluateAssetAsync</c> directly
/// in-process and never goes through this channel at all). Exactly like <see cref="AddTradeFillRequest.UserId"/>
/// and its siblings, any value supplied by an untrusted client is discarded and overwritten server-side
/// (FinlyticBackend) with the identity from the JWT before the request is forwarded over MQTT; the default of
/// <see cref="Guid.Empty"/> here only exists so <see cref="Ticker"/>/<see cref="ForceAiEvaluation"/> can keep
/// their own defaults (C# requires optional parameters to trail).
/// </summary>
public record EvaluateAssetRequest(
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("userId")] Guid UserId = default,
[property: JsonPropertyName("ticker")] string? Ticker = null,
[property: JsonPropertyName("forceAiEvaluation")] bool ForceAiEvaluation = false
);
/// <summary>
/// Request payload for adding an executed fill to an active trade. <see cref="UserId"/> is mandatory so
/// FinlyticEngine can verify the caller owns <see cref="TradeId"/> before mutating it; a value supplied by an
/// untrusted client must always be overwritten server-side (FinlyticBackend) with the identity from the JWT.
/// </summary>
public record AddTradeFillRequest(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("tradeId")] Guid TradeId,
[property: JsonPropertyName("executedPrice")] decimal ExecutedPrice,
[property: JsonPropertyName("quantity")] decimal Quantity,
[property: JsonPropertyName("fee")] decimal Fee = 0m,
[property: JsonPropertyName("note")] string? Note = null
);
/// <summary>
/// Request payload for manually or algorithmically adjusting a trade's stop loss. <see cref="UserId"/> is
/// mandatory so FinlyticEngine can verify the caller owns <see cref="TradeId"/> before mutating it; a value
/// supplied by an untrusted client must always be overwritten server-side (FinlyticBackend) with the identity
/// from the JWT.
/// </summary>
public record UpdateTradeStopLossRequest(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("tradeId")] Guid TradeId,
[property: JsonPropertyName("newStopLoss")] decimal NewStopLoss,
[property: JsonPropertyName("reason")] string Reason
);
/// <summary>
/// Request payload for closing an active trade. <see cref="UserId"/> is mandatory so FinlyticEngine can verify
/// the caller owns <see cref="TradeId"/> before closing it; a value supplied by an untrusted client must always
/// be overwritten server-side (FinlyticBackend) with the identity from the JWT.
/// </summary>
public record CloseEngineTradeRequest(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("tradeId")] Guid TradeId,
[property: JsonPropertyName("closePrice")] decimal ClosePrice,
[property: JsonPropertyName("reason")] string Reason
);
/// <summary>
/// Request payload for accepting an open trade proposal on behalf of a single user. A proposal is a
/// system-wide opportunity, so accepting it does NOT consume or deactivate it — it creates one independent
/// trade owned by <see cref="UserId"/>, and other users may still accept the same proposal. Proposals
/// disappear on their own once <c>ExpiresAtUtc</c> passes; there is deliberately no "reject" round trip,
/// because declining a proposal has no server-side effect.
/// <see cref="UserId"/> must always be overwritten server-side (FinlyticBackend) with the identity from
/// the JWT and never trusted from the client.
/// </summary>
public record AcceptTradeProposalRequest(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("proposalId")] Guid ProposalId,
[property: JsonPropertyName("executedPrice")] decimal? ExecutedPrice = null,
[property: JsonPropertyName("quantity")] decimal? Quantity = null
);
/// <summary>
/// Request payload for manually opening a trade in FinlyticEngine with no backing proposal (e.g. a user
/// enters a position in the Web UI that FinlyticEngine never evaluated or scored). <see cref="UserId"/> is
/// mandatory and must always be overwritten server-side (FinlyticBackend) with the identity from the JWT,
/// exactly like every other engine trade-mutation request.
/// There is deliberately no <c>ProposalId</c> field: <c>EngineTradeEntity.ProposalId</c> stays a
/// non-nullable <see cref="Guid"/> everywhere else in the codebase (grouping trades that share one accepted
/// proposal), so FinlyticEngine substitutes <see cref="Guid.Empty"/> for a manually created trade instead of
/// widening that column to nullable for the sake of this single caller.
/// </summary>
public record CreateManualTradeRequest(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("underlyingIsin")] string UnderlyingIsin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("direction")] FinlyticCore.Dtos.TechnicalAnalysis.SignalDirection Direction,
[property: JsonPropertyName("entryPrice")] decimal EntryPrice,
[property: JsonPropertyName("quantity")] decimal Quantity,
[property: JsonPropertyName("initialStopLoss")] decimal InitialStopLoss,
[property: JsonPropertyName("takeProfit1")] decimal TakeProfit1,
[property: JsonPropertyName("takeProfit2")] decimal? TakeProfit2 = null,
[property: JsonPropertyName("instrumentType")] FinlyticCore.Dtos.Trading.InstrumentCategoryType InstrumentType = FinlyticCore.Dtos.Trading.InstrumentCategoryType.Stock,
[property: JsonPropertyName("derivativeIsin")] string? DerivativeIsin = null,
[property: JsonPropertyName("derivativeWkn")] string? DerivativeWkn = null,
[property: JsonPropertyName("fee")] decimal Fee = 0m
);
/// <summary>
/// Machine-readable classification of a server-side RPC fault, carried by <see cref="RpcErrorResponse"/> so a
/// caller can react to the specific failure mode instead of only learning "something went wrong" (or, before
/// this error channel existed, learning nothing at all and simply timing out). The set is deliberately small and
/// mirrors the handful of exception shapes actually thrown by <c>SubscribeRpcAsync</c> handlers across the
/// fleet today (see <see cref="FinlyticCore.Util.ManagedMqttClient"/>); it is not meant to be a full HTTP-status
/// mirror. Each value has a corresponding standard .NET exception type that
/// <see cref="FinlyticCore.Util.ManagedMqttClient"/> reconstructs client-side, so existing
/// <c>catch (InvalidOperationException)</c> / <c>catch (ArgumentException)</c> blocks written against the
/// service-layer methods' local exception types keep working unchanged across the MQTT boundary.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RpcFaultCode>))]
public enum RpcFaultCode
{
/// <summary>
/// Uncategorized/unexpected server-side failure with no safe, specific detail to disclose over MQTT (the
/// broker runs without authentication). The full exception is logged locally on the serving side only.
/// </summary>
Internal = 0,
/// <summary>The request conflicts with current server-side state (e.g. a proposal already accepted by this same user).</summary>
Conflict = 1,
/// <summary>The request payload failed validation (e.g. a blank ISIN or a non-positive price/quantity).</summary>
InvalidArgument = 2,
/// <summary>
/// The referenced resource does not exist, or exists but does not belong to the caller. The two cases are
/// deliberately not distinguished (see the multi-tenancy note on <see cref="GetActiveTradesRequest"/>): a
/// caller must never learn that a trade ID exists under another user's account.
/// </summary>
NotFound = 3,
/// <summary>The caller's identity could not be established, or is not permitted to perform this operation.</summary>
Unauthorized = 4
}
/// <summary>
/// Typed error envelope published by <see cref="FinlyticCore.Util.ManagedMqttClient.SubscribeRpcAsync{TRequest,TResponse}"/>
/// on a dedicated error sub-topic when an RPC handler throws, instead of silently dropping the request and
/// leaving the caller to hit its request timeout. The message carries only a machine-readable
/// <see cref="Code"/> and a short, safe, fully-formed <see cref="Message"/>; internal details (stack traces,
/// connection strings, etc.) are never placed on the wire and must be logged locally on the serving side instead
/// (Rules.md §10/§11, and the MQTT broker currently has no authentication).
/// </summary>
public record RpcErrorResponse(
[property: JsonPropertyName("code")] RpcFaultCode Code,
[property: JsonPropertyName("message")] string Message
);
@@ -40,7 +40,7 @@ public record FinBertResultDto
/// <summary> /// <summary>
/// Gets or sets the compound score (-1.0 to +1.0). /// Gets or sets the compound score (-1.0 to +1.0).
/// </summary> /// </summary>
[JsonPropertyName("compoundScore")] [JsonPropertyName("compound_score")]
public double CompoundScore { get; init; } public double CompoundScore { get; init; }
/// <summary> /// <summary>
@@ -49,6 +49,12 @@ public record FinBertResultDto
[JsonPropertyName("confidence")] [JsonPropertyName("confidence")]
public double Confidence { get; init; } public double Confidence { get; init; }
/// <summary>
/// Gets or sets the estimated market impact ("HIGH", "MEDIUM", "LOW").
/// </summary>
[JsonPropertyName("impact")]
public string? Impact { get; init; }
/// <summary> /// <summary>
/// Gets or sets the probability breakdown. /// Gets or sets the probability breakdown.
/// </summary> /// </summary>
@@ -56,8 +62,14 @@ public record FinBertResultDto
public FinBertProbabilities Probabilities { get; init; } = new(); public FinBertProbabilities Probabilities { get; init; } = new();
/// <summary> /// <summary>
/// Gets or sets the short summary snippet highlighting the impact of the article. /// Gets or sets the short key highlight extracted by FinBERT / n8n.
/// </summary>
[JsonPropertyName("key_highlight")]
public string? KeyHighlight { get; init; }
/// <summary>
/// Legacy alias for KeyHighlight / summary snippet.
/// </summary> /// </summary>
[JsonPropertyName("summarySnippet")] [JsonPropertyName("summarySnippet")]
public string? SummarySnippet { get; init; } public string? SummarySnippet => KeyHighlight;
} }
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Sentiment; namespace FinlyticCore.Dtos.Sentiment;
@@ -97,6 +98,36 @@ public record IsinCurrentSummary
[JsonPropertyName("totalArticlesAnalyzed")] [JsonPropertyName("totalArticlesAnalyzed")]
public int TotalArticlesAnalyzed { get; init; } public int TotalArticlesAnalyzed { get; init; }
/// <summary>
/// Gets or sets the number of positive articles.
/// </summary>
[JsonPropertyName("positiveArticles")]
public int PositiveArticles { get; init; }
/// <summary>
/// Gets or sets the number of negative articles.
/// </summary>
[JsonPropertyName("negativeArticles")]
public int NegativeArticles { get; init; }
/// <summary>
/// Gets or sets the number of neutral articles.
/// </summary>
[JsonPropertyName("neutralArticles")]
public int NeutralArticles { get; init; }
/// <summary>
/// Gets or sets the sentiment trend ("IMPROVING", "DETERIORATING", "STABLE").
/// </summary>
[JsonPropertyName("trend")]
public string? Trend { get; init; }
/// <summary>
/// Gets or sets the key highlight summary.
/// </summary>
[JsonPropertyName("keyHighlight")]
public string? KeyHighlight { get; init; }
/// <summary> /// <summary>
/// Gets or sets the overall synthesized sentiment text overview. /// Gets or sets the overall synthesized sentiment text overview.
/// </summary> /// </summary>
@@ -105,7 +136,7 @@ public record IsinCurrentSummary
} }
/// <summary> /// <summary>
/// Data transfer object for an ISIN sentiment summary file (stored in data/summaries/isin/ISIN.json). /// Data transfer object for an ISIN sentiment summary file.
/// </summary> /// </summary>
public record IsinSentimentSummaryDto public record IsinSentimentSummaryDto
{ {
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Sentiment; namespace FinlyticCore.Dtos.Sentiment;
@@ -55,6 +56,18 @@ public record SectorCurrentSummary
[JsonPropertyName("sentimentLabel")] [JsonPropertyName("sentimentLabel")]
public string SentimentLabel { get; init; } = "NEUTRAL"; public string SentimentLabel { get; init; } = "NEUTRAL";
/// <summary>
/// Gets or sets the total number of articles analyzed for this sector.
/// </summary>
[JsonPropertyName("totalArticlesAnalyzed")]
public int TotalArticlesAnalyzed { get; init; }
/// <summary>
/// Gets or sets the total number of distinct companies in this sector.
/// </summary>
[JsonPropertyName("totalCompanies")]
public int TotalCompanies { get; init; }
/// <summary> /// <summary>
/// Gets or sets the list of active asset ISINs influencing the sector. /// Gets or sets the list of active asset ISINs influencing the sector.
/// </summary> /// </summary>
@@ -69,7 +82,7 @@ public record SectorCurrentSummary
} }
/// <summary> /// <summary>
/// Data transfer object for a Sector sentiment summary file (stored in data/summaries/sectors/SectorName.json). /// Data transfer object for a Sector sentiment summary file.
/// </summary> /// </summary>
public record SectorSentimentSummaryDto public record SectorSentimentSummaryDto
{ {
@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticCore.Dtos.Simulation;
/// <param name="StrategyParameters">
/// Per-run overrides for <paramref name="StrategyKey"/>'s tunable indicator parameters, keyed by
/// <c>"{StrategyKey}.{ParameterName}"</c> (e.g. <c>"MeanReversion.RsiOversold"</c>) - see
/// <c>TechnicalContext.ParameterOverrides</c>. <see langword="null"/>/empty means "use that strategy's own
/// hardcoded defaults". Deliberately scoped to backtesting only - live scanning never applies these.
/// </param>
public record BacktestRequestDto(
string Isin,
string Symbol,
string StrategyKey,
string Timeframe,
DateTime StartDateUtc,
DateTime EndDateUtc,
decimal StartingCapital = 10000m,
decimal RiskPerTradePercent = 1.0m, // 1% Risiko pro Trade
bool IncludeFeesAndSlippage = true,
bool SimulateKnockOutDerivatives = false,
decimal? TargetLeverage = 5.0m,
Dictionary<string, decimal>? StrategyParameters = null
);
public record BacktestTradeDto(
Guid TradeId,
DateTime EntryTimeUtc,
DateTime ExitTimeUtc,
SignalDirection Direction,
decimal EntryPrice,
decimal ExitPrice,
decimal Quantity,
decimal InitialStopLoss,
decimal RealizedPnlEur,
decimal ReturnPercent,
decimal RMultiple,
string ExitReason, // "TP1_Hit", "TP2_Hit", "BreakEven", "TrailingStop", "KnockedOut", "TimeExpired"
decimal MaxAdverseExcursionPercent, // MAE: Maximaler zwischenzeitlicher Buchverlust
decimal MaxFavorableExcursionPercent // MFE: Maximaler zwischenzeitlicher Buchgewinn
);
public record EquityPointDto(
DateTime TimestampUtc,
decimal PortfolioValue,
decimal DrawdownPercent
);
public record BacktestReportDto(
Guid RunId,
string Isin,
string Symbol,
string StrategyKey,
string Timeframe,
DateTime StartDateUtc,
DateTime EndDateUtc,
int TotalTrades,
int WinningTrades,
int LosingTrades,
decimal WinRatePercent,
decimal ProfitFactor,
decimal MaxDrawdownPercent,
decimal TotalReturnPercent,
decimal ExpectancyEur,
decimal SharpeRatio,
decimal AverageRiskRewardRatio,
TimeSpan AverageHoldingDuration,
List<BacktestTradeDto> Trades,
List<EquityPointDto> EquityCurve
);
public record StrategyAssetReliabilityDto(
string Isin,
string StrategyKey,
decimal ReliabilityScore, // 0 - 100
decimal WinRatePercent,
decimal ProfitFactor,
int SampleTradeCount,
bool IsStrategyApprovedForAsset,
string RecommendedAction // "BOOST_SCORE", "NEUTRAL", "VETO_DISABLE"
);
public record GetReliabilityRequest(
string Isin,
string StrategyKey,
string Timeframe = "15m"
);
/// <summary>
/// Filters for <c>MqttTopics.Channels.SimGetBacktestHistory</c>. <see cref="StrategyKey"/> is optional -
/// <see langword="null"/> returns every strategy's runs for the ISIN, so the Web UI can show "all history for
/// this asset" and let the user narrow down from there.
/// </summary>
public record GetBacktestHistoryRequest(
string Isin,
string? StrategyKey = null,
int Limit = 20
);
/// <summary>
/// One row of the backtest history list - a lightweight summary (no <c>Trades</c>/<c>EquityCurve</c>) mapped
/// 1:1 from a persisted <c>SimulationRunEntity</c>, so listing many runs for an asset stays cheap. Fetch the
/// full <see cref="BacktestReportDto"/> for one specific run via <c>SimGetBacktestRunDetail</c> when the user
/// drills into it.
/// </summary>
public record BacktestHistoryEntryDto(
Guid RunId,
string Isin,
string Symbol,
string StrategyKey,
string Timeframe,
DateTime StartDateUtc,
DateTime EndDateUtc,
int TotalTrades,
decimal WinRatePercent,
decimal ProfitFactor,
decimal MaxDrawdownPercent,
decimal TotalReturnPercent,
decimal SharpeRatio,
DateTime CreatedAtUtc
);
/// <summary>Looks up one specific past backtest run's full report by its RunId (<c>MqttTopics.Channels.SimGetBacktestRunDetail</c>).</summary>
public record GetBacktestRunDetailRequest(Guid RunId);
/// <summary>Looks up a saved parameter profile for one (Isin, StrategyKey) pair (<c>MqttTopics.Channels.SimGetStrategyParameters</c>).</summary>
public record GetStrategyParametersRequest(string Isin, string StrategyKey);
/// <summary>Upserts a saved parameter profile for one (Isin, StrategyKey) pair (<c>MqttTopics.Channels.SimSaveStrategyParameters</c>).</summary>
public record SaveStrategyParametersRequest(string Isin, string StrategyKey, Dictionary<string, decimal> Parameters);
/// <summary>
/// A saved set of tunable indicator parameter overrides for one (Isin, StrategyKey) pair, keyed by
/// <c>"{StrategyKey}.{ParameterName}"</c> (matching <c>TechnicalContext.ParameterOverrides</c> 1:1) - see
/// <c>SimulationStrategyParameterEntity</c>.
/// </summary>
public record StrategyParameterProfileDto(
string Isin,
string StrategyKey,
Dictionary<string, decimal> Parameters,
DateTime UpdatedAtUtc
);
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TechnicalAnalysis;
/// <summary>
/// Individual take-profit tier in a staged scale-out exit plan.
/// </summary>
public record TakeProfitStage(
[property: JsonPropertyName("stageNumber")] int StageNumber,
[property: JsonPropertyName("targetPrice")] decimal TargetPrice,
[property: JsonPropertyName("percentToClose")] decimal PercentToClose,
[property: JsonPropertyName("rMultiple")] decimal RMultiple,
[property: JsonPropertyName("description")] string Description
);
/// <summary>
/// Break-even trigger rule for locking in free-rolls.
/// </summary>
public record BreakEvenRule(
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("triggerPrice")] decimal TriggerPrice,
[property: JsonPropertyName("offsetToCoverFees")] decimal OffsetToCoverFees
);
/// <summary>
/// Trailing stop management rule for trend following.
/// </summary>
public record TrailingStopRule(
[property: JsonPropertyName("type")] TrailingStopType Type,
[property: JsonPropertyName("multiplier")] decimal Multiplier,
[property: JsonPropertyName("activationPrice")] decimal ActivationPrice,
[property: JsonPropertyName("indicatorKey")] string IndicatorKey
);
/// <summary>
/// Indicator or structural reversal condition that triggers an early trade exit.
/// </summary>
public record ReversalCondition(
[property: JsonPropertyName("ruleDescription")] string RuleDescription,
[property: JsonPropertyName("indicatorTrigger")] string IndicatorTrigger
);
/// <summary>
/// Composable, complete exit plan decoupling entry strategy logic from execution management.
/// </summary>
public record ExitPlan(
[property: JsonPropertyName("strategyType")] ExitStrategyType StrategyType,
[property: JsonPropertyName("initialStopLoss")] decimal InitialStopLoss,
[property: JsonPropertyName("takeProfitStages")] List<TakeProfitStage> TakeProfitStages,
[property: JsonPropertyName("breakEvenRule")] BreakEvenRule? BreakEvenRule = null,
[property: JsonPropertyName("trailingStopRule")] TrailingStopRule? TrailingStopRule = null,
[property: JsonPropertyName("reversalCondition")] ReversalCondition? ReversalCondition = null,
[property: JsonPropertyName("maxHoldingBars")] int? MaxHoldingBars = null
);
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TechnicalAnalysis;
/// <summary>
/// Output result of an isolated pattern detection evaluation.
/// </summary>
public record PatternResultDto(
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("type")] PatternType Type,
[property: JsonPropertyName("category")] PatternCategory Category,
[property: JsonPropertyName("bias")] PatternBias Bias,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("timeframe")] string Timeframe,
[property: JsonPropertyName("detectedAt")] DateTime DetectedAt,
[property: JsonPropertyName("keyPriceLevel")] decimal KeyPriceLevel,
[property: JsonPropertyName("upperBoundary")] decimal UpperBoundary,
[property: JsonPropertyName("lowerBoundary")] decimal LowerBoundary,
[property: JsonPropertyName("invalidationLevel")] decimal InvalidationLevel,
[property: JsonPropertyName("qualityScore")] decimal QualityScore,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("extraData")] Dictionary<string, object>? ExtraData = null
);
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TechnicalAnalysis;
/// <summary>
/// Fully evaluated technical trading setup output from an ITechnicalStrategy.
/// </summary>
/// <param name="UniverseSource">
/// Which FinlyticTechnicals universe-selection mechanism this ISIN was being monitored under at analysis time
/// (favorite/discovery/sentiment-spike), or <see langword="null"/> if it was analyzed ad hoc (e.g. a manual
/// "Analyze now" call for an ISIN not currently in the scan universe). Carried through unchanged onto
/// <c>EngineEvaluationSnapshotEntity</c> so the admin "why no proposals" Web UI can show not just an
/// evaluation's scores but why the asset was being watched in the first place.
/// </param>
/// <param name="UniverseEnteredAtUtc">When the ISIN above entered that scan universe, alongside <paramref name="UniverseSource"/>.</param>
/// <param name="Regime">
/// The overall market/asset technical regime (<see cref="TechnicalContext.Regime"/>) at analysis time - e.g.
/// whether this setup fired during a strong trend or a choppy/rangebound market. Forwarded onto the AI
/// validation payload (<c>AiReasoningGateService</c>) so the model has the same regime context a human trader
/// would use to judge whether a breakout is likely to follow through.
/// </param>
public record StrategyResultDto(
[property: JsonPropertyName("setupId")] Guid SetupId,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("timeframe")] string Timeframe,
[property: JsonPropertyName("strategyKey")] string StrategyKey,
[property: JsonPropertyName("strategyName")] string StrategyName,
[property: JsonPropertyName("direction")] SignalDirection Direction,
[property: JsonPropertyName("qualityScore")] decimal QualityScore,
[property: JsonPropertyName("currentPrice")] decimal CurrentPrice,
[property: JsonPropertyName("entryPrice")] decimal EntryPrice,
[property: JsonPropertyName("invalidationPrice")] decimal InvalidationPrice,
[property: JsonPropertyName("currentAtr")] decimal CurrentAtr,
[property: JsonPropertyName("estimatedRiskRewardRatio")] decimal EstimatedRiskRewardRatio,
[property: JsonPropertyName("exitPlan")] ExitPlan ExitPlan,
[property: JsonPropertyName("technicalRationale")] string TechnicalRationale,
[property: JsonPropertyName("triggeringPatterns")] List<PatternResultDto> TriggeringPatterns,
[property: JsonPropertyName("indicatorSnapshot")] Dictionary<string, decimal> IndicatorSnapshot,
[property: JsonPropertyName("createdAt")] DateTime CreatedAt,
[property: JsonPropertyName("expiresAt")] DateTime ExpiresAt,
[property: JsonPropertyName("isTopPick")] bool IsTopPick = false,
[property: JsonPropertyName("rating")] string Rating = "B",
[property: JsonPropertyName("universeSource")] UniverseSource? UniverseSource = null,
[property: JsonPropertyName("universeEnteredAtUtc")] DateTime? UniverseEnteredAtUtc = null,
[property: JsonPropertyName("regime")] MarketRegime? Regime = null
);
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
namespace FinlyticCore.Dtos.TechnicalAnalysis;
/// <summary>
/// Execution context supplied to pattern detectors and strategy evaluators containing multi-timeframe candles and indicators.
/// </summary>
public class TechnicalContext
{
public string Isin { get; init; } = string.Empty;
public string Symbol { get; init; } = string.Empty;
public string Timeframe { get; init; } = "15m";
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
public decimal CurrentPrice { get; init; }
public decimal CurrentSpread { get; init; }
public bool IsSpreadVolatile { get; init; }
public decimal CurrentAtr { get; init; }
public MarketRegime Regime { get; init; } = MarketRegime.LowVolatilityRangebound;
/// <summary>
/// Multi-timeframe historical candles (e.g. "1m", "5m", "15m", "1h", "1d").
/// </summary>
public Dictionary<string, IReadOnlyList<CandleDto>> MultiTimeframeCandles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Pre-calculated mathematical indicator values for the primary timeframe.
/// </summary>
public Dictionary<string, decimal> Indicators { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Per-run overrides for a strategy's tunable indicator parameters (e.g. <c>"MeanReversion.RsiOversold"</c>),
/// keyed by <c>"{StrategyKey}.{ParameterName}"</c> so a single context could in principle carry overrides
/// for more than one strategy without name collisions. Always empty for live scanning
/// (<c>TechnicalScoringEngine</c> never populates this - Rules.md §4: no silent behavior change to live
/// trade generation as a side effect of a backtesting feature); populated only by
/// <c>FinlyticSimulation.Engine.HistoricalReplayRunner</c> from <c>BacktestRequestDto.StrategyParameters</c>,
/// so per-asset/per-strategy tuning is opt-in and scoped to backtesting. See <see cref="GetParameter"/>.
/// </summary>
public Dictionary<string, decimal> ParameterOverrides { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Resolves a tunable strategy parameter: the override in <see cref="ParameterOverrides"/> under
/// <c>"{strategyKey}.{parameterName}"</c> if present, otherwise <paramref name="defaultValue"/> (the
/// strategy's own hardcoded default, unchanged from before parametrization existed).
/// </summary>
public decimal GetParameter(string strategyKey, string parameterName, decimal defaultValue)
{
return ParameterOverrides.TryGetValue($"{strategyKey}.{parameterName}", out var v) ? v : defaultValue;
}
/// <summary>
/// Gets the candles for a specific timeframe (defaults to empty list if not found).
/// </summary>
public IReadOnlyList<CandleDto> GetCandles(string timeframe)
{
if (MultiTimeframeCandles.TryGetValue(timeframe, out var list))
{
return list;
}
return [];
}
/// <summary>
/// Gets the primary timeframe candle sequence.
/// </summary>
public IReadOnlyList<CandleDto> PrimaryCandles => GetCandles(Timeframe);
/// <summary>
/// Gets a specific indicator value or null if not computed.
/// </summary>
public decimal? GetIndicator(string key)
{
if (Indicators.TryGetValue(key, out var val))
{
return val;
}
return null;
}
}
@@ -0,0 +1,124 @@
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TechnicalAnalysis;
/// <summary>
/// Major category of a chart pattern.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PatternCategory>))]
public enum PatternCategory
{
Candlestick,
Chart,
SmartMoney
}
/// <summary>
/// Directional bias indicated by a pattern or technical setup.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PatternBias>))]
public enum PatternBias
{
Bullish,
Bearish,
Neutral
}
/// <summary>
/// Specific pattern type recognized by pattern detection engines.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PatternType>))]
public enum PatternType
{
// Candlestick Patterns
Hammer,
ShootingStar,
BullishEngulfing,
BearishEngulfing,
MorningStar,
EveningStar,
Doji,
// Classical Chart Patterns
DoubleBottom,
DoubleTop,
HeadAndShoulders,
InverseHeadAndShoulders,
AscendingTriangle,
DescendingTriangle,
// Smart Money Concepts (SMC)
FairValueGapBullish,
FairValueGapBearish,
LiquiditySweepHigh,
LiquiditySweepLow,
BreakOfStructure,
ChangeOfCharacter,
OrderBlock
}
/// <summary>
/// Strategy exit model defining how positions are closed or trailed.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ExitStrategyType>))]
public enum ExitStrategyType
{
StagedScaleOutWithBreakEven,
PureTrailingStop,
DynamicBandTouch,
FixedSingleTarget,
IndicatorReversal
}
/// <summary>
/// Type of trailing stop mechanic.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TrailingStopType>))]
public enum TrailingStopType
{
AtrMultiplier,
SuperTrendLine,
SwingPoints
}
/// <summary>
/// Direction of a technical trading setup signal.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SignalDirection>))]
public enum SignalDirection
{
Buy,
Sell,
Neutral
}
/// <summary>
/// Overall market or asset technical regime.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<MarketRegime>))]
public enum MarketRegime
{
BullishTrending,
BearishTrending,
HighVolatilityChoppy,
LowVolatilityRangebound
}
/// <summary>
/// Which recurring FinlyticTechnicals selection mechanism added an ISIN to the continuously-scanned universe
/// (<c>TechnicalUniverseManager</c> in FinlyticTechnicals). Defined here rather than in FinlyticTechnicals
/// because it is carried on <see cref="StrategyResultDto.UniverseSource"/> across the MQTT boundary into
/// FinlyticEngine's evaluation snapshot, so more than one service needs it (Rules.md §3).
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<UniverseSource>))]
public enum UniverseSource
{
/// <summary>Promoted temporarily because FinlyticSentiment reported a strong/shifting sentiment reading.</summary>
SentimentSpike = 1,
/// <summary>Favorited by at least one user, aggregated across all users via FinlyticBackend.</summary>
UserFavorite = 2,
/// <summary>Part of FinlyticAssets' curated discovery/watchlist asset set.</summary>
Discovery = 3
}
@@ -0,0 +1,26 @@
using System;
namespace FinlyticCore.Dtos.TechnicalAnalysis;
/// <summary>
/// A single entry of FinlyticTechnicals' currently monitored scan universe ("watchlist") - the DB-backed set
/// of assets <c>TechnicalScannerBackgroundService</c> actually evaluates every cycle. Exposed to the admin web
/// UI so it's possible to verify assets are actually being watched, rather than only inferring it indirectly
/// from downstream evaluation results.
/// </summary>
public record WatchlistEntryDto(
string Isin,
string? Symbol,
string Source,
int Priority,
DateTime AddedAtUtc,
DateTime? ExpiresAtUtc
);
/// <summary>
/// Requests the last <paramref name="Limit"/> technical-analysis setups computed for <paramref name="Isin"/>,
/// most recent first, regardless of whether they were active/top-pick at the time - i.e. the raw scoring
/// history (including setups the engine's opportunity poller would have rejected as too weak), so a caller can
/// see whether an asset's quality score is trending up or down across recent scan cycles.
/// </summary>
public record GetRecentSetupHistoryRequest(string Isin, int Limit = 8);
@@ -1,17 +1,18 @@
using System; using System;
using System.Globalization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TradeRepublic; namespace FinlyticCore.Dtos.TradeRepublic;
public record TradeRepublicPriceTick( public record TradeRepublicPriceTick(
[property: JsonPropertyName("time")] long Time, [property: JsonPropertyName("time")] long? Time = null,
[property: JsonPropertyName("price")] string Price, [property: JsonPropertyName("price"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] decimal Price = 0m,
[property: JsonPropertyName("size")] decimal Size [property: JsonPropertyName("size")] decimal? Size = null
) )
{ {
public decimal PriceValue => decimal.TryParse(Price, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0m; public decimal PriceValue => Price;
public DateTime DateTimeUtc => DateTimeOffset.FromUnixTimeMilliseconds(Time).UtcDateTime; public DateTime DateTimeUtc => Time.HasValue && Time.Value > 0
? DateTimeOffset.FromUnixTimeMilliseconds(Time.Value).UtcDateTime
: DateTime.UtcNow;
} }
public record TradeRepublicTickerResponse( public record TradeRepublicTickerResponse(
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticCore.Dtos.Trading;
public record DerivativeSelectionDto(
[property: JsonPropertyName("derivativeIsin")] string DerivativeIsin,
// Trade Republic liefert für Derivate keine WKN, nur die ISIN (siehe TradeRepublicDerivativeItemDto).
// Daher ist dieses Feld nullable: eine ISIN darf hier NICHT als Ersatz-WKN eingetragen werden (Rules.md §4).
[property: JsonPropertyName("derivativeWkn")] string? DerivativeWkn,
[property: JsonPropertyName("issuer")] string Issuer,
[property: JsonPropertyName("optionType")] string OptionType, // "LONG" oder "SHORT"
[property: JsonPropertyName("strike")] decimal Strike,
[property: JsonPropertyName("barrier")] decimal Barrier,
[property: JsonPropertyName("leverage")] decimal Leverage,
[property: JsonPropertyName("safetyBufferPercent")] decimal SafetyBufferPercent,
[property: JsonPropertyName("spreadPercentage")] decimal SpreadPercentage,
[property: JsonPropertyName("size")] decimal Size
);
/// <summary>
/// Kennzeichnet die Herkunft einer <see cref="AiValidationResultDto"/>-Entscheidung, damit
/// Konsumenten (Frontend, Logs) eine echte KI-Analyse von einer regelbasierten Ersatzentscheidung
/// unterscheiden können. Der Enum-Wert <see cref="Ai"/> ist absichtlich der Default (0), damit ein
/// vom N8N-Webhook geliefertes JSON, das dieses Feld (noch) nicht setzt, korrekt als KI-Ergebnis
/// interpretiert wird.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ValidationSource>))]
public enum ValidationSource
{
Ai,
RuleBased
}
/// <summary>
/// Ergebnis des AI-Reasoning-Gates. <see cref="Confidence"/> ist nur gesetzt, wenn <see cref="Source"/>
/// den Wert <see cref="ValidationSource.Ai"/> hat, da eine Konfidenz ohne tatsächliche KI-Bewertung
/// erfunden wäre (Rules.md §4).
/// </summary>
public record AiValidationResultDto(
[property: JsonPropertyName("isApproved")] bool IsApproved,
[property: JsonPropertyName("confidence")] decimal? Confidence,
[property: JsonPropertyName("validationSource")] ValidationSource Source,
[property: JsonPropertyName("thesisSummary")] string ThesisSummary,
[property: JsonPropertyName("invalidationReason")] string InvalidationReason,
[property: JsonPropertyName("keyCatalysts")] List<string> KeyCatalysts,
[property: JsonPropertyName("identifiedRisks")] List<string> IdentifiedRisks
);
public record TradeProposalDto(
[property: JsonPropertyName("proposalId")] Guid ProposalId,
[property: JsonPropertyName("underlyingIsin")] string UnderlyingIsin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("strategyKey")] string StrategyKey,
[property: JsonPropertyName("direction")] SignalDirection Direction,
[property: JsonPropertyName("qualityScore")] decimal QualityScore,
[property: JsonPropertyName("compositeScore")] decimal CompositeScore,
[property: JsonPropertyName("currentPrice")] decimal CurrentPrice,
[property: JsonPropertyName("entryPrice")] decimal EntryPrice,
[property: JsonPropertyName("invalidationPrice")] decimal InvalidationPrice,
[property: JsonPropertyName("exitPlan")] ExitPlan ExitPlan,
[property: JsonPropertyName("selectedDerivative")] DerivativeSelectionDto? SelectedDerivative,
[property: JsonPropertyName("aiValidation")] AiValidationResultDto AiValidation,
[property: JsonPropertyName("createdAtUtc")] DateTime CreatedAtUtc,
[property: JsonPropertyName("expiresAtUtc")] DateTime ExpiresAtUtc
);
/// <summary>
/// Full result of <c>ITradeLifecycleService.EvaluateAssetAsync</c>, carrying both possible outcomes of the
/// evaluation pipeline (technicals, sentiment, fundamentals, simulation-reliability, AI reasoning gate):
/// an accepted opportunity (<see cref="Proposal"/> is set) or a rejection, in which case <see cref="Proposal"/>
/// is <see langword="null"/> but every score/reasoning field below is still populated with the real,
/// already-computed values instead of leaving the caller with silence (Rules.md §4).
/// <para>
/// When the pipeline could not even produce a score (no technical setups available for the ISIN, or the
/// ISIN was blank), the score fields are <c>0</c> and <see cref="AiThesisSummary"/> carries a
/// "<c>[Regelbasiert]</c>"-prefixed explanation — the same prefix <see cref="AiValidationResultDto"/> uses for
/// its <see cref="ValidationSource.RuleBased"/> fallback — so a caller/UI can recognize this is not a real
/// AI verdict, just as it already must for a rule-based <see cref="AiValidationResultDto"/>.
/// </para>
/// </summary>
public record AssetEvaluationResultDto(
[property: JsonPropertyName("proposal")] TradeProposalDto? Proposal,
[property: JsonPropertyName("compositeScore")] decimal CompositeScore,
[property: JsonPropertyName("technicalScore")] decimal TechnicalScore,
[property: JsonPropertyName("sentimentScore")] decimal SentimentScore,
[property: JsonPropertyName("fundamentalScore")] decimal FundamentalScore,
[property: JsonPropertyName("passedEarningsLockout")] bool PassedEarningsLockout,
[property: JsonPropertyName("daysToNextEarnings")] int? DaysToNextEarnings,
[property: JsonPropertyName("passedDividendGate")] bool PassedDividendGate,
[property: JsonPropertyName("daysToNextExDividend")] int? DaysToNextExDividend,
[property: JsonPropertyName("aiApproved")] bool AiApproved,
[property: JsonPropertyName("aiThesisSummary")] string AiThesisSummary,
[property: JsonPropertyName("aiIdentifiedRisks")] List<string> AiIdentifiedRisks
);
public record TradeFillDto(
[property: JsonPropertyName("fillId")] Guid FillId,
[property: JsonPropertyName("executedAtUtc")] DateTime ExecutedAtUtc,
[property: JsonPropertyName("price")] decimal Price,
[property: JsonPropertyName("quantity")] decimal Quantity,
[property: JsonPropertyName("fee")] decimal Fee,
[property: JsonPropertyName("note")] string? Note
);
public record ActiveTradeDto(
[property: JsonPropertyName("tradeId")] Guid TradeId,
[property: JsonPropertyName("proposalId")] Guid ProposalId,
[property: JsonPropertyName("underlyingIsin")] string UnderlyingIsin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("derivativeIsin")] string? DerivativeIsin,
[property: JsonPropertyName("derivativeWkn")] string? DerivativeWkn,
[property: JsonPropertyName("executionMode")] ExecutionMode ExecutionMode,
[property: JsonPropertyName("instrumentType")] InstrumentCategoryType InstrumentType,
[property: JsonPropertyName("direction")] SignalDirection Direction,
[property: JsonPropertyName("status")] TradeStatus Status,
[property: JsonPropertyName("averageBuyIn")] decimal AverageBuyIn,
[property: JsonPropertyName("totalQuantity")] decimal TotalQuantity,
[property: JsonPropertyName("initialStopLoss")] decimal InitialStopLoss,
[property: JsonPropertyName("currentStopLoss")] decimal CurrentStopLoss,
[property: JsonPropertyName("currentPrice")] decimal CurrentPrice,
[property: JsonPropertyName("unrealizedPnlEur")] decimal UnrealizedPnlEur,
[property: JsonPropertyName("unrealizedPnlPercent")] decimal UnrealizedPnlPercent,
[property: JsonPropertyName("realizedPnlEur")] decimal RealizedPnlEur,
[property: JsonPropertyName("exitPlan")] ExitPlan ExitPlan,
[property: JsonPropertyName("fills")] List<TradeFillDto> Fills,
[property: JsonPropertyName("openedAtUtc")] DateTime OpenedAtUtc,
[property: JsonPropertyName("closedAtUtc")] DateTime? ClosedAtUtc
);
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticCore.Dtos.Trading;
/// <summary>
/// Filter/pagination request for the admin-only evaluation-history RPC channel
/// (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>), served by FinlyticEngine and exposed to the Web UI
/// via <c>FinlyticBackend/Controllers/AdminEvaluationHistoryController</c>. All filters are optional and are
/// combined with logical AND; <see langword="null"/> means "do not filter on this field".
/// </summary>
/// <param name="FromUtc">Inclusive lower bound on <c>EngineEvaluationSnapshotEntity.EvaluatedAtUtc</c>.</param>
/// <param name="ToUtc">Inclusive upper bound on <c>EngineEvaluationSnapshotEntity.EvaluatedAtUtc</c>.</param>
/// <param name="OutcomeFilter">Restricts results to a single <see cref="OutcomeReason"/>.</param>
/// <param name="TriggerSourceFilter">Restricts results to a single <see cref="TriggerSource"/>.</param>
/// <param name="IsinOrSymbolSearch">
/// Case-sensitive substring search against both <c>Isin</c> and <c>Symbol</c> (matches either). Trimmed
/// server-side; blank/whitespace-only values are treated as "no search".
/// </param>
/// <param name="Page">1-based page number. Values below 1 are treated as 1 server-side.</param>
/// <param name="PageSize">
/// Requested page size. Server-side clamped to at least 1 and at most 200 (see
/// <c>EvaluationHistoryService.MaxPageSize</c>) so a caller cannot force FinlyticEngine to materialize/transmit
/// an unbounded result set in a single response.
/// </param>
public record GetEvaluationHistoryRequest(
[property: JsonPropertyName("fromUtc")] System.DateTime? FromUtc = null,
[property: JsonPropertyName("toUtc")] System.DateTime? ToUtc = null,
[property: JsonPropertyName("outcomeFilter")] OutcomeReason? OutcomeFilter = null,
[property: JsonPropertyName("triggerSourceFilter")] TriggerSource? TriggerSourceFilter = null,
[property: JsonPropertyName("isinOrSymbolSearch")] string? IsinOrSymbolSearch = null,
[property: JsonPropertyName("page")] int Page = 1,
[property: JsonPropertyName("pageSize")] int PageSize = 50
);
/// <summary>
/// One row of the evaluation history: the full, already-persisted record of a single
/// <c>TradeLifecycleService.EvaluateAssetAsync</c> run, mapped 1:1 from <c>EngineEvaluationSnapshotEntity</c>.
/// Every score field is the real, already-computed value - including the honest "0/default" values recorded
/// for the <see cref="OutcomeReason.NoTechnicalSetups"/> case, never a fabricated placeholder (Rules.md §4).
/// </summary>
public record EvaluationHistoryEntryDto(
[property: JsonPropertyName("id")] System.Guid Id,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("technicalScore")] decimal TechnicalScore,
[property: JsonPropertyName("sentimentScore")] decimal SentimentScore,
[property: JsonPropertyName("fundamentalScore")] decimal FundamentalScore,
[property: JsonPropertyName("compositeOpportunityScore")] decimal CompositeOpportunityScore,
[property: JsonPropertyName("reliabilityBonus")] decimal ReliabilityBonus,
[property: JsonPropertyName("passedEarningsLockout")] bool PassedEarningsLockout,
[property: JsonPropertyName("daysToNextEarnings")] int? DaysToNextEarnings,
[property: JsonPropertyName("passedDividendGate")] bool PassedDividendGate,
[property: JsonPropertyName("daysToNextExDividend")] int? DaysToNextExDividend,
[property: JsonPropertyName("universeSource")] UniverseSource? UniverseSource,
[property: JsonPropertyName("universeEnteredAtUtc")] System.DateTime? UniverseEnteredAtUtc,
[property: JsonPropertyName("passedSimulationVeto")] bool PassedSimulationVeto,
[property: JsonPropertyName("passedAiValidation")] bool PassedAiValidation,
[property: JsonPropertyName("aiThesisSummary")] string AiThesisSummary,
[property: JsonPropertyName("outcomeReason")] OutcomeReason OutcomeReason,
[property: JsonPropertyName("triggerSource")] TriggerSource TriggerSource,
[property: JsonPropertyName("triggeredByUserId")] System.Guid? TriggeredByUserId,
[property: JsonPropertyName("proposalId")] System.Guid? ProposalId,
[property: JsonPropertyName("evaluatedAtUtc")] System.DateTime EvaluatedAtUtc
);
/// <summary>
/// Number of evaluation-history rows matching a given filter set that carry a specific <see cref="OutcomeReason"/>.
/// A typed list of these (rather than a <c>Dictionary&lt;OutcomeReason,int&gt;</c>) is used on
/// <see cref="EvaluationHistorySummaryDto.CountsByOutcome"/> purely so this DTO round-trips through
/// System.Text.Json (including the AOT source-generated <c>FinlyticJsonSerializerContext</c>) without needing a
/// custom enum-keyed dictionary converter.
/// </summary>
public record OutcomeReasonCountDto(
[property: JsonPropertyName("outcomeReason")] OutcomeReason OutcomeReason,
[property: JsonPropertyName("count")] int Count
);
/// <summary>
/// Pre-aggregated headline numbers for the admin evaluation-history tab (e.g. "1.847 Analysen letzte 24h ·
/// 0 Vorschläge seit 14h · Ø-Score 66,7"), computed server-side so the Web UI never has to aggregate the full,
/// unpaginated result set itself. Every field except <see cref="LastProposalCreatedAtUtc"/> is scoped to
/// exactly the same filters as the paginated <see cref="EvaluationHistoryEntryDto"/> list it accompanies (see
/// <see cref="GetEvaluationHistoryResponse"/>) - only pagination (<c>Page</c>/<c>PageSize</c>) does not apply,
/// since these are totals over the whole filtered set, not just the current page.
/// </summary>
/// <param name="TotalEvaluations">Total number of snapshot rows matching the request's filters (unpaginated).</param>
/// <param name="CountsByOutcome">Breakdown of <see cref="TotalEvaluations"/> by <see cref="OutcomeReason"/>.</param>
/// <param name="AverageCompositeScore">
/// Average <c>CompositeOpportunityScore</c> across the filtered set; <c>0</c> when <see cref="TotalEvaluations"/> is 0.
/// </param>
/// <param name="ProposalsCreated">
/// Number of filtered rows whose <see cref="EvaluationHistoryEntryDto.OutcomeReason"/> is
/// <see cref="OutcomeReason.Approved"/> - i.e. the same value as the <see cref="OutcomeReason.Approved"/> entry
/// in <see cref="CountsByOutcome"/>, exposed directly so the UI does not need to search that list.
/// </param>
/// <param name="LastProposalCreatedAtUtc">
/// Timestamp of the most recently created <c>EngineTradeProposalEntity</c> across the ENTIRE proposals table -
/// deliberately NOT scoped to this request's <c>FromUtc</c>/<c>ToUtc</c> filters, because "how long since the
/// last real proposal" is a single wall-clock fact the admin wants regardless of which historical window they
/// are currently browsing. <see langword="null"/> only if no proposal has ever been created.
/// </param>
public record EvaluationHistorySummaryDto(
[property: JsonPropertyName("totalEvaluations")] int TotalEvaluations,
[property: JsonPropertyName("countsByOutcome")] List<OutcomeReasonCountDto> CountsByOutcome,
[property: JsonPropertyName("averageCompositeScore")] decimal AverageCompositeScore,
[property: JsonPropertyName("proposalsCreated")] int ProposalsCreated,
[property: JsonPropertyName("lastProposalCreatedAtUtc")] System.DateTime? LastProposalCreatedAtUtc
);
/// <summary>
/// Full response for the evaluation-history RPC channel: a page of matching rows, the total match count (for
/// pagination), and a pre-aggregated <see cref="Summary"/> so the Web UI never needs a second round trip (and a
/// second, potentially-inconsistent set of filters) just to render a header line above the table.
/// </summary>
public record GetEvaluationHistoryResponse(
[property: JsonPropertyName("totalCount")] int TotalCount,
[property: JsonPropertyName("entries")] List<EvaluationHistoryEntryDto> Entries,
[property: JsonPropertyName("summary")] EvaluationHistorySummaryDto Summary
);
+110
View File
@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Trading;
[JsonConverter(typeof(JsonStringEnumConverter<ExecutionMode>))]
public enum ExecutionMode
{
SignalProposal, // Reines Signal zur manuellen Ansicht
ManualTradeRepublic, // Händisch bei Trade Republic ausgeführt
PaperTradingBot // Vollautomatisch im Paper-Trading-Modus
}
[JsonConverter(typeof(JsonStringEnumConverter<TradeStatus>))]
public enum TradeStatus
{
Proposed, // KI-geprüfter Vorschlag, wartet auf Ausführung
Active, // Mindestens 1 Fill ausgeführt, Trade läuft
BreakEvenTriggered, // Kurs hat TP1 erreicht, SL liegt auf Mischkurs
Tp1Hit, // Teilverkauf 1 ausgeführt
Tp2Hit, // Teilverkauf 2 ausgeführt
Closed, // Vollständig mit Gewinn glattgestellt
StoppedOut, // Durch Stop-Loss beendet
Invalidated, // Kurs hat Invalidation erreicht, bevor Einstieg erfolgte
Expired // Gültigkeitsfenster abgelaufen
}
[JsonConverter(typeof(JsonStringEnumConverter<InstrumentCategoryType>))]
public enum InstrumentCategoryType
{
Stock,
Etf,
TurboLong,
TurboShort,
FactorCertificate
}
/// <summary>
/// Identifies whether an <c>EngineEvaluationSnapshotEntity</c> row was produced by the autonomous
/// <c>OpportunityPollerBackgroundService</c> scan loop or by an on-demand, human-initiated call (Web UI
/// "Analyze now" / <c>EngineController.EvaluateAsset</c> / <c>AnalyzeController.TriggerManualAnalysis</c>).
/// <see cref="Unknown"/> is deliberately value <c>0</c> (the default) so that snapshot rows written before
/// this field existed - and any future row where the caller genuinely failed to specify a source - are never
/// silently mis-reported as one of the two real sources (Rules.md §4: no fabricated data, an honest
/// "we don't know" beats a fabricated default of <see cref="Automatic"/>).
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TriggerSource>))]
public enum TriggerSource
{
Unknown = 0,
Automatic = 1,
Manual = 2
}
/// <summary>
/// Classifies why a single asset evaluation in <c>TradeLifecycleService.EvaluateAssetAsync</c> did or did not
/// result in a trade proposal. <see cref="Unknown"/> is deliberately value <c>0</c> (the default) so snapshot
/// rows persisted before this field existed read honestly as "reason unknown" rather than fabricating a
/// specific-looking cause (Rules.md §4). See the "DetermineOutcomeReason" doc comment in
/// <c>TradeLifecycleService</c> for the exact priority order applied when more than one gate failed at once.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<OutcomeReason>))]
public enum OutcomeReason
{
Unknown = 0,
/// <summary>The AI reasoning gate approved the opportunity and a <c>EngineTradeProposalEntity</c> was created.</summary>
Approved = 1,
/// <summary>
/// <c>ScoringResult.CompositeScore</c> stayed below <c>Engine.MinCompositeScore</c> and the evaluation was
/// not forced, so the AI reasoning gate was never even consulted (a synthetic rule-based rejection was
/// recorded instead).
/// </summary>
BelowScoreThreshold = 2,
/// <summary>The asset is within the earnings blackout window (<c>Engine.EarningsLockoutDays</c>).</summary>
EarningsLockout = 3,
/// <summary>FinlyticSimulation's backtest-reliability matrix vetoed this strategy/asset combination.</summary>
SimulationVeto = 4,
/// <summary>
/// The composite score cleared the minimum threshold (or the evaluation was forced) and neither the
/// earnings-lockout nor the simulation-veto gate blocked it, but the AI reasoning gate itself - whether a
/// real AI call or one of its own rule-based fallbacks (gate disabled, webhook unreachable) - still declined.
/// </summary>
AiRejected = 5,
/// <summary>
/// No technical setup could be produced for the ISIN at all (FinlyticTechnicals returned nothing), or the
/// ISIN itself was blank - in both cases the pipeline never reached scoring, so every score field on the
/// snapshot is <c>0</c>/default rather than fabricated.
/// </summary>
NoTechnicalSetups = 6,
/// <summary>
/// Not a real rejection: the evaluation genuinely cleared every gate and the AI reasoning gate approved the
/// opportunity (<c>PassedAiValidation</c> on this same row is <see langword="true"/>), but an active,
/// non-expired <c>EngineTradeProposalEntity</c> for the same <c>UnderlyingIsin</c> already exists, so no
/// second, near-identical proposal row was created and no <c>finlytic/engine/proposals/created</c> MQTT
/// event was re-broadcast. Exists specifically to stop the autonomous scanner from spamming a fresh
/// proposal (and a fresh push event to every connected client) every single poll cycle for as long as one
/// asset stays above the approval threshold - the underlying bug this value was introduced to fix.
/// </summary>
DuplicateActiveProposal = 7,
/// <summary>The asset is within the ex-dividend blackout window (<c>Engine.DividendGateDays</c>).</summary>
DividendGate = 8
}
+1
View File
@@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
@@ -1,71 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Models.Analyzer;
public class AssetRecommendationDto
{
[JsonPropertyName("mode")]
public string Mode { get; set; } = "AUTO_SCREENER";
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[JsonPropertyName("recommended_asset")]
public RecommendedAssetInfo RecommendedAsset { get; set; } = new();
[JsonPropertyName("rationale")]
public RecommendationRationaleInfo Rationale { get; set; } = new();
[JsonPropertyName("action_required")]
public string ActionRequired { get; set; } = "PROMPT_USER_FOR_MANUAL_TRADE"; // "PROMPT_USER_FOR_MANUAL_TRADE" | "NO_ACTION"
}
public class RecommendedAssetInfo
{
[JsonPropertyName("symbol")]
public string Symbol { get; set; } = string.Empty;
[JsonPropertyName("company_name")]
public string CompanyName { get; set; } = string.Empty;
[JsonPropertyName("isin")]
public string Isin { get; set; } = string.Empty;
[JsonPropertyName("market")]
public string Market { get; set; } = "US_EQUITIES";
[JsonPropertyName("bias")]
public string Bias { get; set; } = "BULLISH"; // "BULLISH" | "BEARISH" | "NEUTRAL"
[JsonPropertyName("confidence_score")]
public double ConfidenceScore { get; set; }
[JsonPropertyName("timeframe")]
public string Timeframe { get; set; } = "1D";
}
public class RecommendationRationaleInfo
{
[JsonPropertyName("pattern_detected")]
public string PatternDetected { get; set; } = string.Empty;
[JsonPropertyName("vix_context")]
public string VixContext { get; set; } = string.Empty;
[JsonPropertyName("key_technical_levels")]
public KeyTechnicalLevelsInfo KeyTechnicalLevels { get; set; } = new();
[JsonPropertyName("summary")]
public string Summary { get; set; } = string.Empty;
}
public class KeyTechnicalLevelsInfo
{
[JsonPropertyName("support")]
public List<double> Support { get; set; } = new();
[JsonPropertyName("resistance")]
public List<double> Resistance { get; set; } = new();
}
@@ -1,31 +0,0 @@
using System.Text.Json.Serialization;
using FinlyticCore.Models.Trades;
namespace FinlyticCore.Models.Analyzer;
/// <summary>
/// Response payload for manual AI analysis trigger RPC.
/// </summary>
public class ManualAnalysisResponseDto
{
[JsonPropertyName("analysisId")]
public string AnalysisId { get; set; } = string.Empty;
[JsonPropertyName("isTradeProposed")]
public bool IsTradeProposed { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "Success";
[JsonPropertyName("recommendation")]
public string Recommendation { get; set; } = "RECOMMENDED";
[JsonPropertyName("n8nResponse")]
public N8nAnalysisResponseDto? N8nResponse { get; set; }
[JsonPropertyName("proposal")]
public TradeProposalDto? Proposal { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
}
@@ -1,101 +0,0 @@
using System;
using System.Collections.Generic;
namespace FinlyticCore.Models.Analyzer;
public class TargetAssetInfo
{
public string Symbol { get; set; } = string.Empty; // e.g. "AAPL"
public string Name { get; set; } = string.Empty; // e.g. "Apple Inc."
public string Isin { get; set; } = string.Empty;
public string Sector { get; set; } = string.Empty;
}
public class MarketContextInfo
{
public decimal Vix { get; set; }
public string MarketRegime { get; set; } = string.Empty;
}
public class FilterContextInfo
{
public double ImpactScore { get; set; }
public string RawNewsHeadline { get; set; } = string.Empty;
}
public class UserPreferencesInfo
{
public int RiskScore { get; set; } = 50; // 0 to 100
public string RiskTolerance { get; set; } = "Balanced";
public int MinTimeframeValue { get; set; } = 1;
public int MaxTimeframeValue { get; set; } = 7;
public string TimeframeUnit { get; set; } = "Tage"; // "Stunden", "Tage", "Wochen", "Monate"
public string TimeframeFormatted { get; set; } = "1-7 Tage";
public string InstrumentType { get; set; } = "Stock"; // "Stock", "KnockOut", "Option", "CFD", "Future"
public string UserNotes { get; set; } = string.Empty;
}
public class TradeFeedbackInfo
{
public int TotalAssetTrades { get; set; }
public double AssetWinRate { get; set; }
public double AvgReturnPercent { get; set; }
public string LastTradeResult { get; set; } = "NONE"; // "WIN", "LOSS", "NONE"
}
public class PatternContextInfo
{
public string PatternName { get; set; } = string.Empty;
public string? BreakoutDirection { get; set; }
public double? TargetPrice { get; set; }
public double? PotentialPercent { get; set; }
}
public class TechnicalContextInfo
{
public string Rsi { get; set; } = "N/A";
public string SupertrendStatus { get; set; } = "N/A";
public string Atr { get; set; } = "N/A";
public double? Sma50 { get; set; }
public double? Sma200 { get; set; }
public List<PatternContextInfo> DetectedPatterns { get; set; } = new();
}
public class SentimentContextInfo
{
public double AssetSentimentScore { get; set; }
public double SectorSentimentScore { get; set; }
public string NewsSentimentSummary { get; set; } = "Neutral";
}
public class FundamentalContextInfo
{
public double? PeRatio { get; set; }
public double? ForwardPeRatio { get; set; }
public double? PegRatio { get; set; }
public double? MarketCap { get; set; }
public double? DebtToEquity { get; set; }
public double? GrossMargin { get; set; }
public double? NetProfitMargin { get; set; }
public double? ReturnOnEquity { get; set; }
public double? DividendYield { get; set; }
public double? ShortPercentOfFloat { get; set; }
public double? AnalystTargetMedian { get; set; }
public double? EvToEbitda { get; set; }
}
public class N8nAnalysisRequestDto
{
public string RequestId { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string TriggerType { get; set; } = "AutomatedNews"; // "Manual" | "AutomatedNews"
public TargetAssetInfo TargetAsset { get; set; } = new();
public MarketContextInfo MarketContext { get; set; } = new();
public FilterContextInfo FilterContext { get; set; } = new();
public UserPreferencesInfo UserPreferences { get; set; } = new();
public TradeFeedbackInfo TradeFeedback { get; set; } = new();
public TechnicalContextInfo TechnicalContext { get; set; } = new();
public SentimentContextInfo SentimentContext { get; set; } = new();
public FundamentalContextInfo FundamentalContext { get; set; } = new();
}
@@ -1,39 +0,0 @@
using System.Collections.Generic;
namespace FinlyticCore.Models.Analyzer;
public class N8nAnalysisResponseDto
{
public string RequestId { get; set; } = string.Empty;
public double EvalScore { get; set; } // 0.00 to 1.00
public string AiDecision { get; set; } = "Proceed"; // "Proceed" | "Reject" | "Hold"
public string SuggestedDirection { get; set; } = "Long"; // "Long" | "Short"
public string AiReasoning { get; set; } = string.Empty;
public string SuggestedTimeframe { get; set; } = "Intraday"; // "Scalp" | "Intraday" | "Swing"
public string SuggestedRisk { get; set; } = "Medium"; // "Low" | "Medium" | "High"
public ExecutionPlanInfo? ExecutionPlan { get; set; }
public DetailedAnalysisInfo? DetailedAnalysis { get; set; }
}
public class ExecutionPlanInfo
{
public EntryZoneInfo? EntryZone { get; set; }
public decimal StopLoss { get; set; }
public List<decimal>? TakeProfitTargets { get; set; }
public decimal RiskRewardRatio { get; set; }
public decimal MaxLeverage { get; set; }
}
public class EntryZoneInfo
{
public decimal Min { get; set; }
public decimal Max { get; set; }
}
public class DetailedAnalysisInfo
{
public string TechnicalRationale { get; set; } = string.Empty;
public string FundamentalRationale { get; set; } = string.Empty;
public string RiskWarning { get; set; } = string.Empty;
}
-16
View File
@@ -1,16 +0,0 @@
using System;
using System.Threading.Tasks;
using FinlyticCore.Models.Trades;
namespace FinlyticCore.Models.Auth;
/// <summary>
/// Strongly typed SignalR client interface for real-time WebSocket/SSE streaming.
/// </summary>
public interface ITradeClient
{
Task OnTradeProposed(TradeProposalDto proposal);
Task OnTradeUpdated(TradeHourlyUpdateDto update);
Task OnTradeClosed(string tradeId, decimal exitPrice, string reason);
Task OnNewsReceived(object newsItem);
}
@@ -1,22 +0,0 @@
namespace FinlyticCore.Models.Auth;
/// <summary>
/// DTO representing a request for self-registration by a new user.
/// </summary>
public class RegisterRequestDto
{
/// <summary>
/// User email address.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// User plain-text password.
/// </summary>
public string Password { get; set; } = string.Empty;
/// <summary>
/// User full name.
/// </summary>
public string FullName { get; set; } = string.Empty;
}
+50
View File
@@ -1,3 +1,6 @@
using System;
using Microsoft.Extensions.Configuration;
namespace FinlyticCore.Models; namespace FinlyticCore.Models;
/// <summary> /// <summary>
@@ -29,4 +32,51 @@ public class MqttConfiguration
/// Gets or sets the password for authentication (optional). /// Gets or sets the password for authentication (optional).
/// </summary> /// </summary>
public string? Password { get; set; } public string? Password { get; set; }
/// <summary>
/// Builds an <see cref="MqttConfiguration"/> from application configuration, understanding both the
/// colon-separated key style (<c>MQTT:Host</c>, used by <c>appsettings.json</c>) and the double-underscore
/// style (<c>MQTT__Host</c>, used by container environment variables). Every one of the eight service MQTT
/// clients previously duplicated this lookup inline; centralizing it here means a new configuration key
/// (e.g. authentication) only has to be wired up once.
/// </summary>
/// <param name="configuration">The application configuration to read MQTT settings from.</param>
/// <param name="defaultClientId">
/// The service-specific client ID prefix to fall back to when no <c>MQTT:ClientId</c>/<c>MQTT__ClientId</c>
/// is configured (e.g. "FinlyticAssets"). A random suffix is always appended to the resolved client ID
/// (whether it came from configuration or from this default) to avoid the broker rejecting a duplicate
/// client ID when a service reconnects or runs multiple instances.
/// </param>
/// <returns>
/// A populated <see cref="MqttConfiguration"/>. <see cref="Username"/> and <see cref="Password"/> are left
/// <see langword="null"/> unless both are actually configured, so connections to brokers without
/// authentication enabled remain anonymous and continue to work unchanged.
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="configuration"/> is <see langword="null"/>.</exception>
public static MqttConfiguration FromConfiguration(IConfiguration configuration, string defaultClientId)
{
ArgumentNullException.ThrowIfNull(configuration);
var host = configuration["MQTT:Host"] ?? configuration["MQTT__Host"] ?? "localhost";
var portRaw = configuration["MQTT:Port"] ?? configuration["MQTT__Port"] ?? "1883";
var port = int.TryParse(portRaw, out var parsedPort) ? parsedPort : 1883;
var configuredClientId = configuration["MQTT:ClientId"] ?? configuration["MQTT__ClientId"];
var clientId = $"{(string.IsNullOrWhiteSpace(configuredClientId) ? defaultClientId : configuredClientId)}_{Guid.NewGuid():N}";
// Optional authentication: only set Username/Password when the broker actually requires them.
// The broker this system currently runs against has no authentication configured, so leaving both
// unset here must keep the connection anonymous (see ManagedMqttClient.ConnectAsync).
var username = configuration["MQTT:Username"] ?? configuration["MQTT__Username"];
var password = configuration["MQTT:Password"] ?? configuration["MQTT__Password"];
return new MqttConfiguration
{
Host = host,
Port = port,
ClientId = clientId,
Username = string.IsNullOrWhiteSpace(username) ? null : username,
Password = string.IsNullOrWhiteSpace(password) ? null : password
};
}
} }
@@ -1,9 +0,0 @@
namespace FinlyticCore.Models.Settings;
public enum LogLevelEnum
{
None,
Debug,
Info,
Error
}
@@ -1,15 +0,0 @@
using System;
namespace FinlyticCore.Models.Trades;
/// <summary>
/// Request payload for manually closing an active trade via REST API.
/// </summary>
public class CloseTradeRequest
{
public decimal UserExitPrice { get; set; }
public DateTime? UserExitTimestamp { get; set; }
public decimal ExitFee { get; set; } = 1.0m;
public string CloseReason { get; set; } = "ManualClosure"; // "TakeProfitHit", "StopLossHit", "ManualClosure", "TimeExpired"
}
@@ -1,35 +0,0 @@
using System;
using FinlyticCore.Models.Analyzer;
namespace FinlyticCore.Models.Trades;
/// <summary>
/// Structured closed trade record exported to JSON/Parquet for AI win-rate calibration feedback loops.
/// </summary>
public class TradeFeedbackRecord
{
public string TradeId { get; set; } = string.Empty;
public string AnalysisId { get; set; } = string.Empty;
public string Sector { get; set; } = string.Empty;
public string Symbol { get; set; } = string.Empty;
public string Isin { get; set; } = string.Empty;
public decimal EntryPrice { get; set; }
public decimal StopLoss { get; set; }
public decimal TakeProfit { get; set; }
public decimal UserExitPrice { get; set; }
public decimal PnlAbsolute { get; set; }
public decimal PnlPercent { get; set; }
public bool IsWin { get; set; }
public string CloseReason { get; set; } = string.Empty;
public VixMarketRegime VixRegime { get; set; }
public decimal VixValue { get; set; }
public double ReactionDelayMinutes { get; set; }
public decimal SlippagePercent { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ClosedAt { get; set; }
}
@@ -1,170 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Models.Analyzer;
namespace FinlyticCore.Models.Trades;
/// <summary>
/// Trade proposal generated by FinlyticAnalyzer and dispatched via MQTT QoS 2.
/// </summary>
public class TradeProposalDto
{
[JsonPropertyName("tradeId")]
public string TradeId { get; set; } = string.Empty;
[JsonPropertyName("userId")]
public string? UserId { get; set; }
[JsonPropertyName("isGlobalProposal")]
public bool IsGlobalProposal { get; set; } = true;
[JsonPropertyName("status")]
public string Status { get; set; } = "Proposed";
[JsonPropertyName("analysisId")]
public string AnalysisId { get; set; } = string.Empty;
[JsonPropertyName("eventId")]
public string EventId { get; set; } = string.Empty;
[JsonPropertyName("sector")]
public string Sector { get; set; } = string.Empty;
[JsonPropertyName("symbol")]
public string Symbol { get; set; } = string.Empty;
[JsonPropertyName("isin")]
public string Isin { get; set; } = string.Empty;
[JsonPropertyName("companyName")]
public string CompanyName { get; set; } = string.Empty;
[JsonPropertyName("entryPrice")]
public decimal EntryPrice { get; set; }
[JsonPropertyName("stopLoss")]
public decimal StopLoss { get; set; }
[JsonPropertyName("takeProfit")]
public decimal TakeProfit { get; set; }
[JsonPropertyName("signalType")]
public string SignalType { get; set; } = "BUY"; // "BUY", "SELL"
[JsonPropertyName("riskTolerance")]
public string RiskTolerance { get; set; } = "Moderate"; // "Conservative", "Moderate", "Aggressive"
[JsonPropertyName("timeframe")]
public string Timeframe { get; set; } = "1D"; // "1H", "4H", "1D", "1W"
[JsonPropertyName("instrumentType")]
public string InstrumentType { get; set; } = "Stock"; // "Stock", "Option", "CFD", "Crypto"
[JsonPropertyName("assetType")]
public string AssetType { get; set; } = "stock"; // "stock", "etf", "crypto", "bond"
[JsonPropertyName("hasCfd")]
public bool HasCfd { get; set; }
[JsonPropertyName("derivativeProductCategories")]
public List<string> DerivativeProductCategories { get; set; } = new();
[JsonPropertyName("derivativeIsin")]
public string? DerivativeIsin { get; set; }
[JsonPropertyName("winRate")]
public double WinRate { get; set; }
[JsonPropertyName("vixRegime")]
public VixMarketRegime VixRegime { get; set; }
[JsonPropertyName("vixValue")]
public decimal VixValue { get; set; }
[JsonPropertyName("ttlMinutes")]
public int TtlMinutes { get; set; } = 60;
[JsonPropertyName("reasoning")]
public string Reasoning { get; set; } = string.Empty;
// --- New Fields for Detailed Execution & Rationale ---
[JsonPropertyName("entryZoneMin")]
public decimal? EntryZoneMin { get; set; }
[JsonPropertyName("entryZoneMax")]
public decimal? EntryZoneMax { get; set; }
[JsonPropertyName("takeProfitTargets")]
public List<decimal>? TakeProfitTargets { get; set; }
[JsonPropertyName("riskRewardRatio")]
public decimal? RiskRewardRatio { get; set; }
[JsonPropertyName("maxLeverage")]
public decimal? MaxLeverage { get; set; }
[JsonPropertyName("technicalRationale")]
public string TechnicalRationale { get; set; } = string.Empty;
[JsonPropertyName("fundamentalRationale")]
public string FundamentalRationale { get; set; } = string.Empty;
[JsonPropertyName("riskWarning")]
public string RiskWarning { get; set; } = string.Empty;
// --- Real Trade Execution Data ---
[JsonPropertyName("actualEntryPrice")]
public decimal? ActualEntryPrice { get; set; }
[JsonPropertyName("positionSize")]
public decimal? PositionSize { get; set; }
[JsonPropertyName("leverageUsed")]
public decimal? LeverageUsed { get; set; }
[JsonPropertyName("entryFee")]
public decimal? EntryFee { get; set; }
[JsonPropertyName("exitFee")]
public decimal? ExitFee { get; set; }
[JsonPropertyName("executionTimestamp")]
public DateTime? ExecutionTimestamp { get; set; }
[JsonPropertyName("quantity")]
public decimal? Quantity { get; set; }
[JsonPropertyName("knockoutThreshold")]
public decimal? KnockoutThreshold { get; set; }
[JsonPropertyName("isRecurring")]
public bool IsRecurring { get; set; } = false;
[JsonPropertyName("currentPrice")]
public decimal? CurrentPrice { get; set; }
[JsonPropertyName("pnlAbsolute")]
public decimal? PnlAbsolute { get; set; }
[JsonPropertyName("pnlPercent")]
public decimal? PnlPercent { get; set; }
[JsonPropertyName("closeReason")]
public string? CloseReason { get; set; }
[JsonPropertyName("userExitTimestamp")]
public DateTime? UserExitTimestamp { get; set; }
[JsonPropertyName("hasPendingExitAlert")]
public bool HasPendingExitAlert { get; set; } = false;
[JsonPropertyName("pendingExitReason")]
public string? PendingExitReason { get; set; }
[JsonPropertyName("hourlyUpdates")]
public List<TradeHourlyUpdateDto>? HourlyUpdates { get; set; }
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
-14
View File
@@ -1,14 +0,0 @@
namespace FinlyticCore.Models.Trades;
/// <summary>
/// Status of a proposed/active trade lifecycle.
/// </summary>
public enum TradeStatus
{
Proposed = 0,
Active = 1,
Closed = 2,
Expired = 3,
Rejected = 4,
Invalidated = 5
}
-32
View File
@@ -1,32 +0,0 @@
# FinlyticCore Library
`FinlyticCore` is the central shared class library for the Finlytic microservice architecture. It provides standardized data transfer objects (DTOs), domain models, MQTT communication primitives (`ManagedMqttClient`), and .NET 8 JSON Source Generators.
---
## Key Modules & Components
1. **`ManagedMqttClient`**:
- Resilient MQTT wrapper handling auto-reconnect, structured JSON publishing, topic subscription management, and synchronous Request-Reply (RPC) execution over MQTT.
2. **`FinlyticJsonSerializerContext`**:
- .NET 8 Source Generator context (`[JsonSourceGenerationOptions]`, `[JsonSerializable]`) for reflection-free, zero-allocation UTF-8 JSON serialization across MQTT messages.
3. **Domain Models & DTOs**:
- `Dtos/News`: `NewsArticleDto`, `DiscoveredArticle`, `MatchedAssetDto`, `FinBertResultDto`.
- `Dtos/Fundamentals`: `AssetFundamentalsDto`, `CorporateEventDto`.
- `Dtos/TechnicalAnalysis`: `CandleDto`, `ChartPatternDto`, `IndicatorValuesDto`, `MarketRegimeDto`, `StrategySignalDto`, `TechnicalAnalysisDto`.
- `Dtos/Sentiment`: `IsinSentimentSummaryDto`, `SectorSentimentSummaryDto`.
- `Models/Trades`: `TradeProposalDto`, `CloseTradeRequest`, `TradeHourlyUpdateDto`, `TradeFeedbackRecord`, `TradeStatus`.
---
## Feature Status
### Implemented Features
- [x] Centralized DTO definitions shared across all C# microservices.
- [x] Zero-allocation .NET 8 JSON Source Generation for all MQTT payloads.
- [x] Resilient MQTT RPC engine (`ExecuteRpcAsync`).
### Planned Features
- [ ] Binary Protocol Buffers (protobuf) serialization option for ultra-low latency internal MQTT streaming.
@@ -1,4 +1,6 @@
using System; using System;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticCore.Dtos.Logging; using FinlyticCore.Dtos.Logging;
using FinlyticCore.Models.Settings; using FinlyticCore.Models.Settings;
@@ -77,31 +79,58 @@ public class FinlyticLogger<TContextClass> : IFinlyticLogger<TContextClass>
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
} }
// Matches a structured-logging placeholder like "{CorrelationId}" or "{Score:F1}" - named-placeholder
// syntax as consumed by ILogger.Log's message templates, NOT .NET's positional composite-format syntax
// ("{0}", "{1}") that string.Format expects.
private static readonly Regex PlaceholderPattern = new(@"\{([^{}:]+)(:[^{}]+)?\}", RegexOptions.Compiled);
/// <summary>
/// Substitutes every named placeholder in <paramref name="message"/> with the corresponding entry of
/// <paramref name="args"/>, in order of appearance - the same positional mapping
/// <c>ILogger.LogInformation(message, args)</c> itself performs internally for structured-logging message
/// templates. <c>string.Format(message, args)</c> (the previous implementation) expects numeric
/// placeholders ("{0}") instead, throws a <see cref="FormatException"/> on a named one like
/// "{CorrelationId}", and the broadcast silently fell back to the raw, unsubstituted template - which is
/// exactly what showed up in the live log console instead of the real value.
/// </summary>
private static string FormatLogMessage(string message, object[]? args)
{
if (string.IsNullOrEmpty(message) || args == null || args.Length == 0) return message;
int argIndex = 0;
return PlaceholderPattern.Replace(message, match =>
{
if (argIndex >= args.Length) return match.Value;
var value = args[argIndex++];
var formatSpec = match.Groups[2].Value; // e.g. ":F2", or "" when the template has no format spec.
if (!string.IsNullOrEmpty(formatSpec) && value is IFormattable formattable)
{
try
{
return formattable.ToString(formatSpec.TrimStart(':'), CultureInfo.InvariantCulture);
}
catch (FormatException)
{
// Fall through to a plain ToString() rather than losing the value entirely.
}
}
return value?.ToString() ?? "null";
});
}
private void DispatchBroadcast(SettingKey<bool> channelKey, LogLevel level, string message, Exception? exception, params object[] args) private void DispatchBroadcast(SettingKey<bool> channelKey, LogLevel level, string message, Exception? exception, params object[] args)
{ {
try FinlyticLogBroadcaster.Broadcast(new LogMessageDto(
{ Timestamp: DateTime.UtcNow,
string formattedMsg = args != null && args.Length > 0 ? string.Format(message, args) : message; ServiceName: ServiceName,
FinlyticLogBroadcaster.Broadcast(new LogMessageDto( Channel: channelKey.Name,
Timestamp: DateTime.UtcNow, Level: level.ToString(),
ServiceName: ServiceName, Message: FormatLogMessage(message, args),
Channel: channelKey.Name, Exception: exception?.ToString()
Level: level.ToString(), ));
Message: formattedMsg,
Exception: exception?.ToString()
));
}
catch
{
FinlyticLogBroadcaster.Broadcast(new LogMessageDto(
Timestamp: DateTime.UtcNow,
ServiceName: ServiceName,
Channel: channelKey.Name,
Level: level.ToString(),
Message: message,
Exception: exception?.ToString()
));
}
} }
#region Debug #region Debug
@@ -108,11 +108,10 @@ public class SettingsService : ISettingsService
IEnumerable<Type>? customKeyHolders = null, IEnumerable<Type>? customKeyHolders = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var holderTypes = new List<Type> { typeof(CoreSettingKeys) }; var isCustomScoped = customKeyHolders != null && customKeyHolders.Any();
if (customKeyHolders != null) var holderTypes = isCustomScoped
{ ? customKeyHolders!.ToList()
holderTypes.AddRange(customKeyHolders); : new List<Type> { typeof(CoreSettingKeys) };
}
var resultList = new List<DynamicSettingDto>(); var resultList = new List<DynamicSettingDto>();
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -154,34 +153,37 @@ public class SettingsService : ISettingsService
} }
} }
// 2. Prüfen, ob in der DB weitere gespeicherte Settings existieren, die nicht im Code deklariert sind // 2. Prüfen, ob in der DB weitere gespeicherte Settings existieren (nur wenn nicht strikt auf custom KeyHolders begrenzt)
try if (!isCustomScoped)
{ {
await using var scope = _scopeFactory.CreateAsyncScope(); try
var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
if (dbContext != null)
{ {
var dbSettings = await dbContext.DynamicSettings.AsNoTracking().ToListAsync(cancellationToken); await using var scope = _scopeFactory.CreateAsyncScope();
foreach (var dbSetting in dbSettings) var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
if (dbContext != null)
{ {
if (!seenKeys.Contains(dbSetting.Key)) var dbSettings = await dbContext.DynamicSettings.AsNoTracking().ToListAsync(cancellationToken);
foreach (var dbSetting in dbSettings)
{ {
seenKeys.Add(dbSetting.Key); if (!seenKeys.Contains(dbSetting.Key))
var (inferredVal, inferredType) = InferJsonValueAndType(dbSetting.ValueJson); {
resultList.Add(new DynamicSettingDto( seenKeys.Add(dbSetting.Key);
Key: dbSetting.Key, var (inferredVal, inferredType) = InferJsonValueAndType(dbSetting.ValueJson);
Value: inferredVal, resultList.Add(new DynamicSettingDto(
Type: inferredType, Key: dbSetting.Key,
Description: FormatDescriptionFromKey(dbSetting.Key), Value: inferredVal,
UpdatedAt: dbSetting.LastUpdatedUtc Type: inferredType,
)); Description: FormatDescriptionFromKey(dbSetting.Key),
UpdatedAt: dbSetting.LastUpdatedUtc
));
}
} }
} }
} }
} catch (Exception ex)
catch (Exception ex) {
{ _logger?.LogWarning(ex, "[SettingsService] Error reading database settings during GetAllRegisteredSettingsAsync.");
_logger?.LogWarning(ex, "[SettingsService] Error reading database settings during GetAllRegisteredSettingsAsync."); }
} }
return resultList.OrderBy(s => s.Key).ToList(); return resultList.OrderBy(s => s.Key).ToList();
@@ -8,7 +8,7 @@ using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Yahoo; using FinlyticCore.Dtos.Yahoo;
using FinlyticCore.Models.Settings; using FinlyticCore.Models.Settings;
using FinlyticCore.Services; using FinlyticCore.Services;
using FinlyticCore.Utils; using FinlyticCore.Util;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace FinlyticCore.Services.Yahoo; namespace FinlyticCore.Services.Yahoo;
@@ -2,7 +2,7 @@ using System.Collections.Concurrent;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Npgsql; using Npgsql;
namespace FinlyticCore.Utils; namespace FinlyticCore.Util;
/// <summary> /// <summary>
/// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'. /// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'.
@@ -6,9 +6,8 @@ using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Yahoo; using FinlyticCore.Dtos.Yahoo;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Models.Analyzer;
using System.Collections.Generic; using System.Collections.Generic;
using FinlyticAssets.Models; using FinlyticCore.Models.Assets;
namespace FinlyticCore.Util; namespace FinlyticCore.Util;
@@ -16,18 +15,11 @@ namespace FinlyticCore.Util;
WriteIndented = false, WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(TradeProposalDto))]
[JsonSerializable(typeof(List<TradeProposalDto>))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Logging.LogMessageDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Logging.LogMessageDto))]
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Logging.LogMessageDto>))] [JsonSerializable(typeof(List<FinlyticCore.Dtos.Logging.LogMessageDto>))]
[JsonSerializable(typeof(TradeAcceptanceDto))] [JsonSerializable(typeof(TradeAcceptanceDto))]
[JsonSerializable(typeof(List<TradeAcceptanceDto>))] [JsonSerializable(typeof(List<TradeAcceptanceDto>))]
[JsonSerializable(typeof(CloseTradeRequest))]
[JsonSerializable(typeof(ManualAnalysisResponseDto))]
[JsonSerializable(typeof(N8nAnalysisResponseDto))]
[JsonSerializable(typeof(TradeHourlyUpdateDto))] [JsonSerializable(typeof(TradeHourlyUpdateDto))]
[JsonSerializable(typeof(TradeFeedbackRecord))]
[JsonSerializable(typeof(List<TradeFeedbackRecord>))]
[JsonSerializable(typeof(NewsArticleDto))] [JsonSerializable(typeof(NewsArticleDto))]
[JsonSerializable(typeof(List<NewsArticleDto>))] [JsonSerializable(typeof(List<NewsArticleDto>))]
[JsonSerializable(typeof(DiscoveredArticle))] [JsonSerializable(typeof(DiscoveredArticle))]
@@ -52,9 +44,12 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(IsinSentimentSummaryDto))] [JsonSerializable(typeof(IsinSentimentSummaryDto))]
[JsonSerializable(typeof(IsinAnalysisEntry))] [JsonSerializable(typeof(IsinAnalysisEntry))]
[JsonSerializable(typeof(SectorSentimentSummaryDto))] [JsonSerializable(typeof(SectorSentimentSummaryDto))]
[JsonSerializable(typeof(GetSentimentByIsinRequest))]
[JsonSerializable(typeof(GetSectorSentimentRequest))]
[JsonSerializable(typeof(CandleDto))] [JsonSerializable(typeof(CandleDto))]
[JsonSerializable(typeof(List<CandleDto>))] [JsonSerializable(typeof(List<CandleDto>))]
[JsonSerializable(typeof(IReadOnlyList<CandleDto>))]
[JsonSerializable(typeof(ChartPatternDto))] [JsonSerializable(typeof(ChartPatternDto))]
[JsonSerializable(typeof(List<ChartPatternDto>))] [JsonSerializable(typeof(List<ChartPatternDto>))]
[JsonSerializable(typeof(IndicatorValuesDto))] [JsonSerializable(typeof(IndicatorValuesDto))]
@@ -62,6 +57,9 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(MarketRegimeDto))] [JsonSerializable(typeof(MarketRegimeDto))]
[JsonSerializable(typeof(StrategySignalDto))] [JsonSerializable(typeof(StrategySignalDto))]
[JsonSerializable(typeof(List<StrategySignalDto>))] [JsonSerializable(typeof(List<StrategySignalDto>))]
[JsonSerializable(typeof(StrategyResultDto))]
[JsonSerializable(typeof(List<StrategyResultDto>))]
[JsonSerializable(typeof(UniverseSource))]
[JsonSerializable(typeof(TechnicalAnalysisDto))] [JsonSerializable(typeof(TechnicalAnalysisDto))]
[JsonSerializable(typeof(LivePriceDto))] [JsonSerializable(typeof(LivePriceDto))]
[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string))]
@@ -79,13 +77,75 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(AnalyzeSentimentRequest))] [JsonSerializable(typeof(AnalyzeSentimentRequest))]
[JsonSerializable(typeof(EmptyRequest))] [JsonSerializable(typeof(EmptyRequest))]
[JsonSerializable(typeof(GetEventsByMonthRequest))] [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(ServiceHealthResponse))]
[JsonSerializable(typeof(List<ServiceHealthResponse>))] [JsonSerializable(typeof(List<ServiceHealthResponse>))]
[JsonSerializable(typeof(FetchLogoResponse))] [JsonSerializable(typeof(FetchLogoResponse))]
[JsonSerializable(typeof(Dictionary<string, string>))] [JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(ServiceConfigUpdatePayload))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.AssetDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.AssetDto))]
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Assets.AssetDto>))] [JsonSerializable(typeof(List<FinlyticCore.Dtos.Assets.AssetDto>))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.StockDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.StockDto))]
@@ -93,12 +153,14 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.CryptoDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.CryptoDto))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.BondDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.BondDto))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.DerivativeDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.DerivativeDto))]
[JsonSerializable(typeof(List<FinlyticCore.Dtos.Assets.DerivativeDto>))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.SyntheticDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.SyntheticDto))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.TagDto))] [JsonSerializable(typeof(FinlyticCore.Dtos.Assets.TagDto))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetValidAssetRequest))] [JsonSerializable(typeof(FinlyticCore.Models.Assets.GetValidAssetRequest))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.SearchAssetsRequest))] [JsonSerializable(typeof(FinlyticCore.Models.Assets.SearchAssetsRequest))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDiscoveryAssetsRequest))] [JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDiscoveryAssetsRequest))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDerivativesRequest))] [JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDerivativesRequest))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicPriceTick))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerResponse))] [JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerResponse))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerRequest))] [JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerRequest))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicConnectRequest))] [JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicConnectRequest))]
@@ -149,8 +211,19 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(YahooQuoteResultWrapperDto))] [JsonSerializable(typeof(YahooQuoteResultWrapperDto))]
[JsonSerializable(typeof(YahooQuoteItemDto))] [JsonSerializable(typeof(YahooQuoteItemDto))]
[JsonSerializable(typeof(List<AssetIndex>))] [JsonSerializable(typeof(List<AssetIndex>))]
[JsonSerializable(typeof(N8nAnalysisRequestDto))]
[JsonSerializable(typeof(TickMessageDto))] [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 public partial class FinlyticJsonSerializerContext : JsonSerializerContext
{ {
} }
+561 -37
View File
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Models; using FinlyticCore.Models;
using FinlyticCore.Models.Settings; using FinlyticCore.Models.Settings;
using FinlyticCore.Services; using FinlyticCore.Services;
@@ -15,11 +17,19 @@ namespace FinlyticCore.Util;
/// <summary> /// <summary>
/// An abstract, resilient MQTT client wrapper designed for microservice architectures. /// 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"/>. /// Supports channel-controlled logging via <see cref="CoreSettingKeys.MqttChannel"/>.
/// </summary> /// </summary>
public abstract class ManagedMqttClient : IDisposable 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 ILogger<ManagedMqttClient> _logger;
private readonly ISettingsService? _settingsService; private readonly ISettingsService? _settingsService;
private readonly IFinlyticLogger<ManagedMqttClient>? _finlyticLogger; 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 // Tracks pending RPC requests waiting for a specific correlation ID reply
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _pendingRequests = new(); 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> /// <summary>
/// Gets a value indicating whether the client is currently connected to the MQTT broker. /// Gets a value indicating whether the client is currently connected to the MQTT broker.
/// </summary> /// </summary>
@@ -89,7 +120,6 @@ public abstract class ManagedMqttClient : IDisposable
/// <summary> /// <summary>
/// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop. /// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop.
/// </summary> /// </summary>
/// <param name="config">The network and credential configuration options for the broker.</param>
public async Task ConnectAsync(MqttConfiguration config) public async Task ConnectAsync(MqttConfiguration config)
{ {
if (IsConnected) if (IsConnected)
@@ -116,6 +146,7 @@ public abstract class ManagedMqttClient : IDisposable
await _mqttClient.ConnectAsync(options, _cts.Token); await _mqttClient.ConnectAsync(options, _cts.Token);
await LogMqttInfoAsync("Successfully connected to MQTT broker."); await LogMqttInfoAsync("Successfully connected to MQTT broker.");
await ResubscribeAllAsync();
await OnConnectedAsync(); await OnConnectedAsync();
} }
catch (Exception ex) catch (Exception ex)
@@ -124,14 +155,22 @@ public abstract class ManagedMqttClient : IDisposable
} }
} }
private bool _disposed;
/// <summary> /// <summary>
/// Gracefully disconnects from the broker and stops all ongoing background loops. /// Gracefully disconnects from the broker and stops all ongoing background loops.
/// </summary> /// </summary>
public async Task DisconnectAsync() public async Task DisconnectAsync()
{ {
if (_disposed) return;
if (_cts != null) if (_cts != null)
{ {
await _cts.CancelAsync(); try
{
await _cts.CancelAsync();
}
catch (ObjectDisposedException) { }
} }
if (_mqttClient.IsConnected) if (_mqttClient.IsConnected)
@@ -152,18 +191,269 @@ public abstract class ManagedMqttClient : IDisposable
} }
/// <summary> /// <summary>
/// Subscribes to a specific MQTT topic filter. /// Subscribes to a specific MQTT topic filter without attaching a direct handler.
/// </summary> /// </summary>
/// <param name="topic">The topic pattern or wildcard to subscribe to.</param> public async Task SubscribeAsync(string topic, bool noLocal = false)
/// <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)
{ {
_subscribedTopics[topic] = noLocal;
if (!IsConnected) 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; 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); var filterBuilder = new MqttTopicFilterBuilder().WithTopic(topic);
if (noLocal) if (noLocal)
{ {
@@ -178,6 +468,21 @@ public abstract class ManagedMqttClient : IDisposable
await LogMqttDebugAsync("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal); 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> /// <summary>
/// Publishes a raw string message payload to the specified topic. /// Publishes a raw string message payload to the specified topic.
/// </summary> /// </summary>
@@ -198,25 +503,43 @@ public abstract class ManagedMqttClient : IDisposable
/// <summary> /// <summary>
/// Serializes a generic object into a structured JSON string and publishes it to the specified topic. /// 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> /// </summary>
public Task PublishAsync<T>(string topic, T data, bool retain = false) public Task PublishAsync<T>(string topic, T data, bool retain = false)
{ {
byte[] jsonBytes; if (!IsConnected)
var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T)) throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null);
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 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) if (typeInfo != null)
throw new InvalidOperationException("Cannot publish message: MQTT client is offline."); {
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo);
}
else
{
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data);
}
}
}
var message = new MqttApplicationMessageBuilder() var message = new MqttApplicationMessageBuilder()
.WithTopic(topic) .WithTopic(topic)
@@ -230,6 +553,7 @@ public abstract class ManagedMqttClient : IDisposable
/// <summary> /// <summary>
/// Sends a parameterless request to an RPC channel and asynchronously blocks until a matching response arrives. /// 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> /// </summary>
public Task<TResponse?> SendRpcRequestAsync<TResponse>( public Task<TResponse?> SendRpcRequestAsync<TResponse>(
string channel, string channel,
@@ -239,10 +563,37 @@ public abstract class ManagedMqttClient : IDisposable
return SendRpcRequestAsync<TResponse, string>(channel, string.Empty, timeout); 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> /// <summary>
/// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives. /// 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>. /// 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> /// </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>( public async Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
string channel, string channel,
TRequest requestData, TRequest requestData,
@@ -253,21 +604,18 @@ public abstract class ManagedMqttClient : IDisposable
if (!IsConnected) if (!IsConnected)
throw new InvalidOperationException("Cannot execute RPC request: MQTT client is offline."); 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"); string correlationId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests.TryAdd(correlationId, tcs); _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 PublishAsync(requestTopic, requestData);
await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId); await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId);
try try
{ {
// 3. Block asynchronously until the response loop resolves the token
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25); var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25);
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout); var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
@@ -276,13 +624,7 @@ public abstract class ManagedMqttClient : IDisposable
return rawJsonResult as TResponse; return rawJsonResult as TResponse;
} }
var respTypeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(TResponse)); return DeserializePayload<TResponse>(rawJsonResult);
if (respTypeInfo != null)
{
return JsonSerializer.Deserialize(rawJsonResult, respTypeInfo) as TResponse;
}
return JsonSerializer.Deserialize<TResponse>(rawJsonResult);
} }
catch (TimeoutException) catch (TimeoutException)
{ {
@@ -291,7 +633,6 @@ public abstract class ManagedMqttClient : IDisposable
} }
finally finally
{ {
// Always clean up the dictionary to prevent memory leaks
_pendingRequests.TryRemove(correlationId, out _); _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); await LogMqttDebugAsync("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0);
// Intercept message if it belongs to the RPC response convention // 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) if (lastSlashIndex != -1)
{ {
string correlationId = topic[(lastSlashIndex + 1)..]; string correlationId = correlationTopic[(lastSlashIndex + 1)..];
if (_pendingRequests.TryRemove(correlationId, out var tcs)) 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 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); await OnMessageReceivedAsync(topic, payload ?? string.Empty);
} }
catch (Exception ex) catch (Exception ex)
@@ -357,6 +738,7 @@ public abstract class ManagedMqttClient : IDisposable
if (_mqttClient.IsConnected) if (_mqttClient.IsConnected)
{ {
await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt); await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt);
await ResubscribeAllAsync();
await OnConnectedAsync(); await OnConnectedAsync();
return; 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> /// <summary>
/// Fired automatically whenever a connection or reconnection is successfully established. /// Fired automatically whenever a connection or reconnection is successfully established.
/// </summary> /// </summary>
@@ -377,7 +867,7 @@ public abstract class ManagedMqttClient : IDisposable
/// <summary> /// <summary>
/// Fired whenever a new message lands on a registered subscription channel. /// Fired whenever a new message lands on a registered subscription channel.
/// </summary> /// </summary>
protected abstract Task OnMessageReceivedAsync(string topic, string payload); protected virtual Task OnMessageReceivedAsync(string topic, string payload) => Task.CompletedTask;
/// <summary> /// <summary>
/// Virtual fallback method to catch and handle processing level exceptions inside the incoming pipeline. /// 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() public void Dispose()
{ {
DisconnectAsync().GetAwaiter().GetResult(); if (_disposed) return;
_disposed = true;
try { DisconnectAsync().GetAwaiter().GetResult(); } catch { }
_cts?.Dispose(); _cts?.Dispose();
_mqttClient.Dispose(); _mqttClient.Dispose();
GC.SuppressFinalize(this); 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;
}
}
+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();
}
}